work-ctrl-flow-logic 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-ctrl-flow-logic",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "description": "WorkCtrlFlow 引擎:可中断可恢复的动态工作流(快照重放 + WaitHandle/ResumeSignal)",
6
6
  "type": "module",
@@ -12,15 +12,15 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "depa-behavior-tree": "0.1.0",
15
- "instant-ctrl-flow-logic": "0.1.0",
16
- "instant-ctrl-flow-contract": "0.1.0",
17
- "work-ctrl-flow-contract": "0.1.0"
15
+ "flow-step-space-logic": "0.1.0",
16
+ "instant-ctrl-flow-contract": "0.1.1",
17
+ "instant-ctrl-flow-logic": "0.1.3",
18
+ "work-ctrl-flow-contract": "0.1.2"
18
19
  },
19
20
  "files": [
20
21
  "src"
21
22
  ],
22
23
  "publishConfig": {
23
- "access": "public",
24
- "registry": "https://registry.npmjs.com"
24
+ "access": "public"
25
25
  }
26
- }
26
+ }
package/src/engine.ts CHANGED
@@ -66,6 +66,40 @@ import type {
66
66
  WorkCtrlFlowOrchestrationFacts,
67
67
  DurableChildFlowResolver,
68
68
  } from 'work-ctrl-flow-contract';
69
+
70
+ function isAsciiDigit(code: number): boolean {
71
+ return code >= 48 && code <= 57;
72
+ }
73
+
74
+ /** Locale-independent natural ordering for durable lane identities. */
75
+ export function compareDurableLaneIds(left: string, right: string): number {
76
+ let leftIndex = 0;
77
+ let rightIndex = 0;
78
+ while (leftIndex < left.length && rightIndex < right.length) {
79
+ const leftCode = left.charCodeAt(leftIndex);
80
+ const rightCode = right.charCodeAt(rightIndex);
81
+ if (isAsciiDigit(leftCode) && isAsciiDigit(rightCode)) {
82
+ let leftEnd = leftIndex;
83
+ let rightEnd = rightIndex;
84
+ while (leftEnd < left.length && isAsciiDigit(left.charCodeAt(leftEnd))) leftEnd += 1;
85
+ while (rightEnd < right.length && isAsciiDigit(right.charCodeAt(rightEnd))) rightEnd += 1;
86
+ const leftRaw = left.slice(leftIndex, leftEnd);
87
+ const rightRaw = right.slice(rightIndex, rightEnd);
88
+ const leftValue = leftRaw.replace(/^0+(?=\d)/, '');
89
+ const rightValue = rightRaw.replace(/^0+(?=\d)/, '');
90
+ if (leftValue.length !== rightValue.length) return leftValue.length - rightValue.length;
91
+ if (leftValue !== rightValue) return leftValue < rightValue ? -1 : 1;
92
+ if (leftRaw.length !== rightRaw.length) return leftRaw.length - rightRaw.length;
93
+ leftIndex = leftEnd;
94
+ rightIndex = rightEnd;
95
+ continue;
96
+ }
97
+ if (leftCode !== rightCode) return leftCode - rightCode;
98
+ leftIndex += 1;
99
+ rightIndex += 1;
100
+ }
101
+ return (left.length - leftIndex) - (right.length - rightIndex);
102
+ }
69
103
  import { WORKFLOW_SNAPSHOT_SCHEMA } from 'work-ctrl-flow-contract';
70
104
 
71
105
  type Ctx = ExecutionContext<CtrlFlowNodeType, INodeConfig>;
@@ -257,9 +291,9 @@ export class DurableCompositeScheduler implements ActionHandler<CtrlFlowNodeType
257
291
  return this.openSchedulerTimer(controller, invocationKey, attempt.nextAttemptAtMs);
258
292
  }
259
293
  if (config.primitive === 'Retry' && attempt.nextAttemptAtMs) {
260
- flowStateOf(ctx).fault = undefined;
294
+ delete flowStateOf(ctx).fault;
261
295
  flowStateOf(ctx).current = fact.input;
262
- attempt.nextAttemptAtMs = undefined;
296
+ delete attempt.nextAttemptAtMs;
263
297
  }
264
298
  const bodyId = config.primitive === 'Retry' ? `attempt:${attempt.attempt}` : fact.bodyId;
265
299
  const result = await controller.withinInvocationScope(
@@ -370,7 +404,7 @@ export class DurableCompositeScheduler implements ActionHandler<CtrlFlowNodeType
370
404
  const laneState = (lane.flowState ??= createFlowExecutionState(
371
405
  config.primitive === 'ForEach' ? lane.item : fact.input,
372
406
  )) as ReturnType<typeof createFlowExecutionState>;
373
- laneState.fault = undefined;
407
+ delete laneState.fault;
374
408
  const laneController = new TickController(workingSnapshot, c.runtime);
375
409
  const laneRuntime = signal
376
410
  ? Object.assign(Object.create((ctx.runtime ?? {}) as object), { abortSignal: signal })
@@ -471,7 +505,7 @@ export class DurableCompositeScheduler implements ActionHandler<CtrlFlowNodeType
471
505
  }
472
506
  const lanes = Object.values(c.snapshot.orchestration.lanes)
473
507
  .filter((lane) => lane.invocationKey === invocationKey)
