@openwop/openwop-conformance 2.45.14 → 2.45.16
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 +45 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2668 -4
- package/scenario-majors.json +164 -2
- package/schemas/CORPUS-STAMP.json +13 -13
- package/src/lib/artifact-schema-url-witness.ts +83 -0
- package/src/lib/backpressure-witness.ts +2 -2
- package/src/lib/budget-witness.ts +3 -3
- package/src/lib/dispatch-witness.ts +212 -0
- package/src/lib/envelope-schema-static.ts +166 -0
- package/src/lib/exhaustion-facet-witness.ts +1 -1
- package/src/lib/family-advert-witness.ts +411 -0
- package/src/lib/fixture-run-observer.ts +97 -0
- package/src/lib/i18n-double.ts +99 -0
- package/src/lib/i18n-negotiation-witness.ts +173 -0
- package/src/lib/localized-content-witness.ts +49 -0
- package/src/lib/model-capability-insufficient-witness.ts +121 -0
- package/src/lib/prompt-double.ts +217 -0
- package/src/lib/prompt-events-witness.ts +211 -0
- package/src/lib/prompt-library-witness.ts +406 -0
- package/src/lib/provider-usage-witness.ts +63 -0
- package/src/lib/run-bounds-witness.ts +111 -0
- package/src/lib/run-graph-double.ts +144 -0
- package/src/lib/scratch-host.ts +4 -1
- package/src/lib/subworkflow-witness.ts +192 -0
- package/src/lib/table-schema-witness.ts +3 -1
- package/src/scenarios/v2-agent-runtime-advertisement.test.ts +51 -0
- package/src/scenarios/v2-ai-envelope-advertisement.test.ts +43 -0
- package/src/scenarios/v2-ai-envelope-shape-static.test.ts +93 -0
- package/src/scenarios/v2-ai-providers-advertisement.test.ts +94 -0
- package/src/scenarios/v2-artifact-created-static.test.ts +92 -0
- package/src/scenarios/v2-artifact-type-schema-url.test.ts +44 -0
- package/src/scenarios/v2-artifact-types-advertisement.test.ts +64 -0
- package/src/scenarios/v2-authorization-advertisement.test.ts +66 -0
- package/src/scenarios/v2-channel-presence-advertisement.test.ts +41 -0
- package/src/scenarios/v2-conversation-turn-model-provenance-advertisement.test.ts +41 -0
- package/src/scenarios/v2-conversation-turn-model-provenance.test.ts +110 -0
- package/src/scenarios/v2-credentials-advertisement.test.ts +66 -0
- package/src/scenarios/v2-data-residency-admission.test.ts +98 -0
- package/src/scenarios/v2-data-residency-advertisement.test.ts +52 -0
- package/src/scenarios/v2-dead-letter-advertisement.test.ts +54 -0
- package/src/scenarios/v2-dispatch-input-mapping.test.ts +70 -0
- package/src/scenarios/v2-dispatch-loop.test.ts +80 -0
- package/src/scenarios/v2-envelope-contracts-advertisement.test.ts +40 -0
- package/src/scenarios/v2-envelope-reasoning-shape.test.ts +81 -0
- package/src/scenarios/v2-envelope-tier-one-subset-static.test.ts +77 -0
- package/src/scenarios/v2-envelope-variant-discriminator-static.test.ts +57 -0
- package/src/scenarios/v2-envelopes-advertisement.test.ts +85 -0
- package/src/scenarios/v2-export-bundle-static.test.ts +108 -0
- package/src/scenarios/v2-i18n-negotiation.test.ts +91 -0
- package/src/scenarios/v2-limits-envelope-caps-advertisement.test.ts +61 -0
- package/src/scenarios/v2-localized-content-delivery.test.ts +136 -0
- package/src/scenarios/v2-media-inline-cap-static.test.ts +59 -0
- package/src/scenarios/v2-model-capabilities-advertisement.test.ts +61 -0
- package/src/scenarios/v2-model-capability-insufficient.test.ts +63 -0
- package/src/scenarios/v2-multi-party-conversation-advertisement.test.ts +52 -0
- package/src/scenarios/v2-node-pack-runtimes-advertisement.test.ts +52 -0
- package/src/scenarios/v2-nondeterminism-policy-advertisement.test.ts +41 -0
- package/src/scenarios/v2-portability-advertisement.test.ts +63 -0
- package/src/scenarios/v2-prompt-all-four-kinds-events.test.ts +69 -0
- package/src/scenarios/v2-prompt-end-to-end-events.test.ts +71 -0
- package/src/scenarios/v2-prompt-library-advertisement.test.ts +40 -0
- package/src/scenarios/v2-prompt-list-and-fetch.test.ts +103 -0
- package/src/scenarios/v2-prompt-mutable-lifecycle.test.ts +130 -0
- package/src/scenarios/v2-prompt-pack-install.test.ts +78 -0
- package/src/scenarios/v2-prompt-render-deterministic.test.ts +87 -0
- package/src/scenarios/v2-prompt-resolution-chain-event.test.ts +46 -0
- package/src/scenarios/v2-prompt-template-shape.test.ts +112 -0
- package/src/scenarios/v2-provider-usage-advertisement.test.ts +61 -0
- package/src/scenarios/v2-provider-usage-emission.test.ts +50 -0
- package/src/scenarios/v2-provider-usage-payload-static.test.ts +92 -0
- package/src/scenarios/v2-purpose-propagation-advertisement.test.ts +52 -0
- package/src/scenarios/v2-run-execution-bounds.test.ts +67 -0
- package/src/scenarios/v2-scheduling-advertisement.test.ts +55 -0
- package/src/scenarios/v2-self-hosted-runner-advertisement.test.ts +52 -0
- package/src/scenarios/v2-self-hosted-runner-frames-static.test.ts +121 -0
- package/src/scenarios/v2-sub-workflow-advertisement.test.ts +53 -0
- package/src/scenarios/v2-subworkflow-input-mapping.test.ts +74 -0
- package/src/scenarios/v2-subworkflow-linkage.test.ts +76 -0
- package/src/scenarios/v2-ui-plugins-advertisement.test.ts +85 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `provider.usage` payload, statically (`spec/v2/core/events.md`
|
|
3
|
+
* §providerUsage; `schemas/v2/run-event-payloads.schema.json#/$defs/providerUsage`;
|
|
4
|
+
* RFC 0026; invariant `provider-usage-no-credential-leak`). The v1 twin is the
|
|
5
|
+
* schema half of `provider-usage`; its emit-seam legs (and the v1
|
|
6
|
+
* `provider_usage_credential_leak` code, which `spec/v2/errors.json` does not
|
|
7
|
+
* carry) are not ported.
|
|
8
|
+
*
|
|
9
|
+
* type `provider.usage` is a registered v2 event type;
|
|
10
|
+
* positive a payload with the four required fields, and one with every
|
|
11
|
+
* optional field, validates;
|
|
12
|
+
* required a payload missing provider, model, inputTokens or outputTokens
|
|
13
|
+
* is rejected;
|
|
14
|
+
* counts a fractional or negative token count is rejected;
|
|
15
|
+
* cost a negative `costEstimateUsd` is rejected; `currency` is an
|
|
16
|
+
* ISO 4217 code ("`providerUsage.currency` is the ISO 4217
|
|
17
|
+
* currency of `costEstimateUsd`");
|
|
18
|
+
* no-leak the payload is closed, so a credential ref, a hashed credential
|
|
19
|
+
* id or prompt/response text is rejected ("The payload MUST NOT
|
|
20
|
+
* carry credential refs, hashed credential identifiers, or prompt
|
|
21
|
+
* or response text").
|
|
22
|
+
*
|
|
23
|
+
* Host-free: every leg runs with no OPENWOP_BASE_URL.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v2/core/events.md §providerUsage
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { v2RefValidator } from '../lib/v2.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
|
|
32
|
+
const DOC = 'events.md §providerUsage';
|
|
33
|
+
const ID_TYPE = 'openwop.requirement.provider-usage.event-type-registered';
|
|
34
|
+
const ID_POSITIVE = 'openwop.requirement.provider-usage.payload-well-formed-accepted';
|
|
35
|
+
const ID_REQUIRED = 'openwop.requirement.provider-usage.payload-required-fields';
|
|
36
|
+
const ID_COUNTS = 'openwop.requirement.provider-usage.payload-token-counts';
|
|
37
|
+
const ID_COST = 'openwop.requirement.provider-usage.payload-cost-and-currency';
|
|
38
|
+
const ID_NO_LEAK = 'openwop.requirement.provider-usage.payload-no-credential-or-content';
|
|
39
|
+
|
|
40
|
+
const MINIMAL = { provider: 'anthropic', model: 'claude-x', inputTokens: 120, outputTokens: 30 } as const;
|
|
41
|
+
const FULL = { ...MINIMAL, totalTokens: 150, costEstimateUsd: 0.0021, currency: 'EUR', cacheHit: false, cacheReadTokens: 0, cacheWriteTokens: 64 } as const;
|
|
42
|
+
|
|
43
|
+
function without(field: string): Record<string, unknown> {
|
|
44
|
+
const c: Record<string, unknown> = { ...MINIMAL };
|
|
45
|
+
delete c[field];
|
|
46
|
+
return c;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('v2 provider.usage payload (events.md §providerUsage)', () => {
|
|
50
|
+
const validate = v2RefValidator('run-event-payloads.schema.json#/$defs/providerUsage');
|
|
51
|
+
|
|
52
|
+
it('provider.usage is a registered v2 run-event type', () => {
|
|
53
|
+
const r = v2RefValidator('run-event.schema.json#/properties/type')('provider.usage');
|
|
54
|
+
expect(r.ok, req(ID_TYPE, DOC, `provider.usage MUST be accepted by the v2 run-event type union (${r.errors})`)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts a minimal and a full payload', () => {
|
|
58
|
+
for (const p of [MINIMAL, FULL]) {
|
|
59
|
+
const r = validate(p);
|
|
60
|
+
expect(r.ok, req(ID_POSITIVE, DOC, `a well-formed provider.usage payload MUST validate (${r.errors})`)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each(['provider', 'model', 'inputTokens', 'outputTokens'])('rejects a payload without %s', (field) => {
|
|
65
|
+
expect(validate(without(field)).ok, req(ID_REQUIRED, DOC, `a provider.usage payload without ${field} MUST be rejected`)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each<[string, Record<string, unknown>]>([
|
|
69
|
+
['a fractional inputTokens', { ...MINIMAL, inputTokens: 1.5 }],
|
|
70
|
+
['a negative outputTokens', { ...MINIMAL, outputTokens: -1 }],
|
|
71
|
+
['a string inputTokens', { ...MINIMAL, inputTokens: '120' }],
|
|
72
|
+
])('rejects %s', (what, p) => {
|
|
73
|
+
expect(validate(p).ok, req(ID_COUNTS, DOC, `a provider.usage payload with ${what} MUST be rejected`)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each<[string, Record<string, unknown>]>([
|
|
77
|
+
['a negative costEstimateUsd', { ...MINIMAL, costEstimateUsd: -0.01 }],
|
|
78
|
+
['a lower-case currency', { ...MINIMAL, costEstimateUsd: 0.01, currency: 'usd' }],
|
|
79
|
+
['a currency symbol', { ...MINIMAL, costEstimateUsd: 0.01, currency: '$' }],
|
|
80
|
+
])('rejects %s', (what, p) => {
|
|
81
|
+
expect(validate(p).ok, req(ID_COST, DOC, `a provider.usage payload with ${what} MUST be rejected`)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each<[string, Record<string, unknown>]>([
|
|
85
|
+
['a credential ref', { ...MINIMAL, credentialRef: 'secret:tenant:byok-anthropic:v1' }],
|
|
86
|
+
['a hashed credential id', { ...MINIMAL, credentialHash: 'sha256:9f2c' }],
|
|
87
|
+
['prompt text', { ...MINIMAL, prompt: 'summarize the attached notes' }],
|
|
88
|
+
['response text', { ...MINIMAL, response: 'Here is a summary' }],
|
|
89
|
+
])('rejects a payload carrying %s', (what, p) => {
|
|
90
|
+
expect(validate(p).ok, req(ID_NO_LEAK, DOC, `the provider.usage payload MUST NOT carry ${what}`)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `purposePropagation` advertisement (`spec/v2/core/security-defaults.md` §Onward hops): the purpose-label propagation claim. The v1 twin is
|
|
3
|
+
* `purpose-propagation`;
|
|
4
|
+
* the legs live in `lib/family-advert-witness.ts` (witness wave 3).
|
|
5
|
+
*
|
|
6
|
+
* record the record validates against its capabilities-schema seat;
|
|
7
|
+
* propagates-onward-boolean purposePropagation.propagatesOnward is a boolean;
|
|
8
|
+
*
|
|
9
|
+
* Not here: propagation itself needs a carrier the suite can receive with a label seat (TODO §8).
|
|
10
|
+
*
|
|
11
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `purposePropagation` absent, or the
|
|
12
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
13
|
+
*
|
|
14
|
+
* @see spec/v2/core/security-defaults.md §Onward hops
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
19
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
20
|
+
import { req } from '../lib/requirement-ids.js';
|
|
21
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
22
|
+
import { purposePropagatesOnwardLeg, recordSchemaLeg } from '../lib/family-advert-witness.js';
|
|
23
|
+
|
|
24
|
+
const PROFILE = majorProfile(2);
|
|
25
|
+
const DOC = 'security-defaults.md §Onward hops';
|
|
26
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
27
|
+
const ID_RECORD = 'openwop.requirement.purpose-propagation.advert-record-schema';
|
|
28
|
+
const ID_PROPAGATES_ONWARD_BOOLEAN = 'openwop.requirement.purpose-propagation.propagates-onward-boolean';
|
|
29
|
+
|
|
30
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
31
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('v2 purposePropagation advertisement (security-defaults.md §Onward hops)', () => {
|
|
35
|
+
it('the purposePropagation record validates against its capabilities-schema seat', async () => {
|
|
36
|
+
const doc = await discovery();
|
|
37
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
38
|
+
if (!(await familyAdvertised('purposePropagation'))) return softSkip('inapplicable', 'the host does not advertise purposePropagation');
|
|
39
|
+
const out = recordSchemaLeg(PROFILE, doc, 'purposePropagation', DOC);
|
|
40
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
41
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('purposePropagation.propagatesOnward is a boolean', async () => {
|
|
45
|
+
const doc = await discovery();
|
|
46
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
47
|
+
if (!(await familyAdvertised('purposePropagation'))) return softSkip('inapplicable', 'the host does not advertise purposePropagation');
|
|
48
|
+
const out = purposePropagatesOnwardLeg(PROFILE, doc);
|
|
49
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
50
|
+
for (const x of out.findings) expect(x.ok, req(ID_PROPAGATES_ONWARD_BOOLEAN, x.doc, x.message)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — run execution bounds (`spec/v2/core/runs.md` §`run` section; RFC 0058).
|
|
3
|
+
* The v1 twin is `run-execution-bounds-shape`; the legs live in
|
|
4
|
+
* `lib/run-bounds-witness.ts`, and what differs between majors (the run path,
|
|
5
|
+
* the closed `configurable` shape, the event name) is the profile row.
|
|
6
|
+
*
|
|
7
|
+
* shape advertised `limits.maxRunDurationMs` (≥ 1000) and
|
|
8
|
+
* `limits.maxLoopIterations` (≥ 1) are well-formed;
|
|
9
|
+
* refused NEW at v2, unaided: `configurable.run.runTimeoutMs: 0` is refused
|
|
10
|
+
* `400 validation_error` at create (`runs.md`: "An out-of-range
|
|
11
|
+
* recursionLimit or runTimeoutMs MUST return 400");
|
|
12
|
+
* breach the `conformance-run-duration-breach` fixture with a 1 s timeout
|
|
13
|
+
* ends `failed` / `run_timeout` and emits `cap.breached
|
|
14
|
+
* { kind: 'run-duration' }` with `observed > limit`.
|
|
15
|
+
*
|
|
16
|
+
* Dispositions: `limits` not advertised, or neither facet ⇒ shape
|
|
17
|
+
* `inapplicable`; no advertised fixture to name ⇒ refused `inapplicable`; the
|
|
18
|
+
* breach fixture unadvertised ⇒ breach `inapplicable` (the fixture is the
|
|
19
|
+
* opt-in, as for the budget and table-schema witnesses).
|
|
20
|
+
*
|
|
21
|
+
* Proven against a scratch double in `lib/run-bounds-witness.test.ts`.
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v2/core/runs.md §run section, §Limits
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { familyAdvertised, v2Discovery } from '../lib/v2.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
import { getAdvertisedFixtures } from '../lib/fixtures.js';
|
|
31
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
32
|
+
import { driveBreach, judgeBreach, refusedLeg, shapeLeg, TIMEOUT_FIXTURE } from '../lib/run-bounds-witness.js';
|
|
33
|
+
|
|
34
|
+
const PROFILE = majorProfile(2);
|
|
35
|
+
const ID_SHAPE = 'openwop.requirement.runs.limits-bounds-shape';
|
|
36
|
+
const ID_REFUSED = 'openwop.requirement.runs.run-timeout-out-of-range-refused';
|
|
37
|
+
const ID_BREACH = 'openwop.requirement.runs.run-timeout-breach';
|
|
38
|
+
|
|
39
|
+
/** Any advertised fixture names a valid workflow, so a refusal can only be about runTimeoutMs. */
|
|
40
|
+
function aFixture(): string | undefined {
|
|
41
|
+
const all = [...(getAdvertisedFixtures() ?? [])].sort();
|
|
42
|
+
return all.includes('conformance-noop') ? 'conformance-noop' : all.includes(TIMEOUT_FIXTURE) ? TIMEOUT_FIXTURE : all[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('v2 run execution bounds (runs.md §run section)', () => {
|
|
46
|
+
it('advertised limits.maxRunDurationMs and maxLoopIterations are well-formed', async () => {
|
|
47
|
+
let doc: Record<string, unknown> | null;
|
|
48
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
49
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
|
|
50
|
+
if (!(await familyAdvertised('limits'))) return softSkip('inapplicable', 'the host does not advertise limits');
|
|
51
|
+
const out = shapeLeg(PROFILE, doc);
|
|
52
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
53
|
+
for (const x of out.findings) expect(x.ok, req(ID_SHAPE, x.doc, x.message)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('an out-of-range runTimeoutMs is refused 400 validation_error at create', async () => {
|
|
57
|
+
const out = await refusedLeg(PROFILE, aFixture());
|
|
58
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
59
|
+
for (const x of out.findings) expect(x.ok, req(ID_REFUSED, x.doc, x.message)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('a run exceeding runTimeoutMs fails run_timeout with cap.breached { kind: run-duration }', async () => {
|
|
63
|
+
const o = await driveBreach(PROFILE);
|
|
64
|
+
if ('kind' in o) return softSkip(o.disposition, o.reason);
|
|
65
|
+
for (const x of judgeBreach(o)) expect(x.ok, req(ID_BREACH, x.doc, x.message)).toBe(true);
|
|
66
|
+
}, 60_000);
|
|
67
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `scheduling` advertisement (`spec/v2/core/host-services.md`
|
|
3
|
+
* §scheduling; RFC 0052). The v1 twin is the advertisement legs of
|
|
4
|
+
* `scheduling-capability-shape`; the legs live in
|
|
5
|
+
* `lib/family-advert-witness.ts`.
|
|
6
|
+
*
|
|
7
|
+
* record the record validates against its capabilities-schema seat;
|
|
8
|
+
* forms each advertised form (`cron`, `delayed`, `calendar`) is one boolean.
|
|
9
|
+
*
|
|
10
|
+
* Not ported: the v1 leg that `maxFutureHorizon` is an ISO-8601 duration. The v2
|
|
11
|
+
* schema seat is a plain string and no v2 prose states the format as binding
|
|
12
|
+
* the host, so it is not a failing leg here.
|
|
13
|
+
*
|
|
14
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `scheduling` absent, or the
|
|
15
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v2/core/host-services.md §scheduling
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
22
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
23
|
+
import { req } from '../lib/requirement-ids.js';
|
|
24
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
25
|
+
import { recordSchemaLeg, schedulingFormsLeg } from '../lib/family-advert-witness.js';
|
|
26
|
+
|
|
27
|
+
const PROFILE = majorProfile(2);
|
|
28
|
+
const DOC = 'host-services.md §scheduling';
|
|
29
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
30
|
+
const ID_RECORD = 'openwop.requirement.scheduling.advert-record-schema';
|
|
31
|
+
const ID_FORMS_BOOLEAN = 'openwop.requirement.scheduling.forms-boolean';
|
|
32
|
+
|
|
33
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
34
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('v2 scheduling advertisement (host-services.md §scheduling)', () => {
|
|
38
|
+
it('the scheduling record validates against its capabilities-schema seat', async () => {
|
|
39
|
+
const doc = await discovery();
|
|
40
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
41
|
+
if (!(await familyAdvertised('scheduling'))) return softSkip('inapplicable', 'the host does not advertise scheduling');
|
|
42
|
+
const out = recordSchemaLeg(PROFILE, doc, 'scheduling', DOC);
|
|
43
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
44
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('scheduling.cron, delayed and calendar are booleans', async () => {
|
|
48
|
+
const doc = await discovery();
|
|
49
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
50
|
+
if (!(await familyAdvertised('scheduling'))) return softSkip('inapplicable', 'the host does not advertise scheduling');
|
|
51
|
+
const out = schedulingFormsLeg(PROFILE, doc);
|
|
52
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
53
|
+
for (const x of out.findings) expect(x.ok, req(ID_FORMS_BOOLEAN, x.doc, x.message)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `selfHostedRunner` advertisement (`spec/v2/core/execution.md`
|
|
3
|
+
* §selfHostedRunner; RFC 0122). The v1 twin is the advertisement leg of
|
|
4
|
+
* `self-hosted-runner`; the legs live in `lib/family-advert-witness.ts`.
|
|
5
|
+
*
|
|
6
|
+
* record the record validates against its seat in
|
|
7
|
+
* `schemas/v2/capabilities.schema.json` (no v1 `supported` seat);
|
|
8
|
+
* dispatchKinds `dispatchKinds` lists `model`, `tool` or both, each once.
|
|
9
|
+
*
|
|
10
|
+
* Not here: registration, subject isolation, at-most-once and liveness need a
|
|
11
|
+
* runner seam; the frame shapes are `v2-self-hosted-runner-frames-static`.
|
|
12
|
+
*
|
|
13
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `selfHostedRunner` absent, or the
|
|
14
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v2/core/execution.md §selfHostedRunner
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { req } from '../lib/requirement-ids.js';
|
|
23
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
24
|
+
import { recordSchemaLeg, selfHostedRunnerDispatchKindsLeg } from '../lib/family-advert-witness.js';
|
|
25
|
+
|
|
26
|
+
const PROFILE = majorProfile(2);
|
|
27
|
+
const DOC = 'execution.md §selfHostedRunner';
|
|
28
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
29
|
+
const ID_RECORD = 'openwop.requirement.self-hosted-runner.advert-record-schema';
|
|
30
|
+
const ID_DISPATCH_KINDS = 'openwop.requirement.self-hosted-runner.dispatch-kinds';
|
|
31
|
+
|
|
32
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
33
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('v2 selfHostedRunner advertisement (execution.md §selfHostedRunner)', () => {
|
|
37
|
+
it('the selfHostedRunner record validates against its capabilities-schema seat', async () => {
|
|
38
|
+
const doc = await discovery();
|
|
39
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
40
|
+
const out = recordSchemaLeg(PROFILE, doc, 'selfHostedRunner', DOC);
|
|
41
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
42
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('selfHostedRunner.dispatchKinds lists model, tool or both', async () => {
|
|
46
|
+
const doc = await discovery();
|
|
47
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
48
|
+
const out = selfHostedRunnerDispatchKindsLeg(PROFILE, doc);
|
|
49
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
50
|
+
for (const x of out.findings) expect(x.ok, req(ID_DISPATCH_KINDS, x.doc, x.message)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the self-hosted-runner frame and registration shapes, statically
|
|
3
|
+
* (`spec/v2/core/execution.md` §selfHostedRunner; RFC 0122). The v1 twin is
|
|
4
|
+
* the schema half of `self-hosted-runner`; its live legs (subject isolation,
|
|
5
|
+
* `runner_unavailable`, at-most-once) need a runner seam and are not ported.
|
|
6
|
+
*
|
|
7
|
+
* compile the three schemas compile under Ajv 2020 with the v2 tree;
|
|
8
|
+
* dispatch a model and a tool dispatch frame validate; a frame with no
|
|
9
|
+
* `seq`, a non-integer or negative `seq`, an unknown `kind`, a
|
|
10
|
+
* model frame without `provider`/`model`, or a tool frame
|
|
11
|
+
* without `tool` is rejected;
|
|
12
|
+
* result a result frame validates; one without `output` is rejected;
|
|
13
|
+
* closed dispatch, result and registration (and its `capabilities`)
|
|
14
|
+
* are closed objects, so no frame or record can carry a
|
|
15
|
+
* credential field alongside the step ("A runner credential
|
|
16
|
+
* MUST NOT transit the host or appear in any frame");
|
|
17
|
+
* registration a registration validates; one without `subject` is rejected
|
|
18
|
+
* (subject-first routing keys on it).
|
|
19
|
+
*
|
|
20
|
+
* The shape rules these schemas state bind the host's dispatch frames and its
|
|
21
|
+
* registration record; the result frame is the runner's, so its legs witness
|
|
22
|
+
* the corpus shape the host's intake is written against, not the host.
|
|
23
|
+
*
|
|
24
|
+
* Host-free: every leg runs with no OPENWOP_BASE_URL.
|
|
25
|
+
*
|
|
26
|
+
* @see spec/v2/core/execution.md §selfHostedRunner
|
|
27
|
+
* @see schemas/v2/self-hosted-runner-dispatch-frame.schema.json
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { v2Validator } from '../lib/v2.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
const DOC = 'execution.md §selfHostedRunner';
|
|
35
|
+
const ID_COMPILE = 'openwop.requirement.self-hosted-runner.frame-schemas-compile';
|
|
36
|
+
const ID_DISPATCH = 'openwop.requirement.self-hosted-runner.dispatch-frame-shape';
|
|
37
|
+
const ID_RESULT = 'openwop.requirement.self-hosted-runner.result-frame-shape';
|
|
38
|
+
const ID_CLOSED = 'openwop.requirement.self-hosted-runner.frames-closed';
|
|
39
|
+
const ID_REGISTRATION = 'openwop.requirement.self-hosted-runner.registration-shape';
|
|
40
|
+
|
|
41
|
+
const SCHEMAS = ['self-hosted-runner-dispatch-frame', 'self-hosted-runner-result-frame', 'self-hosted-runner-registration'] as const;
|
|
42
|
+
|
|
43
|
+
const MODEL_FRAME = { runId: 'acme/run_x-xxxxxxxxxx', stepId: 'step_3', seq: 12, kind: 'model', provider: 'anthropic', model: 'claude-x', inputs: { messages: [] } } as const;
|
|
44
|
+
const TOOL_FRAME = { runId: 'acme/run_y-xxxxxxxxxx', stepId: 'step_1', seq: 0, kind: 'tool', tool: 'local.shell.readFile', inputs: { path: '/tmp/x' } } as const;
|
|
45
|
+
const RESULT = { runId: 'acme/run_x-xxxxxxxxxx', stepId: 'step_3', seq: 12, output: { role: 'assistant', content: 'ok' } } as const;
|
|
46
|
+
const REGISTRATION = { runnerId: 'runner_laptop_01', subject: 'user_42', capabilities: { providers: ['anthropic'], models: ['claude-x'] } } as const;
|
|
47
|
+
|
|
48
|
+
function without(o: Readonly<Record<string, unknown>>, field: string): Record<string, unknown> {
|
|
49
|
+
const c: Record<string, unknown> = { ...o };
|
|
50
|
+
delete c[field];
|
|
51
|
+
return c;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('v2 self-hosted-runner frames: schemas compile', () => {
|
|
55
|
+
for (const name of SCHEMAS) {
|
|
56
|
+
it(`${name}.schema.json compiles under Ajv 2020`, () => {
|
|
57
|
+
expect(() => v2Validator(name), req(ID_COMPILE, DOC, `schemas/v2/${name}.schema.json MUST compile`)).not.toThrow();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('v2 self-hosted-runner frames: dispatch frame', () => {
|
|
63
|
+
const validate = v2Validator('self-hosted-runner-dispatch-frame');
|
|
64
|
+
|
|
65
|
+
it('accepts a model frame and a tool frame', () => {
|
|
66
|
+
for (const f of [MODEL_FRAME, TOOL_FRAME]) {
|
|
67
|
+
const r = validate(f);
|
|
68
|
+
expect(r.ok, req(ID_DISPATCH, DOC, `a well-formed ${f.kind} dispatch frame MUST validate (${r.errors})`)).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.each<[string, Record<string, unknown>]>([
|
|
73
|
+
['no seq cursor', without(MODEL_FRAME, 'seq')],
|
|
74
|
+
['a fractional seq', { ...MODEL_FRAME, seq: 1.5 }],
|
|
75
|
+
['a negative seq', { ...MODEL_FRAME, seq: -1 }],
|
|
76
|
+
['an unknown kind', { ...MODEL_FRAME, kind: 'agent' }],
|
|
77
|
+
['a model frame without provider', without(MODEL_FRAME, 'provider')],
|
|
78
|
+
['a model frame without model', without(MODEL_FRAME, 'model')],
|
|
79
|
+
['a tool frame without tool', without(TOOL_FRAME, 'tool')],
|
|
80
|
+
['no stepId (the at-most-once key)', without(MODEL_FRAME, 'stepId')],
|
|
81
|
+
])('rejects %s', (_what, frame) => {
|
|
82
|
+
expect(validate(frame).ok, req(ID_DISPATCH, DOC, `a dispatch frame with ${_what} MUST be rejected`)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('v2 self-hosted-runner frames: result frame', () => {
|
|
87
|
+
const validate = v2Validator('self-hosted-runner-result-frame');
|
|
88
|
+
|
|
89
|
+
it('accepts a result frame', () => {
|
|
90
|
+
const r = validate(RESULT);
|
|
91
|
+
expect(r.ok, req(ID_RESULT, DOC, `a well-formed result frame MUST validate (${r.errors})`)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects a result frame without output', () => {
|
|
95
|
+
expect(validate(without(RESULT, 'output')).ok, req(ID_RESULT, DOC, 'a result frame without output MUST be rejected')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('v2 self-hosted-runner frames: closed shapes carry no credential field', () => {
|
|
100
|
+
it.each<[string, string, Record<string, unknown>]>([
|
|
101
|
+
['dispatch frame', 'self-hosted-runner-dispatch-frame', { ...MODEL_FRAME, apiKey: 'sk-canary' }],
|
|
102
|
+
['result frame', 'self-hosted-runner-result-frame', { ...RESULT, credential: 'sk-canary' }],
|
|
103
|
+
['registration', 'self-hosted-runner-registration', { ...REGISTRATION, bearer: 'sk-canary' }],
|
|
104
|
+
['registration capabilities', 'self-hosted-runner-registration', { ...REGISTRATION, capabilities: { providers: ['anthropic'], apiKeys: { anthropic: 'sk-canary' } } }],
|
|
105
|
+
])('a %s with an extra credential-named field is rejected', (what, schema, doc) => {
|
|
106
|
+
expect(v2Validator(schema)(doc).ok, req(ID_CLOSED, DOC, `the ${what} is closed: a runner credential MUST NOT appear in any frame`)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('v2 self-hosted-runner frames: registration', () => {
|
|
111
|
+
const validate = v2Validator('self-hosted-runner-registration');
|
|
112
|
+
|
|
113
|
+
it('accepts a registration', () => {
|
|
114
|
+
const r = validate(REGISTRATION);
|
|
115
|
+
expect(r.ok, req(ID_REGISTRATION, DOC, `a well-formed registration MUST validate (${r.errors})`)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a registration without subject', () => {
|
|
119
|
+
expect(validate(without(REGISTRATION, 'subject')).ok, req(ID_REGISTRATION, DOC, 'a registration without its owning subject MUST be rejected')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the `subWorkflow` advertisement (`spec/v2/core/execution.md`
|
|
3
|
+
* §subWorkflow; RFCs 0007, 0022). The v1 twin is the record shape `subWorkflow`
|
|
4
|
+
* carried at v1; the legs live in `lib/family-advert-witness.ts`.
|
|
5
|
+
*
|
|
6
|
+
* record the record validates against its capabilities-schema seat;
|
|
7
|
+
* inputMapping one boolean.
|
|
8
|
+
*
|
|
9
|
+
* Not here: the refusal of a non-empty `inputMapping` by a host not advertising
|
|
10
|
+
* it, and the child-run legs, need workflow registration and a fixture run.
|
|
11
|
+
*
|
|
12
|
+
* Dispositions: discovery unreadable ⇒ `blocked`; `subWorkflow` absent, or the
|
|
13
|
+
* facet a leg reads absent ⇒ `inapplicable`.
|
|
14
|
+
*
|
|
15
|
+
* @see spec/v2/core/execution.md §subWorkflow
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
20
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
21
|
+
import { req } from '../lib/requirement-ids.js';
|
|
22
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
23
|
+
import { recordSchemaLeg, subWorkflowInputMappingLeg } from '../lib/family-advert-witness.js';
|
|
24
|
+
|
|
25
|
+
const PROFILE = majorProfile(2);
|
|
26
|
+
const DOC = 'execution.md §subWorkflow';
|
|
27
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
28
|
+
const ID_RECORD = 'openwop.requirement.sub-workflow.advert-record-schema';
|
|
29
|
+
const ID_INPUT_MAPPING_BOOLEAN = 'openwop.requirement.sub-workflow.input-mapping-boolean';
|
|
30
|
+
|
|
31
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
32
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('v2 subWorkflow advertisement (execution.md §subWorkflow)', () => {
|
|
36
|
+
it('the subWorkflow record validates against its capabilities-schema seat', async () => {
|
|
37
|
+
const doc = await discovery();
|
|
38
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
39
|
+
if (!(await familyAdvertised('subWorkflow'))) return softSkip('inapplicable', 'the host does not advertise subWorkflow');
|
|
40
|
+
const out = recordSchemaLeg(PROFILE, doc, 'subWorkflow', DOC);
|
|
41
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
42
|
+
for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('subWorkflow.inputMapping is one boolean', async () => {
|
|
46
|
+
const doc = await discovery();
|
|
47
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
48
|
+
if (!(await familyAdvertised('subWorkflow'))) return softSkip('inapplicable', 'the host does not advertise subWorkflow');
|
|
49
|
+
const out = subWorkflowInputMappingLeg(PROFILE, doc);
|
|
50
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
51
|
+
for (const x of out.findings) expect(x.ok, req(ID_INPUT_MAPPING_BOOLEAN, x.doc, x.message)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `core.subWorkflow` `inputMapping` (`spec/v2/core/execution.md`
|
|
3
|
+
* §`subWorkflow`: "`inputMapping` (`childVar → parentVar`) seeds the child
|
|
4
|
+
* once, at creation, after and over its `variables[].defaultValue`, which MUST
|
|
5
|
+
* seed first. An unset parent variable MUST arrive undefined, never an error
|
|
6
|
+
* or `null`."). The v1 twin is `subworkflow-input-mapping`; the legs live in
|
|
7
|
+
* `lib/subworkflow-witness.ts`.
|
|
8
|
+
*
|
|
9
|
+
* seed `conformance-subworkflow-input-mapping`: the child's
|
|
10
|
+
* `receivedPrdId` is seeded at creation;
|
|
11
|
+
* override it is the parent's `currentPrdId` ("prd-1"), not the child's
|
|
12
|
+
* default ("baked-in");
|
|
13
|
+
* unset `conformance-subworkflow-input-mapping-no-default`: the parent's
|
|
14
|
+
* `currentPrdId` is unset — both runs complete, and the child's
|
|
15
|
+
* value is absent or its own default, never `null`.
|
|
16
|
+
* NOT ported: the mid-run mutation leg (it drives a variables seam) and the
|
|
17
|
+
* registration-refusal leg (it drives a capability-toggle seam).
|
|
18
|
+
*
|
|
19
|
+
* Dispositions: the parent fixture or `conformance-subworkflow-input-mapping-child`
|
|
20
|
+
* not in `fixtures[]` ⇒ `inapplicable`; discovery unreadable ⇒ `blocked`.
|
|
21
|
+
*
|
|
22
|
+
* Proven against a double in `lib/subworkflow-witness.test.ts`.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/execution.md §subWorkflow (inputMapping)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
29
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
32
|
+
import type { Outcome, Skip } from '../lib/fixture-run-observer.js';
|
|
33
|
+
import { IM_NO_DEFAULT_FIXTURE, IM_PARENT_FIXTURE, driveInputMapping, judgeOverride, judgeSeed, judgeUnset, type MappingObservation } from '../lib/subworkflow-witness.js';
|
|
34
|
+
|
|
35
|
+
const PROFILE = majorProfile(2);
|
|
36
|
+
const ID_SEED = 'openwop.requirement.sub-workflow.input-mapping-seeds-child';
|
|
37
|
+
const ID_OVERRIDE = 'openwop.requirement.sub-workflow.input-mapping-overrides-default';
|
|
38
|
+
const ID_UNSET = 'openwop.requirement.sub-workflow.input-mapping-unset-undefined';
|
|
39
|
+
|
|
40
|
+
const runs = new Map<string, Promise<Skip | MappingObservation>>();
|
|
41
|
+
async function observation(fixture: string): Promise<MappingObservation | (() => undefined)> {
|
|
42
|
+
if (!process.env['OPENWOP_BASE_URL']) return () => softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
|
|
43
|
+
let doc: Record<string, unknown> | null;
|
|
44
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
45
|
+
if (!doc) return () => softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
|
|
46
|
+
if (!runs.has(fixture)) runs.set(fixture, driveInputMapping(PROFILE, fixture));
|
|
47
|
+
const o = await runs.get(fixture)!;
|
|
48
|
+
return 'kind' in o ? () => softSkip(o.disposition, o.reason) : o;
|
|
49
|
+
}
|
|
50
|
+
function assert(id: string, out: Outcome): undefined {
|
|
51
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
52
|
+
for (const x of out.findings) expect(x.ok, req(id, x.doc, x.message)).toBe(true);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('v2 subWorkflow: inputMapping (execution.md §subWorkflow)', () => {
|
|
57
|
+
it('inputMapping seeds the child variable bag at creation', async () => {
|
|
58
|
+
const o = await observation(IM_PARENT_FIXTURE);
|
|
59
|
+
if (typeof o === 'function') return o();
|
|
60
|
+
return assert(ID_SEED, judgeSeed(o));
|
|
61
|
+
}, 90_000);
|
|
62
|
+
|
|
63
|
+
it('the mapped parent value lands after and over the child defaultValue', async () => {
|
|
64
|
+
const o = await observation(IM_PARENT_FIXTURE);
|
|
65
|
+
if (typeof o === 'function') return o();
|
|
66
|
+
return assert(ID_OVERRIDE, judgeOverride(o));
|
|
67
|
+
}, 90_000);
|
|
68
|
+
|
|
69
|
+
it('an unset parent variable arrives undefined, never an error or null', async () => {
|
|
70
|
+
const o = await observation(IM_NO_DEFAULT_FIXTURE);
|
|
71
|
+
if (typeof o === 'function') return o();
|
|
72
|
+
return assert(ID_UNSET, judgeUnset(o));
|
|
73
|
+
}, 90_000);
|
|
74
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `core.subWorkflow` output and parent link (`spec/v2/core/execution.md`
|
|
3
|
+
* §`subWorkflow`). The v1 twin is `subworkflow`; the legs live in
|
|
4
|
+
* `lib/subworkflow-witness.ts`.
|
|
5
|
+
*
|
|
6
|
+
* A run of `conformance-subworkflow-parent` (node `subwf-call` starts
|
|
7
|
+
* `conformance-subworkflow-child`, `outputMapping: { childOutcome: childResult }`)
|
|
8
|
+
* is created with `POST /runs` and its log read with `events/poll`:
|
|
9
|
+
* output the parent's `node.completed` for `subwf-call` carries
|
|
10
|
+
* `outputs.childRunId` and `outputs.childStatus`, and the
|
|
11
|
+
* status is the child's terminal status;
|
|
12
|
+
* outputMapping the parent's `childOutcome` is the child's `childResult`;
|
|
13
|
+
* ancestry where `getRunAncestry` is served, the child's `parent` is
|
|
14
|
+
* the parent run with `cause: "core.subWorkflow"`.
|
|
15
|
+
* RETIRED, not ported: v1's child `parentRunId` / `parentNodeId` linkage
|
|
16
|
+
* (`spec/v1/migrations.json` C4.18). At v2 `parentRunId` is fork lineage.
|
|
17
|
+
*
|
|
18
|
+
* Dispositions: either fixture not in `fixtures[]` ⇒ `inapplicable`;
|
|
19
|
+
* `multiAgent.executionModel.crossHostCausation.ancestryEndpointSupported` not
|
|
20
|
+
* advertised ⇒ the ancestry leg `inapplicable`; discovery unreadable ⇒ `blocked`.
|
|
21
|
+
*
|
|
22
|
+
* Proven against a double in `lib/subworkflow-witness.test.ts`.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/execution.md §subWorkflow (Output, outputMapping, Parent link)
|
|
25
|
+
* @see spec/v2/core/runs.md §Diff and ancestry
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { v2Discovery, v2RefValidator } from '../lib/v2.js';
|
|
30
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
33
|
+
import type { Outcome, Skip } from '../lib/fixture-run-observer.js';
|
|
34
|
+
import { driveLinkage, judgeAncestry, judgeOutput, judgeOutputMapping, type LinkageObservation } from '../lib/subworkflow-witness.js';
|
|
35
|
+
|
|
36
|
+
const PROFILE = majorProfile(2);
|
|
37
|
+
const ID_OUTPUT = 'openwop.requirement.sub-workflow.node-completed-child-outputs';
|
|
38
|
+
const ID_OUTPUT_MAPPING = 'openwop.requirement.sub-workflow.output-mapping-copied';
|
|
39
|
+
const ID_ANCESTRY = 'openwop.requirement.sub-workflow.ancestry-parent-link';
|
|
40
|
+
const validateAncestry = v2RefValidator('run-ancestry-response.schema.json');
|
|
41
|
+
|
|
42
|
+
let run: Promise<Skip | LinkageObservation> | undefined;
|
|
43
|
+
async function observation(): Promise<LinkageObservation | (() => undefined)> {
|
|
44
|
+
if (!process.env['OPENWOP_BASE_URL']) return () => softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
|
|
45
|
+
let doc: Record<string, unknown> | null;
|
|
46
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
47
|
+
if (!doc) return () => softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
|
|
48
|
+
run ??= driveLinkage(PROFILE, doc);
|
|
49
|
+
const o = await run;
|
|
50
|
+
return 'kind' in o ? () => softSkip(o.disposition, o.reason) : o;
|
|
51
|
+
}
|
|
52
|
+
function assert(id: string, out: Outcome): undefined {
|
|
53
|
+
if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
|
|
54
|
+
for (const x of out.findings) expect(x.ok, req(id, x.doc, x.message)).toBe(true);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('v2 subWorkflow: output and parent link (execution.md §subWorkflow)', () => {
|
|
59
|
+
it('node.completed for the core.subWorkflow node carries outputs.childRunId and outputs.childStatus', async () => {
|
|
60
|
+
const o = await observation();
|
|
61
|
+
if (typeof o === 'function') return o();
|
|
62
|
+
return assert(ID_OUTPUT, judgeOutput(o));
|
|
63
|
+
}, 90_000);
|
|
64
|
+
|
|
65
|
+
it('outputMapping copies the child variable into the parent after the child completes', async () => {
|
|
66
|
+
const o = await observation();
|
|
67
|
+
if (typeof o === 'function') return o();
|
|
68
|
+
return assert(ID_OUTPUT_MAPPING, judgeOutputMapping(o));
|
|
69
|
+
}, 90_000);
|
|
70
|
+
|
|
71
|
+
it('getRunAncestry names the parent run with cause core.subWorkflow', async () => {
|
|
72
|
+
const o = await observation();
|
|
73
|
+
if (typeof o === 'function') return o();
|
|
74
|
+
return assert(ID_ANCESTRY, judgeAncestry(o, validateAncestry));
|
|
75
|
+
}, 90_000);
|
|
76
|
+
});
|