@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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/coverage.md +2 -1
- package/package.json +1 -1
- package/schemas/README.md +3 -0
- package/schemas/capabilities.schema.json +34 -0
- package/schemas/orchestrator-decision.schema.json +5 -0
- package/schemas/self-hosted-runner-dispatch-frame.schema.json +79 -0
- package/schemas/self-hosted-runner-registration.schema.json +53 -0
- package/schemas/self-hosted-runner-result-frame.schema.json +38 -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/self-hosted-runner.test.ts +232 -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
|
@@ -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
|
+
});
|