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 +7 -7
- package/src/engine.ts +65 -9
- package/src/filesystem.ts +148 -1
- package/src/instance.ts +620 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "work-ctrl-flow-logic",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
16
|
-
"instant-ctrl-flow-contract": "0.1.
|
|
17
|
-
"
|
|
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
|
|
294
|
+
delete flowStateOf(ctx).fault;
|
|
261
295
|
flowStateOf(ctx).current = fact.input;
|
|
262
|
-
attempt.nextAttemptAtMs
|
|
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
|
|
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
|
|
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
|
|
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
|
|
796
|
-
|
|
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
|
-
|
|
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] = {
|
|
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 {
|
|
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
|
|
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) */
|