@contractspec/lib.workflow-composer 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -38
- package/dist/browser/index.js +111 -19
- package/dist/composer.test.d.ts +1 -0
- package/dist/index.js +111 -19
- package/dist/injector.d.ts +1 -1
- package/dist/node/index.js +111 -19
- package/dist/types.d.ts +15 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -2,45 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
Website: https://contractspec.io/
|
|
4
4
|
|
|
5
|
+
Compose base `WorkflowSpec` definitions with tenant-, role-, and device-scoped extensions.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
This package is designed for deterministic workflow personalization without duplicating core specs.
|
|
7
8
|
|
|
8
9
|
## Highlights
|
|
9
10
|
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
11
|
+
- Deterministic extension merge ordering with stable tie-breaking.
|
|
12
|
+
- Strict extension validation for anchors, transition endpoints, and duplicate injected step IDs.
|
|
13
|
+
- Safe hidden-step handling with entry-step protections.
|
|
14
|
+
- Metadata and annotations merge support on composed workflow outputs.
|
|
15
|
+
- Post-composition validation using `validateWorkflowSpec` from `@contractspec/lib.contracts-spec/workflow`.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { WorkflowComposer, approvalStepTemplate } from '@contractspec/lib.workflow-composer';
|
|
21
|
+
|
|
22
|
+
const composer = new WorkflowComposer();
|
|
23
|
+
|
|
24
|
+
composer.register({
|
|
25
|
+
workflow: 'billing.invoiceApproval',
|
|
26
|
+
tenantId: 'acme',
|
|
27
|
+
priority: 10,
|
|
28
|
+
metadata: {
|
|
29
|
+
tenant: 'acme',
|
|
30
|
+
},
|
|
31
|
+
annotations: {
|
|
32
|
+
source: 'tenant-extension',
|
|
33
|
+
},
|
|
34
|
+
customSteps: [
|
|
35
|
+
{
|
|
36
|
+
after: 'validate-invoice',
|
|
37
|
+
inject: approvalStepTemplate({
|
|
38
|
+
id: 'acme-legal-review',
|
|
39
|
+
label: 'Legal Review (ACME)',
|
|
40
|
+
description: 'Tenant-specific compliance step.',
|
|
41
|
+
}),
|
|
42
|
+
transitionTo: 'final-approval',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const runtimeSpec = composer.compose({
|
|
48
|
+
base: BaseInvoiceWorkflow,
|
|
49
|
+
tenantId: 'acme',
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Notes
|
|
54
|
+
|
|
55
|
+
- Extensions are additive and validated before application.
|
|
56
|
+
- Composition throws with explicit error codes/messages when extension constraints are violated.
|
|
57
|
+
- Runtime behavior remains controlled by `@contractspec/lib.contracts-spec/workflow` runner and validation.
|
package/dist/browser/index.js
CHANGED
|
@@ -12,16 +12,63 @@ function approvalStepTemplate(options) {
|
|
|
12
12
|
} : undefined
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
// src/injector.ts
|
|
16
|
+
import {
|
|
17
|
+
validateWorkflowSpec
|
|
18
|
+
} from "@contractspec/lib.contracts-spec/workflow";
|
|
19
|
+
|
|
15
20
|
// src/validator.ts
|
|
16
21
|
function validateExtension(extension, base) {
|
|
17
22
|
const issues = [];
|
|
18
|
-
const
|
|
23
|
+
const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
|
|
24
|
+
if (!extension.workflow.trim()) {
|
|
25
|
+
issues.push({
|
|
26
|
+
code: "workflow.extension.workflow",
|
|
27
|
+
message: "workflow is required"
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (extension.workflow !== base.meta.key) {
|
|
31
|
+
issues.push({
|
|
32
|
+
code: "workflow.extension.workflow.mismatch",
|
|
33
|
+
message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const hiddenSteps = new Set(extension.hiddenSteps ?? []);
|
|
37
|
+
hiddenSteps.forEach((stepId) => {
|
|
38
|
+
if (!baseStepIds.has(stepId)) {
|
|
39
|
+
issues.push({
|
|
40
|
+
code: "workflow.extension.hidden-step",
|
|
41
|
+
message: `hidden step "${stepId}" does not exist`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
|
|
19
46
|
extension.customSteps?.forEach((injection, idx) => {
|
|
20
|
-
|
|
47
|
+
const injectionId = injection.inject.id?.trim();
|
|
48
|
+
if (!injectionId) {
|
|
21
49
|
issues.push({
|
|
22
50
|
code: "workflow.extension.step.id",
|
|
23
51
|
message: `customSteps[${idx}] is missing an id`
|
|
24
52
|
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (injection.id && injection.id !== injectionId) {
|
|
56
|
+
issues.push({
|
|
57
|
+
code: "workflow.extension.step.id-mismatch",
|
|
58
|
+
message: `customSteps[${idx}] has mismatched id (id="${injection.id}", inject.id="${injectionId}")`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (availableAnchorSteps.has(injectionId)) {
|
|
62
|
+
issues.push({
|
|
63
|
+
code: "workflow.extension.step.id.duplicate",
|
|
64
|
+
message: `customSteps[${idx}] injects duplicate step id "${injectionId}"`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (injection.after && injection.before) {
|
|
68
|
+
issues.push({
|
|
69
|
+
code: "workflow.extension.step.anchor.multiple",
|
|
70
|
+
message: `customSteps[${idx}] cannot set both after and before`
|
|
71
|
+
});
|
|
25
72
|
}
|
|
26
73
|
if (!injection.after && !injection.before) {
|
|
27
74
|
issues.push({
|
|
@@ -29,27 +76,39 @@ function validateExtension(extension, base) {
|
|
|
29
76
|
message: `customSteps[${idx}] must set after or before`
|
|
30
77
|
});
|
|
31
78
|
}
|
|
32
|
-
if (injection.after && !
|
|
79
|
+
if (injection.after && !availableAnchorSteps.has(injection.after)) {
|
|
33
80
|
issues.push({
|
|
34
81
|
code: "workflow.extension.step.after",
|
|
35
82
|
message: `customSteps[${idx}] references unknown step "${injection.after}"`
|
|
36
83
|
});
|
|
37
84
|
}
|
|
38
|
-
if (injection.before && !
|
|
85
|
+
if (injection.before && !availableAnchorSteps.has(injection.before)) {
|
|
39
86
|
issues.push({
|
|
40
87
|
code: "workflow.extension.step.before",
|
|
41
88
|
message: `customSteps[${idx}] references unknown step "${injection.before}"`
|
|
42
89
|
});
|
|
43
90
|
}
|
|
44
|
-
|
|
45
|
-
extension.hiddenSteps?.forEach((stepId) => {
|
|
46
|
-
if (!stepIds.has(stepId)) {
|
|
91
|
+
if (injection.transitionFrom && !availableAnchorSteps.has(injection.transitionFrom)) {
|
|
47
92
|
issues.push({
|
|
48
|
-
code: "workflow.extension.
|
|
49
|
-
message: `
|
|
93
|
+
code: "workflow.extension.step.transition-from",
|
|
94
|
+
message: `customSteps[${idx}] references unknown transitionFrom step "${injection.transitionFrom}"`
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (injection.transitionTo && !availableAnchorSteps.has(injection.transitionTo)) {
|
|
98
|
+
issues.push({
|
|
99
|
+
code: "workflow.extension.step.transition-to",
|
|
100
|
+
message: `customSteps[${idx}] references unknown transitionTo step "${injection.transitionTo}"`
|
|
50
101
|
});
|
|
51
102
|
}
|
|
103
|
+
availableAnchorSteps.add(injectionId);
|
|
52
104
|
});
|
|
105
|
+
const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
|
|
106
|
+
if (entryStepId && hiddenSteps.has(entryStepId)) {
|
|
107
|
+
issues.push({
|
|
108
|
+
code: "workflow.extension.hidden-step.entry",
|
|
109
|
+
message: `hiddenSteps removes the entry step "${entryStepId}"`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
53
112
|
if (issues.length) {
|
|
54
113
|
const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
|
|
55
114
|
throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
|
|
@@ -78,10 +137,13 @@ function applyWorkflowExtension(base, extension) {
|
|
|
78
137
|
});
|
|
79
138
|
spec.definition.steps = steps;
|
|
80
139
|
spec.definition.transitions = dedupeTransitions(transitions);
|
|
81
|
-
spec.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
140
|
+
spec.metadata = mergeRecords(spec.metadata, extension.metadata);
|
|
141
|
+
spec.annotations = mergeRecords(spec.annotations, extension.annotations);
|
|
142
|
+
const issues = validateWorkflowSpec(spec);
|
|
143
|
+
const blockingIssues = issues.filter((issue) => issue.level === "error");
|
|
144
|
+
if (blockingIssues.length > 0) {
|
|
145
|
+
throw new Error(`Invalid composed workflow ${spec.meta.key}.v${spec.meta.version}: ${blockingIssues.map((issue) => issue.message).join("; ")}`);
|
|
146
|
+
}
|
|
85
147
|
return spec;
|
|
86
148
|
}
|
|
87
149
|
function insertStep(steps, injection) {
|
|
@@ -135,9 +197,41 @@ function dedupeTransitions(transitions) {
|
|
|
135
197
|
function cloneWorkflowSpec(spec) {
|
|
136
198
|
return JSON.parse(JSON.stringify(spec));
|
|
137
199
|
}
|
|
200
|
+
function mergeRecords(base, patch) {
|
|
201
|
+
if (!base && !patch) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...base ?? {},
|
|
206
|
+
...patch ?? {}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
138
209
|
// src/composer.ts
|
|
139
210
|
import { satisfies } from "compare-versions";
|
|
140
211
|
|
|
212
|
+
// src/merger.ts
|
|
213
|
+
function mergeExtensions(extensions) {
|
|
214
|
+
return [...extensions].sort((a, b) => {
|
|
215
|
+
const priorityOrder = (a.priority ?? 0) - (b.priority ?? 0);
|
|
216
|
+
if (priorityOrder !== 0)
|
|
217
|
+
return priorityOrder;
|
|
218
|
+
const workflowOrder = a.workflow.localeCompare(b.workflow);
|
|
219
|
+
if (workflowOrder !== 0)
|
|
220
|
+
return workflowOrder;
|
|
221
|
+
const tenantOrder = (a.tenantId ?? "").localeCompare(b.tenantId ?? "");
|
|
222
|
+
if (tenantOrder !== 0)
|
|
223
|
+
return tenantOrder;
|
|
224
|
+
const roleOrder = (a.role ?? "").localeCompare(b.role ?? "");
|
|
225
|
+
if (roleOrder !== 0)
|
|
226
|
+
return roleOrder;
|
|
227
|
+
const deviceOrder = (a.device ?? "").localeCompare(b.device ?? "");
|
|
228
|
+
if (deviceOrder !== 0)
|
|
229
|
+
return deviceOrder;
|
|
230
|
+
return (a.baseVersion ?? "").localeCompare(b.baseVersion ?? "");
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/composer.ts
|
|
141
235
|
class WorkflowComposer {
|
|
142
236
|
extensions = [];
|
|
143
237
|
register(extension) {
|
|
@@ -145,11 +239,13 @@ class WorkflowComposer {
|
|
|
145
239
|
return this;
|
|
146
240
|
}
|
|
147
241
|
registerMany(extensions) {
|
|
148
|
-
extensions.forEach((extension) =>
|
|
242
|
+
extensions.forEach((extension) => {
|
|
243
|
+
this.register(extension);
|
|
244
|
+
});
|
|
149
245
|
return this;
|
|
150
246
|
}
|
|
151
247
|
compose(params) {
|
|
152
|
-
const applicable = this.extensions.filter((extension) => matches(params, extension))
|
|
248
|
+
const applicable = mergeExtensions(this.extensions.filter((extension) => matches(params, extension)));
|
|
153
249
|
return applicable.reduce((acc, extension) => applyWorkflowExtension(acc, extension), params.base);
|
|
154
250
|
}
|
|
155
251
|
}
|
|
@@ -170,10 +266,6 @@ function matches(params, extension) {
|
|
|
170
266
|
}
|
|
171
267
|
return true;
|
|
172
268
|
}
|
|
173
|
-
// src/merger.ts
|
|
174
|
-
function mergeExtensions(extensions) {
|
|
175
|
-
return extensions.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
176
|
-
}
|
|
177
269
|
export {
|
|
178
270
|
validateExtension,
|
|
179
271
|
mergeExtensions,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -13,16 +13,63 @@ function approvalStepTemplate(options) {
|
|
|
13
13
|
} : undefined
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
// src/injector.ts
|
|
17
|
+
import {
|
|
18
|
+
validateWorkflowSpec
|
|
19
|
+
} from "@contractspec/lib.contracts-spec/workflow";
|
|
20
|
+
|
|
16
21
|
// src/validator.ts
|
|
17
22
|
function validateExtension(extension, base) {
|
|
18
23
|
const issues = [];
|
|
19
|
-
const
|
|
24
|
+
const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
|
|
25
|
+
if (!extension.workflow.trim()) {
|
|
26
|
+
issues.push({
|
|
27
|
+
code: "workflow.extension.workflow",
|
|
28
|
+
message: "workflow is required"
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (extension.workflow !== base.meta.key) {
|
|
32
|
+
issues.push({
|
|
33
|
+
code: "workflow.extension.workflow.mismatch",
|
|
34
|
+
message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const hiddenSteps = new Set(extension.hiddenSteps ?? []);
|
|
38
|
+
hiddenSteps.forEach((stepId) => {
|
|
39
|
+
if (!baseStepIds.has(stepId)) {
|
|
40
|
+
issues.push({
|
|
41
|
+
code: "workflow.extension.hidden-step",
|
|
42
|
+
message: `hidden step "${stepId}" does not exist`
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
|
|
20
47
|
extension.customSteps?.forEach((injection, idx) => {
|
|
21
|
-
|
|
48
|
+
const injectionId = injection.inject.id?.trim();
|
|
49
|
+
if (!injectionId) {
|
|
22
50
|
issues.push({
|
|
23
51
|
code: "workflow.extension.step.id",
|
|
24
52
|
message: `customSteps[${idx}] is missing an id`
|
|
25
53
|
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (injection.id && injection.id !== injectionId) {
|
|
57
|
+
issues.push({
|
|
58
|
+
code: "workflow.extension.step.id-mismatch",
|
|
59
|
+
message: `customSteps[${idx}] has mismatched id (id="${injection.id}", inject.id="${injectionId}")`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (availableAnchorSteps.has(injectionId)) {
|
|
63
|
+
issues.push({
|
|
64
|
+
code: "workflow.extension.step.id.duplicate",
|
|
65
|
+
message: `customSteps[${idx}] injects duplicate step id "${injectionId}"`
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (injection.after && injection.before) {
|
|
69
|
+
issues.push({
|
|
70
|
+
code: "workflow.extension.step.anchor.multiple",
|
|
71
|
+
message: `customSteps[${idx}] cannot set both after and before`
|
|
72
|
+
});
|
|
26
73
|
}
|
|
27
74
|
if (!injection.after && !injection.before) {
|
|
28
75
|
issues.push({
|
|
@@ -30,27 +77,39 @@ function validateExtension(extension, base) {
|
|
|
30
77
|
message: `customSteps[${idx}] must set after or before`
|
|
31
78
|
});
|
|
32
79
|
}
|
|
33
|
-
if (injection.after && !
|
|
80
|
+
if (injection.after && !availableAnchorSteps.has(injection.after)) {
|
|
34
81
|
issues.push({
|
|
35
82
|
code: "workflow.extension.step.after",
|
|
36
83
|
message: `customSteps[${idx}] references unknown step "${injection.after}"`
|
|
37
84
|
});
|
|
38
85
|
}
|
|
39
|
-
if (injection.before && !
|
|
86
|
+
if (injection.before && !availableAnchorSteps.has(injection.before)) {
|
|
40
87
|
issues.push({
|
|
41
88
|
code: "workflow.extension.step.before",
|
|
42
89
|
message: `customSteps[${idx}] references unknown step "${injection.before}"`
|
|
43
90
|
});
|
|
44
91
|
}
|
|
45
|
-
|
|
46
|
-
extension.hiddenSteps?.forEach((stepId) => {
|
|
47
|
-
if (!stepIds.has(stepId)) {
|
|
92
|
+
if (injection.transitionFrom && !availableAnchorSteps.has(injection.transitionFrom)) {
|
|
48
93
|
issues.push({
|
|
49
|
-
code: "workflow.extension.
|
|
50
|
-
message: `
|
|
94
|
+
code: "workflow.extension.step.transition-from",
|
|
95
|
+
message: `customSteps[${idx}] references unknown transitionFrom step "${injection.transitionFrom}"`
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (injection.transitionTo && !availableAnchorSteps.has(injection.transitionTo)) {
|
|
99
|
+
issues.push({
|
|
100
|
+
code: "workflow.extension.step.transition-to",
|
|
101
|
+
message: `customSteps[${idx}] references unknown transitionTo step "${injection.transitionTo}"`
|
|
51
102
|
});
|
|
52
103
|
}
|
|
104
|
+
availableAnchorSteps.add(injectionId);
|
|
53
105
|
});
|
|
106
|
+
const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
|
|
107
|
+
if (entryStepId && hiddenSteps.has(entryStepId)) {
|
|
108
|
+
issues.push({
|
|
109
|
+
code: "workflow.extension.hidden-step.entry",
|
|
110
|
+
message: `hiddenSteps removes the entry step "${entryStepId}"`
|
|
111
|
+
});
|
|
112
|
+
}
|
|
54
113
|
if (issues.length) {
|
|
55
114
|
const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
|
|
56
115
|
throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
|
|
@@ -79,10 +138,13 @@ function applyWorkflowExtension(base, extension) {
|
|
|
79
138
|
});
|
|
80
139
|
spec.definition.steps = steps;
|
|
81
140
|
spec.definition.transitions = dedupeTransitions(transitions);
|
|
82
|
-
spec.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
141
|
+
spec.metadata = mergeRecords(spec.metadata, extension.metadata);
|
|
142
|
+
spec.annotations = mergeRecords(spec.annotations, extension.annotations);
|
|
143
|
+
const issues = validateWorkflowSpec(spec);
|
|
144
|
+
const blockingIssues = issues.filter((issue) => issue.level === "error");
|
|
145
|
+
if (blockingIssues.length > 0) {
|
|
146
|
+
throw new Error(`Invalid composed workflow ${spec.meta.key}.v${spec.meta.version}: ${blockingIssues.map((issue) => issue.message).join("; ")}`);
|
|
147
|
+
}
|
|
86
148
|
return spec;
|
|
87
149
|
}
|
|
88
150
|
function insertStep(steps, injection) {
|
|
@@ -136,9 +198,41 @@ function dedupeTransitions(transitions) {
|
|
|
136
198
|
function cloneWorkflowSpec(spec) {
|
|
137
199
|
return JSON.parse(JSON.stringify(spec));
|
|
138
200
|
}
|
|
201
|
+
function mergeRecords(base, patch) {
|
|
202
|
+
if (!base && !patch) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
...base ?? {},
|
|
207
|
+
...patch ?? {}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
139
210
|
// src/composer.ts
|
|
140
211
|
import { satisfies } from "compare-versions";
|
|
141
212
|
|
|
213
|
+
// src/merger.ts
|
|
214
|
+
function mergeExtensions(extensions) {
|
|
215
|
+
return [...extensions].sort((a, b) => {
|
|
216
|
+
const priorityOrder = (a.priority ?? 0) - (b.priority ?? 0);
|
|
217
|
+
if (priorityOrder !== 0)
|
|
218
|
+
return priorityOrder;
|
|
219
|
+
const workflowOrder = a.workflow.localeCompare(b.workflow);
|
|
220
|
+
if (workflowOrder !== 0)
|
|
221
|
+
return workflowOrder;
|
|
222
|
+
const tenantOrder = (a.tenantId ?? "").localeCompare(b.tenantId ?? "");
|
|
223
|
+
if (tenantOrder !== 0)
|
|
224
|
+
return tenantOrder;
|
|
225
|
+
const roleOrder = (a.role ?? "").localeCompare(b.role ?? "");
|
|
226
|
+
if (roleOrder !== 0)
|
|
227
|
+
return roleOrder;
|
|
228
|
+
const deviceOrder = (a.device ?? "").localeCompare(b.device ?? "");
|
|
229
|
+
if (deviceOrder !== 0)
|
|
230
|
+
return deviceOrder;
|
|
231
|
+
return (a.baseVersion ?? "").localeCompare(b.baseVersion ?? "");
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/composer.ts
|
|
142
236
|
class WorkflowComposer {
|
|
143
237
|
extensions = [];
|
|
144
238
|
register(extension) {
|
|
@@ -146,11 +240,13 @@ class WorkflowComposer {
|
|
|
146
240
|
return this;
|
|
147
241
|
}
|
|
148
242
|
registerMany(extensions) {
|
|
149
|
-
extensions.forEach((extension) =>
|
|
243
|
+
extensions.forEach((extension) => {
|
|
244
|
+
this.register(extension);
|
|
245
|
+
});
|
|
150
246
|
return this;
|
|
151
247
|
}
|
|
152
248
|
compose(params) {
|
|
153
|
-
const applicable = this.extensions.filter((extension) => matches(params, extension))
|
|
249
|
+
const applicable = mergeExtensions(this.extensions.filter((extension) => matches(params, extension)));
|
|
154
250
|
return applicable.reduce((acc, extension) => applyWorkflowExtension(acc, extension), params.base);
|
|
155
251
|
}
|
|
156
252
|
}
|
|
@@ -171,10 +267,6 @@ function matches(params, extension) {
|
|
|
171
267
|
}
|
|
172
268
|
return true;
|
|
173
269
|
}
|
|
174
|
-
// src/merger.ts
|
|
175
|
-
function mergeExtensions(extensions) {
|
|
176
|
-
return extensions.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
177
|
-
}
|
|
178
270
|
export {
|
|
179
271
|
validateExtension,
|
|
180
272
|
mergeExtensions,
|
package/dist/injector.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
|
|
2
2
|
import type { WorkflowExtension } from './types';
|
|
3
3
|
export declare function applyWorkflowExtension(base: WorkflowSpec, extension: WorkflowExtension): WorkflowSpec;
|
package/dist/node/index.js
CHANGED
|
@@ -12,16 +12,63 @@ function approvalStepTemplate(options) {
|
|
|
12
12
|
} : undefined
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
// src/injector.ts
|
|
16
|
+
import {
|
|
17
|
+
validateWorkflowSpec
|
|
18
|
+
} from "@contractspec/lib.contracts-spec/workflow";
|
|
19
|
+
|
|
15
20
|
// src/validator.ts
|
|
16
21
|
function validateExtension(extension, base) {
|
|
17
22
|
const issues = [];
|
|
18
|
-
const
|
|
23
|
+
const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
|
|
24
|
+
if (!extension.workflow.trim()) {
|
|
25
|
+
issues.push({
|
|
26
|
+
code: "workflow.extension.workflow",
|
|
27
|
+
message: "workflow is required"
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (extension.workflow !== base.meta.key) {
|
|
31
|
+
issues.push({
|
|
32
|
+
code: "workflow.extension.workflow.mismatch",
|
|
33
|
+
message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const hiddenSteps = new Set(extension.hiddenSteps ?? []);
|
|
37
|
+
hiddenSteps.forEach((stepId) => {
|
|
38
|
+
if (!baseStepIds.has(stepId)) {
|
|
39
|
+
issues.push({
|
|
40
|
+
code: "workflow.extension.hidden-step",
|
|
41
|
+
message: `hidden step "${stepId}" does not exist`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
|
|
19
46
|
extension.customSteps?.forEach((injection, idx) => {
|
|
20
|
-
|
|
47
|
+
const injectionId = injection.inject.id?.trim();
|
|
48
|
+
if (!injectionId) {
|
|
21
49
|
issues.push({
|
|
22
50
|
code: "workflow.extension.step.id",
|
|
23
51
|
message: `customSteps[${idx}] is missing an id`
|
|
24
52
|
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (injection.id && injection.id !== injectionId) {
|
|
56
|
+
issues.push({
|
|
57
|
+
code: "workflow.extension.step.id-mismatch",
|
|
58
|
+
message: `customSteps[${idx}] has mismatched id (id="${injection.id}", inject.id="${injectionId}")`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (availableAnchorSteps.has(injectionId)) {
|
|
62
|
+
issues.push({
|
|
63
|
+
code: "workflow.extension.step.id.duplicate",
|
|
64
|
+
message: `customSteps[${idx}] injects duplicate step id "${injectionId}"`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (injection.after && injection.before) {
|
|
68
|
+
issues.push({
|
|
69
|
+
code: "workflow.extension.step.anchor.multiple",
|
|
70
|
+
message: `customSteps[${idx}] cannot set both after and before`
|
|
71
|
+
});
|
|
25
72
|
}
|
|
26
73
|
if (!injection.after && !injection.before) {
|
|
27
74
|
issues.push({
|
|
@@ -29,27 +76,39 @@ function validateExtension(extension, base) {
|
|
|
29
76
|
message: `customSteps[${idx}] must set after or before`
|
|
30
77
|
});
|
|
31
78
|
}
|
|
32
|
-
if (injection.after && !
|
|
79
|
+
if (injection.after && !availableAnchorSteps.has(injection.after)) {
|
|
33
80
|
issues.push({
|
|
34
81
|
code: "workflow.extension.step.after",
|
|
35
82
|
message: `customSteps[${idx}] references unknown step "${injection.after}"`
|
|
36
83
|
});
|
|
37
84
|
}
|
|
38
|
-
if (injection.before && !
|
|
85
|
+
if (injection.before && !availableAnchorSteps.has(injection.before)) {
|
|
39
86
|
issues.push({
|
|
40
87
|
code: "workflow.extension.step.before",
|
|
41
88
|
message: `customSteps[${idx}] references unknown step "${injection.before}"`
|
|
42
89
|
});
|
|
43
90
|
}
|
|
44
|
-
|
|
45
|
-
extension.hiddenSteps?.forEach((stepId) => {
|
|
46
|
-
if (!stepIds.has(stepId)) {
|
|
91
|
+
if (injection.transitionFrom && !availableAnchorSteps.has(injection.transitionFrom)) {
|
|
47
92
|
issues.push({
|
|
48
|
-
code: "workflow.extension.
|
|
49
|
-
message: `
|
|
93
|
+
code: "workflow.extension.step.transition-from",
|
|
94
|
+
message: `customSteps[${idx}] references unknown transitionFrom step "${injection.transitionFrom}"`
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (injection.transitionTo && !availableAnchorSteps.has(injection.transitionTo)) {
|
|
98
|
+
issues.push({
|
|
99
|
+
code: "workflow.extension.step.transition-to",
|
|
100
|
+
message: `customSteps[${idx}] references unknown transitionTo step "${injection.transitionTo}"`
|
|
50
101
|
});
|
|
51
102
|
}
|
|
103
|
+
availableAnchorSteps.add(injectionId);
|
|
52
104
|
});
|
|
105
|
+
const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
|
|
106
|
+
if (entryStepId && hiddenSteps.has(entryStepId)) {
|
|
107
|
+
issues.push({
|
|
108
|
+
code: "workflow.extension.hidden-step.entry",
|
|
109
|
+
message: `hiddenSteps removes the entry step "${entryStepId}"`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
53
112
|
if (issues.length) {
|
|
54
113
|
const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
|
|
55
114
|
throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
|
|
@@ -78,10 +137,13 @@ function applyWorkflowExtension(base, extension) {
|
|
|
78
137
|
});
|
|
79
138
|
spec.definition.steps = steps;
|
|
80
139
|
spec.definition.transitions = dedupeTransitions(transitions);
|
|
81
|
-
spec.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
140
|
+
spec.metadata = mergeRecords(spec.metadata, extension.metadata);
|
|
141
|
+
spec.annotations = mergeRecords(spec.annotations, extension.annotations);
|
|
142
|
+
const issues = validateWorkflowSpec(spec);
|
|
143
|
+
const blockingIssues = issues.filter((issue) => issue.level === "error");
|
|
144
|
+
if (blockingIssues.length > 0) {
|
|
145
|
+
throw new Error(`Invalid composed workflow ${spec.meta.key}.v${spec.meta.version}: ${blockingIssues.map((issue) => issue.message).join("; ")}`);
|
|
146
|
+
}
|
|
85
147
|
return spec;
|
|
86
148
|
}
|
|
87
149
|
function insertStep(steps, injection) {
|
|
@@ -135,9 +197,41 @@ function dedupeTransitions(transitions) {
|
|
|
135
197
|
function cloneWorkflowSpec(spec) {
|
|
136
198
|
return JSON.parse(JSON.stringify(spec));
|
|
137
199
|
}
|
|
200
|
+
function mergeRecords(base, patch) {
|
|
201
|
+
if (!base && !patch) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...base ?? {},
|
|
206
|
+
...patch ?? {}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
138
209
|
// src/composer.ts
|
|
139
210
|
import { satisfies } from "compare-versions";
|
|
140
211
|
|
|
212
|
+
// src/merger.ts
|
|
213
|
+
function mergeExtensions(extensions) {
|
|
214
|
+
return [...extensions].sort((a, b) => {
|
|
215
|
+
const priorityOrder = (a.priority ?? 0) - (b.priority ?? 0);
|
|
216
|
+
if (priorityOrder !== 0)
|
|
217
|
+
return priorityOrder;
|
|
218
|
+
const workflowOrder = a.workflow.localeCompare(b.workflow);
|
|
219
|
+
if (workflowOrder !== 0)
|
|
220
|
+
return workflowOrder;
|
|
221
|
+
const tenantOrder = (a.tenantId ?? "").localeCompare(b.tenantId ?? "");
|
|
222
|
+
if (tenantOrder !== 0)
|
|
223
|
+
return tenantOrder;
|
|
224
|
+
const roleOrder = (a.role ?? "").localeCompare(b.role ?? "");
|
|
225
|
+
if (roleOrder !== 0)
|
|
226
|
+
return roleOrder;
|
|
227
|
+
const deviceOrder = (a.device ?? "").localeCompare(b.device ?? "");
|
|
228
|
+
if (deviceOrder !== 0)
|
|
229
|
+
return deviceOrder;
|
|
230
|
+
return (a.baseVersion ?? "").localeCompare(b.baseVersion ?? "");
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/composer.ts
|
|
141
235
|
class WorkflowComposer {
|
|
142
236
|
extensions = [];
|
|
143
237
|
register(extension) {
|
|
@@ -145,11 +239,13 @@ class WorkflowComposer {
|
|
|
145
239
|
return this;
|
|
146
240
|
}
|
|
147
241
|
registerMany(extensions) {
|
|
148
|
-
extensions.forEach((extension) =>
|
|
242
|
+
extensions.forEach((extension) => {
|
|
243
|
+
this.register(extension);
|
|
244
|
+
});
|
|
149
245
|
return this;
|
|
150
246
|
}
|
|
151
247
|
compose(params) {
|
|
152
|
-
const applicable = this.extensions.filter((extension) => matches(params, extension))
|
|
248
|
+
const applicable = mergeExtensions(this.extensions.filter((extension) => matches(params, extension)));
|
|
153
249
|
return applicable.reduce((acc, extension) => applyWorkflowExtension(acc, extension), params.base);
|
|
154
250
|
}
|
|
155
251
|
}
|
|
@@ -170,10 +266,6 @@ function matches(params, extension) {
|
|
|
170
266
|
}
|
|
171
267
|
return true;
|
|
172
268
|
}
|
|
173
|
-
// src/merger.ts
|
|
174
|
-
function mergeExtensions(extensions) {
|
|
175
|
-
return extensions.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
176
|
-
}
|
|
177
269
|
export {
|
|
178
270
|
validateExtension,
|
|
179
271
|
mergeExtensions,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import type { Step, WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
|
|
1
|
+
import type { Step, StepModelHints, WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
|
|
2
|
+
import type { ModelSelector } from '@contractspec/lib.ai-providers/selector-types';
|
|
3
|
+
/**
|
|
4
|
+
* Context provided to operation executors during workflow step execution.
|
|
5
|
+
* Includes an optional ranking-driven model selector for AI-powered steps.
|
|
6
|
+
*/
|
|
7
|
+
export interface OperationExecutorContext {
|
|
8
|
+
tenantId?: string;
|
|
9
|
+
role?: string;
|
|
10
|
+
/** Ranking-driven model selector; executors should use this when selecting AI models. */
|
|
11
|
+
modelSelector?: ModelSelector;
|
|
12
|
+
/** Model hints from the current step, if any. */
|
|
13
|
+
stepModelHints?: StepModelHints;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
2
16
|
export interface WorkflowExtensionScope {
|
|
3
17
|
tenantId?: string;
|
|
4
18
|
role?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractspec/lib.workflow-composer",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Tenant-aware workflow composition helpers for ContractSpec.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"contractspec",
|
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@contractspec/lib.contracts-spec": "3.
|
|
34
|
+
"@contractspec/lib.contracts-spec": "3.1.1",
|
|
35
|
+
"@contractspec/lib.ai-providers": "3.1.1",
|
|
35
36
|
"compare-versions": "^6.1.1"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
|
-
"@contractspec/tool.typescript": "3.
|
|
39
|
+
"@contractspec/tool.typescript": "3.1.0",
|
|
39
40
|
"typescript": "^5.9.3",
|
|
40
|
-
"@contractspec/tool.bun": "3.
|
|
41
|
+
"@contractspec/tool.bun": "3.1.0"
|
|
41
42
|
},
|
|
42
43
|
"exports": {
|
|
43
44
|
".": {
|