@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.
- package/README.md +1 -0
- package/dist/browser/{ContentIcon-Cm1hbVzW.js → ContentIcon-VZQO8VZU.js} +8 -5
- package/dist/browser/{FlowCanvas-NzKtla_c.js → FlowCanvas-BNGDMaWr.js} +15 -3
- package/dist/browser/{createResourceDialog-BgdedQqu.js → createResourceDialog-BmtakfVg.js} +63 -57
- package/dist/browser/{dialog-aSElFFw2.js → dialog-BcFBfRB1.js} +84 -15
- package/dist/browser/{exportSvg-Dcz4HTo_.js → exportSvg-CG8hEUNk.js} +2 -2
- package/dist/browser/{flowChanges-NZyS7HSp.js → flowChanges-BGo_4Ypo.js} +5849 -10882
- package/dist/browser/{flowWorkspace-Bl0SDehy.js → flowWorkspace-BFHGev7s.js} +1025 -2607
- package/dist/browser/preview.css +112 -5
- package/dist/browser/preview.js +1630 -9295
- package/dist/browser/theme.css +4 -2
- package/dist/browser/{tooltip-3XMK9jkr.js → tooltip-BAWSUexx.js} +1 -1
- package/dist/browser/{typeScriptSession-C9sBGOKZ.js → typeScriptSession-CRoezJPN.js} +1 -1
- package/dist/browser/ui.css +111 -4
- package/dist/browser/ui.js +3 -2
- package/dist/browser/workbench.css +9906 -9778
- package/dist/browser/workbench.d.ts +6 -2
- package/dist/browser/workbench.js +838 -499
- package/dist/browser/{workbenchSelect-DBGP7Y4Z.js → workbenchSelect-CjK-xdm3.js} +2 -2
- package/dist/command/index.js +48 -48
- package/dist/common/authoring.d.ts +4 -4
- package/dist/common/authoringExamples.d.ts +3 -2
- package/dist/common/authoringSchema.d.ts +23 -4
- package/dist/common/control-api-conformance.js +162 -15
- package/dist/common/control-api-errors.d.ts +12 -0
- package/dist/common/control-api.d.ts +35 -2
- package/dist/common/control-api.js +187 -37
- package/dist/common/control-requests.d.ts +22 -3
- package/dist/common/control-requests.js +152 -14
- package/dist/common/engine-contract.d.ts +3 -2
- package/dist/common/flow-authoring.js +55 -7
- package/dist/common/flow-change-schema.d.ts +1 -1
- package/dist/common/flow-change.d.ts +2 -2
- package/dist/common/flow-change.js +56 -8
- package/dist/common/flow-encoding.js +55 -7
- package/dist/common/flow-semantics.js +91 -7
- package/dist/common/flow-state.d.ts +123 -0
- package/dist/common/flow-state.js +5852 -0
- package/dist/common/mcp.d.ts +96 -3
- package/dist/common/mcp.js +165 -13
- package/dist/common/run-events.js +1 -0
- package/dist/common/runtime-contract.d.ts +14 -0
- package/dist/common/runtime-contract.js +118 -5
- package/dist/common/scheduler-node-execution.d.ts +1 -0
- package/dist/common/scheduler.d.ts +1 -1
- package/dist/common/scheduler.js +52 -3
- package/dist/common/state-conformance.d.ts +16 -0
- package/dist/common/tags.d.ts +4 -0
- package/dist/index.d.ts +11 -0
- package/package.json +5 -1
- package/src/control/common/api.ts +84 -11
- package/src/control/common/authoring.ts +6 -1
- package/src/control/common/authoringExamples.ts +8 -0
- package/src/control/common/authoringSchema.ts +18 -12
- package/src/control/common/errors.ts +6 -0
- package/src/control/common/flowDecoders.ts +1 -0
- package/src/control/common/mcp.ts +16 -0
- package/src/control/common/requests.ts +10 -2
- package/src/control/common/tags.ts +20 -0
- package/src/execution/common/events.ts +3 -0
- package/src/execution/common/node-execution.ts +3 -0
- package/src/execution/common/scheduler.ts +2 -0
- package/src/flow/common/change.ts +2 -1
- package/src/flow/common/changeSchema.ts +11 -1
- package/src/flow/common/errorHandling.ts +1 -1
- package/src/flow/common/semantics.ts +20 -0
- package/src/runtime/common/engineContract.ts +6 -3
- package/src/runtime/common/runtime.ts +116 -0
- package/src/state/common/conformance.ts +86 -0
- package/src/state/common/state.ts +169 -0
- 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 =
|
|
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
|
-
/**
|
|
15
|
-
export const currentEngineContract = 'open-flow-engine/
|
|
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
|
|
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
|
+
})
|
package/src/types/index.ts
CHANGED
|
@@ -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[]>
|