@oomol-lab/open-flow 0.1.0-beta.85 → 0.1.0-beta.86

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 (97) hide show
  1. package/dist/browser/{FlowCanvas-CZoHhB1y.js → FlowCanvas-CHIaMaBv.js} +3 -3
  2. package/dist/browser/{createResourceDialog-Bi4fUnbD.js → createResourceDialog-C0E5yPe6.js} +3 -3
  3. package/dist/browser/{dialog-BzoSxhXQ.js → dialog-b3hsdu8i.js} +291 -143
  4. package/dist/browser/{exportPng-CXVWVlLc.js → exportPng-DGHcejgy.js} +2 -2
  5. package/dist/browser/{flowChanges-C567g-Pc.js → flowChanges-BvSWezEb.js} +375 -83
  6. package/dist/browser/{flowWorkspace-BNdBKtla.js → flowWorkspace-BKnMRClO.js} +1619 -475
  7. package/dist/browser/fr-CNTSedYU.js +593 -0
  8. package/dist/browser/ja-CxQBHr2J.js +593 -0
  9. package/dist/browser/ko-D1Exiv1h.js +593 -0
  10. package/dist/browser/licenses.md +1 -1
  11. package/dist/browser/{markdownContent-DF39sd9T.js → markdownContent-BR9-1ikU.js} +1 -1
  12. package/dist/browser/preview.css +181 -181
  13. package/dist/browser/preview.js +1994 -623
  14. package/dist/browser/ru-BzLZv0B7.js +593 -0
  15. package/dist/browser/{tooltip-DGj5pudg.js → tooltip-DSBF3bRw.js} +55 -23
  16. package/dist/browser/workbench.css +181 -181
  17. package/dist/browser/workbench.js +86 -17
  18. package/dist/browser/{workbenchSelect-BIaEVTx6.js → workbenchSelect-Di1Bm_-d.js} +2 -2
  19. package/dist/browser/{wrapLayout-C4moMKgb.js → wrapLayout-B0AT8_-i.js} +1 -1
  20. package/dist/browser/zh-CN-Dg_xSGde.js +593 -0
  21. package/dist/browser/zh-TW-oxuLwsV0.js +593 -0
  22. package/dist/command/index.js +61 -57
  23. package/dist/command/licenses.md +28 -4
  24. package/dist/common/authoring.d.ts +5 -5
  25. package/dist/common/authoringExamples.d.ts +3 -2
  26. package/dist/common/authoringSchema.d.ts +41 -4
  27. package/dist/common/catalogCache.d.ts +46 -0
  28. package/dist/common/connector-action.js +13 -10
  29. package/dist/common/connector-proxy.d.ts +96 -0
  30. package/dist/common/connector-proxy.js +5209 -0
  31. package/dist/common/control-api-conformance.js +312 -46
  32. package/dist/common/control-api.d.ts +12 -1
  33. package/dist/common/control-api.js +303 -41
  34. package/dist/common/control-requests.d.ts +4 -3
  35. package/dist/common/control-requests.js +14111 -171
  36. package/dist/common/engine-contract.d.ts +3 -2
  37. package/dist/common/event-source.js +55 -23
  38. package/dist/common/flow-authoring-node.d.ts +2 -0
  39. package/dist/common/flow-authoring.js +6936 -6781
  40. package/dist/common/flow-change-schema.d.ts +1 -1
  41. package/dist/common/flow-change.d.ts +2 -2
  42. package/dist/common/flow-change.js +91 -36
  43. package/dist/common/flow-connectionUsage.d.ts +1 -1
  44. package/dist/common/flow-encoding.js +93 -38
  45. package/dist/common/flow-requestParameters.d.ts +34 -0
  46. package/dist/common/flow-semantics.js +9429 -9236
  47. package/dist/common/flow-state.js +55 -23
  48. package/dist/common/flow-workflowFile.d.ts +3 -1
  49. package/dist/common/http-request.d.ts +6 -11
  50. package/dist/common/http-request.js +192 -141
  51. package/dist/common/licenses.md +1 -1
  52. package/dist/common/mcp.d.ts +3 -3
  53. package/dist/common/mcp.js +303 -41
  54. package/dist/common/run-events.js +1 -0
  55. package/dist/common/runtime-contract.js +10 -4
  56. package/dist/common/runtime-nodejs-compat.js +55 -23
  57. package/dist/common/scheduler-checkpoint.d.ts +3 -3
  58. package/dist/common/scheduler-dependencies.d.ts +22 -28
  59. package/dist/common/scheduler-node-execution.d.ts +1 -0
  60. package/dist/common/scheduler.d.ts +8 -2
  61. package/dist/common/scheduler.js +274 -95
  62. package/dist/common/workflow.js +51 -19
  63. package/dist/common/workflowAuthoring.d.ts +411 -0
  64. package/dist/render/browser/licenses.md +1 -1
  65. package/dist/render/browser/runtime.js +180 -48
  66. package/dist/render/node/index.js +85 -30
  67. package/dist/render/node/licenses.md +1 -1
  68. package/package.json +1 -1
  69. package/skills/open-flow/references/flow-authoring.md +14 -13
  70. package/src/base/common/jsonSchema.ts +11 -0
  71. package/src/connector/common/actionSchema.ts +4 -11
  72. package/src/connector/common/proxy.ts +152 -0
  73. package/src/control/common/api.ts +99 -3
  74. package/src/control/common/authoring.ts +23 -9
  75. package/src/control/common/authoringExamples.ts +9 -0
  76. package/src/control/common/authoringSchema.ts +21 -12
  77. package/src/control/common/catalogCache.ts +145 -0
  78. package/src/control/common/conformance.ts +9 -3
  79. package/src/control/common/connectorQueries.ts +17 -2
  80. package/src/control/common/requests.ts +1 -0
  81. package/src/control/common/workflowAuthoring.ts +120 -0
  82. package/src/execution/common/checkpoint.ts +18 -12
  83. package/src/execution/common/dependencies.ts +132 -15
  84. package/src/execution/common/events.ts +3 -0
  85. package/src/execution/common/node-execution.ts +22 -15
  86. package/src/execution/common/scheduler.ts +20 -31
  87. package/src/flow/common/change.ts +2 -1
  88. package/src/flow/common/changeSchema.ts +11 -1
  89. package/src/flow/common/connectionUsage.ts +4 -2
  90. package/src/flow/common/errorHandling.ts +3 -1
  91. package/src/flow/common/nodeChanges.ts +35 -18
  92. package/src/flow/common/requestParameters.ts +120 -0
  93. package/src/flow/common/semantics.ts +51 -1
  94. package/src/flow/common/sourceField.ts +3 -2
  95. package/src/flow/common/workflowFile.ts +2 -2
  96. package/src/http/common/httpRequest.ts +28 -78
  97. package/src/runtime/common/engineContract.ts +6 -3
