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

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 (71) hide show
  1. package/README.md +1 -0
  2. package/dist/browser/{ContentIcon-Cm1hbVzW.js → ContentIcon-VZQO8VZU.js} +8 -5
  3. package/dist/browser/{FlowCanvas-NzKtla_c.js → FlowCanvas-BNGDMaWr.js} +15 -3
  4. package/dist/browser/{createResourceDialog-BgdedQqu.js → createResourceDialog-BmtakfVg.js} +63 -57
  5. package/dist/browser/{dialog-aSElFFw2.js → dialog-BcFBfRB1.js} +84 -15
  6. package/dist/browser/{exportSvg-Dcz4HTo_.js → exportSvg-CG8hEUNk.js} +2 -2
  7. package/dist/browser/{flowChanges-NZyS7HSp.js → flowChanges-BGo_4Ypo.js} +5849 -10882
  8. package/dist/browser/{flowWorkspace-Bl0SDehy.js → flowWorkspace-BFHGev7s.js} +1025 -2607
  9. package/dist/browser/preview.css +112 -5
  10. package/dist/browser/preview.js +1630 -9295
  11. package/dist/browser/theme.css +4 -2
  12. package/dist/browser/{tooltip-3XMK9jkr.js → tooltip-BAWSUexx.js} +1 -1
  13. package/dist/browser/{typeScriptSession-C9sBGOKZ.js → typeScriptSession-CRoezJPN.js} +1 -1
  14. package/dist/browser/ui.css +111 -4
  15. package/dist/browser/ui.js +3 -2
  16. package/dist/browser/workbench.css +9906 -9778
  17. package/dist/browser/workbench.d.ts +6 -2
  18. package/dist/browser/workbench.js +838 -499
  19. package/dist/browser/{workbenchSelect-DBGP7Y4Z.js → workbenchSelect-CjK-xdm3.js} +2 -2
  20. package/dist/command/index.js +48 -48
  21. package/dist/common/authoring.d.ts +4 -4
  22. package/dist/common/authoringExamples.d.ts +3 -2
  23. package/dist/common/authoringSchema.d.ts +23 -4
  24. package/dist/common/control-api-conformance.js +162 -15
  25. package/dist/common/control-api-errors.d.ts +12 -0
  26. package/dist/common/control-api.d.ts +35 -2
  27. package/dist/common/control-api.js +187 -37
  28. package/dist/common/control-requests.d.ts +22 -3
  29. package/dist/common/control-requests.js +152 -14
  30. package/dist/common/engine-contract.d.ts +3 -2
  31. package/dist/common/flow-authoring.js +55 -7
  32. package/dist/common/flow-change-schema.d.ts +1 -1
  33. package/dist/common/flow-change.d.ts +2 -2
  34. package/dist/common/flow-change.js +56 -8
  35. package/dist/common/flow-encoding.js +55 -7
  36. package/dist/common/flow-semantics.js +91 -7
  37. package/dist/common/flow-state.d.ts +123 -0
  38. package/dist/common/flow-state.js +5852 -0
  39. package/dist/common/mcp.d.ts +96 -3
  40. package/dist/common/mcp.js +165 -13
  41. package/dist/common/run-events.js +1 -0
  42. package/dist/common/runtime-contract.d.ts +14 -0
  43. package/dist/common/runtime-contract.js +118 -5
  44. package/dist/common/scheduler-node-execution.d.ts +1 -0
  45. package/dist/common/scheduler.d.ts +1 -1
  46. package/dist/common/scheduler.js +52 -3
  47. package/dist/common/state-conformance.d.ts +16 -0
  48. package/dist/common/tags.d.ts +4 -0
  49. package/dist/index.d.ts +11 -0
  50. package/package.json +5 -1
  51. package/src/control/common/api.ts +84 -11
  52. package/src/control/common/authoring.ts +6 -1
  53. package/src/control/common/authoringExamples.ts +8 -0
  54. package/src/control/common/authoringSchema.ts +18 -12
  55. package/src/control/common/errors.ts +6 -0
  56. package/src/control/common/flowDecoders.ts +1 -0
  57. package/src/control/common/mcp.ts +16 -0
  58. package/src/control/common/requests.ts +10 -2
  59. package/src/control/common/tags.ts +20 -0
  60. package/src/execution/common/events.ts +3 -0
  61. package/src/execution/common/node-execution.ts +3 -0
  62. package/src/execution/common/scheduler.ts +2 -0
  63. package/src/flow/common/change.ts +2 -1
  64. package/src/flow/common/changeSchema.ts +11 -1
  65. package/src/flow/common/errorHandling.ts +1 -1
  66. package/src/flow/common/semantics.ts +20 -0
  67. package/src/runtime/common/engineContract.ts +6 -3
  68. package/src/runtime/common/runtime.ts +116 -0
  69. package/src/state/common/conformance.ts +86 -0
  70. package/src/state/common/state.ts +169 -0
  71. package/src/types/index.ts +11 -0
