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

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 (35) hide show
  1. package/dist/browser/{createResourceDialog-DCGxfKFM.js → createResourceDialog-DkovGogv.js} +2 -2
  2. package/dist/browser/{dialog-WzR4CRFH.js → dialog-CFFuRM8s.js} +27 -6
  3. package/dist/browser/{flowChanges-Bz4dbDN_.js → flowChanges-CN2R-m1M.js} +59 -40
  4. package/dist/browser/{flowWorkspace-BI6L6a4r.js → flowWorkspace-CXVL1FNv.js} +85 -39
  5. package/dist/browser/preview.js +122 -48
  6. package/dist/browser/{typeScriptSession-CRoezJPN.js → typeScriptSession-CoEIptNC.js} +1 -1
  7. package/dist/browser/workbench.js +4 -4
  8. package/dist/browser/{workbenchSelect-DtAd0zc4.js → workbenchSelect-CkOS95gX.js} +1 -1
  9. package/dist/command/index.js +29 -29
  10. package/dist/common/control-api-conformance.js +80 -7
  11. package/dist/common/control-api.d.ts +3 -2
  12. package/dist/common/control-api.js +40 -7
  13. package/dist/common/control-requests.d.ts +5 -0
  14. package/dist/common/control-requests.js +28 -5
  15. package/dist/common/flow-authoring.js +26 -5
  16. package/dist/common/flow-change.js +26 -5
  17. package/dist/common/flow-encoding.js +26 -5
  18. package/dist/common/flow-semantics.js +26 -5
  19. package/dist/common/flow-state.d.ts +33 -4
  20. package/dist/common/flow-state.js +49 -6
  21. package/dist/common/mcp.d.ts +32 -1
  22. package/dist/common/mcp.js +40 -8
  23. package/dist/common/runtime-contract.d.ts +4 -1
  24. package/dist/common/runtime-contract.js +60 -5
  25. package/dist/common/scheduler.js +26 -5
  26. package/dist/index.d.ts +1 -0
  27. package/package.json +1 -1
  28. package/src/control/common/api.ts +16 -4
  29. package/src/control/common/conformance.ts +31 -0
  30. package/src/control/common/mcp.ts +7 -2
  31. package/src/control/common/requests.ts +3 -0
  32. package/src/runtime/common/runtime.ts +39 -5
  33. package/src/state/common/conformance.ts +16 -0
  34. package/src/state/common/state.ts +41 -8
  35. package/src/types/index.ts +1 -0