@@ -64,10 +64,27 @@ export type { WorkflowFile, WorkflowCleanupSummary } from '../../flow/common/wor
64
64
  export type { WorkflowBundle, WorkflowExportPreview } from '../../flow/common/workflowBundle.ts'
65
65
  import type { InputPortDefinition, JsonValue, PortDefinition, RevisionContent, TriggerKeySnapshot, WaitAction } from '../../flow/common/change.ts'
66
66
  import type { FlowResourceReferences } from '../../flow/common/semantics.ts'
67
+ import type { ControlCacheOptions } from './catalogCache.ts'
67
68
 
69
+ import { ControlCatalogCache } from './catalogCache.ts'
68
70
  import { flowCheck } from './checkDecoders.ts'
69
- import { connection, connectorAccess, connectorAccessSnapshot, connectorAccessCandidatesBatch, connectorAction, leinaBots } from './connectorDecoders.ts'
70
- import { allConnectorConnectionsQuery, connectorActionQuery, connectorConnectionsQuery, connectorProvidersQuery } from './connectorQueries.ts'
71
+ import {
72
+ connection,
73
+ connectorAccess,
74
+ connectorAccessSnapshot,
75
+ connectorAccessCandidatesBatch,
76
+ connectorAction,
77
+ connectorActionMetadata,
78
+ leinaBots,
79
+ } from './connectorDecoders.ts'
80
+ import {
81
+ allConnectorConnectionsQuery,
82
+ connectorActionQuery,
83
+ connectorActionMetadataQuery,
84
+ connectorConnectionsQuery,
85
+ connectorProvidersQuery,
86
+ } from './connectorQueries.ts'
87
+ export type { ControlCacheDelegate, ControlCacheOptions, ControlCacheRead } from './catalogCache.ts'
71
88
  import { requiredFields, integer, invalidResponse, jsonValue, record, string } from './decoding.ts'
72
89
  import { errorListener, flow, flowPage, variable, variableListItem, variableReferences } from './flowDecoders.ts'
73
90
  import { live, publication, publicationPage, publishOperation } from './publicationDecoders.ts'