@@ -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').DedupeExecutor
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 { dedupeExecutorSchema } 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
+ dedupeExecutorSchema,
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 === 'dedupe',
733
+ )
734
+ )
735
+ throw new Error('Deduplication 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 === 'dedupe')
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 { dedupeTask } 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 === 'dedupe') {
278
+ const expected = dedupeTask(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: 'dedupe.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,11 @@ 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((node) => node.kind === 'task' && 'executor' in node.task && node.task.executor.kind === 'dedupe')
461
+ )
462
+ return { kind: 'engine-unsupported' }
443
463
  if (
444
464
  !engine.httpRequests &&
445
465
  Object.values(revision.document.graph.nodes).some((node) => node.kind == 'task' && 'executor' in node.task && node.task.executor.kind == 'http-request')
@@ -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')
@@ -5,6 +5,58 @@ import { currentEngineContract } from './engineContract.ts'
5
5
 
6
6
  export { currentEngineContract, nodejsEngineContract, findEngineContract, type EngineContract } from './engineContract.ts'
7
7
 
8
+ /** Serialized into the guest; keep validation and helpers self-contained. */
9
+ export function createState(invoke: (kind: string, payload: JsonValue) => Promise<RuntimeCapabilityResponse>) {
10
+ // Serialized into the guest together with this factory.
11
+ // oxlint-disable-next-line unicorn/consistent-function-scoping
12
+ const invalid = (message: string) => Object.assign(new TypeError(message), { code: 'capability.invalid' })
13
+ const keyOf = (key: unknown) => {
14
+ if (typeof key !== 'string' || key.length === 0 || new TextEncoder().encode(key).length > 256) throw invalid('State keys require 1 to 256 UTF-8 bytes.')
15
+ return key
16
+ }
17
+ const valueOf = (value: unknown): JsonValue => {
18
+ if (value === undefined) throw invalid('State values must be JSON.')
19
+ try {
20
+ return (snapshotRuntimeResult({ value }) as { value: JsonValue }).value
21
+ } catch {
22
+ throw invalid('State values must be JSON.')
23
+ }
24
+ }
25
+ const idsOf = (ids: unknown): (string | number)[] => {
26
+ if (!Array.isArray(ids) || !Array.from(ids).every((id) => typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id))))
27
+ throw invalid('Identities must be strings or finite numbers.')
28
+ return ids
29
+ }
30
+ return {
31
+ state: Object.freeze({
32
+ async get(key: unknown) {
33
+ return ((await invoke('state.get', { key: keyOf(key) })).body as { value: JsonValue }).value
34
+ },
35
+ async set(key: unknown, value: unknown) {
36
+ await invoke('state.set', { key: keyOf(key), value: valueOf(value) })
37
+ },
38
+ async insert(key: unknown, value: unknown) {
39
+ if (value === null) throw invalid('State insert does not accept null.')
40
+ return ((await invoke('state.insert', { key: keyOf(key), value: valueOf(value) })).body as { inserted: boolean }).inserted
41
+ },
42
+ async delete(key: unknown) {
43
+ await invoke('state.delete', { key: keyOf(key) })
44
+ },
45
+ async list() {
46
+ return ((await invoke('state.list', {})).body as { keys: string[] }).keys
47
+ },
48
+ }),
49
+ dedupe: Object.freeze({
50
+ async insert(ids: unknown) {
51
+ return ((await invoke('dedupe.insert', { ids: idsOf(ids) })).body as { inserted: boolean[] }).inserted
52
+ },
53
+ async has(ids: unknown) {
54
+ return ((await invoke('dedupe.has', { ids: idsOf(ids) })).body as { present: boolean[] }).present
55
+ },
56
+ }),
57
+ }
58
+ }
59
+
8
60
  export type RuntimeModule = Pick<CodeModule, 'imports' | 'source'>