474
- .sort((a, b) => a.laneId.localeCompare(b.laneId, undefined, { numeric: true }));
508
+ .sort((a, b) => compareDurableLaneIds(a.laneId, b.laneId));
475
509
  if (c.suspended.length > 0 || lanes.some((lane) => lane.status === 'Open')) {
476
510
  fact.status = 'Waiting';
477
511
  await this.persist(c.snapshot);
@@ -760,7 +794,7 @@ export class WaitNodeHandler implements ActionHandler<CtrlFlowNodeType, INodeCon
760
794
  if (done) {
761
795
  const closed = c.snapshot.closedWaitHandles.find((handle) => handle.invocationKey === key);
762
796
  if (done.result === 'Success') {
763
- flow.fault = undefined;
797
+ delete flow.fault;
764
798
  flow.current = this.waitKind === 'timer' ? closed?.metadata?.input : done.payload;
765
799
  } else {
766
800
  flow.fault = new Error(`${this.waitKind} ${ctx.node.Key} finished with ${done.result}`);
@@ -792,8 +826,8 @@ export class WaitNodeHandler implements ActionHandler<CtrlFlowNodeType, INodeCon
792
826
  : cfg.timeoutMs !== undefined
793
827
  ? now + Number(cfg.timeoutMs)
794
828
  : 0,
795
- assignTo: cfg.assignTo as string | undefined,
796
- metadata: this.waitKind === 'timer' ? { input: flow.current } : undefined,
829
+ ...(typeof cfg.assignTo === 'string' ? { assignTo: cfg.assignTo } : {}),
830
+ ...(this.waitKind === 'timer' ? { metadata: { input: flow.current } } : {}),
797
831
  };
798
832
  c.snapshot.openWaitHandles.push(handle);
799
833
  if (handle.deadlineAtMs > 0) {
@@ -814,6 +848,11 @@ export interface WorkCtrlFlowEngineOptions {
814
848
  definition: WorkCtrlFlowDefinition;
815
849
  store: WorkCtrlFlowStore;
816
850
  runtime?: WorkCtrlFlowRuntime;
851
+ /** Derives the explicit Processor runtime for one ordinary node invocation. */
852
+ bindNodeRuntime?: (
853
+ runtime: WorkCtrlFlowRuntime,
854
+ identity: { readonly treeId: string; readonly nodeId: string; readonly invocationKey: string },
855
+ ) => WorkCtrlFlowRuntime;
817
856
  clock?: WorkCtrlFlowClock;
818
857
  view?: WorkCtrlFlowViewAdapter;
819
858
  /** 产品形态扩展(BPCtrlFlow 注入 TaskStep 编译与 handler) */
@@ -870,7 +909,9 @@ export class WorkCtrlFlowEngine {
870
909
  treeId,
871
910
  fqn: spec.fqn,
872
911
  status: 'Waiting',
873
- vars: { ...(spec.stateSeed ?? {}) },
912
+ // state.seed is an immutable definition facet. Each new run receives one
913
+ // detached initialization copy; resume always reuses persisted vars.
914
+ vars: structuredClone(spec.stateSeed ?? {}),
874
915
  input: { ...(opts.input ?? {}) },
875
916
  branchDecisions: {},
876
917
  nodeResults: {},
@@ -883,6 +924,10 @@ export class WorkCtrlFlowEngine {
883
924
  updatedAtMs: this.now(),
884
925
  };
885
926
  snapshot.vars[FLOW_STATE_KEY] = createFlowExecutionState(opts.input ?? {});
927
+ // The initialization checkpoint is durable before any user node can run.
928
+ // lastTickNo=0 is the closed distinction between admitted-but-unexecuted
929
+ // and a checkpoint produced by an execution tick.
930
+ await this.options.store.save(snapshot);
886
931
  return this.tick(snapshot, spec);
887
932
  }
888
933
 
@@ -914,7 +959,10 @@ export class WorkCtrlFlowEngine {
914
959
 
915
960
  snapshot.appliedResumeSignals.push(`${handle.waitHandleId}:${signal.resumeToken}`);
916
961
  const result: TerminalResult = signal.outcome ?? 'Success';
917
- snapshot.waitResults[handle.invocationKey] = { result, payload: signal.payload };
962
+ snapshot.waitResults[handle.invocationKey] = {
963
+ result,
964
+ ...(signal.payload === undefined ? {} : { payload: signal.payload }),
965
+ };
918
966
  handle.status = result === 'Cancelled' ? 'Cancelled' : 'Consumed';
919
967
  handle.closedAtMs = this.now();
920
968
  snapshot.openWaitHandles = snapshot.openWaitHandles.filter((h) => h !== handle);
@@ -1017,6 +1065,14 @@ export class WorkCtrlFlowEngine {
1017
1065
  new ReplayingHandler(h, controller, replayResults);
1018
1066
 
1019
1067
  const decisionHooks: FlowDecisionHooks = {
1068
+ runtimeForNode: (nodeId, runtime) => {
1069
+ const c = controller();
1070
+ return this.options.bindNodeRuntime?.(runtime, {
1071
+ treeId: c.snapshot.treeId,
1072
+ nodeId,
1073
+ invocationKey: c.invocationKeyFor(nodeId),
1074
+ }) ?? runtime;
1075
+ },
1020
1076
  begin: (ifPath) => {
1021
1077
  const c = controller();
1022
1078
  const invocationKey = c.invocationKeyFor(ifPath);
package/src/filesystem.ts CHANGED
@@ -5,7 +5,24 @@ import {
5
5
  loadFlowBundle,
6
6
  } from 'instant-ctrl-flow-logic/filesystem';
7
7
  import { WorkCtrlFlowEngine, WorkCtrlFlowLoadError, type WorkCtrlFlowEngineOptions } from './engine.js';
8
- import { annotateFlowFile, materializeInstance } from './instance.js';
8
+ import type {
9
+ ResumeSignal,
10
+ TickOutcome,
11
+ WorkCtrlFlowStartOptions,
12
+ WorkCtrlFlowStore,
13
+ } from 'work-ctrl-flow-contract';
14
+ import {
15
+ annotateFlowFile,
16
+ computeFlowBundleDigest,
17
+ FlowInstanceMaterializationError,
18
+ loadMaterializedFlowInstance,
19
+ materializeFlowInstance,
20
+ materializeInstance,
21
+ readFlowInstanceDescriptor,
22
+ type FlowInstanceSourceIdentity,
23
+ } from './instance.js';
24
+
25
+ export { computeFlowBundleDigest, readFlowInstanceDescriptor } from './instance.js';
9
26
 
10
27
  export interface WorkCtrlFlowDirectoryOptions
11
28
  extends Omit<WorkCtrlFlowEngineOptions, 'definition' | 'view'> {
@@ -32,3 +49,133 @@ export function createWorkCtrlFlowEngineFromDirectory(options: WorkCtrlFlowDirec
32
49
  : {}),
33
50
  });
34
51
  }
52
+
53
+ export interface MaterializedWorkCtrlFlowDirectoryOptions
54
+ extends Omit<WorkCtrlFlowEngineOptions, 'definition'> {
55
+ /** Required only when the bound instance has not been admitted yet. */
56
+ bundleDir?: string;
57
+ instancesDir: string;
58
+ instanceId: string;
59
+ /** Required only for first admission; ignored by fresh recovery of an existing instance. */
60
+ source?: FlowInstanceSourceIdentity;
61
+ }
62
+
63
+ /**
64
+ * Instance-bound engine facade. Every operation builds its delegate solely
65
+ * from the admitted frozen definition; live bundleDir is an admission input.
66
+ */
67
+ export class MaterializedWorkCtrlFlowDirectoryEngine {
68
+ readonly store: WorkCtrlFlowStore;
69
+
70
+ constructor(private readonly options: MaterializedWorkCtrlFlowDirectoryOptions) {
71
+ this.store = options.store;
72
+ }
73
+
74
+ get spec() {
75
+ return this.loadDefinition(this.requireExistingInstance().definitionDir).spec;
76
+ }
77
+
78
+ async start(treeId: string, options: WorkCtrlFlowStartOptions = {}): Promise<TickOutcome> {
79
+ const instance = this.ensureInstanceForStart();
80
+ const delegate = this.delegate(instance.definitionDir);
81
+ const existing = await this.store.load(treeId);
82
+ if (existing) {
83
+ if (options.input !== undefined && JSON.stringify(options.input) !== JSON.stringify(existing.input)) {
84
+ throw new WorkCtrlFlowLoadError(['tree-start-input-mismatch'], `tree ${treeId} is already bound to different start input`);
85
+ }
86
+ const outcome = await delegate.getOutcome(treeId);
87
+ if (!outcome) throw new WorkCtrlFlowLoadError(['tree-outcome-missing'], `tree ${treeId} has no durable outcome`);
88
+ return outcome;
89
+ }
90
+ return delegate.start(treeId, options);
91
+ }
92
+
93
+ async resume(treeId: string, signal: ResumeSignal): Promise<TickOutcome> {
94
+ return this.delegate(this.requireExistingInstance().definitionDir).resume(treeId, signal);
95
+ }
96
+
97
+ async fireDueDeadlines(treeId: string): Promise<TickOutcome> {
98
+ return this.delegate(this.requireExistingInstance().definitionDir).fireDueDeadlines(treeId);
99
+ }
100
+
101
+ async fireDueTimers(treeId: string): Promise<TickOutcome> {
102
+ return this.fireDueDeadlines(treeId);
103
+ }
104
+
105
+ async refresh(treeId: string): Promise<TickOutcome> {
106
+ return this.delegate(this.requireExistingInstance().definitionDir).refresh(treeId);
107
+ }
108
+
109
+ async getOutcome(treeId: string): Promise<TickOutcome | undefined> {
110
+ return this.delegate(this.requireExistingInstance().definitionDir).getOutcome(treeId);
111
+ }
112
+
113
+ private ensureInstanceForStart() {
114
+ const instanceDir = this.instanceDir();
115
+ if (fs.existsSync(instanceDir)) {
116
+ return loadMaterializedFlowInstance(instanceDir, this.options.instanceId);
117
+ }
118
+ if (!this.options.bundleDir || !this.options.source) {
119
+ throw new FlowInstanceMaterializationError(
120
+ 'instance-admission-source-required',
121
+ 'bundleDir and source identity are required to admit a new instance',
122
+ );
123
+ }
124
+ return materializeFlowInstance({
125
+ definitionDir: this.options.bundleDir,
126
+ instancesDir: this.options.instancesDir,
127
+ instanceId: this.options.instanceId,
128
+ source: this.options.source,
129
+ materializedAtMs: this.options.clock?.now(),
130
+ });
131
+ }
132
+
133
+ private requireExistingInstance() {
134
+ return loadMaterializedFlowInstance(this.instanceDir(), this.options.instanceId);
135
+ }
136
+
137
+ private instanceDir(): string {
138
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(this.options.instanceId)
139
+ || this.options.instanceId === '.'
140
+ || this.options.instanceId === '..') {
141
+ throw new FlowInstanceMaterializationError('instance-id-invalid', 'instanceId must be one safe path segment');
142
+ }
143
+ const root = path.resolve(this.options.instancesDir);
144
+ const target = path.resolve(root, this.options.instanceId);
145
+ const relative = path.relative(root, target);
146
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
147
+ throw new FlowInstanceMaterializationError('instance-containment-invalid', 'instance path escapes instances root');
148
+ }
149
+ return target;
150
+ }
151
+
152
+ private delegate(definitionDir: string): WorkCtrlFlowEngine {
153
+ const { bundleDir: _bundleDir, instancesDir: _instancesDir, instanceId: _instanceId, source: _source, ...engineOptions } = this.options;
154
+ const loaded = this.loadDefinition(definitionDir);
155
+ return new WorkCtrlFlowEngine({
156
+ ...engineOptions,
157
+ definition: {
158
+ spec: loaded.spec,
159
+ resolveCode: createFilesystemFlowCodeResolver(),
160
+ },
161
+ });
162
+ }
163
+
164
+ private loadDefinition(definitionDir: string) {
165
+ const result = loadFlowBundle(definitionDir);
166
+ const hard = result.diagnostics.filter((diagnostic) => diagnostic.code !== 'parser-warning');
167
+ if (!result.spec || hard.length > 0) {
168
+ throw new WorkCtrlFlowLoadError(
169
+ result.diagnostics.map((item) => item.code),
170
+ result.diagnostics.map((item) => item.message).join('; '),
171
+ );
172
+ }
173
+ return { spec: result.spec };
174
+ }
175
+ }
176
+
177
+ export function createMaterializedWorkCtrlFlowEngineFromDirectory(
178
+ options: MaterializedWorkCtrlFlowDirectoryOptions,
179
+ ): MaterializedWorkCtrlFlowDirectoryEngine {
180
+ return new MaterializedWorkCtrlFlowDirectoryEngine(options);
181
+ }
package/src/instance.ts CHANGED
@@ -6,7 +6,17 @@
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import type { NodeAnnotation, NodeViewStatus, TreeViewAnnotation } from 'work-ctrl-flow-contract';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { loadFlowBundle } from 'instant-ctrl-flow-logic/filesystem';
11
+ import { assembleDefinitionStepProfileDirectory } from 'flow-step-space-logic/filesystem';
12
+ import {
13
+ FLOW_INSTANCE_SCHEMA_VERSION,
14
+ type FlowDefinitionProvenance,
15
+ type FlowInstanceDescriptor,
16
+ type NodeAnnotation,
17
+ type NodeViewStatus,
18
+ type TreeViewAnnotation,
19
+ } from 'work-ctrl-flow-contract';
10
20
 
11
21
  export type { NodeAnnotation, NodeViewStatus, TreeViewAnnotation };
12
22
 
@@ -16,6 +26,596 @@ export function materializeInstance(definitionDir: string, instanceDir: string):
16
26
  fs.cpSync(definitionDir, instanceDir, { recursive: true });
17
27
  }
18
28
 
29
+ export interface FlowInstanceSourceIdentity {
30
+ readonly revision: string;
31
+ readonly provenance: FlowDefinitionProvenance;
32
+ readonly expectedDigest?: `sha256:${string}`;
33
+ }
34
+
35
+ export interface MaterializeFlowInstanceOptions {
36
+ readonly definitionDir: string;
37
+ readonly instancesDir: string;
38
+ readonly instanceId: string;
39
+ readonly source: FlowInstanceSourceIdentity;
40
+ readonly materializedAtMs?: number;
41
+ /** Product-owned schema validator; WorkCtrlFlow remains the compatibility default. */
42
+ readonly validateDefinition?: FlowInstanceDefinitionValidator;
43
+ }
44
+
45
+ export type FlowInstanceDefinitionValidator = (definitionDir: string) => void;
46
+
47
+ export interface MaterializedFlowInstance {
48
+ readonly instanceDir: string;
49
+ readonly definitionDir: string;
50
+ readonly descriptor: FlowInstanceDescriptor;
51
+ readonly created: boolean;
52
+ }
53
+
54
+ export class FlowInstanceMaterializationError extends Error {
55
+ constructor(public readonly code: string, message: string) {
56
+ super(message);
57
+ this.name = 'FlowInstanceMaterializationError';
58
+ }
59
+ }
60
+
61
+ const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
62
+
63
+ /** Closed path-independent source identity accepted before filesystem mutation. */
64
+ export function normalizeFlowInstanceSourceIdentity(value: unknown): FlowInstanceSourceIdentity {
65
+ const record = exactDataRecord(value, ['revision', 'provenance'], ['expectedDigest'], 'source-identity-invalid');
66
+ const revision = normalizedString(dataValue(record, 'revision', 'source-identity-invalid'), 'definition revision', 'source-identity-invalid');
67
+ const provenance = normalizeDefinitionProvenance(
68
+ dataValue(record, 'provenance', 'source-identity-invalid'),
69
+ 'source-identity-invalid',
70
+ );
71
+ const expectedDigestValue = Object.prototype.hasOwnProperty.call(record, 'expectedDigest')
72
+ ? dataValue(record, 'expectedDigest', 'source-identity-invalid')
73
+ : undefined;
74
+ if (expectedDigestValue !== undefined
75
+ && (typeof expectedDigestValue !== 'string' || !SHA256_DIGEST.test(expectedDigestValue))) {
76
+ throw new FlowInstanceMaterializationError('source-identity-invalid', 'expectedDigest must be a lowercase sha256 digest');
77
+ }
78
+ return deepFreeze({
79
+ revision,
80
+ provenance,
81
+ ...(expectedDigestValue === undefined ? {} : { expectedDigest: expectedDigestValue as `sha256:${string}` }),
82
+ });
83
+ }
84
+
85
+ /** Exact closed-data normalizer for the persisted instance descriptor. */
86
+ export function normalizeFlowInstanceDescriptor(value: unknown): FlowInstanceDescriptor {
87
+ const record = exactDataRecord(
88
+ value,
89
+ ['schemaVersion', 'instanceId', 'definition', 'materializedAtMs'],
90
+ [],
91
+ 'instance-descriptor-invalid',
92
+ );
93
+ if (dataValue(record, 'schemaVersion', 'instance-descriptor-invalid') !== FLOW_INSTANCE_SCHEMA_VERSION) {
94
+ throw new FlowInstanceMaterializationError('instance-descriptor-invalid', 'instance descriptor schemaVersion is invalid');
95
+ }
96
+ const instanceId = normalizedString(
97
+ dataValue(record, 'instanceId', 'instance-descriptor-invalid'),
98
+ 'instanceId',
99
+ 'instance-descriptor-invalid',
100
+ );
101
+ assertSafeInstanceId(instanceId);
102
+ const definitionRecord = exactDataRecord(
103
+ dataValue(record, 'definition', 'instance-descriptor-invalid'),
104
+ ['revision', 'digest', 'provenance'],
105
+ [],
106
+ 'instance-descriptor-invalid',
107
+ );
108
+ const revision = normalizedString(
109
+ dataValue(definitionRecord, 'revision', 'instance-descriptor-invalid'),
110
+ 'definition revision',
111
+ 'instance-descriptor-invalid',
112
+ );
113
+ const digest = dataValue(definitionRecord, 'digest', 'instance-descriptor-invalid');
114
+ if (typeof digest !== 'string' || !SHA256_DIGEST.test(digest)) {
115
+ throw new FlowInstanceMaterializationError('instance-descriptor-invalid', 'definition digest must be a lowercase sha256 digest');
116
+ }
117
+ const provenance = normalizeDefinitionProvenance(
118
+ dataValue(definitionRecord, 'provenance', 'instance-descriptor-invalid'),
119
+ 'instance-descriptor-invalid',
120
+ );
121
+ const materializedAtMs = dataValue(record, 'materializedAtMs', 'instance-descriptor-invalid');
122
+ if (typeof materializedAtMs !== 'number' || !Number.isSafeInteger(materializedAtMs) || materializedAtMs < 0) {
123
+ throw new FlowInstanceMaterializationError('instance-descriptor-invalid', 'materializedAtMs must be a non-negative safe integer');
124
+ }
125
+ return deepFreeze({
126
+ schemaVersion: FLOW_INSTANCE_SCHEMA_VERSION,
127
+ instanceId,
128
+ definition: { revision, digest: digest as `sha256:${string}`, provenance },
129
+ materializedAtMs,
130
+ });
131
+ }
132
+
133
+ /** Deterministic digest over relative paths, entry kinds and raw file bytes. */
134
+ export function computeFlowBundleDigest(definitionDir: string): `sha256:${string}` {
135
+ const root = requireDirectory(definitionDir, 'definition-not-found');
136
+ const hash = createHash('sha256');
137
+ for (const entry of collectBundleEntries(root)) {
138
+ const pathBytes = Buffer.from(entry.relativePath, 'utf8');
139
+ const pathLength = Buffer.allocUnsafe(4);
140
+ pathLength.writeUInt32BE(pathBytes.length);
141
+ hash.update(entry.kind === 'directory' ? 'd' : 'f');
142
+ hash.update(pathLength);
143
+ hash.update(pathBytes);
144
+ if (entry.kind === 'file') {
145
+ const bytes = fs.readFileSync(entry.absolutePath);
146
+ const byteLength = Buffer.allocUnsafe(8);
147
+ byteLength.writeBigUInt64BE(BigInt(bytes.length));
148
+ hash.update(byteLength);
149
+ hash.update(bytes);
150
+ }
151
+ }
152
+ return `sha256:${hash.digest('hex')}`;
153
+ }
154
+
155
+ /**
156
+ * Creates a complete candidate, verifies bytes/schema/containment, then admits
157
+ * the immutable instance with one same-parent rename.
158
+ */
159
+ export function materializeFlowInstance(options: MaterializeFlowInstanceOptions): MaterializedFlowInstance {
160
+ assertSafeInstanceId(options.instanceId);
161
+ const source = normalizeFlowInstanceSourceIdentity(options.source);
162
+ const materializedAtMs = nonNegativeSafeInteger(options.materializedAtMs ?? Date.now(), 'materializedAtMs');
163
+ const sourceRoot = requireDirectory(options.definitionDir, 'definition-not-found');
164
+ const requestedIdentity = requestedDefinitionIdentity(sourceRoot, source);
165
+ const boundary = prepareInstancesBoundary(options.instancesDir);
166
+ const instancesRoot = boundary.instancesRoot;
167
+ const instanceDir = containedChild(instancesRoot, options.instanceId);
168
+ assertInstancesBoundary(boundary);
169
+ if (fs.existsSync(instanceDir)) {
170
+ const admitted = loadMaterializedFlowInstance(
171
+ instanceDir,
172
+ options.instanceId,
173
+ options.validateDefinition,
174
+ );
175
+ if (JSON.stringify(admitted.descriptor.definition) !== JSON.stringify(requestedIdentity)) {
176
+ throw new FlowInstanceMaterializationError(
177
+ 'instance-source-identity-mismatch',
178
+ 'existing instance definition revision, digest or provenance differs from the requested source identity',
179
+ );
180
+ }
181
+ return admitted;
182
+ }
183
+
184
+ const sourceDigest = requestedIdentity.digest;
185
+
186
+ const candidateDir = containedChild(instancesRoot, `.candidate-${options.instanceId}-${randomUUID()}`);
187
+ const candidateDefinitionDir = path.join(candidateDir, 'definition');
188
+ try {
189
+ assertInstancesBoundary(boundary);
190
+ fs.mkdirSync(candidateDefinitionDir, { recursive: true });
191
+ assertOwnedDirectory(candidateDir, boundary.realInstancesRoot, 'instance-candidate-boundary-invalid');
192
+ copyBundle(sourceRoot, candidateDefinitionDir);
193
+ assertBundleBytesEqual(sourceRoot, candidateDefinitionDir);
194
+ const candidateDigest = computeFlowBundleDigest(candidateDefinitionDir);
195
+ if (candidateDigest !== sourceDigest) {
196
+ throw new FlowInstanceMaterializationError('candidate-digest-mismatch', 'candidate digest differs from source digest');
197
+ }
198
+ (options.validateDefinition ?? requireWorkCtrlFlowSchema)(candidateDefinitionDir);
199
+ assertTreeContained(candidateDir);
200
+
201
+ const descriptor: FlowInstanceDescriptor = deepFreeze({
202
+ schemaVersion: FLOW_INSTANCE_SCHEMA_VERSION,
203
+ instanceId: options.instanceId,
204
+ definition: requestedIdentity,
205
+ materializedAtMs,
206
+ });
207
+ assertInstancesBoundary(boundary);
208
+ assertOwnedDirectory(candidateDir, boundary.realInstancesRoot, 'instance-candidate-boundary-invalid');
209
+ fs.writeFileSync(path.join(candidateDir, 'instance.json'), `${JSON.stringify(descriptor, null, 2)}\n`);
210
+ const readback = readFlowInstanceDescriptor(candidateDir);
211
+ if (JSON.stringify(readback) !== JSON.stringify(descriptor)) {
212
+ throw new FlowInstanceMaterializationError('instance-readback-mismatch', 'instance descriptor readback differs');
213
+ }
214
+ assertInstancesBoundary(boundary);
215
+ assertOwnedDirectory(candidateDir, boundary.realInstancesRoot, 'instance-candidate-boundary-invalid');
216
+ if (fs.existsSync(instanceDir)) {
217
+ throw new FlowInstanceMaterializationError('instance-admission-conflict', 'instance target appeared during admission');
218
+ }
219
+ fs.renameSync(candidateDir, instanceDir);
220
+ assertInstancesBoundary(boundary);
221
+ assertOwnedDirectory(instanceDir, boundary.realInstancesRoot, 'instance-boundary-invalid');
222
+ const admitted = loadMaterializedFlowInstance(
223
+ instanceDir,
224
+ options.instanceId,
225
+ options.validateDefinition,
226
+ );
227
+ return Object.freeze({ ...admitted, created: true });
228
+ } catch (error) {
229
+ removeOwnedCandidate(candidateDir, boundary);
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ function requestedDefinitionIdentity(
235
+ definitionDir: string,
236
+ source: FlowInstanceSourceIdentity,
237
+ ): FlowInstanceDescriptor['definition'] {
238
+ const sourceDigest = computeFlowBundleDigest(definitionDir);
239
+ if (source.expectedDigest !== undefined && source.expectedDigest !== sourceDigest) {
240
+ throw new FlowInstanceMaterializationError(
241
+ 'definition-digest-mismatch',
242
+ `definition digest ${sourceDigest} does not match expected ${source.expectedDigest}`,
243
+ );
244
+ }
245
+ return deepFreeze({
246
+ revision: source.revision,
247
+ digest: sourceDigest,
248
+ provenance: source.provenance,
249
+ });
250
+ }
251
+
252
+ export function loadMaterializedFlowInstance(
253
+ instanceDir: string,
254
+ expectedInstanceId?: string,
255
+ validateDefinition: FlowInstanceDefinitionValidator = requireWorkCtrlFlowSchema,
256
+ ): MaterializedFlowInstance {
257
+ const resolved = requireOwnedInstanceDirectory(instanceDir);
258
+ const descriptor = readFlowInstanceDescriptor(resolved);
259
+ if (expectedInstanceId !== undefined && descriptor.instanceId !== expectedInstanceId) {
260
+ throw new FlowInstanceMaterializationError('instance-id-mismatch', 'instance descriptor identity differs from requested instance');
261
+ }
262
+ const definitionPath = path.join(resolved, 'definition');
263
+ let definitionStat: fs.Stats;
264
+ try {
265
+ definitionStat = fs.lstatSync(definitionPath);
266
+ } catch {
267
+ throw new FlowInstanceMaterializationError('instance-definition-not-found', 'frozen definition directory does not exist');
268
+ }
269
+ if (definitionStat.isSymbolicLink()) {
270
+ throw new FlowInstanceMaterializationError('bundle-link-unsupported', 'frozen definition must not be a symbolic link');
271
+ }
272
+ const definitionDir = requireDirectory(definitionPath, 'instance-definition-not-found');
273
+ // runs/ is mutable checkpoint support. Only the frozen definition subtree
274
+ // participates in instance artifact containment and digest verification.
275
+ assertTreeContained(definitionDir);
276
+ const digest = computeFlowBundleDigest(definitionDir);
277
+ if (digest !== descriptor.definition.digest) {
278
+ throw new FlowInstanceMaterializationError('instance-definition-digest-mismatch', 'frozen definition digest differs from instance descriptor');
279
+ }
280
+ validateDefinition(definitionDir);
281
+ return Object.freeze({ instanceDir: resolved, definitionDir, descriptor, created: false });
282
+ }
283
+
284
+ export function readFlowInstanceDescriptor(instanceDir: string): FlowInstanceDescriptor {
285
+ const resolved = requireOwnedInstanceDirectory(instanceDir);
286
+ const file = path.join(resolved, 'instance.json');
287
+ let value: unknown;
288
+ try {
289
+ const stat = fs.lstatSync(file);
290
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('descriptor must be a regular no-link file');
291
+ value = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown;
292
+ } catch (error) {
293
+ throw new FlowInstanceMaterializationError('instance-descriptor-invalid', `cannot read instance descriptor: ${(error as Error).message}`);
294
+ }
295
+ return normalizeFlowInstanceDescriptor(value);
296
+ }
297
+
298
+ type InstancesPhysicalBoundary = {
299
+ readonly supportRoot: string;
300
+ readonly realSupportRoot: string;
301
+ readonly instancesRoot: string;
302
+ readonly realInstancesRoot: string;
303
+ };
304
+
305
+ function prepareInstancesBoundary(value: string): InstancesPhysicalBoundary {
306
+ const instancesRoot = path.resolve(value);
307
+ const supportRoot = path.dirname(instancesRoot);
308
+ const realSupportRoot = requireNoLinkDirectory(supportRoot, 'instance-support-boundary-invalid');
309
+ if (!fs.existsSync(instancesRoot)) {
310
+ fs.mkdirSync(instancesRoot);
311
+ }
312
+ const realInstancesRoot = requireNoLinkDirectory(instancesRoot, 'instance-instances-boundary-invalid');
313
+ assertRealChild(realSupportRoot, realInstancesRoot, 'instance-instances-boundary-invalid');
314
+ const boundary = { supportRoot, realSupportRoot, instancesRoot, realInstancesRoot };
315
+ assertInstancesBoundary(boundary);
316
+ return boundary;
317
+ }
318
+
319
+ function assertInstancesBoundary(boundary: InstancesPhysicalBoundary): void {
320
+ const currentSupport = requireNoLinkDirectory(boundary.supportRoot, 'instance-support-boundary-invalid');
321
+ if (currentSupport !== boundary.realSupportRoot) {
322
+ throw new FlowInstanceMaterializationError('instance-support-boundary-invalid', 'support root identity changed during instance operation');
323
+ }
324
+ const currentInstances = requireNoLinkDirectory(boundary.instancesRoot, 'instance-instances-boundary-invalid');
325
+ if (currentInstances !== boundary.realInstancesRoot) {
326
+ throw new FlowInstanceMaterializationError('instance-instances-boundary-invalid', 'instances root identity changed during instance operation');
327
+ }
328
+ assertRealChild(boundary.realSupportRoot, currentInstances, 'instance-instances-boundary-invalid');
329
+ }
330
+
331
+ function requireOwnedInstanceDirectory(value: string): string {
332
+ const instanceDir = path.resolve(value);
333
+ const instancesRoot = path.dirname(instanceDir);
334
+ const supportRoot = path.dirname(instancesRoot);
335
+ const realSupportRoot = requireNoLinkDirectory(supportRoot, 'instance-support-boundary-invalid');
336
+ const realInstancesRoot = requireNoLinkDirectory(instancesRoot, 'instance-instances-boundary-invalid');
337
+ assertRealChild(realSupportRoot, realInstancesRoot, 'instance-instances-boundary-invalid');
338
+ return assertOwnedDirectory(instanceDir, realInstancesRoot, 'instance-boundary-invalid');
339
+ }
340
+
341
+ function requireNoLinkDirectory(value: string, code: string): string {
342
+ let stat: fs.Stats;
343
+ try {
344
+ stat = fs.lstatSync(value);
345
+ } catch {
346
+ throw new FlowInstanceMaterializationError(code, 'required physical directory does not exist');
347
+ }
348
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
349
+ throw new FlowInstanceMaterializationError(code, 'physical boundary must be a no-link directory');
350
+ }
351
+ return fs.realpathSync(value);
352
+ }
353
+
354
+ function assertOwnedDirectory(value: string, realParent: string, code: string): string {
355
+ const realValue = requireNoLinkDirectory(value, code);
356
+ assertRealChild(realParent, realValue, code);
357
+ return realValue;
358
+ }
359
+
360
+ function assertRealChild(realParent: string, realChild: string, code: string): void {
361
+ const relative = path.relative(realParent, realChild);
362
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
363
+ throw new FlowInstanceMaterializationError(code, 'physical directory escapes its owner boundary');
364
+ }
365
+ }
366
+
367
+ function removeOwnedCandidate(candidateDir: string, boundary: InstancesPhysicalBoundary): void {
368
+ if (!fs.existsSync(candidateDir)) return;
369
+ try {
370
+ assertInstancesBoundary(boundary);
371
+ assertOwnedDirectory(candidateDir, boundary.realInstancesRoot, 'instance-candidate-boundary-invalid');
372
+ fs.rmSync(candidateDir, { recursive: true, force: true });
373
+ } catch {
374
+ // Cleanup must never cross a changed physical owner boundary. A contained
375
+ // candidate can be recovered by a later owner after the boundary is valid.
376
+ }
377
+ }
378
+
379
+ type BundleEntry = {
380
+ readonly absolutePath: string;
381
+ readonly relativePath: string;
382
+ readonly kind: 'directory' | 'file';
383
+ };
384
+
385
+ function collectBundleEntries(root: string): BundleEntry[] {
386
+ const entries: BundleEntry[] = [];
387
+ const visit = (current: string) => {
388
+ for (const item of fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => compareCodeUnits(left.name, right.name))) {
389
+ const absolutePath = path.join(current, item.name);
390
+ const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
391
+ if (!relativePath || relativePath.startsWith('../') || path.isAbsolute(relativePath)) {
392
+ throw new FlowInstanceMaterializationError('bundle-containment-invalid', `bundle entry escapes root: ${relativePath}`);
393
+ }
394
+ const stat = fs.lstatSync(absolutePath);
395
+ if (stat.isSymbolicLink()) {
396
+ throw new FlowInstanceMaterializationError('bundle-link-unsupported', `bundle entry must not be a symbolic link: ${relativePath}`);
397
+ }
398
+ if (stat.isDirectory()) {
399
+ entries.push({ absolutePath, relativePath, kind: 'directory' });
400
+ visit(absolutePath);
401
+ } else if (stat.isFile()) {
402
+ entries.push({ absolutePath, relativePath, kind: 'file' });
403
+ } else {
404
+ throw new FlowInstanceMaterializationError('bundle-entry-unsupported', `unsupported bundle entry: ${relativePath}`);
405
+ }
406
+ }
407
+ };
408
+ visit(root);
409
+ return entries.sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath));
410
+ }
411
+
412
+ function copyBundle(sourceRoot: string, targetRoot: string): void {
413
+ for (const entry of collectBundleEntries(sourceRoot)) {
414
+ const target = path.join(targetRoot, ...entry.relativePath.split('/'));
415
+ if (entry.kind === 'directory') fs.mkdirSync(target, { recursive: true });
416
+ else {
417
+ fs.mkdirSync(path.dirname(target), { recursive: true });
418
+ fs.copyFileSync(entry.absolutePath, target);
419
+ }
420
+ }
421
+ }
422
+
423
+ function assertBundleBytesEqual(sourceRoot: string, targetRoot: string): void {
424
+ const sourceEntries = collectBundleEntries(sourceRoot);
425
+ const targetEntries = collectBundleEntries(targetRoot);
426
+ const sourceShape = sourceEntries.map(({ relativePath, kind }) => ({ relativePath, kind }));
427
+ const targetShape = targetEntries.map(({ relativePath, kind }) => ({ relativePath, kind }));
428
+ if (JSON.stringify(sourceShape) !== JSON.stringify(targetShape)) {
429
+ throw new FlowInstanceMaterializationError('candidate-shape-mismatch', 'candidate entries differ from source entries');
430
+ }
431
+ for (const entry of sourceEntries) {
432
+ if (entry.kind !== 'file') continue;
433
+ const target = path.join(targetRoot, ...entry.relativePath.split('/'));
434
+ if (!fs.readFileSync(entry.absolutePath).equals(fs.readFileSync(target))) {
435
+ throw new FlowInstanceMaterializationError('candidate-byte-mismatch', `candidate bytes differ for ${entry.relativePath}`);
436
+ }
437
+ }
438
+ }
439
+
440
+ function requireWorkCtrlFlowSchema(definitionDir: string): void {
441
+ const loaded = loadFlowBundle(definitionDir);
442
+ const hard = loaded.diagnostics.filter((diagnostic) => diagnostic.code !== 'parser-warning');
443
+ if (!loaded.spec || hard.length > 0 || loaded.spec.form !== 'WorkCtrlFlow') {
444
+ throw new FlowInstanceMaterializationError(
445
+ 'definition-schema-invalid',
446
+ loaded.diagnostics.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join('; ') || 'definition is not a WorkCtrlFlow',
447
+ );
448
+ }
449
+ }
450
+
451
+ function assertTreeContained(root: string): void {
452
+ const realRoot = fs.realpathSync(root);
453
+ for (const entry of collectBundleEntries(realRoot)) {
454
+ const realEntry = fs.realpathSync(entry.absolutePath);
455
+ const relative = path.relative(realRoot, realEntry);
456
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
457
+ throw new FlowInstanceMaterializationError('bundle-containment-invalid', `entry escapes admitted root: ${entry.relativePath}`);
458
+ }
459
+ }
460
+ }
461
+
462
+ function requireDirectory(value: string, code: string): string {
463
+ const resolved = path.resolve(value);
464
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
465
+ throw new FlowInstanceMaterializationError(code, `directory does not exist: ${resolved}`);
466
+ }
467
+ return fs.realpathSync(resolved);
468
+ }
469
+
470
+ function containedChild(root: string, segment: string): string {
471
+ const target = path.resolve(root, segment);
472
+ const relative = path.relative(root, target);
473
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
474
+ throw new FlowInstanceMaterializationError('instance-containment-invalid', 'instance path escapes instances root');
475
+ }
476
+ return target;
477
+ }
478
+
479
+ function assertSafeInstanceId(value: string): void {
480
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) || value === '.' || value === '..') {
481
+ throw new FlowInstanceMaterializationError('instance-id-invalid', 'instanceId must be one safe path segment');
482
+ }
483
+ }
484
+
485
+ function nonNegativeSafeInteger(value: number, name: string): number {
486
+ if (!Number.isSafeInteger(value) || value < 0) throw new FlowInstanceMaterializationError('source-identity-invalid', `${name} must be non-negative`);
487
+ return value;
488
+ }
489
+
490
+ function deepFreeze<T>(value: T): T {
491
+ if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value;
492
+ for (const nested of Object.values(value)) deepFreeze(nested);
493
+ return Object.freeze(value);
494
+ }
495
+
496
+ function normalizeDefinitionProvenance(value: unknown, code: string): FlowDefinitionProvenance {
497
+ const record = exactDataRecord(value, ['authority', 'artifactRef'], ['metadata'], code);
498
+ const authority = normalizedString(dataValue(record, 'authority', code), 'provenance authority', code);
499
+ const artifactRef = normalizedString(dataValue(record, 'artifactRef', code), 'provenance artifactRef', code);
500
+ const metadata = Object.prototype.hasOwnProperty.call(record, 'metadata')
501
+ ? cloneClosedObject(dataValue(record, 'metadata', code), code)
502
+ : undefined;
503
+ return deepFreeze({ authority, artifactRef, ...(metadata === undefined ? {} : { metadata }) });
504
+ }
505
+
506
+ function exactDataRecord(
507
+ value: unknown,
508
+ required: readonly string[],
509
+ optional: readonly string[],
510
+ code: string,
511
+ ): Record<string, unknown> {
512
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
513
+ throw new FlowInstanceMaterializationError(code, 'expected a plain-data object');
514
+ }
515
+ const prototype = Object.getPrototypeOf(value);
516
+ if (prototype !== Object.prototype && prototype !== null) {
517
+ throw new FlowInstanceMaterializationError(code, 'expected a plain-data object');
518
+ }
519
+ const keys = Reflect.ownKeys(value);
520
+ if (keys.some((key) => typeof key !== 'string')) {
521
+ throw new FlowInstanceMaterializationError(code, 'symbol fields are not closed data');
522
+ }
523
+ const allowed = new Set([...required, ...optional]);
524
+ for (const key of keys as string[]) {
525
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
526
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
527
+ throw new FlowInstanceMaterializationError(code, 'fields must be enumerable data properties');
528
+ }
529
+ if (!allowed.has(key)) throw new FlowInstanceMaterializationError(code, `unsupported field: ${key}`);
530
+ }
531
+ for (const key of required) {
532
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
533
+ throw new FlowInstanceMaterializationError(code, `required field is missing: ${key}`);
534
+ }
535
+ }
536
+ return value as Record<string, unknown>;
537
+ }
538
+
539
+ function dataValue(record: Record<string, unknown>, field: string, code: string): unknown {
540
+ const descriptor = Object.getOwnPropertyDescriptor(record, field);
541
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
542
+ throw new FlowInstanceMaterializationError(code, `field must be an enumerable data property: ${field}`);
543
+ }
544
+ return descriptor.value;
545
+ }
546
+
547
+ function normalizedString(value: unknown, name: string, code: string): string {
548
+ if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) {
549
+ throw new FlowInstanceMaterializationError(code, `${name} must be a non-empty canonical string`);
550
+ }
551
+ return value;
552
+ }
553
+
554
+ function cloneClosedObject(value: unknown, code: string): FlowDefinitionProvenance['metadata'] {
555
+ const cloned = cloneClosedValue(value, code);
556
+ if (typeof cloned !== 'object' || cloned === null || Array.isArray(cloned)) {
557
+ throw new FlowInstanceMaterializationError(code, 'metadata must be a closed plain-data object');
558
+ }
559
+ return cloned as FlowDefinitionProvenance['metadata'];
560
+ }
561
+
562
+ function cloneClosedValue(value: unknown, code: string, ancestors = new WeakSet<object>()): unknown {
563
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
564
+ if (typeof value === 'number') {
565
+ if (!Number.isFinite(value)) throw new FlowInstanceMaterializationError(code, 'closed-data numbers must be finite');
566
+ return Object.is(value, -0) ? 0 : value;
567
+ }
568
+ if (typeof value !== 'object') throw new FlowInstanceMaterializationError(code, 'value is not closed data');
569
+ if (ancestors.has(value)) throw new FlowInstanceMaterializationError(code, 'closed data must not contain cycles');
570
+ ancestors.add(value);
571
+ try {
572
+ if (Array.isArray(value)) {
573
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
574
+ throw new FlowInstanceMaterializationError(code, 'closed arrays must use the standard prototype');
575
+ }
576
+ const keys = Reflect.ownKeys(value);
577
+ const expected = new Set<string>(['length']);
578
+ for (let index = 0; index < value.length; index += 1) expected.add(String(index));
579
+ if (keys.length !== expected.size
580
+ || keys.some((key) => typeof key !== 'string' || !expected.has(key))) {
581
+ throw new FlowInstanceMaterializationError(code, 'closed arrays must be dense without extra fields');
582
+ }
583
+ return Object.freeze(value.map((_, index) => {
584
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
585
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
586
+ throw new FlowInstanceMaterializationError(code, 'array entries must be enumerable data properties');
587
+ }
588
+ return cloneClosedValue(descriptor.value, code, ancestors);
589
+ }));
590
+ }
591
+ const record = exactClosedRecord(value, code);
592
+ const output: Record<string, unknown> = {};
593
+ for (const key of (Reflect.ownKeys(record) as string[]).sort(compareCodeUnits)) {
594
+ output[key] = cloneClosedValue(dataValue(record, key, code), code, ancestors);
595
+ }
596
+ return Object.freeze(output);
597
+ } finally {
598
+ ancestors.delete(value);
599
+ }
600
+ }
601
+
602
+ function exactClosedRecord(value: object, code: string): Record<string, unknown> {
603
+ const prototype = Object.getPrototypeOf(value);
604
+ if (prototype !== Object.prototype && prototype !== null) {
605
+ throw new FlowInstanceMaterializationError(code, 'closed objects must use a plain prototype');
606
+ }
607
+ const keys = Reflect.ownKeys(value);
608
+ if (keys.some((key) => typeof key !== 'string')) {
609
+ throw new FlowInstanceMaterializationError(code, 'symbol fields are not closed data');
610
+ }
611
+ for (const key of keys as string[]) dataValue(value as Record<string, unknown>, key, code);
612
+ return value as Record<string, unknown>;
613
+ }
614
+
615
+ function compareCodeUnits(left: string, right: string): number {
616
+ return left < right ? -1 : left > right ? 1 : 0;
617
+ }
618
+
19
619
  const VIEW_ATTR_RE = /\s*(?:status|iterations|runStatus|tickNo)\s*=\s*(?:"[^"]*"|[\w.]+)/g;
