@openwop/openwop-conformance 1.1.0 → 1.2.0
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/CHANGELOG.md +25 -0
- package/README.md +2 -2
- package/coverage.md +29 -17
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +12 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +300 -3
- package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +18 -2
- package/schemas/run-event.schema.json +2 -1
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/behavior-gate.ts +44 -5
- package/src/lib/env.ts +27 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +66 -0
- package/src/scenarios/blob-roundtrip.test.ts +48 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +13 -6
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
- package/src/scenarios/registry-public.test.ts +91 -0
- package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
- package/src/scenarios/spec-corpus-validity.test.ts +28 -7
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +47 -0
- package/src/scenarios/table-schema-enforcement.test.ts +47 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain expansion semantics — `workflow-chain-packs.md` §"Expansion semantics (normative)"
|
|
3
|
+
* + reference impl at `conformance/src/lib/workflow-chain-expansion.ts` (closes RFC 0013 Phase 3).
|
|
4
|
+
*
|
|
5
|
+
* Server-free scenario. Exercises the 9-step expansion algorithm
|
|
6
|
+
* through the reference library. Asserts:
|
|
7
|
+
*
|
|
8
|
+
* - Step 5: `{{params.<name>}}` placeholders substitute literally,
|
|
9
|
+
* recursively into nested string fields of `config` / `inputs`.
|
|
10
|
+
* - Step 6: node id rewriting — same chainId expanded TWICE in one
|
|
11
|
+
* parent workflow MUST produce non-colliding ids (the load-bearing
|
|
12
|
+
* property — without it, multi-expansion DAGs would have id
|
|
13
|
+
* duplicates that violate workflow-definition.schema.json).
|
|
14
|
+
* - Step 8: capability propagation — chain-level `capabilities[]`
|
|
15
|
+
* copy into every expanded node's `capabilities[]` so existing
|
|
16
|
+
* side-effect / streamable gates apply uniformly post-expansion.
|
|
17
|
+
* - Edge rewriting — `from`/`to` endpoints that reference fragment
|
|
18
|
+
* nodes get the chain prefix; refs to nodes OUTSIDE the fragment
|
|
19
|
+
* pass through unchanged so the parent workflow's adjacent wiring
|
|
20
|
+
* stays intact.
|
|
21
|
+
* - Output shape — the expanded fragment contains ONLY concrete
|
|
22
|
+
* typeIds (no chain reference survives to runtime).
|
|
23
|
+
*
|
|
24
|
+
* Capability-gated (logical sense): a host that ships chain expansion
|
|
25
|
+
* advertises `capabilities.workflowChainPacks.supported: true`. This
|
|
26
|
+
* scenario runs unconditionally because the reference library is
|
|
27
|
+
* black-box logic — the spec is the spec regardless of host adoption.
|
|
28
|
+
* Host-side end-to-end expansion gates on the capability flag.
|
|
29
|
+
*
|
|
30
|
+
* @see spec/v1/workflow-chain-packs.md §"Expansion semantics (normative)"
|
|
31
|
+
* @see conformance/src/lib/workflow-chain-expansion.ts
|
|
32
|
+
* @see RFCS/0013-workflow-chain-packs.md
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { expandChain, type WorkflowChain } from '../lib/workflow-chain-expansion.js';
|
|
37
|
+
|
|
38
|
+
/** Index helper that narrows away the `T | undefined` from `noUncheckedIndexedAccess`
|
|
39
|
+
* with an actionable error message — replaces `arr[i]!` non-null assertions so a
|
|
40
|
+
* shape mismatch surfaces as a real diagnostic instead of a TypeError-on-property-access. */
|
|
41
|
+
function at<T>(arr: ReadonlyArray<T>, i: number, label: string): T {
|
|
42
|
+
const value = arr[i];
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
throw new Error(`${label}[${i}] expected to be defined (length=${arr.length})`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Canonical sample chain used across multiple cases — mirrors the
|
|
50
|
+
* spec doc's "Positive: 1-node chain" example, kept tight so each
|
|
51
|
+
* assertion is focused on one rule. */
|
|
52
|
+
const SAMPLE_CHAIN: WorkflowChain = {
|
|
53
|
+
chainId: 'vendor.acme.generatePRD',
|
|
54
|
+
version: '1.0.0',
|
|
55
|
+
label: 'Generate PRD',
|
|
56
|
+
description: 'Single-node AI call.',
|
|
57
|
+
parameters: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
required: ['productIdea'],
|
|
60
|
+
properties: {
|
|
61
|
+
productIdea: { type: 'string' },
|
|
62
|
+
targetAudience: { type: 'string', default: '' },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
dag: {
|
|
66
|
+
nodes: [
|
|
67
|
+
{
|
|
68
|
+
id: 'prd-call',
|
|
69
|
+
typeId: 'core.ai.callPrompt',
|
|
70
|
+
name: 'Generate PRD',
|
|
71
|
+
config: {
|
|
72
|
+
systemPrompt: 'Write a PRD for: {{params.productIdea}}\nAudience: {{params.targetAudience}}',
|
|
73
|
+
envelopeType: 'prd.create',
|
|
74
|
+
provider: 'anthropic',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
edges: [],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Resolver helper — accepts every typeId. Used when typeId resolution
|
|
83
|
+
* isn't the property under test. The unresolvable-typeid scenario lives
|
|
84
|
+
* in its own file. */
|
|
85
|
+
const RESOLVE_ALL = (_typeId: string): boolean => true;
|
|
86
|
+
|
|
87
|
+
describe('category: workflow-chain expansion — placeholder substitution', () => {
|
|
88
|
+
it('substitutes {{params.x}} placeholders literally in config strings', () => {
|
|
89
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
90
|
+
expansionId: 'a8f3',
|
|
91
|
+
params: { productIdea: 'AI-powered toaster', targetAudience: 'restaurant chains' },
|
|
92
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
93
|
+
});
|
|
94
|
+
expect(fragment.nodes).toHaveLength(1);
|
|
95
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as { systemPrompt: string };
|
|
96
|
+
expect(
|
|
97
|
+
config.systemPrompt,
|
|
98
|
+
'Per workflow-chain-packs.md §"Parameter substitution": placeholders MUST be substituted literally at expansion time.',
|
|
99
|
+
).toBe('Write a PRD for: AI-powered toaster\nAudience: restaurant chains');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('recurses into nested object structures in config', () => {
|
|
103
|
+
const chain: WorkflowChain = {
|
|
104
|
+
...SAMPLE_CHAIN,
|
|
105
|
+
dag: {
|
|
106
|
+
nodes: [
|
|
107
|
+
{
|
|
108
|
+
id: 'n',
|
|
109
|
+
typeId: 'core.identity',
|
|
110
|
+
config: {
|
|
111
|
+
outer: {
|
|
112
|
+
middle: {
|
|
113
|
+
message: 'Hello {{params.who}}',
|
|
114
|
+
tags: ['static', '{{params.who}}-tag'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
edges: [],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const fragment = expandChain(chain, {
|
|
124
|
+
expansionId: 'x1',
|
|
125
|
+
params: { who: 'world' },
|
|
126
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
127
|
+
});
|
|
128
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as {
|
|
129
|
+
outer: { middle: { message: string; tags: string[] } };
|
|
130
|
+
};
|
|
131
|
+
expect(
|
|
132
|
+
config.outer.middle.message,
|
|
133
|
+
'Recursive substitution MUST walk into nested objects (per spec §Parameter substitution: "Substitution MUST recurse into nested string values within `config` and `inputs`").',
|
|
134
|
+
).toBe('Hello world');
|
|
135
|
+
expect(
|
|
136
|
+
config.outer.middle.tags,
|
|
137
|
+
'Recursive substitution MUST also walk into array elements when those elements are strings.',
|
|
138
|
+
).toEqual(['static', 'world-tag']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('leaves non-placeholder strings untouched', () => {
|
|
142
|
+
const fragment = expandChain(
|
|
143
|
+
{
|
|
144
|
+
...SAMPLE_CHAIN,
|
|
145
|
+
dag: {
|
|
146
|
+
nodes: [
|
|
147
|
+
{
|
|
148
|
+
id: 'n',
|
|
149
|
+
typeId: 'core.identity',
|
|
150
|
+
config: { literal: 'no placeholders here', empty: '' },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
edges: [],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{ expansionId: 'x', params: {}, isTypeIdResolvable: RESOLVE_ALL },
|
|
157
|
+
);
|
|
158
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as { literal: string; empty: string };
|
|
159
|
+
expect(config.literal).toBe('no placeholders here');
|
|
160
|
+
expect(config.empty).toBe('');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('substitutes the same placeholder name in multiple positions', () => {
|
|
164
|
+
const fragment = expandChain(
|
|
165
|
+
{
|
|
166
|
+
...SAMPLE_CHAIN,
|
|
167
|
+
dag: {
|
|
168
|
+
nodes: [
|
|
169
|
+
{
|
|
170
|
+
id: 'n',
|
|
171
|
+
typeId: 'core.identity',
|
|
172
|
+
config: { a: '{{params.who}}', b: '{{params.who}}', joined: 'hi {{params.who}}, hi again {{params.who}}' },
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
edges: [],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{ expansionId: 'x', params: { who: 'Ada' }, isTypeIdResolvable: RESOLVE_ALL },
|
|
179
|
+
);
|
|
180
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as { a: string; b: string; joined: string };
|
|
181
|
+
expect(config.a).toBe('Ada');
|
|
182
|
+
expect(config.b).toBe('Ada');
|
|
183
|
+
expect(config.joined).toBe('hi Ada, hi again Ada');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('category: workflow-chain expansion — node id collision avoidance', () => {
|
|
188
|
+
it('same chain expanded TWICE in one parent workflow produces non-colliding node ids', () => {
|
|
189
|
+
const first = expandChain(SAMPLE_CHAIN, {
|
|
190
|
+
expansionId: 'a8f3',
|
|
191
|
+
params: { productIdea: 'first', targetAudience: '' },
|
|
192
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
193
|
+
});
|
|
194
|
+
const second = expandChain(SAMPLE_CHAIN, {
|
|
195
|
+
expansionId: 'b9e4',
|
|
196
|
+
params: { productIdea: 'second', targetAudience: '' },
|
|
197
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
198
|
+
});
|
|
199
|
+
expect(
|
|
200
|
+
at(first.nodes, 0, 'first.nodes').id,
|
|
201
|
+
'Per spec §"Expansion semantics" step 6: each expansion MUST use a unique `expansionId` prefix so multi-expansion DAGs do not collide.',
|
|
202
|
+
).not.toBe(at(second.nodes, 0, 'second.nodes').id);
|
|
203
|
+
expect(at(first.nodes, 0, 'first.nodes').id).toBe('vendor_acme_generatePRD_a8f3_prd-call');
|
|
204
|
+
expect(at(second.nodes, 0, 'second.nodes').id).toBe('vendor_acme_generatePRD_b9e4_prd-call');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rewritten ids replace dots in chainId with underscores for storage-key safety', () => {
|
|
208
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
209
|
+
expansionId: 'x',
|
|
210
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
211
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
212
|
+
});
|
|
213
|
+
expect(
|
|
214
|
+
at(fragment.nodes, 0, 'fragment.nodes').id,
|
|
215
|
+
'chainId `vendor.acme.generatePRD` MUST be slugged (dots → underscores) so the resulting id is safe to use in storage backends that reserve `.` for hierarchical keys.',
|
|
216
|
+
).toMatch(/^vendor_acme_generatePRD_/);
|
|
217
|
+
expect(at(fragment.nodes, 0, 'fragment.nodes').id).not.toMatch(/\./);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('exposes idMap so the caller can wire parent-workflow edges into the expansion', () => {
|
|
221
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
222
|
+
expansionId: 'a8f3',
|
|
223
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
224
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
225
|
+
});
|
|
226
|
+
expect(
|
|
227
|
+
fragment.idMap.get('prd-call'),
|
|
228
|
+
'The expansion contract MUST surface the original-id → rewritten-id map so the caller can route adjacent parent-workflow edges to the right expanded node.',
|
|
229
|
+
).toBe('vendor_acme_generatePRD_a8f3_prd-call');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('category: workflow-chain expansion — edge rewriting', () => {
|
|
234
|
+
const MULTI_NODE: WorkflowChain = {
|
|
235
|
+
chainId: 'vendor.acme.twoStep',
|
|
236
|
+
version: '1.0.0',
|
|
237
|
+
label: 'Two-step',
|
|
238
|
+
description: 'Two nodes connected by an edge.',
|
|
239
|
+
parameters: {},
|
|
240
|
+
dag: {
|
|
241
|
+
nodes: [
|
|
242
|
+
{ id: 'first', typeId: 'core.identity', config: {} },
|
|
243
|
+
{ id: 'second', typeId: 'core.identity', config: {} },
|
|
244
|
+
],
|
|
245
|
+
edges: [{ from: 'first', to: 'second' }],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
it('rewrites `from` and `to` ids that reference fragment nodes', () => {
|
|
250
|
+
const fragment = expandChain(MULTI_NODE, {
|
|
251
|
+
expansionId: 'e1',
|
|
252
|
+
params: {},
|
|
253
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
254
|
+
});
|
|
255
|
+
expect(fragment.edges).toHaveLength(1);
|
|
256
|
+
expect(
|
|
257
|
+
at(fragment.edges, 0, 'fragment.edges').from,
|
|
258
|
+
'Edge `from` ids that match fragment node ids MUST be rewritten with the same prefix as the nodes themselves.',
|
|
259
|
+
).toBe('vendor_acme_twoStep_e1_first');
|
|
260
|
+
expect(at(fragment.edges, 0, 'fragment.edges').to).toBe('vendor_acme_twoStep_e1_second');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('preserves port-name suffix (`nodeId.portName`) when rewriting edge refs', () => {
|
|
264
|
+
const withPorts: WorkflowChain = {
|
|
265
|
+
...MULTI_NODE,
|
|
266
|
+
dag: {
|
|
267
|
+
...MULTI_NODE.dag,
|
|
268
|
+
edges: [{ from: 'first.out', to: 'second.in' }],
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
const fragment = expandChain(withPorts, {
|
|
272
|
+
expansionId: 'e2',
|
|
273
|
+
params: {},
|
|
274
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
275
|
+
});
|
|
276
|
+
expect(
|
|
277
|
+
at(fragment.edges, 0, 'fragment.edges').from,
|
|
278
|
+
'Port-name suffix (after the `.`) MUST be preserved verbatim during id rewriting.',
|
|
279
|
+
).toBe('vendor_acme_twoStep_e2_first.out');
|
|
280
|
+
expect(at(fragment.edges, 0, 'fragment.edges').to).toBe('vendor_acme_twoStep_e2_second.in');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('leaves edge refs alone when they don\'t match a fragment node id', () => {
|
|
284
|
+
// Refs to OUTSIDE the fragment let the parent-workflow host splice
|
|
285
|
+
// in adjacent wiring. Critical for the "this chain plugs into
|
|
286
|
+
// node-X-of-parent-workflow" use case.
|
|
287
|
+
const withExternalRefs: WorkflowChain = {
|
|
288
|
+
...MULTI_NODE,
|
|
289
|
+
dag: {
|
|
290
|
+
...MULTI_NODE.dag,
|
|
291
|
+
edges: [
|
|
292
|
+
{ from: 'parent-upstream', to: 'first' },
|
|
293
|
+
{ from: 'second', to: 'parent-downstream' },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
const fragment = expandChain(withExternalRefs, {
|
|
298
|
+
expansionId: 'e3',
|
|
299
|
+
params: {},
|
|
300
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
301
|
+
});
|
|
302
|
+
expect(
|
|
303
|
+
at(fragment.edges, 0, 'fragment.edges').from,
|
|
304
|
+
'Edge refs to nodes OUTSIDE the fragment MUST pass through unchanged so the parent host can wire adjacent edges post-splice.',
|
|
305
|
+
).toBe('parent-upstream');
|
|
306
|
+
expect(at(fragment.edges, 0, 'fragment.edges').to).toBe('vendor_acme_twoStep_e3_first');
|
|
307
|
+
expect(at(fragment.edges, 1, 'fragment.edges').from).toBe('vendor_acme_twoStep_e3_second');
|
|
308
|
+
expect(at(fragment.edges, 1, 'fragment.edges').to).toBe('parent-downstream');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('category: workflow-chain expansion — capability propagation', () => {
|
|
313
|
+
it('copies chain.capabilities[] into every expanded node\'s capabilities[]', () => {
|
|
314
|
+
const chainWithCaps: WorkflowChain = {
|
|
315
|
+
...SAMPLE_CHAIN,
|
|
316
|
+
capabilities: ['side-effectful', 'cacheable'],
|
|
317
|
+
dag: {
|
|
318
|
+
nodes: [
|
|
319
|
+
{ id: 'n1', typeId: 'core.identity', config: {} },
|
|
320
|
+
{ id: 'n2', typeId: 'core.identity', config: {} },
|
|
321
|
+
],
|
|
322
|
+
edges: [{ from: 'n1', to: 'n2' }],
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
const fragment = expandChain(chainWithCaps, {
|
|
326
|
+
expansionId: 'c1',
|
|
327
|
+
params: {},
|
|
328
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
329
|
+
});
|
|
330
|
+
for (const node of fragment.nodes) {
|
|
331
|
+
expect(
|
|
332
|
+
node.capabilities,
|
|
333
|
+
'Per spec §"Expansion semantics" step 8: chain-level `capabilities[]` MUST be copied to every expanded node so existing capability gates (side-effect, streamable) apply uniformly.',
|
|
334
|
+
).toEqual(['side-effectful', 'cacheable']);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('omits capabilities[] on expanded nodes when the chain declares none', () => {
|
|
339
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
340
|
+
expansionId: 'c2',
|
|
341
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
342
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
343
|
+
});
|
|
344
|
+
expect(
|
|
345
|
+
at(fragment.nodes, 0, 'fragment.nodes').capabilities,
|
|
346
|
+
'When the chain declares no `capabilities[]`, expanded nodes MUST NOT carry an empty array (preserves wire-shape minimality).',
|
|
347
|
+
).toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('category: workflow-chain expansion — runtime-invariance contract', () => {
|
|
352
|
+
it('expanded fragment carries ONLY concrete typeIds (no chain reference survives)', () => {
|
|
353
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
354
|
+
expansionId: 'r1',
|
|
355
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
356
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
357
|
+
});
|
|
358
|
+
for (const node of fragment.nodes) {
|
|
359
|
+
expect(
|
|
360
|
+
node.typeId,
|
|
361
|
+
'Per workflow-chain-packs.md §"What hosts dispatch": dispatching runtimes see only concrete `core.*`/published-vendor typeIds — the chain reference MUST NOT be preserved at runtime.',
|
|
362
|
+
).not.toMatch(/^vendor\.acme\.generatePRD$/);
|
|
363
|
+
expect(node.typeId).toBe('core.ai.callPrompt');
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain pack manifest validation — `workflow-chain-packs.md` §Manifest format
|
|
3
|
+
* + `schemas/workflow-chain-pack-manifest.schema.json` (closes RFC 0013 Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* Server-free schema-validation scenario. Exercises the new
|
|
6
|
+
* `workflow-chain-pack-manifest.schema.json` with a positive sample and
|
|
7
|
+
* two negative samples derived from the RFC's Negative examples:
|
|
8
|
+
*
|
|
9
|
+
* 1. Positive: a valid `kind: "workflow-chain"` manifest with a single
|
|
10
|
+
* `chains[]` entry validates cleanly.
|
|
11
|
+
* 2. Negative — kind/contents mismatch: a manifest carrying BOTH
|
|
12
|
+
* `chains[]` AND `nodes[]` is rejected. Surface-level outcome at
|
|
13
|
+
* the registry HTTP API is `pack_kind_invalid` per the spec;
|
|
14
|
+
* schema-level outcome is an `additionalProperties` violation on
|
|
15
|
+
* `nodes` (the workflow-chain schema does not declare that field).
|
|
16
|
+
* 3. Negative — invalid `chainId`: a chain entry whose `chainId` does
|
|
17
|
+
* not match the reverse-DNS pattern is rejected with a `pattern`
|
|
18
|
+
* violation.
|
|
19
|
+
*
|
|
20
|
+
* Capability-gated scenarios for end-to-end expansion
|
|
21
|
+
* (`workflow-chain-expansion.test.ts`) and signature verification
|
|
22
|
+
* (`workflow-chain-pack-signature-verification.test.ts`) are deferred
|
|
23
|
+
* to Phase 2/3 per the RFC.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v1/workflow-chain-packs.md
|
|
26
|
+
* @see schemas/workflow-chain-pack-manifest.schema.json
|
|
27
|
+
* @see RFCS/0013-workflow-chain-packs.md
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
32
|
+
import { join, dirname } from 'node:path';
|
|
33
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
34
|
+
import addFormats from 'ajv-formats';
|
|
35
|
+
import type { ErrorObject } from 'ajv';
|
|
36
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
37
|
+
|
|
38
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
|
|
39
|
+
// In-repo example pack — proves the schema validates a non-trivial
|
|
40
|
+
// real-world-shaped manifest (closes RFC 0013 Phase 4 in-tree path).
|
|
41
|
+
// Resolved relative to the repo root (V1_DIR is non-null in the repo
|
|
42
|
+
// layout AND in any in-tree mirror; null under the published-tarball
|
|
43
|
+
// layout where examples/ isn't bundled). Skipped cleanly when unavailable.
|
|
44
|
+
const REPO_ROOT = V1_DIR ? dirname(dirname(V1_DIR)) : null;
|
|
45
|
+
const EXAMPLE_PACK_PATH = REPO_ROOT
|
|
46
|
+
? join(REPO_ROOT, 'examples/packs/workflow-chain-sample/pack.json')
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
describe('category: workflow-chain-pack manifest validation', () => {
|
|
50
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
51
|
+
addFormats(ajv);
|
|
52
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
53
|
+
const validate = ajv.compile(schema);
|
|
54
|
+
|
|
55
|
+
it('positive: a valid workflow-chain pack manifest validates cleanly', () => {
|
|
56
|
+
const manifest = {
|
|
57
|
+
name: 'vendor.acme.editor-presets',
|
|
58
|
+
version: '1.0.0',
|
|
59
|
+
kind: 'workflow-chain',
|
|
60
|
+
description: 'Author-time editor presets.',
|
|
61
|
+
engines: { openwop: '>=1.0.0 <2.0.0' },
|
|
62
|
+
chains: [
|
|
63
|
+
{
|
|
64
|
+
chainId: 'vendor.acme.generatePRD',
|
|
65
|
+
version: '1.0.0',
|
|
66
|
+
label: 'Generate PRD',
|
|
67
|
+
description: 'Single-node AI call with PRD authoring prompt.',
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
required: ['productIdea'],
|
|
71
|
+
properties: {
|
|
72
|
+
productIdea: { type: 'string' },
|
|
73
|
+
targetAudience: { type: 'string', default: '' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
dag: {
|
|
77
|
+
nodes: [
|
|
78
|
+
{
|
|
79
|
+
id: 'prd-call',
|
|
80
|
+
typeId: 'core.ai.callPrompt',
|
|
81
|
+
config: {
|
|
82
|
+
systemPrompt: 'Write a PRD for: {{params.productIdea}}',
|
|
83
|
+
envelopeType: 'prd.create',
|
|
84
|
+
provider: 'anthropic',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
edges: [],
|
|
89
|
+
},
|
|
90
|
+
outputs: {
|
|
91
|
+
prdId: { type: 'string', description: 'Created PRD artifact id.' },
|
|
92
|
+
},
|
|
93
|
+
capabilities: ['side-effectful'],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
const ok = validate(manifest);
|
|
98
|
+
const errs = (validate.errors ?? [])
|
|
99
|
+
.map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
|
|
100
|
+
.join('\n');
|
|
101
|
+
expect(
|
|
102
|
+
ok,
|
|
103
|
+
`Positive sample MUST validate against workflow-chain-pack-manifest.schema.json — got:\n${errs}`,
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('negative: manifest mixing chains[] AND nodes[] is rejected (pack_kind_invalid)', () => {
|
|
108
|
+
// Per workflow-chain-packs.md §Pack kind discriminator: "Manifests MUST
|
|
109
|
+
// have exactly one of nodes[] (kind=node) OR chains[] (kind=workflow-chain).
|
|
110
|
+
// Manifests containing both MUST be rejected at manifest validation with
|
|
111
|
+
// error code pack_kind_invalid." The schema-level enforcement is via
|
|
112
|
+
// additionalProperties: false (the workflow-chain schema does not declare
|
|
113
|
+
// a `nodes` property, so its presence triggers the violation).
|
|
114
|
+
const manifest = {
|
|
115
|
+
name: 'vendor.acme.mixed',
|
|
116
|
+
version: '1.0.0',
|
|
117
|
+
kind: 'workflow-chain',
|
|
118
|
+
engines: { openwop: '>=1.0.0' },
|
|
119
|
+
nodes: [
|
|
120
|
+
{ typeId: 'vendor.acme.foo', version: '1.0.0', category: 'data', role: 'pure' },
|
|
121
|
+
],
|
|
122
|
+
chains: [
|
|
123
|
+
{
|
|
124
|
+
chainId: 'vendor.acme.bar',
|
|
125
|
+
version: '1.0.0',
|
|
126
|
+
label: 'Bar',
|
|
127
|
+
description: 'x',
|
|
128
|
+
parameters: {},
|
|
129
|
+
dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
const ok = validate(manifest);
|
|
134
|
+
expect(
|
|
135
|
+
ok,
|
|
136
|
+
'Manifest with both nodes[] and chains[] MUST fail workflow-chain schema validation (pack_kind_invalid at the registry surface).',
|
|
137
|
+
).toBe(false);
|
|
138
|
+
const hasAdditionalPropertiesErr = (validate.errors ?? []).some(
|
|
139
|
+
(e: ErrorObject) => e.keyword === 'additionalProperties',
|
|
140
|
+
);
|
|
141
|
+
expect(
|
|
142
|
+
hasAdditionalPropertiesErr,
|
|
143
|
+
'Expected an `additionalProperties` violation flagging the unexpected `nodes` field.',
|
|
144
|
+
).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('negative: chain entry with invalid chainId is rejected (pattern violation)', () => {
|
|
148
|
+
// Per workflow-chain-packs.md §Chain entry shape: chainId MUST match the
|
|
149
|
+
// reverse-DNS pattern `^[a-z][a-zA-Z0-9._-]*$`. An empty string, leading
|
|
150
|
+
// digit, or any other shape violating the pattern fails validation.
|
|
151
|
+
const manifest = {
|
|
152
|
+
name: 'vendor.acme.editor-presets',
|
|
153
|
+
version: '1.0.0',
|
|
154
|
+
kind: 'workflow-chain',
|
|
155
|
+
engines: { openwop: '>=1.0.0' },
|
|
156
|
+
chains: [
|
|
157
|
+
{
|
|
158
|
+
// INVALID — leading digit, contains uppercase that breaks the
|
|
159
|
+
// first-char rule, AND a slash that no chainId is allowed to carry.
|
|
160
|
+
chainId: '9Bad/Id',
|
|
161
|
+
version: '1.0.0',
|
|
162
|
+
label: 'Bad',
|
|
163
|
+
description: 'x',
|
|
164
|
+
parameters: {},
|
|
165
|
+
dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
const ok = validate(manifest);
|
|
170
|
+
expect(
|
|
171
|
+
ok,
|
|
172
|
+
'Manifest with invalid chainId MUST fail workflow-chain schema validation.',
|
|
173
|
+
).toBe(false);
|
|
174
|
+
const hasPatternErr = (validate.errors ?? []).some(
|
|
175
|
+
(e: ErrorObject) =>
|
|
176
|
+
e.keyword === 'pattern' && (e.instancePath ?? '').includes('chainId'),
|
|
177
|
+
);
|
|
178
|
+
expect(
|
|
179
|
+
hasPatternErr,
|
|
180
|
+
'Expected a `pattern` violation on the chains[].chainId field.',
|
|
181
|
+
).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('positive: the in-repo example pack at examples/packs/workflow-chain-sample/ validates against the schema', () => {
|
|
185
|
+
if (!EXAMPLE_PACK_PATH || !existsSync(EXAMPLE_PACK_PATH)) {
|
|
186
|
+
// Published-tarball layout doesn't ship examples/; skip cleanly.
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const manifest = JSON.parse(readFileSync(EXAMPLE_PACK_PATH, 'utf8'));
|
|
190
|
+
const ok = validate(manifest);
|
|
191
|
+
const errs = (validate.errors ?? [])
|
|
192
|
+
.map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
|
|
193
|
+
.join('\n');
|
|
194
|
+
expect(
|
|
195
|
+
ok,
|
|
196
|
+
`examples/packs/workflow-chain-sample/pack.json MUST validate against workflow-chain-pack-manifest.schema.json (closes RFC 0013 Phase 4 in-tree path). Errors:\n${errs}`,
|
|
197
|
+
).toBe(true);
|
|
198
|
+
// Spot-check the structural claims the example README makes:
|
|
199
|
+
expect(manifest.kind, 'example pack MUST declare kind: "workflow-chain"').toBe(
|
|
200
|
+
'workflow-chain',
|
|
201
|
+
);
|
|
202
|
+
expect(
|
|
203
|
+
Array.isArray(manifest.chains) && manifest.chains.length === 2,
|
|
204
|
+
'example pack MUST ship exactly 2 chains (1-node + 2-node shapes) per its README contract',
|
|
205
|
+
).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('negative: omitting kind field rejects (kind is required)', () => {
|
|
209
|
+
// Defensive — the workflow-chain schema makes `kind` REQUIRED so a
|
|
210
|
+
// node-pack-shape manifest can't accidentally validate against it.
|
|
211
|
+
const manifest = {
|
|
212
|
+
name: 'vendor.acme.editor-presets',
|
|
213
|
+
version: '1.0.0',
|
|
214
|
+
engines: { openwop: '>=1.0.0' },
|
|
215
|
+
chains: [
|
|
216
|
+
{
|
|
217
|
+
chainId: 'vendor.acme.x',
|
|
218
|
+
version: '1.0.0',
|
|
219
|
+
label: 'X',
|
|
220
|
+
description: 'x',
|
|
221
|
+
parameters: {},
|
|
222
|
+
dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
const ok = validate(manifest);
|
|
227
|
+
expect(
|
|
228
|
+
ok,
|
|
229
|
+
'Manifest without kind: "workflow-chain" MUST fail this schema (the other path is node-pack-manifest.schema.json).',
|
|
230
|
+
).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|