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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +3 -0
  2. package/dist/browser/{ContentIcon-Cm1hbVzW.js → ContentIcon-CMn0c-kl.js} +99 -6
  3. package/dist/browser/{FlowCanvas-NzKtla_c.js → FlowCanvas-BQGBcfoL.js} +1709 -613
  4. package/dist/browser/{createResourceDialog-BgdedQqu.js → createResourceDialog-DCGxfKFM.js} +63 -57
  5. package/dist/browser/{dialog-aSElFFw2.js → dialog-WzR4CRFH.js} +76 -77
  6. package/dist/browser/{exportSvg-Dcz4HTo_.js → exportSvg-CYwyYiuH.js} +2 -2
  7. package/dist/browser/{flowChanges-NZyS7HSp.js → flowChanges-Bz4dbDN_.js} +6875 -11091
  8. package/dist/browser/{flowWorkspace-Bl0SDehy.js → flowWorkspace-BI6L6a4r.js} +1415 -3037
  9. package/dist/browser/licenses.md +1 -1
  10. package/dist/browser/preview.css +619 -462
  11. package/dist/browser/preview.js +9016 -14956
  12. package/dist/browser/theme.css +4 -2
  13. package/dist/browser/{tooltip-3XMK9jkr.js → tooltip-BDcVTNpu.js} +65 -2
  14. package/dist/browser/{typeScriptSession-C9sBGOKZ.js → typeScriptSession-CRoezJPN.js} +1 -1
  15. package/dist/browser/ui.css +112 -24
  16. package/dist/browser/ui.js +3 -2
  17. package/dist/browser/workbench.css +10020 -9842
  18. package/dist/browser/workbench.d.ts +5 -2
  19. package/dist/browser/workbench.js +2382 -2668
  20. package/dist/browser/{workbenchSelect-DBGP7Y4Z.js → workbenchSelect-DtAd0zc4.js} +2 -2
  21. package/dist/command/index.js +48 -48
  22. package/dist/common/authoring.d.ts +4 -4
  23. package/dist/common/authoringExamples.d.ts +3 -2
  24. package/dist/common/authoringSchema.d.ts +15 -4
  25. package/dist/common/control-api-conformance.js +155 -15
  26. package/dist/common/control-api-errors.d.ts +12 -0
  27. package/dist/common/control-api.d.ts +39 -2
  28. package/dist/common/control-api.js +180 -37
  29. package/dist/common/control-requests.d.ts +23 -3
  30. package/dist/common/control-requests.js +141 -14
  31. package/dist/common/engine-contract.d.ts +3 -2
  32. package/dist/common/flow-authoring.js +47 -7
  33. package/dist/common/flow-change-schema.d.ts +1 -1
  34. package/dist/common/flow-change.d.ts +2 -2
  35. package/dist/common/flow-change.js +48 -8
  36. package/dist/common/flow-encoding.js +47 -7
  37. package/dist/common/flow-semantics.js +86 -8
  38. package/dist/common/flow-state.d.ts +100 -0
  39. package/dist/common/flow-state.js +5794 -0
  40. package/dist/common/mcp.d.ts +96 -3
  41. package/dist/common/mcp.js +158 -13
  42. package/dist/common/run-events.js +19 -8
  43. package/dist/common/runtime-contract.d.ts +14 -0
  44. package/dist/common/runtime-contract.js +124 -17
  45. package/dist/common/runtime-nodejs-compat.js +11 -12
  46. package/dist/common/scheduler-node-execution.d.ts +1 -0
  47. package/dist/common/scheduler.d.ts +2 -1
  48. package/dist/common/scheduler.js +95 -40
  49. package/dist/common/state-conformance.d.ts +16 -0
  50. package/dist/common/tags.d.ts +4 -0
  51. package/dist/index.d.ts +11 -0
  52. package/package.json +5 -1
  53. package/src/control/common/api.ts +101 -11
  54. package/src/control/common/authoring.ts +7 -1
  55. package/src/control/common/authoringExamples.ts +9 -0
  56. package/src/control/common/authoringSchema.ts +18 -12
  57. package/src/control/common/errors.ts +6 -0
  58. package/src/control/common/flowDecoders.ts +1 -0
  59. package/src/control/common/mcp.ts +16 -0
  60. package/src/control/common/requests.ts +16 -2
  61. package/src/control/common/tags.ts +20 -0
  62. package/src/execution/common/events.ts +22 -7
  63. package/src/execution/common/node-execution.ts +3 -0
  64. package/src/execution/common/scheduler.ts +29 -13
  65. package/src/flow/common/change.ts +2 -1
  66. package/src/flow/common/changeSchema.ts +11 -1
  67. package/src/flow/common/errorHandling.ts +1 -1
  68. package/src/flow/common/semantics.ts +27 -1
  69. package/src/runtime/common/engineContract.ts +6 -3
  70. package/src/runtime/common/nodejsGuest.generated.json +11 -12
  71. package/src/runtime/common/runtime.ts +122 -7
  72. package/src/state/common/conformance.ts +81 -0
  73. package/src/state/common/state.ts +141 -0
  74. package/src/types/index.ts +11 -0