@@ -5603,6 +5603,30 @@ function dequal(foo, bar) {
5603
5603
  //#endregion
5604
5604
  //#region src/state/common/conformance.ts
5605
5605
  var stateConformanceCases = [
5606
+ {
5607
+ name: "reads selected State keys in input order with missing, falsy and independent values",
5608
+ async verify(harness) {
5609
+ if (!dequal(await harness.run(`export default async (_, context) => {
5610
+ await context.state.set('object', { nested: [1] });
5611
+ await context.state.set('zero', 0);
5612
+ await context.state.set('false', false);
5613
+ await context.state.set('empty', '');
5614
+ const values = await context.state.get(['false', 'missing', 'object', 'zero', 'empty']);
5615
+ values[2].nested.push(2);
5616
+ return {result: {values, retained: await context.state.get('object'), zero: await context.state.get('zero')}};
5617
+ }`), {
5618
+ values: [
5619
+ false,
5620
+ null,
5621
+ { nested: [1, 2] },
5622
+ 0,
5623
+ ""
5624
+ ],
5625
+ retained: { nested: [1] },
5626
+ zero: 0
5627
+ })) throw new Error("State batch reads must preserve order, missing keys and independent JSON values.");
5628
+ }
5629
+ },
5606
5630
  {
5607
5631
  name: "skips composite identities across successful Runs without depending on field order",
5608
5632
  async verify(harness) {
@@ -5700,13 +5724,22 @@ var stateConformanceCases = [
5700
5724
  ];
5701
5725
  //#endregion
5702
5726
  //#region src/state/common/state.ts
5727
+ var stateReadLimits = {
5728
+ maxKeys: 16,
5729
+ maxBytes: 1048576
5730
+ };
5731
+ var keySchema = string().min(1).refine((value) => new TextEncoder().encode(value).length <= 256);
5703
5732
  var stateQuerySchema = strictObject({
5704
5733
  scope: _enum(["draft", "live"]),
5705
- values: boolean().optional()
5734
+ values: literal(false).optional()
5735
+ });
5736
+ var stateReadSchema = strictObject({
5737
+ scope: _enum(["draft", "live"]),
5738
+ keys: array(keySchema).min(1).max(stateReadLimits.maxKeys).refine((keys) => new Set(keys).size === keys.length, "State keys must be distinct.")
5706
5739
  });
5707
5740
  var stateClearSchema = strictObject({
5708
5741
  scope: _enum(["draft", "live"]),
5709
- key: string().min(1).refine((key) => new TextEncoder().encode(key).length <= 256).optional()
5742
+ key: keySchema.optional()
5710
5743
  });
5711
5744
  var dedupeClearSchema = strictObject({
5712
5745
  scope: _enum(["draft", "live"]),
@@ -5776,11 +5809,10 @@ var flowStateViewSchema = object({
5776
5809
  maxIdentities: number().positive(),
5777
5810
  maxRunWrites: number().positive()
5778
5811
  }),
5779
- values: array(object({
5812
+ values: array(strictObject({
5780
5813
  key: string(),
5781
5814
  size: number().nonnegative(),
5782
- updatedAt: number(),
5783
- value: json().optional()
5815
+ updatedAt: number()
5784
5816
  })),
5785
5817
  spaces: array(object({
5786
5818
  space: string(),
@@ -5790,5 +5822,16 @@ var flowStateViewSchema = object({
5790
5822
  updatedAt: number()
5791
5823
  }))
5792
5824
  });
5825
+ var flowStateValuesSchema = object({
5826
+ version: literal(1),
5827
+ scope: _enum(["draft", "live"]),
5828
+ values: array(strictObject({
5829
+ key: keySchema,
5830
+ value: json()
5831
+ })).min(1).max(stateReadLimits.maxKeys)
5832
+ }).refine((response) => {
5833
+ const encoder = new TextEncoder();
5834
+ return response.values.reduce((bytes, item) => bytes + (item.value === null ? 0 : encoder.encode(JSON.stringify(item.value)).byteLength), 0) <= stateReadLimits.maxBytes;
5835
+ }, "State values exceed the 1 MiB read limit.");
5793
5836
  //#endregion
5794
- export { StateError, dedupeClearSchema, dedupeIdentities, flowStateViewSchema, hashIdentities, skipDuplicatesConfigSchema, skipDuplicatesExecutorSchema, skipDuplicatesIdentities, skipDuplicatesTask, stateClearSchema, stateConformanceCases, stateKey, stateQuerySchema };
5837
+ export { StateError, dedupeClearSchema, dedupeIdentities, flowStateValuesSchema, flowStateViewSchema, hashIdentities, skipDuplicatesConfigSchema, skipDuplicatesExecutorSchema, skipDuplicatesIdentities, skipDuplicatesTask, stateClearSchema, stateConformanceCases, stateKey, stateQuerySchema, stateReadLimits, stateReadSchema };
@@ -22,7 +22,38 @@ export declare const mcpTools: {
22
22
  validate(value: unknown): {
23
23
  value: {
24
24
  scope: "draft" | "live";
25
- values?: boolean | undefined;
25
+ values?: false | undefined;
26
+ flowId: string;
27
+ };
28
+ } | {
29
+ issues: {
30
+ message: string;
31
+ path: string[];
32
+ }[];
33
+ };
34
+ };
35
+ };
36
+ };
37
+ flow_state_read: {
38
+ description: string;
39
+ annotations: {
40
+ readOnlyHint: boolean;
41
+ destructiveHint: boolean;
42
+ idempotentHint: boolean;
43
+ openWorldHint: boolean;
44
+ };
45
+ inputSchema: {
46
+ '~standard': {
47
+ version: 1;
48
+ vendor: string;
49
+ jsonSchema: {
50
+ input: () => Record<string, unknown>;
51
+ output: () => Record<string, unknown>;
52
+ };
53
+ validate(value: unknown): {
54
+ value: {
55
+ scope: "draft" | "live";
56
+ keys: string[];
26
57
  flowId: string;
27
58
  };
28
59
  } | {
@@ -8848,13 +8848,22 @@ var httpRequestConfigSchema = strictObject({
8848
8848
  var httpRequestExecutorSchema = httpRequestConfigSchema.extend({ kind: literal("http-request") });
8849
8849
  //#endregion
8850
8850
  //#region src/state/common/state.ts
8851
+ var stateReadLimits = {
8852
+ maxKeys: 16,
8853
+ maxBytes: 1048576
8854
+ };
8855
+ var keySchema = string$1().min(1).refine((value) => new TextEncoder().encode(value).length <= 256);
8851
8856
  var stateQuerySchema = strictObject({
8852
8857
  scope: _enum(["draft", "live"]),
8853
- values: boolean().optional()
8858
+ values: literal(false).optional()
8859
+ });
8860
+ var stateReadSchema = strictObject({
8861
+ scope: _enum(["draft", "live"]),
8862
+ keys: array(keySchema).min(1).max(stateReadLimits.maxKeys).refine((keys) => new Set(keys).size === keys.length, "State keys must be distinct.")
8854
8863
  });
8855
8864
  var stateClearSchema = strictObject({
8856
8865
  scope: _enum(["draft", "live"]),
8857
- key: string$1().min(1).refine((key) => new TextEncoder().encode(key).length <= 256).optional()
8866
+ key: keySchema.optional()
8858
8867
  });
8859
8868
  var dedupeClearSchema = strictObject({
8860
8869
  scope: _enum(["draft", "live"]),
@@ -8872,11 +8881,10 @@ var flowStateViewSchema = object({
8872
8881
  maxIdentities: number().positive(),
8873
8882
  maxRunWrites: number().positive()
8874
8883
  }),
8875
- values: array(object({
8884
+ values: array(strictObject({
8876
8885
  key: string$1(),
8877
8886
  size: number().nonnegative(),
8878
- updatedAt: number(),
8879
- value: json$4().optional()
8887
+ updatedAt: number()
8880
8888
  })),
8881
8889
  spaces: array(object({
8882
8890
  space: string$1(),
@@ -8886,6 +8894,17 @@ var flowStateViewSchema = object({
8886
8894
  updatedAt: number()
8887
8895
  }))
8888
8896
  });
8897
+ var flowStateValuesSchema = object({
8898
+ version: literal(1),
8899
+ scope: _enum(["draft", "live"]),
8900
+ values: array(strictObject({
8901
+ key: keySchema,
8902
+ value: json$4()
8903
+ })).min(1).max(stateReadLimits.maxKeys)
8904
+ }).refine((response) => {
8905
+ const encoder = new TextEncoder();
8906
+ return response.values.reduce((bytes, item) => bytes + (item.value === null ? 0 : encoder.encode(JSON.stringify(item.value)).byteLength), 0) <= stateReadLimits.maxBytes;
8907
+ }, "State values exceed the 1 MiB read limit.");
8889
8908
  function checkJsonDepth(value, depth = 0) {
8890
8909
  if (depth > 64) throw new TypeError("Value exceeds the maximum JSON depth.");
8891
8910
  if (value != null && typeof value == "object") for (const child of Object.values(value)) checkJsonDepth(child, depth + 1);
@@ -12362,10 +12381,22 @@ var ControlClient = class {
12362
12381
  })
12363
12382
  }));
12364
12383
  }
12365
- async getFlowState(flowId, scope, values = false) {
12366
- const result = flowStateViewSchema.safeParse(await this.request(`/v1/flows/${segment(flowId)}/state?scope=${scope}&values=${values}`));
12384
+ async getFlowState(flowId, scope) {
12385
+ const result = flowStateViewSchema.safeParse(await this.request(`/v1/flows/${segment(flowId)}/state?scope=${scope}`));
12367
12386
  return result.success && result.data.scope === scope ? result.data : invalidResponse();
12368
12387
  }
12388
+ async getFlowStateValues(flowId, scope, keys, signal) {
12389
+ const result = flowStateValuesSchema.safeParse(await this.request(`/v1/flows/${segment(flowId)}/state/read`, {
12390
+ method: "POST",
12391
+ body: JSON.stringify({
12392
+ version: 1,
12393
+ scope,
12394
+ keys
12395
+ }),
12396
+ signal
12397
+ }));
12398
+ return result.success && result.data.scope === scope && result.data.values.length === keys.length && result.data.values.every((item, index) => item.key === keys[index]) ? result.data : invalidResponse();
12399
+ }
12369
12400
  async clearFlowState(flowId, scope, key) {
12370
12401
  const source = record(await this.request(`/v1/flows/${segment(flowId)}/state?scope=${scope}${key == null ? "" : `&key=${encodeURIComponent(key)}`}`, { method: "DELETE" }));
12371
12402
  if (source.version !== 1 || source.cleared !== true) invalidResponse();
@@ -13128,7 +13159,8 @@ function tool(description, schema, readOnly, idempotent = true) {
13128
13159
  };
13129
13160
  }
13130
13161
  var mcpTools = {
13131
- flow_state: tool("Inspect Flow state usage, business keys and deduplication spaces. Values are omitted unless requested. Scope is required.", stateQuerySchema.extend({ flowId: flow }), true),
13162
+ flow_state: tool("Inspect Flow state usage, business key metadata and deduplication spaces. Never returns values. Scope is required.", stateQuerySchema.extend({ flowId: flow }), true),
13163
+ flow_state_read: tool("Read values for 1–16 distinct named State keys in one explicit scope. Values are returned in request order; missing keys return null. The combined stored JSON limit is 1 MiB; oversized reads fail without partial results.", stateReadSchema.extend({ flowId: flow }), true),
13132
13164
  flow_state_clear: tool("Delete one business state key or clear all business values. Explicit draft/live scope is required; live changes production state immediately.", stateClearSchema.extend({ flowId: flow }), false),
13133
13165
  flow_dedupe_clear: tool("Clear one deduplication space (flow or node:<id>). Explicit draft/live scope is required; live changes production history immediately.", dedupeClearSchema.extend({ flowId: flow }), false),
13134
13166
  flow_canvas: tool("Broadcast a transient canvas command to connected Workbenches viewing this Flow. Success only confirms Server broadcast, not delivery or execution. No replay, session selection or Presentation save. Pan uses screen pixels (positive right/down); zoom is an absolute ratio (0.1–3); fit includes all nodes. Repeating pan moves again.", strictObject({
@@ -3,7 +3,10 @@ export { currentEngineContract, nodejsEngineContract, findEngineContract, type E
3
3
  /** Serialized into the guest; keep validation and helpers self-contained. */
4
4
  export declare function createState(invoke: (kind: string, payload: JsonValue) => Promise<RuntimeCapabilityResponse>): {
5
5
  state: Readonly<{
6
- get(key: unknown): Promise<JsonValue>;
6
+ get: {
7
+ (key: string): Promise<JsonValue>;
8
+ (keys: readonly string[]): Promise<JsonValue[]>;
9
+ };
7
10
  set(key: unknown, value: unknown): Promise<void>;
8
11
  insert(key: unknown, value: unknown): Promise<boolean>;
9
12
  delete(key: unknown): Promise<void>;
@@ -120,11 +120,18 @@ function createState(invoke) {
120
120
  if (!Array.isArray(ids) || !Array.from(ids).every((id) => typeof id === "string" || typeof id === "number" && Number.isFinite(id))) throw invalid("Identities must be strings or finite numbers.");
121
121
  return ids;
122
122
  };
123
+ async function get(key) {
124
+ if (Array.isArray(key)) {
125
+ if (key.length === 0 || key.length > 16) throw invalid("State reads require 1 to 16 keys.");
126
+ const keys = Array.from(key, keyOf);
127
+ if (new Set(keys).size !== keys.length) throw invalid("State keys must be distinct.");
128
+ return (await invoke("state.get", { key: keys })).body.values;
129
+ }
130
+ return (await invoke("state.get", { key: keyOf(key) })).body.value;
131
+ }
123
132
  return {
124
133
  state: Object.freeze({
125
- async get(key) {
126
- return (await invoke("state.get", { key: keyOf(key) })).body.value;
127
- },
134
+ get,
128
135
  async set(key, value) {
129
136
  await invoke("state.set", {
130
137
  key: keyOf(key),
@@ -454,6 +461,46 @@ async function rejects(operation, pattern, message) {
454
461
  throw new Error(`${message}: operation unexpectedly succeeded.`);
455
462
  }
456
463
  var runtimeConformanceCases = [
464
+ {
465
+ name: "reads multiple State keys in one Host call while preserving input order",
466
+ async verify(harness) {
467
+ const calls = [];
468
+ equal(await harness.invoke({
469
+ input: null,
470
+ invocationId: "state-batch-read",
471
+ capability: async ({ kind, payload }) => {
472
+ calls.push({
473
+ kind,
474
+ payload
475
+ });
476
+ return {
477
+ status: 200,
478
+ body: { values: [
479
+ 0,
480
+ null,
481
+ false,
482
+ ""
483
+ ] }
484
+ };
485
+ },
486
+ program: program(harness, `export default async (_, context) => context.state.get(['zero', 'missing', 'false', 'empty'])`)
487
+ }), [
488
+ 0,
489
+ null,
490
+ false,
491
+ ""
492
+ ], "State batch values");
493
+ equal(calls, [{
494
+ kind: "state.get",
495
+ payload: { key: [
496
+ "zero",
497
+ "missing",
498
+ "false",
499
+ "empty"
500
+ ] }
501
+ }], "State batch dispatch");
502
+ }
503
+ },
457
504
  {
458
505
  name: "cancels detached State work when its Task finishes",
459
506
  async verify(harness) {
@@ -498,12 +545,20 @@ var runtimeConformanceCases = [
498
545
  const invalid = [undefined, NaN, Infinity, new Date(), { x: undefined }, cyclic, [undefined], { x: () => 1 }];
499
546
  const errors = [];
500
547
  for (const value of invalid) { try { await context.state.set('key', value); errors.push('accepted'); } catch (e) { errors.push(e.code); } }
501
- 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))]) {
548
+ for (const call of [
549
+ () => context.state.get(''), () => context.state.get('x'.repeat(257)),
550
+ () => context.state.get([]), () => context.state.get(['x', 'x']),
551
+ () => context.state.get(Array.from({length: 17}, (_, index) => String(index))),
552
+ () => context.state.get(['']), () => context.state.get(['汉'.repeat(86)]),
553
+ () => context.state.get(new Array(1)), () => context.state.get(null), () => context.state.get(),
554
+ () => context.state.insert('x', null), () => context.dedupe.insert([NaN]),
555
+ () => context.dedupe.has([null]), () => context.dedupe.insert(new Array(1))
556
+ ]) {
502
557
  try { await call(); errors.push('accepted'); } catch (e) { errors.push(e.code); }
503
558
  }
504
559
  return errors;
505
560
  }`)
506
- }), Array.from({ length: 14 }, () => "capability.invalid"), "State boundary validation");
561
+ }), Array.from({ length: 22 }, () => "capability.invalid"), "State boundary validation");
507
562
  equal(calls, 0, "State invalid dispatch");
508
563
  }
509
564
  },
@@ -9535,13 +9535,24 @@ var httpRequestExecutorSchema = strictObject({
9535
9535
  ]).default("auto"),
9536
9536
  statusPolicy: _enum(["success", "all"]).default("success")
9537
9537
  }).extend({ kind: literal("http-request") });
9538
+ //#endregion
9539
+ //#region src/state/common/state.ts
9540
+ var stateReadLimits = {
9541
+ maxKeys: 16,
9542
+ maxBytes: 1048576
9543
+ };
9544
+ var keySchema = string$1().min(1).refine((value) => new TextEncoder().encode(value).length <= 256);
9545
+ strictObject({
9546
+ scope: _enum(["draft", "live"]),
9547
+ values: literal(false).optional()
9548
+ });
9538
9549
  strictObject({
9539
9550
  scope: _enum(["draft", "live"]),
9540
- values: boolean().optional()
9551
+ keys: array(keySchema).min(1).max(stateReadLimits.maxKeys).refine((keys) => new Set(keys).size === keys.length, "State keys must be distinct.")
9541
9552
  });
9542
9553
  strictObject({
9543
9554
  scope: _enum(["draft", "live"]),
9544
- key: string$1().min(1).refine((key) => new TextEncoder().encode(key).length <= 256).optional()
9555
+ key: keySchema.optional()
9545
9556
  });
9546
9557
  strictObject({
9547
9558
  scope: _enum(["draft", "live"]),
@@ -9558,11 +9569,10 @@ object({
9558
9569
  maxIdentities: number().positive(),
9559
9570
  maxRunWrites: number().positive()
9560
9571
  }),
9561
- values: array(object({
9572
+ values: array(strictObject({
9562
9573
  key: string$1(),
9563
9574
  size: number().nonnegative(),
9564
- updatedAt: number(),
9565
- value: json$1().optional()
9575
+ updatedAt: number()
9566
9576
  })),
9567
9577
  spaces: array(object({
9568
9578
  space: string$1(),
@@ -9572,6 +9582,17 @@ object({
9572
9582
  updatedAt: number()
9573
9583
  }))
9574
9584
  });
9585
+ object({
9586
+ version: literal(1),
9587
+ scope: _enum(["draft", "live"]),
9588
+ values: array(strictObject({
9589
+ key: keySchema,
9590
+ value: json$1()
9591
+ })).min(1).max(stateReadLimits.maxKeys)
9592
+ }).refine((response) => {
9593
+ const encoder = new TextEncoder();
9594
+ return response.values.reduce((bytes, item) => bytes + (item.value === null ? 0 : encoder.encode(JSON.stringify(item.value)).byteLength), 0) <= stateReadLimits.maxBytes;
9595
+ }, "State values exceed the 1 MiB read limit.");
9575
9596
  //#endregion
9576
9597
  //#region src/flow/common/retry.ts
9577
9598
  var retryNodeTypes = [
package/dist/index.d.ts CHANGED
@@ -104,6 +104,7 @@ export interface TaskContext<Actions extends object = Record<string, never>, Wor
104
104
  readonly blockId: string;
105
105
  readonly state: {
106
106
  get(key: string): Promise<JsonValue | null>;
107
+ get(keys: readonly string[]): Promise<(JsonValue | null)[]>;
107
108
  set(key: string, value: JsonValue): Promise<void>;
108
109
  insert(key: string, value: Exclude<JsonValue, null>): Promise<boolean>;
109
110
  delete(key: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oomol-lab/open-flow",
3
- "version": "0.1.0-beta.78",
3
+ "version": "0.1.0-beta.79",
4
4
  "description": "TypeScript contracts and Browser runtime for Open Flow.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -1,8 +1,8 @@
1
1
  import type { CanvasCommand } from '../../canvas/common/commands.ts'
2
- import type { FlowStateView, StateScope } from '../../state/common/state.ts'
2
+ import type { FlowStateValues, FlowStateView, StateScope } from '../../state/common/state.ts'
3
3
 
4
4
  import { currentEngineContract } from '../../runtime/common/engineContract.ts'
5
- import { flowStateViewSchema } from '../../state/common/state.ts'
5
+ import { flowStateValuesSchema, flowStateViewSchema } from '../../state/common/state.ts'
6
6
  import { workflowContractSchema, workflowReferencesSchema } from '../../workflow/common/workflow.ts'
7
7
  export type { CanvasCommand, CanvasCommandSubscriber } from '../../canvas/common/commands.ts'
8
8
  import type { LiveDetails } from './liveDetails.ts'
@@ -1017,11 +1017,23 @@ export class ControlClient {
1017
1017
  return flow(await this.request(`/v1/flows/${segment(flowId)}`, { method: 'PATCH', body: JSON.stringify({ ...changes, version: 1 }) }))
1018
1018
  }
1019
1019
 
1020
- async getFlowState(flowId: string, scope: StateScope, values = false): Promise<FlowStateView> {
1021
- const result = flowStateViewSchema.safeParse(await this.request(`/v1/flows/${segment(flowId)}/state?scope=${scope}&values=${values}`))
1020
+ async getFlowState(flowId: string, scope: StateScope): Promise<FlowStateView> {
1021
+ const result = flowStateViewSchema.safeParse(await this.request(`/v1/flows/${segment(flowId)}/state?scope=${scope}`))
1022
1022
  return result.success && result.data.scope === scope ? result.data : invalidResponse()
1023
1023
  }
1024
1024
 
1025
+ async getFlowStateValues(flowId: string, scope: StateScope, keys: readonly string[], signal?: AbortSignal): Promise<FlowStateValues> {
1026
+ const result = flowStateValuesSchema.safeParse(
1027
+ await this.request(`/v1/flows/${segment(flowId)}/state/read`, { method: 'POST', body: JSON.stringify({ version: 1, scope, keys }), signal }),
1028
+ )
1029
+ return result.success &&
1030
+ result.data.scope === scope &&
1031
+ result.data.values.length === keys.length &&
1032
+ result.data.values.every((item, index) => item.key === keys[index])
1033
+ ? result.data
1034
+ : invalidResponse()
1035
+ }
1036
+
1025
1037
  async clearFlowState(flowId: string, scope: StateScope, key?: string): Promise<void> {
1026
1038
  const source = record(
1027
1039
  await this.request(`/v1/flows/${segment(flowId)}/state?scope=${scope}${key == null ? '' : `&key=${encodeURIComponent(key)}`}`, { method: 'DELETE' }),
@@ -205,6 +205,37 @@ function liveRunRequest(harness: ControlApiConformanceHarness, publicationId: st
205
205
  }
206
206
 
207
207
  export const controlApiConformanceCases: readonly ControlApiConformanceCase[] = [
208
+ {
209
+ name: 'reads State metadata and bounded explicit keys without scope-wide value retrieval',
210
+ async verify(harness) {
211
+ const api = new ControlClient((url, init) => harness.request(new Request(new URL(url, harness.origin), init)))
212
+ const flow = await api.createFlow('State reads')
213
+ equal((await api.getFlowState(flow.flowId, 'draft')).values, [], 'New Flow State metadata')
214
+ equal(
215
+ await api.getFlowStateValues(flow.flowId, 'live', ['second', 'first']),
216
+ {
217
+ version: 1,
218
+ scope: 'live',
219
+ values: [
220
+ { key: 'second', value: null },
221
+ { key: 'first', value: null },
222
+ ],
223
+ },
224
+ 'Missing named State keys',
225
+ )
226
+ const metadata = await request(harness, `/v1/flows/${flow.flowId}/state?scope=draft&values=false`)
227
+ await json(metadata, 200, 'Legacy metadata-only query')
228
+ equal(metadata.headers.get('cache-control'), 'no-store', 'State metadata cache policy')
229
+ await error(await request(harness, `/v1/flows/${flow.flowId}/state?scope=draft&values=true`), 400, 'flow.invalid', 'Scope-wide State values')
230
+ for (const input of [{ scope: 'draft', keys: [] }, { keys: ['key'] }, { scope: 'draft', keys: Array.from({ length: 17 }, (_, index) => String(index)) }])
231
+ await error(
232
+ await request(harness, `/v1/flows/${flow.flowId}/state/read`, { method: 'POST', body: JSON.stringify({ version: 1, ...input }) }),
233
+ 400,
234
+ 'flow.invalid',
235
+ 'Invalid named State read',
236
+ )
237
+ },
238
+ },
208
239
  {
209
240
  name: 'imports workflow files atomically and exports a new unpublished draft with its Presentation',
210
241
  async verify(harness) {
@@ -5,7 +5,7 @@ import { canvasCommandSchema } from '../../canvas/common/commands.ts'
5
5
  import { runStatuses } from '../../execution/common/runLifecycle.ts'
6
6
  import { waitCommentSchema } from '../../execution/common/wait.ts'
7
7
  import { resourceNameIssue } from '../../flow/common/change.ts'
8
- import { stateQuerySchema, stateClearSchema, dedupeClearSchema } from '../../state/common/state.ts'
8
+ import { stateQuerySchema, stateReadSchema, stateClearSchema, dedupeClearSchema } from '../../state/common/state.ts'
9
9
  import { authoringReadSchema, authoringSearchSchema, authoringRequestSchema, authoringSchemaQuery } from './authoringSchema.ts'
10
10
  import { resultQuerySchema } from './resultQuery.ts'
11
11
 
@@ -52,10 +52,15 @@ function tool<Args>(description: string, schema: z.ZodType<Args>, readOnly: bool
52
52
 
53
53
  export const mcpTools = {
54
54
  flow_state: tool(
55
- 'Inspect Flow state usage, business keys and deduplication spaces. Values are omitted unless requested. Scope is required.',
55
+ 'Inspect Flow state usage, business key metadata and deduplication spaces. Never returns values. Scope is required.',
56
56
  stateQuerySchema.extend({ flowId: flow }),
57
57
  true,
58
58
  ),
59
+ flow_state_read: tool(
60
+ 'Read values for 1–16 distinct named State keys in one explicit scope. Values are returned in request order; missing keys return null. The combined stored JSON limit is 1 MiB; oversized reads fail without partial results.',
61
+ stateReadSchema.extend({ flowId: flow }),
62
+ true,
63
+ ),
59
64
  flow_state_clear: tool(
60
65
  'Delete one business state key or clear all business values. Explicit draft/live scope is required; live changes production state immediately.',
61
66
  stateClearSchema.extend({ flowId: flow }),
@@ -10,6 +10,7 @@ import { decodeDraftOperations, draftOperationsSchema } from './draftOperations.
10
10
  export { decodeDraftOperations, draftOperationsSchema, resolveDraftOperations, type DraftOperation } from './draftOperations.ts'
11
11
  import { z } from 'zod'
12
12
  import { resourceNameIssue } from '../../flow/common/change.ts'
13
+ import { stateReadSchema } from '../../state/common/state.ts'
13
14
  import { createEventSourceSchema, updateEventSourceSchema, eventSourceRevisionSchema } from './eventSourceSchemas.ts'
14
15
 
15
16
  export const waitActionBodySchema: { parse(value: unknown): { comment?: string | null } } = z.strictObject({ comment: waitCommentSchema })
@@ -23,6 +24,7 @@ const tagName = id.transform((name) => name.trim()).refine((name) => resourceNam
23
24
  const inputs = z.record(z.string(), z.record(z.string(), json))
24
25
  const trigger = z.strictObject({ nodeId: id, outputs: z.record(z.string(), json) })
25
26
  const schemas = {
27
+ readFlowState: stateReadSchema.extend({ version }),
26
28
  canvas: z.strictObject({ command: canvasCommandSchema, version }),
27
29
  authoringCheck: authoringCheckSchema,
28
30
  authoringEdit: authoringRequestSchema,
@@ -78,6 +80,7 @@ function decoder<Value>(schema: z.ZodType<Value>): (value: unknown) => Value {
78
80
  }
79
81
 
80
82
  export const controlRequests = {
83
+ readFlowState: decoder(schemas.readFlowState),
81
84
  canvas: decoder(schemas.canvas),
82
85
  authoringCheck: decoder(schemas.authoringCheck),
83
86
  authoringEdit: decoder(schemas.authoringEdit),
@@ -27,11 +27,20 @@ export function createState(invoke: (kind: string, payload: JsonValue) => Promis
27
27
  throw invalid('Identities must be strings or finite numbers.')
28
28
  return ids
29
29
  }
30
+ function get(key: string): Promise<JsonValue>
31
+ function get(keys: readonly string[]): Promise<JsonValue[]>
32
+ async function get(key: unknown) {
33
+ if (Array.isArray(key)) {
34
+ if (key.length === 0 || key.length > 16) throw invalid('State reads require 1 to 16 keys.')
35
+ const keys = Array.from(key, keyOf)
36
+ if (new Set(keys).size !== keys.length) throw invalid('State keys must be distinct.')
37
+ return ((await invoke('state.get', { key: keys })).body as { values: JsonValue[] }).values
38
+ }
39
+ return ((await invoke('state.get', { key: keyOf(key) })).body as { value: JsonValue }).value
40
+ }
30
41
  return {
31
42
  state: Object.freeze({
32
- async get(key: unknown) {
33
- return ((await invoke('state.get', { key: keyOf(key) })).body as { value: JsonValue }).value
34
- },
43
+ get,
35
44
  async set(key: unknown, value: unknown) {
36
45
  await invoke('state.set', { key: keyOf(key), value: valueOf(value) })
37
46
  },
@@ -443,6 +452,23 @@ async function rejects(operation: Promise<unknown>, pattern: RegExp, message: st
443
452
  }
444
453
 
445
454
  export const runtimeConformanceCases: readonly RuntimeConformanceCase[] = [
455
+ {
456
+ name: 'reads multiple State keys in one Host call while preserving input order',
457
+ async verify(harness) {
458
+ const calls: { kind: string; payload: JsonValue }[] = []
459
+ const result = await harness.invoke({
460
+ input: null,
461
+ invocationId: 'state-batch-read',
462
+ capability: async ({ kind, payload }) => {
463
+ calls.push({ kind, payload })
464
+ return { status: 200, body: { values: [0, null, false, ''] } }
465
+ },
466
+ program: program(harness, `export default async (_, context) => context.state.get(['zero', 'missing', 'false', 'empty'])`),
467
+ })
468
+ equal(result, [0, null, false, ''], 'State batch values')
469
+ equal(calls, [{ kind: 'state.get', payload: { key: ['zero', 'missing', 'false', 'empty'] } }], 'State batch dispatch')
470
+ },
471
+ },
446
472
  {
447
473
  name: 'cancels detached State work when its Task finishes',
448
474
  async verify(harness) {
@@ -492,7 +518,15 @@ export const runtimeConformanceCases: readonly RuntimeConformanceCase[] = [
492
518
  const invalid = [undefined, NaN, Infinity, new Date(), { x: undefined }, cyclic, [undefined], { x: () => 1 }];
493
519
  const errors = [];
494
520
  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))]) {
521
+ for (const call of [
522
+ () => context.state.get(''), () => context.state.get('x'.repeat(257)),
523
+ () => context.state.get([]), () => context.state.get(['x', 'x']),
524
+ () => context.state.get(Array.from({length: 17}, (_, index) => String(index))),
525
+ () => context.state.get(['']), () => context.state.get(['汉'.repeat(86)]),
526
+ () => context.state.get(new Array(1)), () => context.state.get(null), () => context.state.get(),
527
+ () => context.state.insert('x', null), () => context.dedupe.insert([NaN]),
528
+ () => context.dedupe.has([null]), () => context.dedupe.insert(new Array(1))
529
+ ]) {
496
530
  try { await call(); errors.push('accepted'); } catch (e) { errors.push(e.code); }
497
531
  }
498
532
  return errors;
@@ -501,7 +535,7 @@ export const runtimeConformanceCases: readonly RuntimeConformanceCase[] = [
501
535
  })
502
536
  equal(
503
537
  result,
504
- Array.from({ length: 14 }, () => 'capability.invalid'),
538
+ Array.from({ length: 22 }, () => 'capability.invalid'),
505
539
  'State boundary validation',
506
540
  )
507
541
  equal(calls, 0, 'State invalid dispatch')
@@ -12,6 +12,22 @@ export interface StateConformanceHarness {
12
12
  }
13
13
 
14
14
  export const stateConformanceCases: readonly { name: string; verify(harness: StateConformanceHarness): Promise<void> }[] = [
15
+ {
16
+ name: 'reads selected State keys in input order with missing, falsy and independent values',
17
+ async verify(harness) {
18
+ const result = await harness.run(`export default async (_, context) => {
19
+ await context.state.set('object', { nested: [1] });
20
+ await context.state.set('zero', 0);
21
+ await context.state.set('false', false);
22
+ await context.state.set('empty', '');
23
+ const values = await context.state.get(['false', 'missing', 'object', 'zero', 'empty']);
24
+ values[2].nested.push(2);
25
+ return {result: {values, retained: await context.state.get('object'), zero: await context.state.get('zero')}};
26
+ }`)
27
+ if (!dequal(result, { values: [false, null, { nested: [1, 2] }, 0, ''], retained: { nested: [1] }, zero: 0 }))
28
+ throw new Error('State batch reads must preserve order, missing keys and independent JSON values.')
29
+ },
30
+ },
15
31
  {
16
32
  name: 'skips composite identities across successful Runs without depending on field order',
17
33
  async verify(harness) {