9
61
 
10
62
  /** Snapshot a Task result before JSON transport can discard or coerce invalid values. */
@@ -391,6 +443,70 @@ async function rejects(operation: Promise<unknown>, pattern: RegExp, message: st
391
443
  }
392
444
 
393
445
  export const runtimeConformanceCases: readonly RuntimeConformanceCase[] = [
446
+ {
447
+ name: 'cancels detached State work when its Task finishes',
448
+ async verify(harness) {
449
+ let aborted = false
450
+ const result = await harness.invoke({
451
+ input: null,
452
+ invocationId: 'state-detached',
453
+ capability: async ({ signal }) =>
454
+ await new Promise((resolve) => {
455
+ signal.addEventListener(
456
+ 'abort',
457
+ () => {
458
+ aborted = true
459
+ resolve({ status: 200, body: null })
460
+ },
461
+ { once: true },
462
+ )
463
+ }),
464
+ program: program(
465
+ harness,
466
+ `export default async (_, context) => {
467
+ void context.state.set('detached', 1);
468
+ await new Promise(resolve => setTimeout(resolve, 20));
469
+ return null;
470
+ }`,
471
+ ),
472
+ })
473
+ equal(result, null, 'Detached State Task result')
474
+ equal(aborted, true, 'Detached State cancellation')
475
+ },
476
+ },
477
+ {
478
+ name: 'rejects non-JSON State values and invalid identities before Host dispatch',
479
+ async verify(harness) {
480
+ let calls = 0
481
+ const result = await harness.invoke({
482
+ input: null,
483
+ invocationId: 'state-invalid',
484
+ capability: async () => {
485
+ calls += 1
486
+ return { status: 200, body: null }
487
+ },
488
+ program: program(
489
+ harness,
490
+ `export default async (_, context) => {
491
+ const cyclic = {}; cyclic.self = cyclic;
492
+ const invalid = [undefined, NaN, Infinity, new Date(), { x: undefined }, cyclic, [undefined], { x: () => 1 }];
493
+ const errors = [];
494
+ for (const value of invalid) { try { await context.state.set('key', value); errors.push('accepted'); } catch (e) { errors.push(e.code); } }
495
+ for (const call of [() => context.state.get(''), () => context.state.get('x'.repeat(257)), () => context.state.insert('x', null), () => context.dedupe.insert([NaN]), () => context.dedupe.has([null]), () => context.dedupe.insert(new Array(1))]) {
496
+ try { await call(); errors.push('accepted'); } catch (e) { errors.push(e.code); }
497
+ }
498
+ return errors;
499
+ }`,
500
+ ),
501
+ })
502
+ equal(
503
+ result,
504
+ Array.from({ length: 14 }, () => 'capability.invalid'),
505
+ 'State boundary validation',
506
+ )
507
+ equal(calls, 0, 'State invalid dispatch')
508
+ },
509
+ },
394
510
  {
395
511
  name: 'rejects Task results that JSON transport would discard or coerce',
396
512
  async verify(harness) {
@@ -0,0 +1,86 @@
1
+ import type { JsonValue } from '../../flow/common/model.ts'
2
+ import type { DedupeConfig } from './state.ts'
3
+
4
+ import { dequal } from 'dequal/lite'
5
+
6
+ export interface StateConformanceHarness {
7
+ readonly maxIdentities: number
8
+ /** Executes Code on the same initially empty Flow and scope on every call; returns its `result` output. */
9
+ run(source: string): Promise<JsonValue>
10
+ /** Executes the same dedupe node across Runs, updating its business config and items. */
11
+ dedupe(config: DedupeConfig, items: JsonValue[]): Promise<{ items: JsonValue[]; duplicates: JsonValue[] }>
12
+ }
13
+
14
+ export const stateConformanceCases: readonly { name: string; verify(harness: StateConformanceHarness): Promise<void> }[] = [
15
+ {
16
+ name: 'persists watermarks by instant and resets changed modes atomically',
17
+ async verify(harness) {
18
+ const config: DedupeConfig = { field: '', mode: 'increasing', space: 'node' }
19
+ const first = await harness.dedupe(config, ['2026-10-30T09:00:00+08:00', '2026-10-30T01:00:00Z', '2026-10-30T02:00:00Z'])
20
+ if (!dequal(first, { items: ['2026-10-30T09:00:00+08:00', '2026-10-30T02:00:00Z'], duplicates: ['2026-10-30T01:00:00Z'] }))
21
+ throw new Error('Watermarks must compare instants and preserve item order.')
22
+ let rejected = false
23
+ try {
24
+ await harness.dedupe(config, [1])
25
+ } catch {
26
+ rejected = true
27
+ }
28
+ if (!rejected) throw new Error('Mixed watermark types must fail.')
29
+ const second = await harness.dedupe(config, ['2026-10-30T02:00:00Z', '2026-10-30T03:00:00Z'])
30
+ if (!dequal(second, { items: ['2026-10-30T03:00:00Z'], duplicates: ['2026-10-30T02:00:00Z'] }))
31
+ throw new Error('A failed batch must preserve the previous watermark.')
32
+ await harness.dedupe({ ...config, mode: 'new' }, [])
33
+ const reset = await harness.dedupe(config, [5, 3, 5])
34
+ if (!dequal(reset, { items: [5, 3], duplicates: [5] })) throw new Error('A mode reset must discard previous watermarks, including on empty batches.')
35
+ },
36
+ },
37
+ {
38
+ name: 'round-trips independent JSON values and deletes without exposing stored references',
39
+ async verify(harness) {
40
+ const result = await harness.run(`export default async (_, context) => {
41
+ await context.state.set('b', { nested: [1, null, false] });
42
+ const copy = await context.state.get('b');
43
+ copy.nested.push(2);
44
+ const retained = await context.state.get('b');
45
+ const inserted = await context.state.insert('b', 'replacement');
46
+ await context.state.set('a', 0);
47
+ const keys = await context.state.list();
48
+ await context.state.delete('a');
49
+ await context.state.set('b', null);
50
+ return {result: {retained, inserted, keys, missing: await context.state.get('a'), remaining: await context.state.list()}};
51
+ }`)
52
+ if (!dequal(result, { retained: { nested: [1, null, false] }, inserted: false, keys: ['a', 'b'], missing: null, remaining: [] }))
53
+ throw new Error('State JSON round-trip, conditional insertion or deletion differs from the contract.')
54
+ },
55
+ },
56
+ {
57
+ name: 'persists explicit values and dedupe history across Runs',
58
+ async verify(harness) {
59
+ const source = `export default async (_, context) => {
60
+ const first = await context.state.insert('count', 0);
61
+ const count = await context.state.get('count');
62
+ await context.state.set('count', count + 1);
63
+ return { result: { first, count, ids: await context.dedupe.insert(['a', 'a']) } };
64
+ }`
65
+ if (
66
+ !dequal(await harness.run(source), { first: true, count: 0, ids: [true, false] }) ||
67
+ !dequal(await harness.run(source), { first: false, count: 1, ids: [false, false] })
68
+ )
69
+ throw new Error('State must persist across Runs without replacing existing values on insert.')
70
+ },
71
+ },
72
+ {
73
+ name: 'rejects a full batch atomically while permitting duplicates at capacity',
74
+ async verify(harness) {
75
+ const result = await harness.run(`export default async (_, context) => {
76
+ const ids = Array.from({length: ${harness.maxIdentities}}, (_, index) => index);
77
+ await context.dedupe.insert(ids);
78
+ let error;
79
+ try { await context.dedupe.insert([0, 'extra']); } catch (failure) { error = failure.code; }
80
+ return { result: { error, extra: await context.dedupe.has(['extra']), duplicate: await context.dedupe.insert([0, 0]) } };
81
+ }`)
82
+ if (!dequal(result, { error: 'dedupe.full', extra: [false], duplicate: [false, false] }))
83
+ throw new Error('Dedupe capacity must fail atomically and preserve duplicate membership.')
84
+ },
85
+ },
86
+ ]
@@ -0,0 +1,169 @@
1
+ import type { JsonValue, ManagedTaskDefinitionOf } from '../../flow/common/model.ts'
2
+
3
+ import { z } from 'zod'
4
+
5
+ export { stateConformanceCases, type StateConformanceHarness } from './conformance.ts'
6
+
7
+ export type StateScope = 'draft' | 'live'
8
+ export const stateQuerySchema = z.strictObject({ scope: z.enum(['draft', 'live']), values: z.boolean().optional() })
9
+ export const stateClearSchema = z.strictObject({
10
+ scope: z.enum(['draft', 'live']),
11
+ key: z
12
+ .string()
13
+ .min(1)
14
+ .refine((key) => new TextEncoder().encode(key).length <= 256)
15
+ .optional(),
16
+ })
17
+ export const dedupeClearSchema = z.strictObject({ scope: z.enum(['draft', 'live']), space: z.string().regex(/^(flow|node:.+)$/) })
18
+ export type DedupeIdentity = string | number
19
+ export const dedupeConfigSchema = z.strictObject({
20
+ field: z.string().default(''),
21
+ mode: z.enum(['new', 'increasing']).default('new'),
22
+ space: z.enum(['node', 'flow']).default('node'),
23
+ })
24
+ export type DedupeConfig = z.infer<typeof dedupeConfigSchema>
25
+ export interface DedupeExecutor extends DedupeConfig {
26
+ readonly kind: 'dedupe'
27
+ }
28
+ export const dedupeExecutorSchema = dedupeConfigSchema.extend({ kind: z.literal('dedupe') })
29
+
30
+ export function dedupeTask(config: DedupeConfig = dedupeConfigSchema.parse({}), name = 'Remove Duplicates'): ManagedTaskDefinitionOf<DedupeExecutor> {
31
+ return {
32
+ name,
33
+ executor: { ...config, kind: 'dedupe' },
34
+ inputs: [{ handle: 'items', jsonSchema: { type: 'array' }, nullable: false }],
35
+ outputs: ['items', 'duplicates'].map((handle) => ({ handle, jsonSchema: { type: 'array' }, nullable: false })),
36
+ }
37
+ }
38
+
39
+ export class StateError extends Error {
40
+ readonly retryable = false
41
+ readonly catchable: boolean
42
+ readonly code: 'capability.invalid' | 'capability.denied' | 'dedupe.full' | 'limit-exceeded'
43
+
44
+ constructor(code: StateError['code'], message: string) {
45
+ super(message)
46
+ this.name = 'StateError'
47
+ this.code = code
48
+ this.catchable = code === 'dedupe.full'
49
+ }
50
+ }
51
+
52
+ export function stateKey(key: unknown): string {
53
+ if (typeof key !== 'string' || key.length === 0 || new TextEncoder().encode(key).length > 256)
54
+ throw new StateError('capability.invalid', 'State keys require 1 to 256 UTF-8 bytes.')
55
+ return key
56
+ }
57
+
58
+ export function dedupeIdentities(value: unknown): DedupeIdentity[] {
59
+ if (!Array.isArray(value) || !Array.from(value).every((id) => typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id))))
60
+ throw new StateError('capability.invalid', 'Identities must be strings or finite numbers.')
61
+ return value
62
+ }
63
+
64
+ export async function hashIdentities(ids: readonly DedupeIdentity[]): Promise<Uint8Array[]> {
65
+ return Promise.all(
66
+ ids.map(async (id) => new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${typeof id}:${String(id)}`))).slice(0, 16)),
67
+ )
68
+ }
69
+
70
+ export function watermarkValue(id: DedupeIdentity): number {
71
+ if (typeof id === 'number') {
72
+ if (!Number.isFinite(id)) throw new StateError('capability.invalid', 'Watermarks must be finite.')
73
+ return id
74
+ }
75
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(id)
76
+ if (match == null) throw new StateError('capability.invalid', 'Watermarks require RFC 3339 timestamps with an explicit timezone.')
77
+ const [, year, month, day, hour, minute, second, , zone] = match
78
+ const y = Number(year)
79
+ const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][Number(month) - 1] ?? 0
80
+ const result = Date.parse(id)
81
+ if (
82
+ Number(month) < 1 ||
83
+ Number(month) > 12 ||
84
+ Number(day) < 1 ||
85
+ Number(day) > days ||
86
+ Number(hour) > 23 ||
87
+ Number(minute) > 59 ||
88
+ Number(second) > 59 ||
89
+ (zone !== 'Z' && (Number(zone!.slice(1, 3)) > 23 || Number(zone!.slice(4)) > 59)) ||
90
+ !Number.isFinite(result)
91
+ )
92
+ throw new StateError('capability.invalid', 'Watermarks require valid calendar timestamps.')
93
+ return result
94
+ }
95
+
96
+ export function increasingIdentities(ids: readonly DedupeIdentity[], watermark: DedupeIdentity | null) {
97
+ const type = typeof (watermark ?? ids[0])
98
+ if (ids.some((id) => typeof id !== type)) throw new StateError('capability.invalid', 'Watermark types must not be mixed.')
99
+ const initial = watermark == null ? -Infinity : watermarkValue(watermark)
100
+ const seen = new Set<number>()
101
+ let maximum = initial
102
+ let next = watermark
103
+ const inserted = ids.map((id) => {
104
+ const value = watermarkValue(id)
105
+ const fresh = value > initial && !seen.has(value)
106
+ seen.add(value)
107
+ if (value > maximum) {
108
+ maximum = value
109
+ next = id
110
+ }
111
+ return fresh
112
+ })
113
+ return { inserted, watermark: next }
114
+ }
115
+
116
+ export function itemIdentities(items: JsonValue, field: string): DedupeIdentity[] {
117
+ if (!Array.isArray(items)) throw new StateError('capability.invalid', 'Deduplication requires an array of items.')
118
+ const path = field === '' ? [] : field.split('.')
119
+ return dedupeIdentities(
120
+ items.map((item) =>
121
+ path.reduce<JsonValue | undefined>(
122
+ (value, key) => (value != null && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, JsonValue>)[key] : undefined),
123
+ item,
124
+ ),
125
+ ),
126
+ )
127
+ }
128
+
129
+ export interface FlowStateView {
130
+ readonly version: 1
131
+ readonly scope: StateScope
132
+ readonly limits: {
133
+ readonly maxValueBytes: number
134
+ readonly maxValues: number
135
+ readonly maxSpaces: number
136
+ readonly maxIdentities: number
137
+ readonly maxRunWrites: number
138
+ }
139
+ readonly values: readonly { readonly key: string; readonly size: number; readonly updatedAt: number; readonly value?: JsonValue }[]
140
+ readonly spaces: readonly {
141
+ readonly space: string
142
+ readonly mode: 'new' | 'increasing'
143
+ readonly count: number
144
+ readonly watermark: DedupeIdentity | null
145
+ readonly updatedAt: number
146
+ }[]
147
+ }
148
+
149
+ export const flowStateViewSchema = z.object({
150
+ version: z.literal(1),
151
+ scope: z.enum(['draft', 'live']),
152
+ limits: z.object({
153
+ maxValueBytes: z.number().positive(),
154
+ maxValues: z.number().int().positive(),
155
+ maxSpaces: z.number().int().positive(),
156
+ maxIdentities: z.number().positive(),
157
+ maxRunWrites: z.number().positive(),
158
+ }),
159
+ values: z.array(z.object({ key: z.string(), size: z.number().nonnegative(), updatedAt: z.number(), value: z.json().optional() })),
160
+ spaces: z.array(
161
+ z.object({
162
+ space: z.string(),
163
+ mode: z.enum(['new', 'increasing']),
164
+ count: z.number().nonnegative(),
165
+ watermark: z.union([z.string(), z.number(), z.null()]),
166
+ updatedAt: z.number(),
167
+ }),
168
+ ),
169
+ })
@@ -119,6 +119,17 @@ export interface TaskContext<
119
119
  readonly inputs: Readonly<Record<string, unknown>>
120
120
  readonly runId: string
121
121
  readonly blockId: string
122
+ readonly state: {
123
+ get(key: string): Promise<JsonValue | null>
124
+ set(key: string, value: JsonValue): Promise<void>
125
+ insert(key: string, value: Exclude<JsonValue, null>): Promise<boolean>
126
+ delete(key: string): Promise<void>
127
+ list(): Promise<string[]>
128
+ }
129
+ readonly dedupe: {
130
+ insert(ids: readonly (string | number)[]): Promise<boolean[]>
131
+ has(ids: readonly (string | number)[]): Promise<boolean[]>
132
+ }
122
133
  readonly artifact: ArtifactCapability
123
134
  /** Reads the selected direct predecessors, sorted by node ID; empty for a graph root. */
124
135
  getPrevious(): Promise<readonly PreviousNode[]>