20
620
 
21
621
  /** 移除既往注入的视图键(仅 manifest.xnl 使用;task.space 的 status 是真实 DSL 字段,走 replace 路径) */
@@ -87,6 +687,25 @@ export function annotateFlowFile(instanceDir: string, view: TreeViewAnnotation):
87
687
  const tmp = file + '.tmp';
88
688
  fs.writeFileSync(tmp, text);
89
689
  fs.renameSync(tmp, file);
690
+
691
+ const assembled = assembleDefinitionStepProfileDirectory(instanceDir, 'WorkCtrlFlow');
692
+ if (!assembled.forest || assembled.diagnostics.length > 0) return;
693
+ for (const stepId of assembled.forest.stepOrder) {
694
+ const stepFile = path.join(instanceDir, ...assembled.forest.steps[stepId].sourceRef.split('/'));
695
+ let stepText = stripViewAttrs(fs.readFileSync(stepFile, 'utf8'));
696
+ const coreOffset = stepText.indexOf('<Core');
697
+ const prefix = coreOffset >= 0 ? stepText.slice(0, coreOffset) : '';
698
+ let coreText = coreOffset >= 0 ? stepText.slice(coreOffset) : stepText;
699
+ for (const [id, annotation] of Object.entries(view.nodes)) {
700
+ const entries: Record<string, string | number> = { status: annotation.status };
701
+ if (annotation.iterations !== undefined) entries.iterations = annotation.iterations;
702
+ coreText = injectAttrs(coreText, id, entries);
703
+ }
704
+ stepText = `${prefix}${coreText}`;
705
+ const stepTmp = `${stepFile}.tmp`;
706
+ fs.writeFileSync(stepTmp, stepText);
707
+ fs.renameSync(stepTmp, stepFile);
708
+ }
90
709
  }
91
710
 
92
711
  /** BPCtrlFlow:把任务状态机现值同步进实例 task.space.xnl(替换既有 status 值;真源在 TaskSpaceStore) */