@@ -726,6 +743,7 @@ export function decodeRunEvent(value: unknown) {
726
743
  nodeKind !== 'approval' &&
727
744
  nodeKind !== 'condition' &&
728
745
  nodeKind !== 'connector' &&
746
+ nodeKind !== 'connector-proxy' &&
729
747
  nodeKind !== 'javascript' &&
730
748
  nodeKind !== 'decision' &&
731
749
  nodeKind !== 'openapi' &&
@@ -850,10 +868,14 @@ export type ConditionalResult<T> =
850
868
  export class ControlClient {
851
869
  private readonly requestControl: ControlRequest
852
870
  private readonly channel: 'workbench' | 'cli' | 'api'
871
+ private readonly catalogCache?: ControlCatalogCache
872
+ private readonly options: { readonly cache?: ControlCacheOptions }
853
873
 
854
- constructor(requestControl: ControlRequest, channel: 'workbench' | 'cli' | 'api' = 'api') {
874
+ constructor(requestControl: ControlRequest, channel: 'workbench' | 'cli' | 'api' = 'api', options: { readonly cache?: ControlCacheOptions } = {}) {
875
+ this.options = options
855
876
  this.channel = channel
856
877
  this.requestControl = requestControl
878
+ if (options.cache != null) this.catalogCache = new ControlCatalogCache(options.cache)
857
879
  }
858
880
 
859
881
  async loadOpenApiDocument(url: string, signal?: AbortSignal): Promise<JsonValue> {
@@ -1140,6 +1162,14 @@ export class ControlClient {
1140
1162
  }
1141
1163
 
1142
1164
  async getTriggerCatalog(locale: string, cached?: TriggerCatalogCache, signal?: AbortSignal): Promise<TriggerCatalogCache> {
1165
+ if (this.catalogCache != null) {
1166
+ const result = await this.catalogResponse(
1167
+ { path: `/v1/trigger-keys/catalog?locale=${encodeURIComponent(locale)}`, decode: decodeTriggerCatalog },
1168
+ 300_000,
1169
+ signal,
1170
+ )
1171
+ return { data: result.data, etag: result.etag }
1172
+ }
1143
1173
  const headers = new Headers()
1144
1174
  if (cached?.etag) headers.set('if-none-match', cached.etag)
1145
1175
  const response = await this.response(`/v1/trigger-keys/catalog?locale=${encodeURIComponent(locale)}`, { headers, signal }, true)
@@ -1151,6 +1181,23 @@ export class ControlClient {
1151
1181
  }
1152
1182
 
1153
1183
  async getTriggerKey(key: string, signal?: AbortSignal): Promise<TriggerKeySnapshot> {
1184
+ if (this.catalogCache != null)
1185
+ return (
1186
+ await this.catalogResponse(
1187
+ {
1188
+ path: `/v1/trigger-keys/${segment(key)}`,
1189
+ decode: (value) => {
1190
+ const source = record(value)
1191
+ if (source.version != 1) return invalidResponse()
1192
+ const definition = triggerKey(source.definition)
1193
+ if (definition.key != key) return invalidResponse('Trigger key does not match the requested definition.')
1194
+ return definition
1195
+ },
1196
+ },
1197
+ 300_000,
1198
+ signal,
1199
+ )
1200
+ ).data
1154
1201
  const source = record(await this.request(`/v1/trigger-keys/${segment(key)}`, { signal }))
1155
1202
  if (source.version != 1) return invalidResponse()
1156
1203
  return triggerKey(source.definition)
@@ -1265,6 +1312,7 @@ export class ControlClient {
1265
1312
 
1266
1313
  async listConnectorProviders(signal?: AbortSignal, flowId?: string, locale?: string): Promise<readonly ConnectorProvider[]> {
1267
1314
  const query = connectorProvidersQuery(flowId, locale)
1315
+ if (this.catalogCache != null) return (await this.catalogResponse(query, 300_000, signal)).data
1268
1316
  return this.connectorRequest(query.path, 'providers', signal, query.decode)
1269
1317
  }
1270
1318
 
@@ -1350,6 +1398,21 @@ export class ControlClient {
1350
1398
  return this.connectorRequest(query.path, 'actions', signal, query.decode, fresh)
1351
1399
  }
1352
1400
 
1401
+ async listConnectorActionMetadata(serviceId?: string, signal?: AbortSignal, flowId?: string, locale?: string): Promise<readonly ConnectorActionMetadata[]> {
1402
+ return this.connectorMetadata(
1403
+ { ...(serviceId == null ? {} : { service: serviceId }), ...(flowId == null ? {} : { flowId }), ...(locale == null ? {} : { locale }) },
1404
+ signal,
1405
+ )
1406
+ }
1407
+
1408
+ async searchConnectorActionMetadata(query: string, signal?: AbortSignal, flowId?: string, locale?: string): Promise<readonly ConnectorActionMetadata[]> {
1409
+ return this.connectorMetadata({ q: query.trim(), ...(flowId == null ? {} : { flowId }), ...(locale == null ? {} : { locale }) }, signal)
1410
+ }
1411
+
1412
+ async getConnectorActionMetadata(actionId: string, signal?: AbortSignal, flowId?: string, locale?: string, fresh = false): Promise<ConnectorActionMetadata> {
1413
+ return (await this.catalogResponse(connectorActionMetadataQuery(actionId, flowId, locale), 30_000, signal, fresh)).data
1414
+ }
1415
+
1353
1416
  async listAllConnectorConnections(signal?: AbortSignal, flowId?: string, fresh = false): Promise<readonly ConnectorConnection[]> {
1354
1417
  const query = allConnectorConnectionsQuery(flowId)
1355
1418
  return this.connectorRequest(query.path, 'connections', signal, query.decode, fresh)
@@ -1618,6 +1681,38 @@ export class ControlClient {
1618
1681
  )
1619
1682
  }
1620
1683
 
1684
+ private async catalogResponse<Value>(
1685
+ query: { readonly path: string; readonly decode: (value: unknown) => Value },
1686
+ maxAgeMs: number,
1687
+ signal?: AbortSignal,
1688
+ fresh = false,
1689
+ ) {
1690
+ if (this.catalogCache != null)
1691
+ return this.catalogCache.read(query, (etag) => this.readCatalog({ path: query.path, decode: jsonValue }, etag), maxAgeMs, signal, fresh)
1692
+ const response = await this.readCatalog(query, null, signal)
1693
+ if (!response.modified) return invalidResponse()
1694
+ return { data: response.data, etag: response.etag }
1695
+ }
1696
+
1697
+ private async connectorMetadata(parameters: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<readonly ConnectorActionMetadata[]> {
1698
+ const query = new URLSearchParams(parameters).toString()
1699
+ return (
1700
+ await this.catalogResponse(
1701
+ {
1702
+ path: `/v1/connector/action-metadata${query ? `?${query}` : ''}`,
1703
+ decode: (value) => {
1704
+ const source = record(value)
1705
+ requiredFields(source, ['actions', 'version'])
1706
+ if (source.version != 1 || !Array.isArray(source.actions)) return invalidResponse()
1707
+ return source.actions.map(connectorActionMetadata)
1708
+ },
1709
+ },
1710
+ 30_000,
1711
+ signal,
1712
+ )
1713
+ ).data
1714
+ }
1715
+
1621
1716
  private async connectorActions(parameters: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<readonly ConnectorAction[]> {
1622
1717
  const query = Object.entries(parameters)
1623
1718
  .map(([key, value]) => `${segment(key)}=${segment(value)}`)
@@ -1642,6 +1737,7 @@ export class ControlClient {
1642
1737
  }
1643
1738
 
1644
1739
  protected async response(path: string, init: RequestInit, allowNotModified = false): Promise<Response> {
1740
+ if (this.options.cache?.mode == 'offline') throw new ApiError(503, 'control.offline', 'Network requests are disabled for this Control client.')
1645
1741
  const headers = new Headers(init.headers)
1646
1742
  if (init.body != null) headers.set('content-type', 'application/json')
1647
1743
  const response = await this.requestControl(path, { ...init, headers })
@@ -16,7 +16,7 @@ import type { AuthoringRead, AuthoringRequest, AuthoringSearch, AuthoringSource,
16
16
  import type { AuthoringAgentTool, AuthoringCapability } from './authoringTools.ts'
17
17
 
18
18
  import { dequal } from 'dequal/lite'
19
- import { connectorOutputMode } from '../../connector/common/actionSchema.ts'
19
+ import { connectorProxyConfigSchema, connectorProxyInputs, connectorProxyTask } from '../../connector/common/proxy.ts'
20
20
  import { decisionTask } from '../../decision/common/decision.ts'
21
21
  import { supportsNodeRetry, isTriggerNode, applyFlowChanges, decodeChangeOperations, upgradeRevisionContent } from '../../flow/common/change.ts'
22
22
  import { nodeInputMappings } from '../../flow/common/condition.ts'
@@ -40,6 +40,7 @@ import {
40
40
  updateStartInputDefs,
41
41
  updateOutputsDefs,
42
42
  renameFlowOutputSources,
43
+ setInputMappings,
43
44
  } from '../../flow/common/nodeChanges.ts'
44
45
  import { retryConfigurationSchema } from '../../flow/common/retry.ts'
45
46
  import { httpRequestTask, httpRequestConfigSchema, httpRequestInputs } from '../../http/common/httpRequest.ts'
@@ -147,7 +148,7 @@ export function authoringNode(content: RevisionContent, ref: string) {
147
148
  ),
148
149
  }
149
150
  else if (task.executor.kind == 'workflow') config = { flowId: task.executor.flowId }
150
- else if (task.executor.kind == 'http-request' || task.executor.kind == 'skip-duplicates') {
151
+ else if (task.executor.kind == 'http-request' || task.executor.kind == 'skip-duplicates' || task.executor.kind == 'connector-proxy') {
151
152
  const { kind: _kind, ...settings } = task.executor
152
153
  config = settings
153
154
  } else if (task.executor.kind == 'openapi') {
@@ -319,7 +320,7 @@ export interface AuthoringHost {
319
320
  workflow?(flowId: string): Promise<WorkflowContract | WorkflowCandidate>
320
321
  id(label: string): string
321
322
  action(id: string): Promise<ConnectorAction>
322
- trigger(key: string): TriggerKeySnapshot
323
+ trigger(key: string): TriggerKeySnapshot | Promise<TriggerKeySnapshot>
323
324
  openapi(url: string): Promise<JsonValue>
324
325
  }
325
326
  function merge(base: unknown, patch: unknown): unknown {
@@ -407,13 +408,20 @@ export async function compileAuthoring(base: RevisionContent, request: Authoring
407
408
 
408
409
  async function connectorTask(config: Record<string, unknown>, name: string): Promise<ManagedTaskDefinition> {
409
410
  const action = await host.action(String(config.action))
411
+ // The source owns projection. A whole-response port carries the root schema;
412
+ // named ports carry their field schemas, including a field named "output".
413
+ const wholeResponse =
414
+ action.outputSchema !== undefined &&
415
+ Object.keys(action.outputs).length == 1 &&
416
+ Object.hasOwn(action.outputs, 'output') &&
417
+ dequal(action.outputs.output!.jsonSchema, action.outputSchema)
410
418
  return {
411
419
  name,
412
420
  executor: {
413
421
  kind: 'connector',
414
422
  action: action.actionId,
415
423
  ...(config.connectionId == null ? {} : { connectionId: String(config.connectionId) }),
416
- ...(action.outputSchema !== undefined && connectorOutputMode(action.outputSchema) == 'value' ? { outputMode: 'value' as const } : {}),
424
+ ...(wholeResponse ? { outputMode: 'value' as const } : {}),
417
425
  },
418
426
  inputs: Object.entries(action.inputs).map(([handle, port]) => Object.assign({ handle }, port)),
419
427
  outputs: Object.entries(action.outputs).map(([handle, port]) => Object.assign({ handle }, port)),
@@ -524,7 +532,10 @@ export async function compileAuthoring(base: RevisionContent, request: Authoring
524
532
  } else if (type == 'decision') task = decisionTask(config.questions as Parameters<typeof decisionTask>[0], String(update.name))
525
533
  else if (type == 'skip-duplicates')
526
534
  task = { ...before, name: String(update.name), executor: { kind: 'skip-duplicates', ...skipDuplicatesConfigSchema.parse(config) } }
527
- else if (type == 'http-request') {
535
+ else if (type == 'connector-proxy') {
536
+ task = connectorProxyTask(connectorProxyConfigSchema.parse(config), String(update.name), before)
537
+ next = { ...next, inputs: connectorProxyInputs(node.inputs, before, task) } as GraphNode
538
+ } else if (type == 'http-request') {
528
539
  task = httpRequestTask(httpRequestConfigSchema.parse(config), String(update.name), before)
529
540
  next = { ...next, inputs: httpRequestInputs(node.inputs, before, task) } as GraphNode
530
541
  } else if (type == 'openapi') {
@@ -677,7 +688,7 @@ export async function compileAuthoring(base: RevisionContent, request: Authoring
677
688
  replace(ref, { ...node, config })
678
689
  return
679
690
  }
680
- if (!Object.hasOwn(inputDefinitions(node), input)) {
691
+ if (!Object.hasOwn(inputDefinitions(node), input) && !(typeOf(node) === 'connector-proxy' && input === 'body')) {
681
692
  const type = typeOf(node)
682
693
  if (!['agent', 'code', 'llm', 'wait', 'approval'].includes(type))
683
694
  fail('input.not-found', `Unknown input ${input}.`, { node: ref, input, choices: Object.keys(inputDefinitions(node)) })
@@ -691,8 +702,8 @@ export async function compileAuthoring(base: RevisionContent, request: Authoring
691
702
  : value.kind == 'value' || value.kind == 'unset'
692
703
  ? value
693
704
  : { kind: 'sources', sources: (value.kind == 'sources' ? value.sources : [value]).map(source) }
694
- const before = nodeInputMappings(nodeAt(content, ref))[input]
695
- if (!dequal(before, mapping)) apply([{ kind: 'graph.node.input.set', nodeId: ref, handle: input, before, ...(mapping == null ? {} : { value: mapping }) }])
705
+ const changes = setInputMappings(content, ref, { [input]: mapping }) ?? []
706
+ if (changes.length > 0) apply(changes)
696
707
  }
697
708
  for (const edit of parsed.edits) {
698
709
  try {
@@ -777,6 +788,9 @@ export async function compileAuthoring(base: RevisionContent, request: Authoring
777
788
  case 'connector':
778
789
  changes = createManagedTask(identity, await connectorTask(config, name))
779
790
  break
791
+ case 'connector-proxy':
792
+ changes = createManagedTask(identity, connectorProxyTask(connectorProxyConfigSchema.parse(config), name))
793
+ break
780
794
  case 'skip-duplicates':
781
795
  changes = createManagedTask(identity, skipDuplicatesTask(skipDuplicatesConfigSchema.parse(config), name))
782
796
  break
@@ -803,7 +817,7 @@ export async function compileAuthoring(base: RevisionContent, request: Authoring
803
817
  break
804
818
  case 'poll':
805
819
  case 'integration': {
806
- const definition = host.trigger(String(config.key))
820
+ const definition = await host.trigger(String(config.key))
807
821
  if (definition.type != edit.type) fail('trigger.type-mismatch', 'Trigger key has a different type.')
808
822
  changes = createProviderTrigger(nodeId, definition, { name, config: {} })
809
823
  break
@@ -1,5 +1,6 @@
1
1
  import type { DraftOperation } from './draftOperations.ts'
2
2
 
3
+ import { connectorProxyConfigSchema, connectorProxyTask } from '../../connector/common/proxy.ts'
3
4
  import {
4
5
  createApproval,
5
6
  createBuiltinTrigger,
@@ -27,6 +28,8 @@ const descriptions = {
27
28
  'skip-duplicates':
28
29
  'Skip items already recorded by successful Runs. Configure identity fields; an empty list uses each string or numeric item. History is private to the node and only commits after Run success.',
29
30
  'http-request': 'A single HTTP request. Bind url and request inputs; outputs are body, statusCode and headers.',
31
+ 'connector-proxy':
32
+ 'A Provider API request through Connector Proxy. Select a Provider and account, then bind endpoint. Outputs preserve body, statusCode, headers and bodyEncoding.',
30
33
  'openapi': 'A fixed JSON API operation. Replace the example document and URL before running.',
31
34
  'connector':
32
35
  'A Connector Task and its node. Replace ACTION_ID, CONNECTION_ID and port definitions using connector_get / connector show. Set connectionId to an empty string to defer account configuration; execution will require an account.',
@@ -72,6 +75,12 @@ export function authoringExample(name: string): { version: 1; operations: readon
72
75
  { kind: 'graph.node.input.set', nodeId: 'http', handle: 'url', value: { kind: 'value', value: 'https://example.com' } },
73
76
  ]
74
77
  break
78
+ case 'connector-proxy':
79
+ operations = [
80
+ ...createManagedTask({ nodeId: 'request' }, connectorProxyTask(connectorProxyConfigSchema.parse({ providerId: 'github', connectionId: '' }))),
81
+ { kind: 'graph.node.input.set', nodeId: 'request', handle: 'endpoint', value: { kind: 'value', value: '/user' } },
82
+ ]
83
+ break
75
84
  case 'openapi': {
76
85
  const document = {
77
86
  openapi: '3.1.0',
@@ -1,6 +1,7 @@
1
1
  import type { JsonValue } from '../../types/index.ts'
2
2
 
3
3
  import { z } from 'zod'
4
+ import { connectorProxyConfigSchema } from '../../connector/common/proxy.ts'
4
5
  import {
5
6
  agentToolFields,
6
7
  connectorAgentToolSchema,
@@ -31,6 +32,7 @@ export const authoringTypes = [
31
32
  'integration',
32
33
  'code',
33
34
  'connector',
35
+ 'connector-proxy',
34
36
  'agent',
35
37
  'llm',
36
38
  'decision',
@@ -118,6 +120,7 @@ export const authoringConfigurations = {
118
120
  'integration': z.strictObject({ key: id, connectionId: id.optional() }),
119
121
  'code': z.strictObject({ inputs: inputFields, outputs: outputFields, capabilities: authoringCapabilities.optional() }),
120
122
  'connector': definitions.connector.omit({ outputMode: true }).strict(),
123
+ 'connector-proxy': connectorProxyConfigSchema,
121
124
  'openapi': authoringOpenApiSchema,
122
125
  'http-request': httpRequestConfigSchema,
123
126
  'skip-duplicates': skipDuplicatesConfigSchema,
@@ -286,7 +289,8 @@ export function authoringSchema(kind?: AuthoringType) {
286
289
  }
287
290
  : {
288
291
  type: kind,
289
- onError: kind == 'skip-duplicates' || retryNodeTypes.some((nodeType) => nodeType == kind) ? { enum: ['stop', 'error'] } : false,
292
+ onError:
293
+ kind == 'skip-duplicates' || kind == 'connector-proxy' || retryNodeTypes.some((nodeType) => nodeType == kind) ? { enum: ['stop', 'error'] } : false,
290
294
  retry: retryNodeTypes.some((nodeType) => nodeType == kind) ? z.toJSONSchema(retryConfigurationSchema) : false,
291
295
  inputSources: ['poll', 'integration'].includes(kind)
292
296
  ? ['value', 'unset', 'default']
@@ -311,17 +315,21 @@ export function authoringSchema(kind?: AuthoringType) {
311
315
  ? 'Configure model settings, message templates and history as defaults. Input bindings with the same names override these defaults; named business inputs can be bound directly. Template strings interpolate {{name}}.'
312
316
  : kind == 'skip-duplicates'
313
317
  ? 'Bind an items array. Configure fields as dot-separated identity paths; an empty list uses each scalar item. Outputs are items and duplicates. History belongs to this node and commits only when the Run completes successfully. Fields accept strings or finite numbers; missing or complex values reject the batch.'
314
- : kind == 'http-request'
315
- ? 'Configure method, query/headers modes and names, bodyFormat, authentication, responseFormat and statusPolicy. Bind url, query.<name>/header.<name> or whole query/headers objects, body and auth.token (Basic: auth.username/auth.password). Credentials accept variables or upstream outputs only. Outputs: body, statusCode, headers. Authentication is replaced as a complete choice.'
316
- : kind == 'openapi'
317
- ? 'Select an operation; the tool resolves its ports and authentication. Read authenticationChoices from node details. authentication is replaced as a complete choice. Bind advertised credentials through inputs.'
318
- : kind == 'workflow'
319
- ? 'Select another workflow with config.flowId. A workflow cannot call itself, including in a disconnected node. Owned unpublished or unavailable targets may be saved as incomplete drafts. Use node.refresh to load the current published interface.'
320
- : kind == 'agent'
321
- ? 'Tools accept Connector Actions or {kind: "workflow", flowId, name, description, approval, inputs}. Workflow parameter sources use model, input or value. The platform derives the interface. Refresh a workflow tool with node.refresh and tool: its name.'
322
- : kind == 'code'
323
- ? codeGuidance
324
- : 'Supply business configuration and input data. Fixed definitions are assembled by the tool. Existing constraints and defaults are preserved on partial updates.',
318
+ : kind == 'connector-proxy'
319
+ ? 'Call a Provider API using its Connector account. Configure providerId, connectionId, method and query/headers modes and names. Bind endpoint, query.<name>/header.<name> or complete query/headers objects, and optional body. Setting or binding body automatically selects JSON; clearing it omits the body. Explicit null is a JSON body. Outputs: body, statusCode, headers, bodyEncoding. Proxy permissions are independent of Action permissions. Upstream HTTP errors remain response data when Connector returns a successful proxy result. Unsupported Providers fail at execution. Use connectionId: "" to defer account configuration. Retries are not supported.'
320
+ : kind == 'http-request'
321
+ ? 'Configure method, query/headers modes and names, bodyFormat, authentication, responseFormat and statusPolicy. Bind url, query.<name>/header.<name> or whole query/headers objects, body and auth.token (Basic: auth.username/auth.password). Credentials accept variables or upstream outputs only. Outputs: body, statusCode, headers. Authentication is replaced as a complete choice.'
322
+ : kind == 'openapi'
323
+ ? 'Select an operation; the tool resolves its ports and authentication. Read authenticationChoices from node details. authentication is replaced as a complete choice. Bind advertised credentials through inputs.'
324
+ : kind == 'workflow'
325
+ ? 'Select another workflow with config.flowId. A workflow cannot call itself, including in a disconnected node. Owned unpublished or unavailable targets may be saved as incomplete drafts. Use node.refresh to load the current published interface.'
326
+ : kind == 'connector'
327
+ ? 'Select an Action and account. The platform derives ports from its schema. Open or dynamic response objects expose the complete response as one output; closed objects expose named fields. Read the created node for output names. Bind the whole response for dynamic fields; declared direct properties also support output field selection. Local edits preserve saved ports and response mapping.'
328
+ : kind == 'agent'
329
+ ? 'Tools accept Connector Actions or {kind: "workflow", flowId, name, description, approval, inputs}. Workflow parameter sources use model, input or value. The platform derives the interface. Refresh a workflow tool with node.refresh and tool: its name.'
330
+ : kind == 'code'
331
+ ? codeGuidance
332
+ : 'Supply business configuration and input data. Fixed definitions are assembled by the tool. Existing constraints and defaults are preserved on partial updates.',
325
333
  example: {
326
334
  op: 'node.add',
327
335
  as: 'step',
@@ -333,6 +341,7 @@ export function authoringSchema(kind?: AuthoringType) {
333
341
  'outputs': { config: { outputDefs: {} } },
334
342
  'workflow': { config: { flowId: '<Flow ID>' } },
335
343
  'connector': { config: { action: '<Action ID>', connectionId: '<Connection ID>' } },
344
+ 'connector-proxy': { config: { providerId: 'github', connectionId: '' }, inputs: { endpoint: { kind: 'value', value: '/user' } } },
336
345
  'poll': { config: { key: '<Trigger key>', schedule: [{ type: 'every', unit: 'minute', value: 5 }] } },
337
346
  'integration': { config: { key: '<Trigger key>' } },
338
347
  'skip-duplicates': { config: { fields: ['id'] }, inputs: { items: { kind: 'value', value: [{ id: 'a' }, { id: 'b' }] } } },
@@ -0,0 +1,145 @@
1
+ import type { JsonValue } from '../../flow/common/change.ts'
2
+
3
+ import { jsonValue } from './decoding.ts'
4
+ import { ApiError } from './errors.ts'
5
+
6
+ /** Storage is host-owned. Keys and records are opaque to the delegate. */
7
+ export interface ControlCacheDelegate {
8
+ get(key: string): Promise<unknown>
9
+ set(key: string, value: JsonValue): Promise<void>
10
+ delete(key: string): Promise<void>
11
+ }
12
+
13
+ export interface ControlCacheRead {
14
+ readonly path: string
15
+ readonly source: 'cache' | 'network' | 'revalidated'
16
+ readonly body: JsonValue
17
+ readonly etag: string | null
18
+ readonly validatedAt: number
19
+ readonly stale: boolean
20
+ }
21
+
22
+ export interface ControlCacheOptions {
23
+ /** Non-secret identity of the deployment and effective caller/resource scope. */
24
+ readonly scope: string
25
+ readonly delegate: ControlCacheDelegate
26
+ readonly mode?: 'default' | 'refresh' | 'offline'
27
+ readonly maxAgeMs?: number
28
+ readonly onRead?: (read: ControlCacheRead) => void
29
+ }
30
+
31
+ interface CachedResponse {
32
+ readonly version: 1
33
+ readonly body: JsonValue
34
+ readonly etag: string | null
35
+ readonly validatedAt: number
36
+ }
37
+
38
+ interface CatalogQuery<Value> {
39
+ readonly path: string
40
+ readonly decode: (value: unknown) => Value
41
+ }
42
+
43
+ type CatalogLoad = (
44
+ etag: string | null,
45
+ ) => Promise<{ readonly modified: true; readonly data: JsonValue; readonly etag: string | null } | { readonly modified: false; readonly etag: string | null }>
46
+
47
+ function cachedResponse(value: unknown): CachedResponse | undefined {
48
+ if (value == null || typeof value != 'object' || Array.isArray(value)) return
49
+ const entry = value as Record<string, unknown>
50
+ if (
51
+ entry.version !== 1 ||
52
+ !Object.hasOwn(entry, 'body') ||
53
+ !(entry.etag === null || typeof entry.etag == 'string') ||
54
+ typeof entry.validatedAt != 'number' ||
55
+ !Number.isFinite(entry.validatedAt) ||
56
+ entry.validatedAt < 0
57
+ )
58
+ return
59
+ return { version: 1, body: jsonValue(entry.body), etag: entry.etag, validatedAt: entry.validatedAt }
60
+ }
61
+
62
+ /** Cancellation belongs to each waiter, not the shared metadata request. */
63
+ function wait<Value>(promise: Promise<Value>, signal?: AbortSignal): Promise<Value> {
64
+ if (signal == null) return promise
65
+ if (signal.aborted) {
66
+ void promise.catch(() => {})
67
+ return Promise.reject(signal.reason)
68
+ }
69
+ return new Promise((resolve, reject) => {
70
+ const abort = () => reject(signal.reason)
71
+ signal.addEventListener('abort', abort, { once: true })
72
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
73
+ })
74
+ }
75
+
76
+ /** Complete Flow API responses, never a merged store of catalog entities. */
77
+ export class ControlCatalogCache {
78
+ readonly #pending = new Map<string, { readonly fresh: boolean; readonly promise: Promise<ControlCacheRead> }>()
79
+ private readonly options: ControlCacheOptions
80
+
81
+ constructor(options: ControlCacheOptions) {
82
+ this.options = options
83
+ if (!options.scope.trim()) throw new TypeError('Control cache scope must be nonempty.')
84
+ if (options.maxAgeMs != null && (!Number.isFinite(options.maxAgeMs) || options.maxAgeMs < 0))
85
+ throw new TypeError('Control cache maxAgeMs must be finite and nonnegative.')
86
+ }
87
+
88
+ async read<Value>(query: CatalogQuery<Value>, load: CatalogLoad, maxAgeMs: number, signal?: AbortSignal, fresh = false) {
89
+ signal?.throwIfAborted()
90
+ const key = JSON.stringify(['open-flow:catalog', 1, this.options.scope, query.path])
91
+ const result = await wait(this.#request(key, query, load, this.options.maxAgeMs ?? maxAgeMs, fresh || this.options.mode == 'refresh'), signal)
92
+ const data = query.decode(structuredClone(result.body))
93
+ try {
94
+ this.options.onRead?.({ ...result, body: structuredClone(result.body) })
95
+ } catch {}
96
+ return { data, etag: result.etag }
97
+ }
98
+
99
+ #request<Value>(key: string, query: CatalogQuery<Value>, load: CatalogLoad, maxAgeMs: number, fresh: boolean): Promise<ControlCacheRead> {
100
+ const previous = this.#pending.get(key)
101
+ if (previous != null) {
102
+ if (!fresh || previous.fresh) return previous.promise
103
+ // An explicit refresh must validate after a preceding ordinary lookup.
104
+ return previous.promise.catch(() => undefined).then(() => this.#request(key, query, load, maxAgeMs, true))
105
+ }
106
+ const promise = this.#load(key, query, load, maxAgeMs, fresh).finally(() => {
107
+ if (this.#pending.get(key)?.promise === promise) this.#pending.delete(key)
108
+ })
109
+ this.#pending.set(key, { fresh, promise })
110
+ return promise
111
+ }
112
+
113
+ async #load<Value>(key: string, query: CatalogQuery<Value>, load: CatalogLoad, maxAgeMs: number, fresh: boolean): Promise<ControlCacheRead> {
114
+ let cached: CachedResponse | undefined
115
+ try {
116
+ const stored = await this.options.delegate.get(key)
117
+ cached = cachedResponse(stored)
118
+ if (cached != null) query.decode(structuredClone(cached.body))
119
+ if (stored !== undefined && cached == null) await this.options.delegate.delete(key)
120
+ } catch {
121
+ cached = undefined
122
+ try {
123
+ await this.options.delegate.delete(key)
124
+ } catch {}
125
+ }
126
+ const now = Date.now()
127
+ const stale = cached == null || cached.validatedAt > now || now - cached.validatedAt >= maxAgeMs
128
+ if (cached != null && (this.options.mode == 'offline' || (!fresh && !stale))) return { ...cached, path: query.path, source: 'cache', stale }
129
+ if (this.options.mode == 'offline') throw new ApiError(503, 'catalog.cache-miss', `No compatible cached response for ${query.path}.`)
130
+
131
+ const result = await load(cached?.etag ?? null)
132
+ if (!result.modified && cached == null) throw new ApiError(502, 'response.invalid', 'A catalog returned 304 without a cached response.')
133
+ const entry: CachedResponse = {
134
+ version: 1,
135
+ body: result.modified ? result.data : cached!.body,
136
+ etag: result.etag ?? (result.modified ? null : cached!.etag),
137
+ validatedAt: Date.now(),
138
+ }
139
+ query.decode(structuredClone(entry.body))
140
+ try {
141
+ await this.options.delegate.set(key, structuredClone({ ...entry }))
142
+ } catch {}
143
+ return { ...entry, path: query.path, source: result.modified ? 'network' : 'revalidated', stale: false }
144
+ }
145
+ }
@@ -31,6 +31,10 @@ function equal(actual: unknown, expected: unknown, message: string): void {
31
31
  if (!dequal(actual, expected)) fail(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`)
32
32
  }
33
33
 
34
+ function oneOf(actual: unknown, expected: readonly unknown[], message: string): void {
35
+ if (!expected.some((value) => dequal(actual, value))) fail(`${message}: expected one of ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`)
36
+ }
37
+
34
38
  function record(value: unknown, message: string): RecordValue {
35
39
  if (value == null || typeof value != 'object' || Array.isArray(value)) fail(`${message}: expected an object.`)
36
40
  return value as RecordValue
@@ -940,9 +944,11 @@ export const controlApiConformanceCases: readonly ControlApiConformanceCase[] =
940
944
  }
941
945
  return current
942
946
  }
947
+ // Deployments may keep an idle Wait session in memory or freeze it immediately.
948
+ const pausedStatuses = ['running', 'waiting']
943
949
  const hasWait = (value: RecordValue) => list(value.waits, 'Waits').length > 0
944
950
  let detail = await awaitRun(run, hasWait, 'Wait Run to pause')
945
- equal(detail.status, 'running', 'Wait Run status')
951
+ oneOf(detail.status, pausedStatuses, 'Wait Run status')
946
952
  const waiting = record(list(detail.waits, 'Waits')[0], 'Active Wait')
947
953
  equal(waiting.actions, ['approve', 'reject'], 'Wait actions')
948
954
  equal(waiting.nodeId, 'approval', 'Wait node')
@@ -972,7 +978,7 @@ export const controlApiConformanceCases: readonly ControlApiConformanceCase[] =
972
978
  const approved = await json(await resolveWait('approve'), 200, 'Approve Wait')
973
979
  equal(approved.action, 'approve', 'Approved action')
974
980
  equal(approved.resolutionAccepted, true, 'Approved resolution')
975
- equal(approved.status, 'running', 'Resolved Run status')
981
+ oneOf(approved.status, ['running', 'queued'], 'Resolved Run status')
976
982
  requiredString(approved.resolvedAt, 'Wait resolution time')
977
983
  const rejected = await json(await resolveWait('reject'), 200, 'Reject resolved Wait')
978
984
  equal(rejected.action, 'approve', 'Winning action')
@@ -995,7 +1001,7 @@ export const controlApiConformanceCases: readonly ControlApiConformanceCase[] =
995
1001
  const canceled = await create('wait-run-cancel', 'Create cancelable Wait Run')
996
1002
  const canceledRunId = requiredString(canceled.runId, 'Cancelable Wait Run id')
997
1003
  const canceledDetail = await awaitRun(canceled, hasWait, 'Wait cancelable Run to pause')
998
- equal(canceledDetail.status, 'running', 'Cancelable Wait status')
1004
+ oneOf(canceledDetail.status, pausedStatuses, 'Cancelable Wait status')
999
1005
  equal(
1000
1006
  (
1001
1007
  await json(
@@ -1,6 +1,6 @@
1
- import type { ConnectorAction, ConnectorConnection, ConnectorProvider } from './api.ts'
1
+ import type { ConnectorAction, ConnectorActionMetadata, ConnectorConnection, ConnectorProvider } from './api.ts'
2
2
 
3
- import { connection, connectorAction, connectorProvider } from './connectorDecoders.ts'
3
+ import { connection, connectorAction, connectorActionMetadata, connectorProvider } from './connectorDecoders.ts'
4
4
  import { requiredFields, invalidResponse, record, string } from './decoding.ts'
5
5
 
6
6
  export interface RequestQuery<Value> {
@@ -36,6 +36,21 @@ export function connectorActionQuery(actionId: string, flowId?: string, locale?:
36
36
  }
37
37
  }
38
38
 
39
+ export function connectorActionMetadataQuery(actionId: string, flowId?: string, locale?: string): RequestQuery<ConnectorActionMetadata> {
40
+ const parameters = new URLSearchParams({ ...(flowId == null ? {} : { flowId }), ...(locale == null ? {} : { locale }) }).toString()
41
+ return {
42
+ path: `/v1/connector/action-metadata/${encodeURIComponent(actionId)}${parameters ? `?${parameters}` : ''}`,
43
+ decode: (value) => {
44
+ const source = record(value)
45
+ requiredFields(source, ['action', 'version'])
46
+ if (source.version != 1) return invalidResponse()
47
+ const action = connectorActionMetadata(source.action)
48
+ if (action.actionId != actionId) return invalidResponse('action.actionId: does not match the requested Action.')
49
+ return action
50
+ },
51
+ }
52
+ }
53
+
39
54
  export function allConnectorConnectionsQuery(flowId?: string): RequestQuery<readonly ConnectorConnection[]> {
40
55
  return {
41
56
  path: `/v1/connector/connections${scope(flowId)}`,
@@ -2,6 +2,7 @@ import { canvasCommandSchema } from '../../canvas/common/commands.ts'
2
2
  export { canvasCommandSchema } from '../../canvas/common/commands.ts'
3
3
  import { authoringRequestSchema, authoringReadSchema, authoringSearchSchema, authoringSchemaQuery, authoringCheckSchema } from './authoringSchema.ts'
4
4
  export * from './authoring.ts'
5
+ export { createWorkflowAuthoring, type WorkflowAuthoringOptions } from './workflowAuthoring.ts'
5
6
  import type { JsonValue } from '../../flow/common/change.ts'
6
7
  import type { DraftOperation } from './draftOperations.ts'
7
8