@openwop/openwop-conformance 1.47.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.
@@ -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
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Self-hosted runner — remote-driven local execution (RFC 0122, `Active`).
3
+ *
4
+ * A host routes a run's per-step model/tool DISPATCH to a user-controlled runner
5
+ * that dials OUT to the host (SSE receive + POST result) and holds local
6
+ * credentials the host cannot reach. The host stays the sole orchestration/
7
+ * persistence/replay authority; the runner is a stateless dispatch executor.
8
+ *
9
+ * Three assertion tiers (mirroring RFC 0108/0121 shape/behavior split):
10
+ * 1. Schema shape (always-on, server-free) — the `selfHostedRunner` capability
11
+ * block + the dispatch-frame / result-frame / registration schema shapes.
12
+ * 2. Advertisement-gated — the live `selfHostedRunner` block is well-formed
13
+ * (`supported` boolean; `dispatchKinds` ⊂ {model,tool}). Gated on
14
+ * `behaviorGate('openwop-self-hosted-runner', supported)`.
15
+ * 3. Seam-gated behavioral — drives the `POST /v1/host/sample/runner/*` seams
16
+ * (`host-sample-test-seams.md` §19), soft-skipping on 404, to assert
17
+ * subject-first match (no cross-subject routing), at-most-once dispatch
18
+ * dedup, credential non-transit, and retriable `runner_unavailable` on
19
+ * liveness loss.
20
+ *
21
+ * RFC 0122 is `Active` (not `Accepted`): no reference host advertises
22
+ * `selfHostedRunner.supported: true` yet, so tiers 2/3 soft-skip today; the
23
+ * shape tier is the always-on floor.
24
+ *
25
+ * Spec references:
26
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/self-hosted-runner.md
27
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/capabilities.md §"selfHostedRunner"
28
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0122-self-hosted-runner-remote-execution.md
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { readFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { driver } from '../lib/driver.js';
35
+ import { behaviorGate } from '../lib/behavior-gate.js';
36
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
37
+ import { SCHEMAS_DIR } from '../lib/paths.js';
38
+
39
+ const GATE = 'openwop-self-hosted-runner';
40
+
41
+ interface JsonSchema {
42
+ properties?: Record<string, JsonSchema>;
43
+ required?: string[];
44
+ additionalProperties?: boolean;
45
+ type?: string;
46
+ enum?: string[];
47
+ items?: JsonSchema;
48
+ }
49
+
50
+ function readSchema(name: string): JsonSchema {
51
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as JsonSchema;
52
+ }
53
+
54
+ /** Read the canonical error code from a response body (tolerant of shapes). */
55
+ function errCode(json: unknown): string | undefined {
56
+ const j = json as { error?: unknown; code?: unknown };
57
+ if (typeof j?.code === 'string') return j.code;
58
+ if (typeof j?.error === 'string') return j.error;
59
+ const e = j?.error as { code?: unknown } | undefined;
60
+ if (e && typeof e.code === 'string') return e.code;
61
+ return undefined;
62
+ }
63
+
64
+ /** Read a boolean `retriable` flag from either `{retriable}` or `{error:{retriable}}`. */
65
+ function retriable(json: unknown): boolean | undefined {
66
+ const j = json as { retriable?: unknown; error?: { retriable?: unknown } };
67
+ if (typeof j?.retriable === 'boolean') return j.retriable;
68
+ if (j?.error && typeof j.error.retriable === 'boolean') return j.error.retriable;
69
+ return undefined;
70
+ }
71
+
72
+ interface SelfHostedRunner {
73
+ supported?: boolean;
74
+ dispatchKinds?: string[];
75
+ }
76
+
77
+ const REGISTER = '/v1/host/sample/runner/register';
78
+ const DISPATCH = '/v1/host/sample/runner/dispatch';
79
+
80
+ describe('self-hosted-runner: schema shape (RFC 0122, server-free)', () => {
81
+ it('capabilities.schema.json declares selfHostedRunner {supported} with required supported', () => {
82
+ const caps = readSchema('capabilities.schema.json');
83
+ const shr = caps.properties?.selfHostedRunner;
84
+ expect(shr, 'capabilities.md §selfHostedRunner — the block MUST be declared').toBeDefined();
85
+ expect(
86
+ shr?.required,
87
+ 'RFC 0122 — selfHostedRunner.supported is REQUIRED when the block is present',
88
+ ).toContain('supported');
89
+ expect(
90
+ shr?.properties?.supported?.type,
91
+ 'selfHostedRunner.supported MUST be a boolean',
92
+ ).toBe('boolean');
93
+ expect(
94
+ shr?.additionalProperties,
95
+ 'selfHostedRunner MUST be a closed object',
96
+ ).toBe(false);
97
+ });
98
+
99
+ it('the dispatch frame schema pins {runId, stepId, seq, kind, inputs} with an integer cursor', () => {
100
+ const s = readSchema('self-hosted-runner-dispatch-frame.schema.json');
101
+ for (const f of ['runId', 'stepId', 'seq', 'kind', 'inputs']) {
102
+ expect(s.required, `dispatch frame MUST require '${f}'`).toContain(f);
103
+ }
104
+ expect(s.additionalProperties, 'dispatch frame MUST be a closed object').toBe(false);
105
+ expect(
106
+ s.properties?.seq?.type,
107
+ 'RFC 0122 §Channel — the dispatch cursor `seq` MUST be an integer (distinct from the event-log sequence)',
108
+ ).toBe('integer');
109
+ expect(s.properties?.kind?.enum, "dispatch kind MUST be one of {model, tool}").toEqual(
110
+ expect.arrayContaining(['model', 'tool']),
111
+ );
112
+ });
113
+
114
+ it('the result frame schema pins {runId, stepId, seq, output} and is closed', () => {
115
+ const s = readSchema('self-hosted-runner-result-frame.schema.json');
116
+ for (const f of ['runId', 'stepId', 'seq', 'output']) {
117
+ expect(s.required, `result frame MUST require '${f}'`).toContain(f);
118
+ }
119
+ // additionalProperties:false is the schema-level runner-credential-non-transit rail —
120
+ // a runner cannot smuggle a credential field onto a result frame.
121
+ expect(
122
+ s.additionalProperties,
123
+ 'result frame MUST be a closed object (runner-credential-non-transit)',
124
+ ).toBe(false);
125
+ });
126
+
127
+ it('the registration schema pins {runnerId, subject, capabilities} and is closed', () => {
128
+ const s = readSchema('self-hosted-runner-registration.schema.json');
129
+ for (const f of ['runnerId', 'subject', 'capabilities']) {
130
+ expect(s.required, `registration MUST require '${f}'`).toContain(f);
131
+ }
132
+ expect(s.additionalProperties, 'registration MUST be a closed object').toBe(false);
133
+ expect(
134
+ s.properties?.capabilities?.additionalProperties,
135
+ 'registration.capabilities MUST be a closed object',
136
+ ).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe('self-hosted-runner: advertisement shape (gated)', () => {
141
+ it('an advertised selfHostedRunner block is well-formed', async () => {
142
+ const shr = await readCapabilityFamily<SelfHostedRunner>('selfHostedRunner');
143
+ if (!behaviorGate(GATE, shr?.supported === true)) return;
144
+
145
+ expect(
146
+ typeof shr?.supported,
147
+ driver.describe('capabilities.md §selfHostedRunner', 'supported MUST be a boolean'),
148
+ ).toBe('boolean');
149
+ if (shr?.dispatchKinds !== undefined) {
150
+ for (const k of shr.dispatchKinds) {
151
+ expect(
152
+ ['model', 'tool'],
153
+ driver.describe('capabilities.md §selfHostedRunner', `dispatchKinds entry '${k}' MUST be model|tool`),
154
+ ).toContain(k);
155
+ }
156
+ }
157
+ });
158
+ });
159
+
160
+ describe('self-hosted-runner: behavioral (seam-gated, soft-skip 404)', () => {
161
+ it('a dispatch for a subject with no runner fails retriably with runner_unavailable', async () => {
162
+ // Register a runner for subject B only, then dispatch for subject A. The host
163
+ // MUST NOT fall back to B's runner (subject-first isolation); with no runner
164
+ // for A the dispatch MUST fail with the retriable `runner_unavailable`.
165
+ const reg = await driver.post(REGISTER, {
166
+ runnerId: 'runner_b_1',
167
+ subject: 'subject_B',
168
+ capabilities: { providers: ['anthropic'] },
169
+ });
170
+ if (reg.status === 404) return; // seam unwired — soft-skip
171
+
172
+ const res = await driver.post(DISPATCH, {
173
+ subject: 'subject_A',
174
+ runId: 'run_iso',
175
+ stepId: 'step_0',
176
+ seq: 0,
177
+ kind: 'model',
178
+ provider: 'anthropic',
179
+ model: 'claude-opus-4-8',
180
+ inputs: { messages: [] },
181
+ });
182
+ if (res.status === 404) return;
183
+
184
+ expect(
185
+ res.status >= 400,
186
+ driver.describe('self-hosted-runner.md §Behavior#1', 'a subject-A dispatch MUST NOT route to a subject-B runner'),
187
+ ).toBe(true);
188
+ expect(
189
+ errCode(res.json),
190
+ driver.describe('RFC 0122 §Behavior#5', 'a dispatch with no owning-subject runner MUST fail `runner_unavailable`'),
191
+ ).toBe('runner_unavailable');
192
+ expect(
193
+ retriable(res.json),
194
+ driver.describe('RFC 0122 §Behavior#5', '`runner_unavailable` MUST be retriable'),
195
+ ).toBe(true);
196
+ });
197
+
198
+ it('a redelivered {runId, stepId} dispatch is dropped, not re-executed (at-most-once)', async () => {
199
+ const reg = await driver.post(REGISTER, {
200
+ runnerId: 'runner_a_1',
201
+ subject: 'subject_A',
202
+ capabilities: { providers: ['anthropic'] },
203
+ });
204
+ if (reg.status === 404) return;
205
+
206
+ const frame = {
207
+ subject: 'subject_A',
208
+ runId: 'run_idem',
209
+ stepId: 'step_1',
210
+ seq: 0,
211
+ kind: 'model',
212
+ provider: 'anthropic',
213
+ model: 'claude-opus-4-8',
214
+ inputs: { messages: [] },
215
+ };
216
+ const first = await driver.post(DISPATCH, frame);
217
+ if (first.status === 404) return;
218
+ // A host without a live runner backing the seam MAY answer runner_unavailable;
219
+ // the at-most-once property is only observable when the first dispatch resolved.
220
+ if (errCode(first.json) === 'runner_unavailable') return;
221
+
222
+ const second = await driver.post(DISPATCH, frame);
223
+ const body = second.json as { deduped?: unknown };
224
+ expect(
225
+ body?.deduped,
226
+ driver.describe(
227
+ 'self-hosted-runner.md §At-most-once dispatch',
228
+ 'a redelivered {runId, stepId} with a persisted result MUST be dropped (deduped:true), not re-dispatched',
229
+ ),
230
+ ).toBe(true);
231
+ });
232
+ });