@openwop/openwop-conformance 1.48.0 → 1.51.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/README.md +2 -2
- package/coverage.md +1 -1
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +16 -0
- package/schemas/orchestrator-decision.schema.json +5 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +22 -2
- package/src/lib/workflow-chain-expansion.ts +287 -2
- package/src/scenarios/dispatch-per-item-input.test.ts +198 -0
- package/src/scenarios/workflow-chain-deferred-parameters.test.ts +298 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +49 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +24 -10
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +59 -0
|
@@ -54,10 +54,25 @@ export interface FragmentNode {
|
|
|
54
54
|
inputs?: Record<string, unknown>;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A fan-in / error-routing rule mirrored from `WorkflowEdge.triggerRule`
|
|
58
|
+
* (workflow-definition.schema.json). RFC 0125. */
|
|
59
|
+
export type TriggerRule =
|
|
60
|
+
| 'all_success'
|
|
61
|
+
| 'any_success'
|
|
62
|
+
| 'all_complete'
|
|
63
|
+
| 'none_failed'
|
|
64
|
+
| 'any_failed';
|
|
65
|
+
|
|
57
66
|
export interface FragmentEdge {
|
|
58
67
|
from: string;
|
|
59
68
|
to: string;
|
|
60
|
-
condition
|
|
69
|
+
/** Edge condition — an `EdgeCondition` object (RFC 0013 amendment #818).
|
|
70
|
+
* Carried through expansion opaquely; typed `unknown` since the lib does
|
|
71
|
+
* not evaluate it. */
|
|
72
|
+
condition?: unknown;
|
|
73
|
+
/** Fan-in / error-routing rule (RFC 0125). Carried through expansion onto
|
|
74
|
+
* the resulting WorkflowEdge so the scheduler honors it. */
|
|
75
|
+
triggerRule?: TriggerRule;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** Per-expansion context the caller supplies. */
|
|
@@ -90,7 +105,7 @@ export interface ExpandedFragment {
|
|
|
90
105
|
inputs?: Record<string, unknown>;
|
|
91
106
|
capabilities?: ReadonlyArray<string>;
|
|
92
107
|
}>;
|
|
93
|
-
edges: ReadonlyArray<{ from: string; to: string; condition?:
|
|
108
|
+
edges: ReadonlyArray<{ from: string; to: string; condition?: unknown; triggerRule?: TriggerRule }>;
|
|
94
109
|
/** Map of original-fragment-id → rewritten-id, so the caller can
|
|
95
110
|
* wire the parent workflow's adjacent edges into the expansion. */
|
|
96
111
|
idMap: ReadonlyMap<string, string>;
|
|
@@ -110,6 +125,12 @@ export class ChainUnresolvableTypeIdError extends Error {
|
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
const PARAM_PATTERN = /\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
128
|
+
/** A value that is EXACTLY a single `{{params.<name>}}` token (whole-value),
|
|
129
|
+
* distinct from a token embedded in a larger string. Whole-value tokens are
|
|
130
|
+
* the only non-prompt position deferrable to a variable-sourced PortValue
|
|
131
|
+
* (WCP2 raw-typed rule); an embedded non-prompt token has no runtime `{{}}`
|
|
132
|
+
* construct and MUST resolve at expansion time. */
|
|
133
|
+
const WHOLE_VALUE_PATTERN = /^\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}$/;
|
|
113
134
|
|
|
114
135
|
/** Recursive literal substitution of `{{params.<name>}}` placeholders in
|
|
115
136
|
* any string field. Non-string values pass through unchanged; nested
|
|
@@ -206,8 +227,272 @@ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): Expand
|
|
|
206
227
|
to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
|
|
207
228
|
};
|
|
208
229
|
if (e.condition !== undefined) out.condition = e.condition;
|
|
230
|
+
// RFC 0125: carry the fan-in/error-routing rule onto the expanded
|
|
231
|
+
// WorkflowEdge so the scheduler honors it (mirrors the `condition`
|
|
232
|
+
// pass-through; without this the field is silently dropped at expansion).
|
|
233
|
+
if (e.triggerRule !== undefined) out.triggerRule = e.triggerRule;
|
|
209
234
|
return out;
|
|
210
235
|
});
|
|
211
236
|
|
|
212
237
|
return { nodes: expandedNodes, edges: expandedEdges, idMap };
|
|
213
238
|
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// RFC 0124 (WCP4) — Portable per-run parameter deferral.
|
|
242
|
+
//
|
|
243
|
+
// The DEFERRED expansion mode: instead of freezing `{{params.*}}` values into
|
|
244
|
+
// persisted `config`/`inputs` at drop time (the RFC 0013 default), the host
|
|
245
|
+
// materializes the chain's `parameters` into top-level workflow `variables[]`
|
|
246
|
+
// (author value → `defaultValue`) and rewrites each token into an already-spec'd
|
|
247
|
+
// RUNTIME binding — so the persisted fragment carries ZERO `{{params.*}}` tokens
|
|
248
|
+
// yet every parameter stays overridable per run via `configurable`. This is the
|
|
249
|
+
// spec-authoritative reference for `spec/v1/workflow-chain-packs.md`
|
|
250
|
+
// §"Deferred-parameter expansion (RFC 0124)".
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/** The parameter JSON Schema fragment (`chain.parameters`), narrowed to the
|
|
254
|
+
* fields deferred expansion reads: each property's `type`, `description`, and
|
|
255
|
+
* the RFC 0124 `x-openwop-sensitive` extension key. */
|
|
256
|
+
export interface ParameterSchema {
|
|
257
|
+
properties?: Record<
|
|
258
|
+
string,
|
|
259
|
+
{ type?: string; description?: string; 'x-openwop-sensitive'?: boolean }
|
|
260
|
+
>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Host capability context deferred expansion needs (RFC 0124 §Capability
|
|
264
|
+
* gating). The prompt-bearing rewrite path requires `prompts.variableSources`
|
|
265
|
+
* to include `variable`; a `source:"secret"` sensitive lift requires
|
|
266
|
+
* `capabilities.secrets.supported`. */
|
|
267
|
+
export interface DeferredHostContext {
|
|
268
|
+
/** `capabilities.prompts.variableSources` includes `"variable"`. */
|
|
269
|
+
promptVariableSource: boolean;
|
|
270
|
+
/** `capabilities.secrets.supported`. */
|
|
271
|
+
secretsSupported: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface DeferredExpansionContext {
|
|
275
|
+
expansionId: string;
|
|
276
|
+
/** Author-supplied parameter values (already validated) → `defaultValue` seeds. */
|
|
277
|
+
params: Record<string, unknown>;
|
|
278
|
+
/** The chain's `parameters` JSON Schema (type + `x-openwop-sensitive` per property). */
|
|
279
|
+
parameterSchema: ParameterSchema;
|
|
280
|
+
isTypeIdResolvable: (typeId: string) => boolean;
|
|
281
|
+
host: DeferredHostContext;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A materialized top-level `WorkflowVariable` (subset — the fields deferred
|
|
285
|
+
* expansion sets). A NON-sensitive parameter materializes here with its author
|
|
286
|
+
* value as `defaultValue`. A sensitive parameter does NOT (its value never
|
|
287
|
+
* lands in the run-scoped bag); it is bound as a `source:"secret"` prompt
|
|
288
|
+
* variable instead. */
|
|
289
|
+
export interface MaterializedVariable {
|
|
290
|
+
name: string;
|
|
291
|
+
type: string;
|
|
292
|
+
description?: string;
|
|
293
|
+
defaultValue?: unknown;
|
|
294
|
+
sensitive?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A rewritten prompt-template variable slot (RFC 0027). `source:"variable"`
|
|
298
|
+
* resolves from the run bag; `source:"secret"` resolves a BYOK secret at
|
|
299
|
+
* compose time, redacted in `prompt.composed` (RFC 0124 §Security). */
|
|
300
|
+
export interface PromptVariableBinding {
|
|
301
|
+
name: string;
|
|
302
|
+
source: 'variable' | 'secret';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface DeferredExpandedFragment {
|
|
306
|
+
nodes: ExpandedFragment['nodes'];
|
|
307
|
+
edges: ExpandedFragment['edges'];
|
|
308
|
+
idMap: ReadonlyMap<string, string>;
|
|
309
|
+
/** Materialized top-level `variables[]` (non-sensitive params only). */
|
|
310
|
+
variables: ReadonlyArray<MaterializedVariable>;
|
|
311
|
+
/** Prompt-site variable bindings introduced by the rewrite. */
|
|
312
|
+
promptVariables: ReadonlyArray<PromptVariableBinding>;
|
|
313
|
+
/** Auto-generated `configurableSchema` mapping the BARE param name (the
|
|
314
|
+
* normative override key) to its type, so a per-run `configurable` keyed on
|
|
315
|
+
* the bare name resolves (R6 cross-host key stability). */
|
|
316
|
+
configurableSchema: { properties: Record<string, { type: string }> };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Thrown when a `x-openwop-sensitive` parameter cannot be securely deferred:
|
|
320
|
+
* it resolves to a non-prompt position (whole-value `node.inputs`, embedded
|
|
321
|
+
* non-prompt `config`), or the host lacks `secrets`/deferred support. Wire
|
|
322
|
+
* code `sensitive_param_not_deferrable` (HTTP 422) per
|
|
323
|
+
* `workflow-chain-packs.md` §"Error codes" (RFC 0124 §Security). */
|
|
324
|
+
export class SensitiveParamNotDeferrableError extends Error {
|
|
325
|
+
readonly code = 'sensitive_param_not_deferrable';
|
|
326
|
+
readonly httpStatus = 422;
|
|
327
|
+
constructor(readonly param: string, readonly reason: string) {
|
|
328
|
+
super(`sensitive_param_not_deferrable: '${param}' — ${reason}`);
|
|
329
|
+
this.name = 'SensitiveParamNotDeferrableError';
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The prompt-bearing `config` fields a token in which is a "prompt position"
|
|
334
|
+
* (lifted to a PromptTemplate `{{varName}}` slot). Everything else in `config`
|
|
335
|
+
* is a non-prompt position. */
|
|
336
|
+
const PROMPT_CONFIG_FIELDS: ReadonlySet<string> = new Set(['systemPrompt', 'userPrompt']);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Deferred-mode expansion (RFC 0124). Rewrites every `{{params.<name>}}` token
|
|
340
|
+
* into a spec'd runtime binding and materializes non-sensitive parameters into
|
|
341
|
+
* top-level `variables[]`, leaving ZERO `{{params.*}}` tokens in the persisted
|
|
342
|
+
* fragment. Sensitive parameters (`x-openwop-sensitive`) are handled per
|
|
343
|
+
* §Security: prompt-body → `source:"secret"`; anywhere else → fail closed.
|
|
344
|
+
*
|
|
345
|
+
* @throws SensitiveParamNotDeferrableError when a sensitive parameter is in a
|
|
346
|
+
* non-prompt position, or the host lacks `secrets` support.
|
|
347
|
+
* @throws ChainUnresolvableTypeIdError when any node typeId fails resolution.
|
|
348
|
+
*/
|
|
349
|
+
export function expandChainDeferred(
|
|
350
|
+
chain: WorkflowChain,
|
|
351
|
+
ctx: DeferredExpansionContext,
|
|
352
|
+
): DeferredExpandedFragment {
|
|
353
|
+
for (const node of chain.dag.nodes) {
|
|
354
|
+
if (!ctx.isTypeIdResolvable(node.typeId)) {
|
|
355
|
+
throw new ChainUnresolvableTypeIdError(node.typeId, chain.chainId);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const props = ctx.parameterSchema.properties ?? {};
|
|
360
|
+
const isSensitive = (name: string): boolean => props[name]?.['x-openwop-sensitive'] === true;
|
|
361
|
+
const typeOf = (name: string): string => props[name]?.type ?? 'string';
|
|
362
|
+
|
|
363
|
+
const prefix = computePrefix(chain.chainId, ctx.expansionId);
|
|
364
|
+
const fragmentNodeIds = new Set(chain.dag.nodes.map((n) => n.id));
|
|
365
|
+
const idMap = new Map<string, string>();
|
|
366
|
+
for (const id of fragmentNodeIds) idMap.set(id, `${prefix}${id}`);
|
|
367
|
+
|
|
368
|
+
const usedParams = new Set<string>();
|
|
369
|
+
const promptVariables = new Map<string, PromptVariableBinding>();
|
|
370
|
+
|
|
371
|
+
/** Rewrite a prompt-position string: each embedded `{{params.x}}` → a
|
|
372
|
+
* PromptTemplate `{{x}}` slot. A sensitive param binds `source:"secret"`
|
|
373
|
+
* (requires host secrets support), else `source:"variable"`. If the host
|
|
374
|
+
* does not advertise the `variable` prompt source at all, the deferred
|
|
375
|
+
* prompt path is unavailable — the caller falls back to expansion-time
|
|
376
|
+
* substitution (G5); we surface that by returning `null`. */
|
|
377
|
+
function rewritePrompt(text: string): string | null {
|
|
378
|
+
if (!ctx.host.promptVariableSource) return null; // G5 fallback → expansion-time
|
|
379
|
+
return text.replace(PARAM_PATTERN, (_m, name: string) => {
|
|
380
|
+
usedParams.add(name);
|
|
381
|
+
if (isSensitive(name)) {
|
|
382
|
+
if (!ctx.host.secretsSupported) {
|
|
383
|
+
throw new SensitiveParamNotDeferrableError(name, 'host lacks capabilities.secrets support');
|
|
384
|
+
}
|
|
385
|
+
promptVariables.set(name, { name, source: 'secret' });
|
|
386
|
+
} else {
|
|
387
|
+
promptVariables.set(name, { name, source: 'variable' });
|
|
388
|
+
}
|
|
389
|
+
return `{{${name}}}`;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Rewrite a config object: prompt fields → PromptTemplate slots; a token in
|
|
394
|
+
* any NON-prompt config field is embedded-non-prompt → sensitive fails
|
|
395
|
+
* closed, non-sensitive resolves at expansion time (author-trusted). */
|
|
396
|
+
function rewriteConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
397
|
+
const out: Record<string, unknown> = {};
|
|
398
|
+
for (const [k, v] of Object.entries(config)) {
|
|
399
|
+
if (typeof v === 'string' && PROMPT_CONFIG_FIELDS.has(k) && PARAM_PATTERN.test(v)) {
|
|
400
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
401
|
+
const rewritten = rewritePrompt(v);
|
|
402
|
+
out[k] = rewritten === null ? substitute(v, ctx.params) : rewritten;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
406
|
+
if (typeof v === 'string' && PARAM_PATTERN.test(v)) {
|
|
407
|
+
// embedded non-prompt token
|
|
408
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
409
|
+
let m: RegExpExecArray | null;
|
|
410
|
+
while ((m = PARAM_PATTERN.exec(v)) !== null) {
|
|
411
|
+
if (isSensitive(m[1])) {
|
|
412
|
+
throw new SensitiveParamNotDeferrableError(m[1], `embedded non-prompt config field '${k}'`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
out[k] = substitute(v, ctx.params); // author-trusted expansion-time resolution
|
|
416
|
+
} else {
|
|
417
|
+
out[k] = substitute(v, ctx.params);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Rewrite an inputs object: a WHOLE-VALUE `{{params.x}}` → a variable-sourced
|
|
424
|
+
* PortValue (WCP2 raw-typed); a sensitive whole-value fails closed (no
|
|
425
|
+
* plaintext-bag path for a secret). Embedded input tokens follow the same
|
|
426
|
+
* non-prompt rule as config. */
|
|
427
|
+
function rewriteInputs(inputs: Record<string, unknown>): Record<string, unknown> {
|
|
428
|
+
const out: Record<string, unknown> = {};
|
|
429
|
+
for (const [k, v] of Object.entries(inputs)) {
|
|
430
|
+
const whole = typeof v === 'string' ? WHOLE_VALUE_PATTERN.exec(v) : null;
|
|
431
|
+
if (whole) {
|
|
432
|
+
const name = whole[1];
|
|
433
|
+
if (isSensitive(name)) {
|
|
434
|
+
throw new SensitiveParamNotDeferrableError(name, `whole-value node input '${k}'`);
|
|
435
|
+
}
|
|
436
|
+
usedParams.add(name);
|
|
437
|
+
out[k] = { source: 'variable', variable: name }; // variable-sourced PortValue
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
441
|
+
if (typeof v === 'string' && PARAM_PATTERN.test(v)) {
|
|
442
|
+
PARAM_PATTERN.lastIndex = 0;
|
|
443
|
+
let m: RegExpExecArray | null;
|
|
444
|
+
while ((m = PARAM_PATTERN.exec(v)) !== null) {
|
|
445
|
+
if (isSensitive(m[1])) {
|
|
446
|
+
throw new SensitiveParamNotDeferrableError(m[1], `embedded non-prompt input '${k}'`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
out[k] = substitute(v, ctx.params);
|
|
450
|
+
} else {
|
|
451
|
+
out[k] = substitute(v, ctx.params);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const expandedNodes = chain.dag.nodes.map((n) => {
|
|
458
|
+
const out: ExpandedFragment['nodes'][number] = { id: `${prefix}${n.id}`, typeId: n.typeId };
|
|
459
|
+
if (n.name !== undefined) out.name = n.name;
|
|
460
|
+
if (n.position !== undefined) out.position = n.position;
|
|
461
|
+
if (n.config !== undefined) out.config = rewriteConfig(n.config);
|
|
462
|
+
if (n.inputs !== undefined) out.inputs = rewriteInputs(n.inputs);
|
|
463
|
+
if (chain.capabilities && chain.capabilities.length > 0) out.capabilities = [...chain.capabilities];
|
|
464
|
+
return out;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const expandedEdges = (chain.dag.edges ?? []).map((e) => {
|
|
468
|
+
const out: ExpandedFragment['edges'][number] = {
|
|
469
|
+
from: rewriteEdgeRef(e.from, fragmentNodeIds, prefix),
|
|
470
|
+
to: rewriteEdgeRef(e.to, fragmentNodeIds, prefix),
|
|
471
|
+
};
|
|
472
|
+
if (e.condition !== undefined) out.condition = e.condition;
|
|
473
|
+
if (e.triggerRule !== undefined) out.triggerRule = e.triggerRule;
|
|
474
|
+
return out;
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Materialize NON-sensitive used params into top-level variables[]; build the
|
|
478
|
+
// bare-param → type configurableSchema for the override key.
|
|
479
|
+
const variables: MaterializedVariable[] = [];
|
|
480
|
+
const configurableSchema: { properties: Record<string, { type: string }> } = { properties: {} };
|
|
481
|
+
for (const name of usedParams) {
|
|
482
|
+
configurableSchema.properties[name] = { type: typeOf(name) };
|
|
483
|
+
if (isSensitive(name)) continue; // sensitive value never lands in the bag
|
|
484
|
+
const v: MaterializedVariable = { name, type: typeOf(name) };
|
|
485
|
+
if (props[name]?.description !== undefined) v.description = props[name]!.description;
|
|
486
|
+
if (name in ctx.params) v.defaultValue = ctx.params[name];
|
|
487
|
+
variables.push(v);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
nodes: expandedNodes,
|
|
492
|
+
edges: expandedEdges,
|
|
493
|
+
idMap,
|
|
494
|
+
variables,
|
|
495
|
+
promptVariables: [...promptVariables.values()],
|
|
496
|
+
configurableSchema,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-parallel dispatch — per-item child inputs. `node-packs.md` §"`core.dispatch`
|
|
3
|
+
* per-item input — data-parallel fan-out" (RFC 0126). Validates the additive, OPTIONAL
|
|
4
|
+
* `nextWorkerInputs` array on `NextWorkerDecision` (`orchestrator-decision.schema.json`)
|
|
5
|
+
* and the `capabilities.dispatch.perItemInput` fail-closed gate.
|
|
6
|
+
*
|
|
7
|
+
* `nextWorkerInputs[i]` is a per-child input object, index-aligned with `nextWorkerIds`,
|
|
8
|
+
* projected into the child dispatched for `nextWorkerIds[i]` — fanning ONE childWorkflowId
|
|
9
|
+
* over N runtime items with distinct inputs (the map-over-collection pattern). It rides the
|
|
10
|
+
* recorded `runOrchestrator.decided` event, so `:fork`/replay reproduces byte-identical
|
|
11
|
+
* children.
|
|
12
|
+
*
|
|
13
|
+
* Two layers:
|
|
14
|
+
*
|
|
15
|
+
* A. Always-on, server-free schema probe — `NextWorkerDecision` accepts a well-formed
|
|
16
|
+
* `nextWorkerInputs`, still accepts a decision that omits it (additive/back-compat),
|
|
17
|
+
* rejects a non-object item, and — because `additionalProperties:false` — rejects the
|
|
18
|
+
* field on a pre-RFC-0126 strict validator only when the property name differs (the
|
|
19
|
+
* point of the fail-closed gate). Array-length equality with `nextWorkerIds` is NOT
|
|
20
|
+
* JSON-Schema-expressible, so the schema ADMITS a length-mismatch; that MUST is a
|
|
21
|
+
* HOST runtime check, driven in layer B.
|
|
22
|
+
*
|
|
23
|
+
* B. Capability-gated behavioral legs — on a host advertising
|
|
24
|
+
* `capabilities.dispatch.perItemInput: true` that exposes the dispatch test seam, a
|
|
25
|
+
* length-mismatched decision fails with a validation_error and dispatches no child,
|
|
26
|
+
* and each child receives its own `nextWorkerInputs[i]`. On a host NOT advertising the
|
|
27
|
+
* capability, a non-empty `nextWorkerInputs` MUST fail closed (validation_error), never
|
|
28
|
+
* silently drop-and-dispatch N identical children. No conformant host advertises
|
|
29
|
+
* perItemInput yet — these legs soft-skip until a reference host wires it (the first
|
|
30
|
+
* witness toward `Active → Accepted`).
|
|
31
|
+
*
|
|
32
|
+
* @see spec/v1/node-packs.md §"core.dispatch per-item input — data-parallel fan-out (RFC 0126)"
|
|
33
|
+
* @see spec/v1/capabilities.md §dispatch
|
|
34
|
+
* @see schemas/orchestrator-decision.schema.json
|
|
35
|
+
* @see RFCS/0126-data-parallel-dispatch-per-item-input.md
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, expect } from 'vitest';
|
|
39
|
+
import { readFileSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
42
|
+
import addFormats from 'ajv-formats';
|
|
43
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
44
|
+
import { driver } from '../lib/driver.js';
|
|
45
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
46
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
47
|
+
|
|
48
|
+
const DECISION = join(SCHEMAS_DIR, 'orchestrator-decision.schema.json');
|
|
49
|
+
|
|
50
|
+
describe('dispatch-per-item: NextWorkerDecision.nextWorkerInputs schema (always-on, server-free)', () => {
|
|
51
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
52
|
+
addFormats(ajv);
|
|
53
|
+
const validate = ajv.compile(JSON.parse(readFileSync(DECISION, 'utf8')));
|
|
54
|
+
|
|
55
|
+
it('accepts a next-worker decision carrying a well-formed, index-aligned nextWorkerInputs', () => {
|
|
56
|
+
const decision = {
|
|
57
|
+
kind: 'next-worker',
|
|
58
|
+
nextWorkerIds: ['pack.re-engage-contact', 'pack.re-engage-contact', 'pack.re-engage-contact'],
|
|
59
|
+
nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }, { contactId: 'c-3' }],
|
|
60
|
+
};
|
|
61
|
+
expect(
|
|
62
|
+
validate(decision),
|
|
63
|
+
`orchestrator-decision.schema.json §NextWorkerDecision — a well-formed nextWorkerInputs MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
64
|
+
).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('still accepts a next-worker decision that omits nextWorkerInputs (additive / back-compat)', () => {
|
|
68
|
+
expect(
|
|
69
|
+
validate({ kind: 'next-worker', nextWorkerIds: ['pack.child'] }),
|
|
70
|
+
'a pre-RFC-0126 next-worker decision MUST stay valid — the field is OPTIONAL',
|
|
71
|
+
).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects a non-object nextWorkerInputs item', () => {
|
|
75
|
+
expect(
|
|
76
|
+
validate({ kind: 'next-worker', nextWorkerIds: ['a'], nextWorkerInputs: ['not-an-object'] }),
|
|
77
|
+
'each nextWorkerInputs entry MUST be a per-child input object',
|
|
78
|
+
).toBe(false);
|
|
79
|
+
expect(
|
|
80
|
+
validate({ kind: 'next-worker', nextWorkerIds: ['a'], nextWorkerInputs: 'nope' }),
|
|
81
|
+
'nextWorkerInputs MUST be an array',
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('still rejects an unknown property (additionalProperties:false) — the fail-closed gate', () => {
|
|
86
|
+
expect(
|
|
87
|
+
validate({ kind: 'next-worker', nextWorkerIds: ['a'], perItemInputs: [{ x: 1 }] }),
|
|
88
|
+
'NextWorkerDecision is additionalProperties:false — a mis-named field MUST be rejected, so old strict validators fail closed on unknown per-item shapes',
|
|
89
|
+
).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ADMITS a length-mismatch — array-length equality is a runtime MUST, not schema-expressible', () => {
|
|
93
|
+
expect(
|
|
94
|
+
validate({ kind: 'next-worker', nextWorkerIds: ['a', 'b'], nextWorkerInputs: [{ x: 1 }] }),
|
|
95
|
+
'the wire schema cannot express nextWorkerInputs.length == nextWorkerIds.length; the host enforces it at decision time (layer B)',
|
|
96
|
+
).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('dispatch-per-item: per-item input behavior (capability-gated, RFC 0126)', () => {
|
|
101
|
+
it('a host advertising perItemInput projects nextWorkerInputs[i] into child i', async () => {
|
|
102
|
+
const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
|
|
103
|
+
if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
|
|
104
|
+
|
|
105
|
+
const res = await driver.post('/v1/host/sample/dispatch/per-item', {
|
|
106
|
+
nextWorkerIds: ['conformance.child', 'conformance.child'],
|
|
107
|
+
nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
|
|
108
|
+
});
|
|
109
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
110
|
+
|
|
111
|
+
const body = res.json as { children?: Array<{ inputs?: Record<string, unknown> }> } | undefined;
|
|
112
|
+
expect(
|
|
113
|
+
body?.children?.length,
|
|
114
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'one child dispatched per nextWorkerIds entry'),
|
|
115
|
+
).toBe(2);
|
|
116
|
+
expect(
|
|
117
|
+
body?.children?.map((c) => c.inputs?.contactId),
|
|
118
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'each child receives its own nextWorkerInputs[i] (per-item value wins over inputMapping)'),
|
|
119
|
+
).toEqual(['c-1', 'c-2']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('a length-mismatched nextWorkerInputs fails with a validation_error and dispatches no child', async () => {
|
|
123
|
+
const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
|
|
124
|
+
if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
|
|
125
|
+
|
|
126
|
+
const res = await driver.post('/v1/host/sample/dispatch/per-item', {
|
|
127
|
+
nextWorkerIds: ['conformance.child', 'conformance.child'],
|
|
128
|
+
nextWorkerInputs: [{ contactId: 'c-1' }],
|
|
129
|
+
});
|
|
130
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
131
|
+
|
|
132
|
+
expect(
|
|
133
|
+
res.status >= 400 && res.status < 500,
|
|
134
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'nextWorkerInputs.length != nextWorkerIds.length MUST fail the dispatch node (4xx validation_error), dispatching no child'),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('nextWorkerInputs[i] OVERRIDES the inputMapping projection on key collision (G1 precedence)', async () => {
|
|
139
|
+
const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
|
|
140
|
+
if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
|
|
141
|
+
|
|
142
|
+
// The seam applies `inputMapping` first (parent-variable projection, RFC 0022), then overlays
|
|
143
|
+
// nextWorkerInputs[i]. A key present in BOTH MUST resolve to the per-item value (most-specific wins).
|
|
144
|
+
const res = await driver.post('/v1/host/sample/dispatch/per-item', {
|
|
145
|
+
nextWorkerIds: ['conformance.child', 'conformance.child'],
|
|
146
|
+
inputMapping: { contactId: 'from-mapping', region: 'us' },
|
|
147
|
+
nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
|
|
148
|
+
});
|
|
149
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
150
|
+
|
|
151
|
+
const body = res.json as { children?: Array<{ inputs?: Record<string, unknown> }> } | undefined;
|
|
152
|
+
expect(
|
|
153
|
+
body?.children?.map((c) => c.inputs?.contactId),
|
|
154
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'on key collision the per-item value wins over inputMapping (G1)'),
|
|
155
|
+
).toEqual(['c-1', 'c-2']);
|
|
156
|
+
expect(
|
|
157
|
+
body?.children?.every((c) => c.inputs?.region === 'us'),
|
|
158
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'non-colliding inputMapping keys still project (per-item merges OVER, does not replace)'),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('replay re-reads the recorded nextWorkerInputs verbatim — no recomputation (R5 replay-freeze)', async () => {
|
|
163
|
+
const dispatch = await readCapabilityFamily<{ perItemInput?: boolean }>('dispatch');
|
|
164
|
+
if (!behaviorGate('dispatch.perItemInput', dispatch?.perItemInput === true)) return;
|
|
165
|
+
|
|
166
|
+
// A :fork/replay MUST re-read the per-item inputs frozen in the recorded runOrchestrator.decided
|
|
167
|
+
// decision and reproduce byte-identical children (CP-2), never re-derive them at replay time.
|
|
168
|
+
const res = await driver.post('/v1/host/sample/dispatch/per-item', {
|
|
169
|
+
nextWorkerIds: ['conformance.child', 'conformance.child'],
|
|
170
|
+
nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
|
|
171
|
+
replay: true,
|
|
172
|
+
});
|
|
173
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
174
|
+
|
|
175
|
+
const body = res.json as { children?: Array<{ inputs?: Record<string, unknown> }>; replayed?: boolean } | undefined;
|
|
176
|
+
expect(
|
|
177
|
+
body?.children?.map((c) => c.inputs?.contactId),
|
|
178
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'replay/:fork reproduces the recorded per-item children verbatim (frozen at decision time)'),
|
|
179
|
+
).toEqual(['c-1', 'c-2']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('a host NOT advertising perItemInput MUST fail closed on a non-empty nextWorkerInputs', async () => {
|
|
183
|
+
const dispatch = await readCapabilityFamily<{ supported?: boolean; perItemInput?: boolean }>('dispatch');
|
|
184
|
+
if (!dispatch?.supported) return; // no dispatch surface → out of scope
|
|
185
|
+
if (dispatch.perItemInput === true) return; // this leg targets non-supporting hosts
|
|
186
|
+
|
|
187
|
+
const res = await driver.post('/v1/host/sample/dispatch/per-item', {
|
|
188
|
+
nextWorkerIds: ['conformance.child', 'conformance.child'],
|
|
189
|
+
nextWorkerInputs: [{ contactId: 'c-1' }, { contactId: 'c-2' }],
|
|
190
|
+
});
|
|
191
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
192
|
+
|
|
193
|
+
expect(
|
|
194
|
+
res.status >= 400 && res.status < 500,
|
|
195
|
+
driver.describe('node-packs.md §core.dispatch per-item input', 'a host not advertising perItemInput MUST fail closed (4xx) on a non-empty nextWorkerInputs — never silently drop it and dispatch N identical children'),
|
|
196
|
+
).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
});
|