@@ -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) {
@@ -935,14 +1051,13 @@ export function createBase64() {
935
1051
  const padding = text.endsWith('==') ? 2 : text.endsWith('=') ? 1 : 0
936
1052
  const bytes = new Uint8Array((text.length / 4) * 3 - padding)
937
1053
  for (let index = 0, offset = 0; index < text.length; index += 4) {
938
- const digits = [0, 1, 2, 3].map((position) => {
1054
+ let value = 0
1055
+ for (let position = 0; position < 4; position++) {
939
1056
  const code = text.charCodeAt(index + position)
940
- if (code == 61 && index + position >= text.length - padding) return 0
941
- const value = code < 128 ? values[code]! : 255
942
- if (value == 255) throw new TypeError('Base64 content is invalid.')
943
- return value
944
- })
945
- const value = (digits[0]! << 18) | (digits[1]! << 12) | (digits[2]! << 6) | digits[3]!
1057
+ const digit = code == 61 && index + position >= text.length - padding ? 0 : code < 128 ? values[code]! : 255
1058
+ if (digit == 255) throw new TypeError('Base64 content is invalid.')
1059
+ value = (value << 6) | digit
1060
+ }
946
1061
  if (offset < bytes.length) bytes[offset++] = value >> 16
947
1062
  if (offset < bytes.length) bytes[offset++] = (value >> 8) & 255
948
1063
  if (offset < bytes.length) bytes[offset++] = value & 255
@@ -0,0 +1,81 @@
1
+ import type { JsonValue } from '../../flow/common/model.ts'
2
+ import type { SkipDuplicatesConfig } 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 Skip Duplicates node across Runs, updating its business config and items. */
11
+ skipDuplicates(config: SkipDuplicatesConfig, items: JsonValue[]): Promise<{ items: JsonValue[]; duplicates: JsonValue[] }>
12
+ }
13
+
14
+ export const stateConformanceCases: readonly { name: string; verify(harness: StateConformanceHarness): Promise<void> }[] = [
15
+ {
16
+ name: 'skips composite identities across successful Runs without depending on field order',
17
+ async verify(harness) {
18
+ const items = [
19
+ { company: 'a', id: 1 },
20
+ { company: 'b', id: 1 },
21
+ { company: 'a', id: 1 },
22
+ ]
23
+ const first = await harness.skipDuplicates({ fields: ['company', 'id'] }, items)
24
+ if (!dequal(first, { items: items.slice(0, 2), duplicates: [items[2]] }))
25
+ throw new Error('Composite identities must retain the first item in input order.')
26
+ const second = await harness.skipDuplicates({ fields: ['id', 'company'] }, items)
27
+ if (!dequal(second, { items: [], duplicates: items })) throw new Error('Successful Run history must persist independently of field order.')
28
+ const changed = await harness.skipDuplicates({ fields: ['id'] }, items)
29
+ if (!dequal(changed, { items: [items[0]], duplicates: items.slice(1) })) throw new Error('The field definition must participate in identity.')
30
+ },
31
+ },
32
+ {
33
+ name: 'round-trips independent JSON values and deletes without exposing stored references',
34
+ async verify(harness) {
35
+ const result = await harness.run(`export default async (_, context) => {
36
+ await context.state.set('b', { nested: [1, null, false] });
37
+ const copy = await context.state.get('b');
38
+ copy.nested.push(2);
39
+ const retained = await context.state.get('b');
40
+ const inserted = await context.state.insert('b', 'replacement');
41
+ await context.state.set('a', 0);
42
+ const keys = await context.state.list();
43
+ await context.state.delete('a');
44
+ await context.state.set('b', null);
45
+ return {result: {retained, inserted, keys, missing: await context.state.get('a'), remaining: await context.state.list()}};
46
+ }`)
47
+ if (!dequal(result, { retained: { nested: [1, null, false] }, inserted: false, keys: ['a', 'b'], missing: null, remaining: [] }))
48
+ throw new Error('State JSON round-trip, conditional insertion or deletion differs from the contract.')
49
+ },
50
+ },
51
+ {
52
+ name: 'persists explicit values and dedupe history across Runs',
53
+ async verify(harness) {
54
+ const source = `export default async (_, context) => {
55
+ const first = await context.state.insert('count', 0);
56
+ const count = await context.state.get('count');
57
+ await context.state.set('count', count + 1);
58
+ return { result: { first, count, ids: await context.dedupe.insert(['a', 'a']) } };
59
+ }`
60
+ if (
61
+ !dequal(await harness.run(source), { first: true, count: 0, ids: [true, false] }) ||
62
+ !dequal(await harness.run(source), { first: false, count: 1, ids: [false, false] })
63
+ )
64
+ throw new Error('State must persist across Runs without replacing existing values on insert.')
65
+ },
66
+ },
67
+ {
68
+ name: 'rejects a full batch atomically while permitting duplicates at capacity',
69
+ async verify(harness) {
70
+ const result = await harness.run(`export default async (_, context) => {
71
+ const ids = Array.from({length: ${harness.maxIdentities}}, (_, index) => index);
72
+ await context.dedupe.insert(ids);
73
+ let error;
74
+ try { await context.dedupe.insert([0, 'extra']); } catch (failure) { error = failure.code; }
75
+ return { result: { error, extra: await context.dedupe.has(['extra']), duplicate: await context.dedupe.insert([0, 0]) } };
76
+ }`)
77
+ if (!dequal(result, { error: 'dedupe.full', extra: [false], duplicate: [false, false] }))
78
+ throw new Error('Dedupe capacity must fail atomically and preserve duplicate membership.')
79
+ },
80
+ },
81
+ ]
@@ -0,0 +1,141 @@
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 skipDuplicatesConfigSchema = z.strictObject({
20
+ fields: z
21
+ .array(z.string().refine((field) => field.length > 0 && field.split('.').every((part) => part.length > 0), 'Use a nonempty dot-separated field path.'))
22
+ .refine((fields) => new Set(fields).size === fields.length, 'Identity fields must be distinct.')
23
+ .default([]),
24
+ })
25
+ export type SkipDuplicatesConfig = z.infer<typeof skipDuplicatesConfigSchema>
26
+ export interface SkipDuplicatesExecutor extends SkipDuplicatesConfig {
27
+ readonly kind: 'skip-duplicates'
28
+ }
29
+ export const skipDuplicatesExecutorSchema = skipDuplicatesConfigSchema.extend({ kind: z.literal('skip-duplicates') })
30
+
31
+ export function skipDuplicatesTask(
32
+ config: SkipDuplicatesConfig = skipDuplicatesConfigSchema.parse({}),
33
+ name = 'Skip Duplicates',
34
+ ): ManagedTaskDefinitionOf<SkipDuplicatesExecutor> {
35
+ return {
36
+ name,
37
+ executor: { ...config, kind: 'skip-duplicates' },
38
+ inputs: [{ handle: 'items', jsonSchema: { type: 'array' }, nullable: false }],
39
+ outputs: ['items', 'duplicates'].map((handle) => ({ handle, jsonSchema: { type: 'array' }, nullable: false })),
40
+ }
41
+ }
42
+
43
+ export class StateError extends Error {
44
+ readonly retryable = false
45
+ readonly catchable: boolean
46
+ readonly code: 'capability.invalid' | 'capability.denied' | 'dedupe.full' | 'limit-exceeded' | 'node.input-invalid'
47
+
48
+ constructor(code: StateError['code'], message: string) {
49
+ super(message)
50
+ this.name = 'StateError'
51
+ this.code = code
52
+ this.catchable = code === 'dedupe.full' || code === 'node.input-invalid'
53
+ }
54
+ }
55
+
56
+ export function stateKey(key: unknown): string {
57
+ if (typeof key !== 'string' || key.length === 0 || new TextEncoder().encode(key).length > 256)
58
+ throw new StateError('capability.invalid', 'State keys require 1 to 256 UTF-8 bytes.')
59
+ return key
60
+ }
61
+
62
+ export function dedupeIdentities(value: unknown): DedupeIdentity[] {
63
+ if (!Array.isArray(value) || !Array.from(value).every((id) => typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id))))
64
+ throw new StateError('capability.invalid', 'Identities must be strings or finite numbers.')
65
+ return value
66
+ }
67
+
68
+ export async function hashIdentities(ids: readonly DedupeIdentity[]): Promise<Uint8Array[]> {
69
+ return Promise.all(
70
+ ids.map(async (id) => new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${typeof id}:${String(id)}`))).slice(0, 16)),
71
+ )
72
+ }
73
+
74
+ /** Canonical identities include the field definition and preserve scalar types. */
75
+ export function skipDuplicatesIdentities(items: JsonValue, fields: readonly string[]): string[] {
76
+ if (!Array.isArray(items)) throw new StateError('node.input-invalid', 'Skip Duplicates requires an array of items.')
77
+ const paths = fields.toSorted()
78
+ return items.map((item, index) => {
79
+ const values = (paths.length === 0 ? [''] : paths).map((field) => {
80
+ const value =
81
+ field === ''
82
+ ? item
83
+ : field
84
+ .split('.')
85
+ .reduce<JsonValue | undefined>(
86
+ (current, key) =>
87
+ current != null && typeof current === 'object' && Object.hasOwn(current, key) ? (current as Record<string, JsonValue>)[key] : undefined,
88
+ item,
89
+ )
90
+ if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value)))
91
+ throw new StateError(
92
+ 'node.input-invalid',
93
+ `Item ${index + 1}, ${field === '' ? 'the item itself' : `field ${JSON.stringify(field)}`} must be a string or finite number.`,
94
+ )
95
+ return value
96
+ })
97
+ return JSON.stringify([paths, values])
98
+ })
99
+ }
100
+
101
+ export interface FlowStateView {
102
+ readonly version: 1
103
+ readonly scope: StateScope
104
+ readonly limits: {
105
+ readonly maxValueBytes: number
106
+ readonly maxValues: number
107
+ readonly maxSpaces: number
108
+ readonly maxIdentities: number
109
+ readonly maxRunWrites: number
110
+ }
111
+ readonly values: readonly { readonly key: string; readonly size: number; readonly updatedAt: number; readonly value?: JsonValue }[]
112
+ readonly spaces: readonly {
113
+ readonly space: string
114
+ readonly count: number
115
+ readonly pendingCount: number
116
+ readonly reservedCount: number
117
+ readonly updatedAt: number
118
+ }[]
119
+ }
120
+
121
+ export const flowStateViewSchema = z.object({
122
+ version: z.literal(1),
123
+ scope: z.enum(['draft', 'live']),
124
+ limits: z.object({
125
+ maxValueBytes: z.number().positive(),
126
+ maxValues: z.number().int().positive(),
127
+ maxSpaces: z.number().int().positive(),
128
+ maxIdentities: z.number().positive(),
129
+ maxRunWrites: z.number().positive(),
130
+ }),
131
+ values: z.array(z.object({ key: z.string(), size: z.number().nonnegative(), updatedAt: z.number(), value: z.json().optional() })),
132
+ spaces: z.array(
133
+ z.object({
134
+ space: z.string(),
135
+ count: z.number().nonnegative(),
136
+ pendingCount: z.number().nonnegative(),
137
+ reservedCount: z.number().nonnegative(),
138
+ updatedAt: z.number(),
139
+ }),
140
+ ),
141
+ })
@@ -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[]>