@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,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The advertisement-shape witness at major 2. Wave 1: the claims-check AI
|
|
3
|
+
* families `envelopes`, `limits` (the three envelope caps), `modelCapabilities`
|
|
4
|
+
* and `aiProviders`. Wave 2: the record and facet shapes of `selfHostedRunner`,
|
|
5
|
+
* `credentials`, `authorization`, `providerUsage`, `subWorkflow`, `scheduling`,
|
|
6
|
+
* `artifactTypes`, `aiEnvelope`, `deadLetter` and `portability`. Wave 3: the
|
|
7
|
+
* families no host serves at v2 — `dataResidency`, `multiPartyConversation`,
|
|
8
|
+
* `nodePackRuntimes`, `uiPlugins`, `purposePropagation` and `agentRuntime` get
|
|
9
|
+
* facet legs; `conversationTurnModelProvenance`, `channelPresence`,
|
|
10
|
+
* `nondeterminismPolicy`, `promptLibrary` and `envelopeContracts` carry no facet
|
|
11
|
+
* a host-binding v2 rule gives meaning to, so they get the record leg only.
|
|
12
|
+
*
|
|
13
|
+
* A claims-check family is witnessed by what the host says about itself, so
|
|
14
|
+
* every leg here reads the discovery record alone. Each leg checks one facet
|
|
15
|
+
* twice: against `schemas/v2/capabilities.schema.json` (the record seat or the
|
|
16
|
+
* facet's subschema, through `v2RefValidator`) and against the v2 prose rule
|
|
17
|
+
* that gives the facet its meaning. The schema and the prose do not always
|
|
18
|
+
* agree; where they do not, the leg says which one it is enforcing.
|
|
19
|
+
*
|
|
20
|
+
* Not here, on purpose:
|
|
21
|
+
* - `limits.maxRunDurationMs` / `maxLoopIterations` — `run-bounds-witness.ts`;
|
|
22
|
+
* - every runtime rule of these families (retry routing, refusal, substitution,
|
|
23
|
+
* cap enforcement, credential resolution, dispatch, schedule firing, dead-
|
|
24
|
+
* letter retention, import) — they need a seam or a fixture run;
|
|
25
|
+
* - `scheduling.maxFutureHorizon` as an ISO-8601 duration: the v2 schema seat
|
|
26
|
+
* is a plain string and no v2 prose states the format as binding the host;
|
|
27
|
+
* - a `supported` field on any record: it is not a v2 field
|
|
28
|
+
* (`capabilities.md` §2). The record-schema leg rejects one as an unknown
|
|
29
|
+
* key; no leg ever asserts its presence.
|
|
30
|
+
*
|
|
31
|
+
* Every leg is pure: `(profile, discovery) → outcome`. A family the host does
|
|
32
|
+
* not advertise, or a facet it does not carry, is `inapplicable` — never a
|
|
33
|
+
* pass. Proven in both directions in `family-advert-witness.test.ts`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { MajorProfile } from './major-profile.js';
|
|
37
|
+
import { v2RefValidator } from './v2.js';
|
|
38
|
+
|
|
39
|
+
export interface AdvertFinding { readonly ok: boolean; readonly doc: string; readonly message: string }
|
|
40
|
+
export type AdvertOutcome =
|
|
41
|
+
| { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
|
|
42
|
+
| { readonly kind: 'observed'; readonly findings: readonly AdvertFinding[] };
|
|
43
|
+
|
|
44
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
45
|
+
const inapplicable = (reason: string): AdvertOutcome => ({ kind: 'skip', disposition: 'inapplicable', reason });
|
|
46
|
+
const observed = (findings: AdvertFinding[]): AdvertOutcome => ({ kind: 'observed', findings });
|
|
47
|
+
const SCHEMA = 'schemas/v2/capabilities.schema.json';
|
|
48
|
+
|
|
49
|
+
/** Validate a value against a pointer into the v2 capabilities schema. */
|
|
50
|
+
function bySchema(pointer: string, value: unknown, doc: string, what: string): AdvertFinding {
|
|
51
|
+
const r = v2RefValidator(`capabilities.schema.json#${pointer}`)(value);
|
|
52
|
+
return { ok: r.ok, doc, message: `${what} MUST validate against ${SCHEMA}#${pointer}${r.ok ? '' : ` (${r.errors})`}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One leg: given the family record, judge one facet. */
|
|
56
|
+
type Leg = (rec: Record<string, unknown>) => AdvertOutcome;
|
|
57
|
+
|
|
58
|
+
/** Run a leg against a discovery document, gating on the family record. */
|
|
59
|
+
function onFamily(profile: MajorProfile, discovery: unknown, family: string, leg: Leg): AdvertOutcome {
|
|
60
|
+
const rec = profile.family(discovery, family);
|
|
61
|
+
if (rec === null) return inapplicable(`the host does not advertise ${family}`);
|
|
62
|
+
return leg(rec);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The record as a whole against its seat in the capabilities schema. Catches every v1 shape v2 retired (`supported`, `selfHosted[]`, the per-provider `authModes` map, …). */
|
|
66
|
+
export type AdvertFamily =
|
|
67
|
+
| 'envelopes' | 'modelCapabilities' | 'aiProviders'
|
|
68
|
+
| 'selfHostedRunner' | 'credentials' | 'authorization' | 'providerUsage' | 'subWorkflow'
|
|
69
|
+
| 'scheduling' | 'artifactTypes' | 'aiEnvelope' | 'deadLetter' | 'portability'
|
|
70
|
+
| 'dataResidency' | 'conversationTurnModelProvenance' | 'multiPartyConversation' | 'channelPresence'
|
|
71
|
+
| 'nodePackRuntimes' | 'uiPlugins' | 'purposePropagation' | 'nondeterminismPolicy'
|
|
72
|
+
| 'agentRuntime' | 'promptLibrary' | 'envelopeContracts';
|
|
73
|
+
|
|
74
|
+
export function recordSchemaLeg(profile: MajorProfile, discovery: unknown, family: AdvertFamily, doc: string): AdvertOutcome {
|
|
75
|
+
return onFamily(profile, discovery, family, (rec) => observed([bySchema(`/properties/${family}`, rec, doc, `the ${family} record`)]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// envelopes — spec/v2/core/events.md §`envelopes`
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const ENV_DOC = 'events.md §envelopes';
|
|
83
|
+
const ENV = '/properties/envelopes/properties';
|
|
84
|
+
|
|
85
|
+
/** `envelopes.reasoning`: an object whose `promptDirective` is one of three values; no `supported` seat at v2. */
|
|
86
|
+
export const envelopesReasoningLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'envelopes', (rec) => {
|
|
87
|
+
const r = rec['reasoning'];
|
|
88
|
+
if (r === undefined) return inapplicable('the host does not advertise envelopes.reasoning (optional)');
|
|
89
|
+
const out = [bySchema(`${ENV}/reasoning`, r, ENV_DOC, 'envelopes.reasoning')];
|
|
90
|
+
if (isRecord(r) && r['promptDirective'] !== undefined) {
|
|
91
|
+
out.push({ ok: ['mandatory', 'advisory', 'off'].includes(String(r['promptDirective'])), doc: ENV_DOC, message: `envelopes.reasoning.promptDirective MUST be mandatory, advisory or off (got ${JSON.stringify(r['promptDirective'])})` });
|
|
92
|
+
}
|
|
93
|
+
return observed(out);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** `envelopes.tierOneSubsetCompliance`: one of strict, warn, off. */
|
|
97
|
+
export const envelopesTierOneLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'envelopes', (rec) => {
|
|
98
|
+
const v = rec['tierOneSubsetCompliance'];
|
|
99
|
+
if (v === undefined) return inapplicable('the host does not advertise envelopes.tierOneSubsetCompliance (optional)');
|
|
100
|
+
return observed([bySchema(`${ENV}/tierOneSubsetCompliance`, v, ENV_DOC, 'envelopes.tierOneSubsetCompliance')]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** The six reliability events by their v2 names, and the four v1 dotted names v2 accepts as aliases until 3.0 (RFC 0228 §G). */
|
|
104
|
+
export const RELIABILITY_EVENTS = ['envelope.retry-attempted', 'envelope.retry-exhausted', 'envelope.refusal', 'envelope.truncated', 'envelope.nl-to-format-engaged', 'envelope.recovery-applied'] as const;
|
|
105
|
+
export const RELIABILITY_ALIASES: Readonly<Record<string, string>> = {
|
|
106
|
+
'envelope.retry.attempted': 'envelope.retry-attempted',
|
|
107
|
+
'envelope.retry.exhausted': 'envelope.retry-exhausted',
|
|
108
|
+
'envelope.nlToFormat.engaged': 'envelope.nl-to-format-engaged',
|
|
109
|
+
'envelope.recovery.applied': 'envelope.recovery-applied',
|
|
110
|
+
};
|
|
111
|
+
/** A listed reliability event by its v2 name: an alias folds to its hyphenated equivalent; anything else is returned unchanged. */
|
|
112
|
+
export const canonicalReliabilityEvent = (e: string): string => RELIABILITY_ALIASES[e] ?? e;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `envelopes.reliability`: `events[]` names only reliability events (v2 name
|
|
116
|
+
* or v1 alias), and — after folding aliases — includes the two MUST-tier ones;
|
|
117
|
+
* `maxRetryAttempts` is an integer in [1, 16].
|
|
118
|
+
*/
|
|
119
|
+
export const envelopesReliabilityLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'envelopes', (rec) => {
|
|
120
|
+
const r = rec['reliability'];
|
|
121
|
+
if (r === undefined) return inapplicable('the host does not advertise envelopes.reliability (optional)');
|
|
122
|
+
const out = [bySchema(`${ENV}/reliability`, r, ENV_DOC, 'envelopes.reliability')];
|
|
123
|
+
const events = isRecord(r) ? r['events'] : undefined;
|
|
124
|
+
if (!Array.isArray(events)) {
|
|
125
|
+
out.push({ ok: false, doc: ENV_DOC, message: `a host advertising envelopes.reliability MUST list the events it emits in reliability.events[] (got ${JSON.stringify(events)})` });
|
|
126
|
+
return observed(out);
|
|
127
|
+
}
|
|
128
|
+
const canonical = new Set(events.filter((e): e is string => typeof e === 'string').map(canonicalReliabilityEvent));
|
|
129
|
+
for (const e of events) {
|
|
130
|
+
const name = typeof e === 'string' ? canonicalReliabilityEvent(e) : undefined;
|
|
131
|
+
out.push({ ok: name !== undefined && (RELIABILITY_EVENTS as readonly string[]).includes(name), doc: ENV_DOC, message: `reliability.events[] MUST name only reliability events, by v2 name or v1 alias (got ${JSON.stringify(e)})` });
|
|
132
|
+
}
|
|
133
|
+
for (const must of ['envelope.retry-exhausted', 'envelope.refusal']) {
|
|
134
|
+
out.push({ ok: canonical.has(must), doc: ENV_DOC, message: `a host advertising envelopes.reliability MUST emit and list ${must} (aliases folded; listed: ${JSON.stringify(events)})` });
|
|
135
|
+
}
|
|
136
|
+
return observed(out);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** `envelopes.reliability.completion`: `distinguishesTruncation` is a boolean; `truncationBudgetMultiplier` a number in [1, 8]. */
|
|
140
|
+
export const envelopesCompletionLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'envelopes', (rec) => {
|
|
141
|
+
const r = rec['reliability'];
|
|
142
|
+
const c = isRecord(r) ? r['completion'] : undefined;
|
|
143
|
+
if (c === undefined) return inapplicable('the host does not advertise envelopes.reliability.completion (optional)');
|
|
144
|
+
const out = [bySchema(`${ENV}/reliability/properties/completion`, c, ENV_DOC, 'envelopes.reliability.completion')];
|
|
145
|
+
const dt = isRecord(c) ? c['distinguishesTruncation'] : undefined;
|
|
146
|
+
out.push({ ok: typeof dt === 'boolean', doc: ENV_DOC, message: `reliability.completion.distinguishesTruncation MUST be a boolean (got ${JSON.stringify(dt)})` });
|
|
147
|
+
return observed(out);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// limits — spec/v2/core/runs.md §Limits: the three envelope caps
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export const ENVELOPE_CAPS = ['clarificationRounds', 'schemaRounds', 'envelopesPerTurn'] as const;
|
|
155
|
+
export type EnvelopeCap = (typeof ENVELOPE_CAPS)[number];
|
|
156
|
+
|
|
157
|
+
/** `limits.<cap>`: present (runs.md: "`limits` always carries" it) and a non-negative integer. */
|
|
158
|
+
export const limitsCapLeg = (p: MajorProfile, d: unknown, cap: EnvelopeCap): AdvertOutcome => onFamily(p, d, 'limits', (rec) => {
|
|
159
|
+
const v = rec[cap];
|
|
160
|
+
const doc = 'runs.md §Limits';
|
|
161
|
+
if (v === undefined) return observed([{ ok: false, doc, message: `limits MUST always carry ${cap} (absent)` }]);
|
|
162
|
+
return observed([
|
|
163
|
+
bySchema(`/properties/limits/properties/${cap}`, v, doc, `limits.${cap}`),
|
|
164
|
+
{ ok: Number.isInteger(v) && (v as number) >= 0, doc, message: `limits.${cap} MUST be a non-negative integer (got ${JSON.stringify(v)})` },
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// modelCapabilities — spec/v2/core/host-services.md §modelCapabilities
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/** The spec-reserved identifiers (RFC 0031 §C, RFC 0055), as listed by the schema seat's description. */
|
|
173
|
+
export const RESERVED_MODEL_CAPABILITIES = ['structured-output', 'discriminator-enum', 'long-context', 'reasoning', 'function-calling', 'vision-input', 'audio-input', 'audio-output', 'image-output'] as const;
|
|
174
|
+
const HOST_PRIVATE = /^x-host-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*$/;
|
|
175
|
+
const MC_DOC = 'host-services.md §modelCapabilities';
|
|
176
|
+
|
|
177
|
+
/** `modelCapabilities.advertised`: unique strings, each spec-reserved or prefixed `x-host-<host>-`. */
|
|
178
|
+
export const modelCapabilitiesAdvertisedLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'modelCapabilities', (rec) => {
|
|
179
|
+
const a = rec['advertised'];
|
|
180
|
+
if (a === undefined) return inapplicable('the host does not advertise modelCapabilities.advertised');
|
|
181
|
+
const out = [bySchema('/properties/modelCapabilities/properties/advertised', a, MC_DOC, 'modelCapabilities.advertised')];
|
|
182
|
+
if (Array.isArray(a)) {
|
|
183
|
+
for (const id of a) {
|
|
184
|
+
const s = typeof id === 'string' ? id : '';
|
|
185
|
+
out.push({ ok: (RESERVED_MODEL_CAPABILITIES as readonly string[]).includes(s) || HOST_PRIVATE.test(s), doc: MC_DOC, message: `a modelCapabilities.advertised identifier MUST be spec-reserved (${RESERVED_MODEL_CAPABILITIES.join(', ')}) or host-private, prefixed x-host-<host>- (got ${JSON.stringify(id)})` });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return observed(out);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** `modelCapabilities.substitutionSupported`: a boolean. */
|
|
192
|
+
export const modelCapabilitiesSubstitutionLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'modelCapabilities', (rec) => {
|
|
193
|
+
const v = rec['substitutionSupported'];
|
|
194
|
+
if (v === undefined) return inapplicable('the host does not advertise modelCapabilities.substitutionSupported (absent means no substitution)');
|
|
195
|
+
return observed([bySchema('/properties/modelCapabilities/properties/substitutionSupported', v, MC_DOC, 'modelCapabilities.substitutionSupported')]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// aiProviders — spec/v2/core/host-services.md §aiProviders (v1 facets narrowed by C2.11)
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const AP_DOC = 'host-services.md §aiProviders';
|
|
203
|
+
const AP = '/properties/aiProviders/properties';
|
|
204
|
+
|
|
205
|
+
/** The modes `host-services.md` §aiProviders names. The v2 schema seat is `string[]` with no enum; the vocabulary is the prose's. */
|
|
206
|
+
|
|
207
|
+
/** `aiProviders.selfHosted`: one boolean at v2 (the v1 `selfHosted[]` id list retired, C2.11). */
|
|
208
|
+
export const aiProvidersSelfHostedLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'aiProviders', (rec) => {
|
|
209
|
+
const v = rec['selfHosted'];
|
|
210
|
+
if (v === undefined) return inapplicable('the host does not advertise aiProviders.selfHosted');
|
|
211
|
+
return observed([bySchema(`${AP}/selfHosted`, v, AP_DOC, 'aiProviders.selfHosted (a boolean at v2, not the v1 id list)')]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/** `aiProviders.authModes`: one flat list of modes at v2 (the v1 per-provider map retired, C2.11). */
|
|
215
|
+
export const aiProvidersAuthModesShapeLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'aiProviders', (rec) => {
|
|
216
|
+
const v = rec['authModes'];
|
|
217
|
+
if (v === undefined) return inapplicable('the host does not advertise aiProviders.authModes');
|
|
218
|
+
return observed([bySchema(`${AP}/authModes`, v, AP_DOC, 'aiProviders.authModes (one flat list at v2, not the v1 per-provider map)')]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** `aiProviders.maxInlineMediaBytes`: an integer ≥ 1. */
|
|
222
|
+
export const aiProvidersInlineMediaLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'aiProviders', (rec) => {
|
|
223
|
+
const v = rec['maxInlineMediaBytes'];
|
|
224
|
+
if (v === undefined) return inapplicable('the host does not advertise aiProviders.maxInlineMediaBytes (default 256 KiB)');
|
|
225
|
+
return observed([bySchema(`${AP}/maxInlineMediaBytes`, v, AP_DOC, 'aiProviders.maxInlineMediaBytes')]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/** `aiProviders.promptPrefixCache`: one boolean at v2 (the v1 `{ supported, providers[] }` object retired, C2.11). */
|
|
229
|
+
export const aiProvidersPromptPrefixCacheLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'aiProviders', (rec) => {
|
|
230
|
+
const v = rec['promptPrefixCache'];
|
|
231
|
+
if (v === undefined) return inapplicable('the host does not advertise aiProviders.promptPrefixCache');
|
|
232
|
+
return observed([bySchema(`${AP}/promptPrefixCache`, v, AP_DOC, 'aiProviders.promptPrefixCache (a boolean at v2, not the v1 object)')]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Wave 2. Each facet leg validates the facet against its seat in the v2
|
|
237
|
+
// capabilities schema, and adds a prose check only where a v2 core document
|
|
238
|
+
// states the rule as binding the host. An absent facet is `inapplicable`.
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** One optional facet against its schema seat: absent ⇒ inapplicable. */
|
|
242
|
+
function facetBySchema(p: MajorProfile, d: unknown, family: AdvertFamily, facet: string, doc: string, what = `${family}.${facet}`): AdvertOutcome {
|
|
243
|
+
return onFamily(p, d, family, (rec) => {
|
|
244
|
+
const v = rec[facet];
|
|
245
|
+
if (v === undefined) return inapplicable(`the host does not advertise ${family}.${facet} (optional)`);
|
|
246
|
+
return observed([bySchema(`/properties/${family}/properties/${facet}`, v, doc, what)]);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// selfHostedRunner — spec/v2/core/execution.md §selfHostedRunner
|
|
251
|
+
|
|
252
|
+
/** `selfHostedRunner.dispatchKinds`: unique members of {model, tool} ("`dispatchKinds` lists `model`, `tool` or both"). */
|
|
253
|
+
export const selfHostedRunnerDispatchKindsLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
254
|
+
facetBySchema(p, d, 'selfHostedRunner', 'dispatchKinds', 'execution.md §selfHostedRunner', 'selfHostedRunner.dispatchKinds (unique members of model, tool)');
|
|
255
|
+
|
|
256
|
+
// credentials — spec/v2/core/oauth.md §Credentials
|
|
257
|
+
|
|
258
|
+
const CRED_DOC = 'oauth.md §Credentials';
|
|
259
|
+
|
|
260
|
+
/** `credentials.scopes`: unique members of {user, workspace, tenant}; a scope outside it is `credential_scope_unsupported`. */
|
|
261
|
+
export const credentialsScopesLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
262
|
+
facetBySchema(p, d, 'credentials', 'scopes', CRED_DOC, 'credentials.scopes (a subset of user, workspace, tenant)');
|
|
263
|
+
|
|
264
|
+
/** `credentials.rotation`: `none` or `two-key-overlap`. */
|
|
265
|
+
export const credentialsRotationLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
266
|
+
facetBySchema(p, d, 'credentials', 'rotation', CRED_DOC, 'credentials.rotation (none or two-key-overlap)');
|
|
267
|
+
|
|
268
|
+
// authorization — spec/v2/core/identity.md §2.1
|
|
269
|
+
|
|
270
|
+
const AUTHZ_DOC = 'identity.md §2.1 (authorization)';
|
|
271
|
+
|
|
272
|
+
/** `authorization.failClosed`: "MUST be `true` when present" (invariant `authorization-fail-closed`). */
|
|
273
|
+
export const authorizationFailClosedLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'authorization', (rec) => {
|
|
274
|
+
const v = rec['failClosed'];
|
|
275
|
+
if (v === undefined) return inapplicable('the host does not advertise authorization.failClosed (optional)');
|
|
276
|
+
return observed([
|
|
277
|
+
bySchema('/properties/authorization/properties/failClosed', v, AUTHZ_DOC, 'authorization.failClosed'),
|
|
278
|
+
{ ok: v === true, doc: AUTHZ_DOC, message: `authorization.failClosed MUST be true when present (got ${JSON.stringify(v)})` },
|
|
279
|
+
]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/** `authorization.roles`: each entry a non-empty `role` and a unique `scopes[]` of non-empty strings. */
|
|
283
|
+
export const authorizationRolesLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'authorization', (rec) => {
|
|
284
|
+
const v = rec['roles'];
|
|
285
|
+
if (v === undefined) return inapplicable('the host does not advertise authorization.roles (optional)');
|
|
286
|
+
const out = [bySchema('/properties/authorization/properties/roles', v, AUTHZ_DOC, 'authorization.roles')];
|
|
287
|
+
if (Array.isArray(v)) {
|
|
288
|
+
v.forEach((r, i) => out.push(bySchema('/properties/authorization/properties/roles/items', r, AUTHZ_DOC, `authorization.roles[${i}]`)));
|
|
289
|
+
}
|
|
290
|
+
return observed(out);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// providerUsage — spec/v2/core/events.md §providerUsage
|
|
294
|
+
|
|
295
|
+
const PU_DOC = 'events.md §providerUsage';
|
|
296
|
+
|
|
297
|
+
/** `providerUsage.costEstimates`: one boolean. */
|
|
298
|
+
export const providerUsageCostEstimatesLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
299
|
+
facetBySchema(p, d, 'providerUsage', 'costEstimates', PU_DOC);
|
|
300
|
+
|
|
301
|
+
/** `providerUsage.currency`: "the ISO 4217 currency of `costEstimateUsd`" — three upper-case letters. */
|
|
302
|
+
export const providerUsageCurrencyLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
303
|
+
facetBySchema(p, d, 'providerUsage', 'currency', PU_DOC, 'providerUsage.currency (an ISO 4217 code, ^[A-Z]{3}$)');
|
|
304
|
+
|
|
305
|
+
// subWorkflow — spec/v2/core/execution.md §subWorkflow
|
|
306
|
+
|
|
307
|
+
/** `subWorkflow.inputMapping`: one boolean (a host not advertising it refuses a non-empty `inputMapping`). */
|
|
308
|
+
export const subWorkflowInputMappingLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
309
|
+
facetBySchema(p, d, 'subWorkflow', 'inputMapping', 'execution.md §subWorkflow');
|
|
310
|
+
|
|
311
|
+
// scheduling — spec/v2/core/host-services.md §scheduling
|
|
312
|
+
|
|
313
|
+
export const SCHEDULING_FORMS = ['cron', 'delayed', 'calendar'] as const;
|
|
314
|
+
|
|
315
|
+
/** `scheduling.{cron,delayed,calendar}`: each present form is one boolean. All three absent ⇒ inapplicable. */
|
|
316
|
+
export const schedulingFormsLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'scheduling', (rec) => {
|
|
317
|
+
const present = SCHEDULING_FORMS.filter((f) => rec[f] !== undefined);
|
|
318
|
+
if (present.length === 0) return inapplicable('the host advertises none of scheduling.cron, delayed, calendar');
|
|
319
|
+
return observed(present.map((f) => bySchema(`/properties/scheduling/properties/${f}`, rec[f], 'host-services.md §scheduling', `scheduling.${f}`)));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// artifactTypes — spec/v2/core/artifact-type-packs.md §The capability
|
|
323
|
+
|
|
324
|
+
const AT_DOC = 'artifact-type-packs.md §The capability';
|
|
325
|
+
|
|
326
|
+
/** `artifactTypes.{store,render,export}`: the global facets — two booleans and a list of export-format ids. */
|
|
327
|
+
export const artifactTypesGlobalFacetsLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'artifactTypes', (rec) => {
|
|
328
|
+
const present = (['store', 'render', 'export'] as const).filter((f) => rec[f] !== undefined);
|
|
329
|
+
if (present.length === 0) return inapplicable('the host advertises none of artifactTypes.store, render, export');
|
|
330
|
+
return observed(present.map((f) => bySchema(`/properties/artifactTypes/properties/${f}`, rec[f], AT_DOC, `artifactTypes.${f}`)));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `artifactTypes.types`: each per-type entry validates against the entry seat —
|
|
335
|
+
* closed, `validation` open | closed, `schemaVersion` a non-negative integer,
|
|
336
|
+
* `registrationSource` pack | host (artifact-type-packs.md §Registration).
|
|
337
|
+
*/
|
|
338
|
+
export const artifactTypesPerTypeLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'artifactTypes', (rec) => {
|
|
339
|
+
const t = rec['types'];
|
|
340
|
+
if (t === undefined) return inapplicable('the host does not advertise artifactTypes.types (optional)');
|
|
341
|
+
if (!isRecord(t)) return observed([bySchema('/properties/artifactTypes/properties/types', t, AT_DOC, 'artifactTypes.types')]);
|
|
342
|
+
const entries = Object.entries(t);
|
|
343
|
+
if (entries.length === 0) return observed([bySchema('/properties/artifactTypes/properties/types', t, AT_DOC, 'artifactTypes.types')]);
|
|
344
|
+
return observed(entries.map(([id, e]) => bySchema('/properties/artifactTypes/properties/types/additionalProperties', e, 'artifact-type-packs.md §Registration', `artifactTypes.types[${JSON.stringify(id)}]`)));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// deadLetter — spec/v2/core/runs.md §Dead letters
|
|
348
|
+
|
|
349
|
+
/** `deadLetter.retentionDays`: an integer ≥ 1 (the failed run stays fork-eligible that long). */
|
|
350
|
+
export const deadLetterRetentionDaysLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
351
|
+
facetBySchema(p, d, 'deadLetter', 'retentionDays', 'runs.md §Dead letters', 'deadLetter.retentionDays (an integer of at least 1)');
|
|
352
|
+
|
|
353
|
+
// portability — spec/v2/core/portability.md §The portability record
|
|
354
|
+
|
|
355
|
+
const PORT_DOC = 'portability.md §The portability record';
|
|
356
|
+
|
|
357
|
+
/** "A host advertising `import` MUST advertise `dryRun: true`." Read as `import: true` ⇒ `dryRun: true`, as the schema's `allOf` if/then reads it. */
|
|
358
|
+
export const portabilityImportDryRunLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'portability', (rec) => {
|
|
359
|
+
if (rec['import'] !== true) return inapplicable('the host does not advertise portability.import: true');
|
|
360
|
+
return observed([{ ok: rec['dryRun'] === true, doc: PORT_DOC, message: `a host advertising portability.import MUST advertise dryRun: true (got ${JSON.stringify(rec['dryRun'])})` }]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/** `portability.kinds`: unique members of the seven export-bundle item kinds. */
|
|
364
|
+
export const portabilityKindsLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
365
|
+
facetBySchema(p, d, 'portability', 'kinds', PORT_DOC, 'portability.kinds (unique export-bundle item kinds)');
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Wave 3 — families no host serves at v2 (architect ruling, 2026-10-04).
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/** `dataResidency.regions`: unique non-empty region codes (runs.md §`dataResidency`). */
|
|
372
|
+
export const dataResidencyRegionsLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
373
|
+
facetBySchema(p, d, 'dataResidency', 'regions', 'runs.md §dataResidency', 'dataResidency.regions (unique non-empty region codes)');
|
|
374
|
+
|
|
375
|
+
/** `multiPartyConversation.maxParticipants`: an integer of at least 2 (a council of one is not multi-party). */
|
|
376
|
+
export const multiPartyMaxParticipantsLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
377
|
+
facetBySchema(p, d, 'multiPartyConversation', 'maxParticipants', 'conversation.md §multiPartyConversation', 'multiPartyConversation.maxParticipants (an integer ≥ 2)');
|
|
378
|
+
|
|
379
|
+
/** `nodePackRuntimes.wasm`: `abiVersions` unique integers ≥ 1 with at least one entry; `maxMemoryBytes` within 1 MiB – 8 GiB. */
|
|
380
|
+
export const nodePackRuntimesWasmLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
381
|
+
facetBySchema(p, d, 'nodePackRuntimes', 'wasm', 'node-pack-runtimes.md §WASM', 'nodePackRuntimes.wasm (abiVersions non-empty unique integers ≥ 1; maxMemoryBytes 1 MiB – 8 GiB)');
|
|
382
|
+
|
|
383
|
+
const UIP_DOC = 'packs.md §Front-end plugin packs';
|
|
384
|
+
/** `uiPlugins.isolation`: one of the five mechanisms or an `x-host-<host>-<key>` value — it names the mechanism, never relaxes the property. */
|
|
385
|
+
export const uiPluginsIsolationLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
386
|
+
facetBySchema(p, d, 'uiPlugins', 'isolation', UIP_DOC, 'uiPlugins.isolation (a closed mechanism or x-host-<host>-<key>)');
|
|
387
|
+
/** `uiPlugins.surfaces`: unique members of the closed surface set. */
|
|
388
|
+
export const uiPluginsSurfacesLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
389
|
+
facetBySchema(p, d, 'uiPlugins', 'surfaces', UIP_DOC, 'uiPlugins.surfaces (unique members of the closed surface set)');
|
|
390
|
+
/** `uiPlugins.hostApi`: unique members of the closed `ui-plugin/1` method set. */
|
|
391
|
+
export const uiPluginsHostApiLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
392
|
+
facetBySchema(p, d, 'uiPlugins', 'hostApi', UIP_DOC, 'uiPlugins.hostApi (unique members of the closed ui-plugin/1 method set)');
|
|
393
|
+
/** `uiPlugins.maxEntryBytes`: a positive integer. */
|
|
394
|
+
export const uiPluginsMaxEntryBytesLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
395
|
+
facetBySchema(p, d, 'uiPlugins', 'maxEntryBytes', UIP_DOC, 'uiPlugins.maxEntryBytes (a positive integer)');
|
|
396
|
+
|
|
397
|
+
/** `purposePropagation.propagatesOnward`: a boolean (security-defaults.md §Onward hops). */
|
|
398
|
+
export const purposePropagatesOnwardLeg = (p: MajorProfile, d: unknown): AdvertOutcome =>
|
|
399
|
+
facetBySchema(p, d, 'purposePropagation', 'propagatesOnward', 'security-defaults.md §Onward hops', 'purposePropagation.propagatesOnward (a boolean)');
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `agentRuntime` implies `agents.manifestRuntime` (host-services.md §`agentRuntime`:
|
|
403
|
+
* "Advertising it implies `agents.manifestRuntime`, which the host MUST satisfy").
|
|
404
|
+
* The suite reads both from discovery, so this is the one cross-family rule of
|
|
405
|
+
* the wave the host's own claim can falsify.
|
|
406
|
+
*/
|
|
407
|
+
export const agentRuntimeImpliesManifestRuntimeLeg = (p: MajorProfile, d: unknown): AdvertOutcome => onFamily(p, d, 'agentRuntime', () => {
|
|
408
|
+
const agents = p.family(d, 'agents');
|
|
409
|
+
const ok = agents !== null && isRecord(agents['manifestRuntime']);
|
|
410
|
+
return observed([{ ok, doc: 'host-services.md §agentRuntime', message: `a host advertising agentRuntime MUST also advertise agents.manifestRuntime${ok ? '' : agents === null ? ' (agents is absent)' : ' (agents.manifestRuntime is absent)'}` }]);
|
|
411
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observe one fixture run on the normative run surface: create it, wait for a
|
|
3
|
+
* terminal status, read its final snapshot and its durable log. Shared by the
|
|
4
|
+
* wave-2 fixture witnesses (`subworkflow-witness.ts`, `dispatch-witness.ts`,
|
|
5
|
+
* `provider-usage-witness.ts`). It observes and asserts nothing; what differs
|
|
6
|
+
* between majors (the run path, the id segment) is the profile row.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { driver } from './driver.js';
|
|
10
|
+
import type { MajorProfile } from './major-profile.js';
|
|
11
|
+
|
|
12
|
+
export const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
14
|
+
|
|
15
|
+
/** A finding: one rule, judged. */
|
|
16
|
+
export interface Finding { readonly ok: boolean; readonly doc: string; readonly message: string }
|
|
17
|
+
export type Skip = { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string };
|
|
18
|
+
export type Outcome = Skip | { readonly kind: 'observed'; readonly findings: readonly Finding[] };
|
|
19
|
+
export type Validate = (doc: unknown) => { ok: boolean; errors: string };
|
|
20
|
+
|
|
21
|
+
export const finding = (ok: boolean, doc: string, message: string): Finding => ({ ok, doc, message });
|
|
22
|
+
export const skip = (disposition: Skip['disposition'], reason: string): Skip => ({ kind: 'skip', disposition, reason });
|
|
23
|
+
export const observed = (findings: readonly Finding[]): Outcome => ({ kind: 'observed', findings });
|
|
24
|
+
|
|
25
|
+
export interface Ev {
|
|
26
|
+
readonly type: string;
|
|
27
|
+
readonly sequence: number;
|
|
28
|
+
/** The envelope `nodeId`, else the payload's. */
|
|
29
|
+
readonly nodeId: string | undefined;
|
|
30
|
+
readonly payload: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RunRead { readonly status: number; readonly snapshot: Record<string, unknown> }
|
|
34
|
+
|
|
35
|
+
export interface ObservedRun {
|
|
36
|
+
readonly workflowId: string;
|
|
37
|
+
readonly createStatus: number;
|
|
38
|
+
readonly runId: string | undefined;
|
|
39
|
+
readonly terminalStatus: string | undefined;
|
|
40
|
+
/** The last `GET /runs/{runId}` body. */
|
|
41
|
+
readonly snapshot: Record<string, unknown>;
|
|
42
|
+
/** Sorted by `sequence`. */
|
|
43
|
+
readonly events: readonly Ev[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ObserveOpts { readonly timeoutMs?: number; readonly pollMs?: number }
|
|
47
|
+
|
|
48
|
+
/** `GET /runs/{runId}`. */
|
|
49
|
+
export async function readRun(profile: MajorProfile, runId: string): Promise<RunRead> {
|
|
50
|
+
const r = await driver.get(`${profile.runsPath}/${profile.idSegment(runId)}`);
|
|
51
|
+
return { status: r.status, snapshot: isRecord(r.json) ? r.json : {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `GET /runs/{runId}/events/poll`, normalised and sorted. */
|
|
55
|
+
export async function readEvents(profile: MajorProfile, runId: string): Promise<Ev[]> {
|
|
56
|
+
const poll = await driver.get(`${profile.runsPath}/${profile.idSegment(runId)}/events/poll`);
|
|
57
|
+
const raw = isRecord(poll.json) && Array.isArray(poll.json['events']) ? (poll.json['events'] as unknown[]) : [];
|
|
58
|
+
return raw.filter(isRecord).map((e): Ev => {
|
|
59
|
+
const payload = isRecord(e['payload']) ? e['payload'] : {};
|
|
60
|
+
const nodeId = typeof e['nodeId'] === 'string' ? e['nodeId'] : typeof payload['nodeId'] === 'string' ? payload['nodeId'] : undefined;
|
|
61
|
+
return { type: String(e['type']), sequence: typeof e['sequence'] === 'number' ? e['sequence'] : Number.MAX_SAFE_INTEGER, nodeId, payload };
|
|
62
|
+
}).sort((x, y) => x.sequence - y.sequence);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Wait for a terminal status (or the deadline); return the last read. */
|
|
66
|
+
export async function pollTerminal(profile: MajorProfile, runId: string, opts: ObserveOpts = {}): Promise<RunRead> {
|
|
67
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 30_000);
|
|
68
|
+
for (;;) {
|
|
69
|
+
const r = await readRun(profile, runId);
|
|
70
|
+
const s = r.snapshot['status'];
|
|
71
|
+
if ((typeof s === 'string' && TERMINAL.has(s)) || Date.now() > deadline) return r;
|
|
72
|
+
await new Promise((res) => setTimeout(res, opts.pollMs ?? 250));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Create a run of `workflowId`, wait for it, read its log. Asserts nothing. */
|
|
77
|
+
export async function observeRun(profile: MajorProfile, workflowId: string, opts: ObserveOpts = {}): Promise<ObservedRun> {
|
|
78
|
+
const created = await driver.post(profile.runsPath, { workflowId });
|
|
79
|
+
const runId = isRecord(created.json) && typeof created.json['runId'] === 'string' ? created.json['runId'] : undefined;
|
|
80
|
+
if ((created.status !== 201 && created.status !== 202) || runId === undefined) {
|
|
81
|
+
return { workflowId, createStatus: created.status, runId: undefined, terminalStatus: undefined, snapshot: {}, events: [] };
|
|
82
|
+
}
|
|
83
|
+
const r = await pollTerminal(profile, runId, opts);
|
|
84
|
+
const status = r.snapshot['status'];
|
|
85
|
+
return { workflowId, createStatus: created.status, runId, terminalStatus: typeof status === 'string' ? status : undefined, snapshot: r.snapshot, events: await readEvents(profile, runId) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The create was accepted and the fixture run ended `completed`, as its catalog entry says. */
|
|
89
|
+
export function runCompleted(o: ObservedRun): Finding[] {
|
|
90
|
+
return [
|
|
91
|
+
finding(o.createStatus === 201 || o.createStatus === 202, 'runs.md createRun', `createRun of ${o.workflowId} MUST be accepted (got ${o.createStatus})`),
|
|
92
|
+
finding(o.terminalStatus === 'completed', `fixtures.md ${o.workflowId}`, `the fixture run MUST end completed (got ${String(o.terminalStatus)})`),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The `node.completed` events of one node. */
|
|
97
|
+
export const completionsOf = (events: readonly Ev[], nodeId: string): Ev[] => events.filter((e) => e.type === 'node.completed' && e.nodeId === nodeId);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scratch double for locale negotiation at major 2 (`i18n.md`): a discovery
|
|
3
|
+
* document carrying an `i18n` record and a run read that answers an error
|
|
4
|
+
* envelope localized by `Accept-Language`. Each self-test in
|
|
5
|
+
* `i18n-negotiation-witness.test.ts` turns on ONE defect to show the witness
|
|
6
|
+
* convicts it; a standalone instance lets the scenario file run against it.
|
|
7
|
+
*
|
|
8
|
+
* A test double, not a host: it executes nothing, and nothing it does is
|
|
9
|
+
* evidence about any implementation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
13
|
+
import type { AddressInfo } from 'node:net';
|
|
14
|
+
import { unprojectBoundId } from './bound-id.js';
|
|
15
|
+
|
|
16
|
+
export type I18nDefect =
|
|
17
|
+
| 'none' | 'malformed-400' | 'unsupported-406' | 'content-language-lies' | 'content-language-garbage'
|
|
18
|
+
| 'code-localized' | 'details-keys-localized' | 'details-locale-garbage';
|
|
19
|
+
|
|
20
|
+
/** The tenant the double treats as the caller's. */
|
|
21
|
+
export const OWN_TENANT = 'acme';
|
|
22
|
+
|
|
23
|
+
const MESSAGES: Record<string, string> = { en: 'No such run', es: 'No existe tal ejecución', 'pt-BR': 'Execução inexistente' };
|
|
24
|
+
|
|
25
|
+
export class I18nDouble {
|
|
26
|
+
defect: I18nDefect = 'none';
|
|
27
|
+
url = '';
|
|
28
|
+
private server: Server | undefined;
|
|
29
|
+
discovery: Record<string, unknown> = { i18n: { status: 'stable', since: '2.0', witness: 'witnessable-gated', defaultLocale: 'en', supportedLocales: ['en', 'es', 'pt-BR'] } };
|
|
30
|
+
|
|
31
|
+
async start(): Promise<string> {
|
|
32
|
+
this.server = createServer((req, res) => { this.handle(req, res); });
|
|
33
|
+
await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
|
|
34
|
+
this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
|
|
35
|
+
return this.url;
|
|
36
|
+
}
|
|
37
|
+
async stop(): Promise<void> { const s = this.server; if (s) { s.closeAllConnections(); await new Promise<void>((r) => s.close(() => r())); } }
|
|
38
|
+
reset(defect: I18nDefect): void { this.defect = defect; }
|
|
39
|
+
|
|
40
|
+
private supported(): string[] { return ((this.discovery['i18n'] as { supportedLocales?: string[] } | undefined)?.supportedLocales) ?? ['en']; }
|
|
41
|
+
|
|
42
|
+
/** RFC 9110 §12.5.4 best-effort: highest q wins, ties to order, then the language family, else the default. `null` = malformed. */
|
|
43
|
+
private negotiate(header: string | undefined): { used: string; malformed: boolean; firstRaw: string | undefined } {
|
|
44
|
+
if (header === undefined) return { used: 'en', malformed: false, firstRaw: undefined };
|
|
45
|
+
const entries: Array<{ tag: string; q: number; i: number }> = [];
|
|
46
|
+
let malformed = false;
|
|
47
|
+
header.split(',').forEach((part, i) => {
|
|
48
|
+
const [tag, ...params] = part.trim().split(';').map((s) => s.trim());
|
|
49
|
+
if (!tag || !/^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$|^\*$/.test(tag)) { malformed = true; return; }
|
|
50
|
+
const qp = params.find((p) => p.startsWith('q='));
|
|
51
|
+
const q = qp === undefined ? 1 : Number(qp.slice(2));
|
|
52
|
+
if (Number.isNaN(q)) { malformed = true; return; }
|
|
53
|
+
entries.push({ tag, q, i });
|
|
54
|
+
});
|
|
55
|
+
entries.sort((x, y) => y.q - x.q || x.i - y.i);
|
|
56
|
+
const sup = this.supported();
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
// `*` (which Node's fetch sends by default) matches any locale: the default serves.
|
|
59
|
+
if (e.tag === '*') return { used: 'en', malformed, firstRaw: undefined };
|
|
60
|
+
const exact = sup.find((s) => s.toLowerCase() === e.tag.toLowerCase());
|
|
61
|
+
if (exact) return { used: exact, malformed, firstRaw: header.split(',')[0]?.split(';')[0]?.trim() };
|
|
62
|
+
const fam = sup.find((s) => s.toLowerCase() === (e.tag.split('-')[0] ?? '').toLowerCase());
|
|
63
|
+
if (fam) return { used: fam, malformed, firstRaw: header.split(',')[0]?.split(';')[0]?.trim() };
|
|
64
|
+
}
|
|
65
|
+
return { used: 'en', malformed, firstRaw: header.split(',')[0]?.split(';')[0]?.trim() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private send(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
|
|
69
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
70
|
+
res.end(JSON.stringify(body));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private handle(req: IncomingMessage, res: ServerResponse): void {
|
|
74
|
+
const path = (req.url ?? '/').split('?')[0] ?? '/';
|
|
75
|
+
if (req.method === 'GET' && path === '/.well-known/openwop') return this.send(res, 200, this.discovery);
|
|
76
|
+
const m = /^\/runs\/([^/]+)$/.exec(path);
|
|
77
|
+
if (req.method !== 'GET' || !m) return this.send(res, 404, { error: 'not_found', message: `no route ${req.method} ${path}` });
|
|
78
|
+
|
|
79
|
+
const header = req.headers['accept-language'];
|
|
80
|
+
const n = this.negotiate(typeof header === 'string' ? header : undefined);
|
|
81
|
+
if (n.malformed && this.defect === 'malformed-400') return this.send(res, 400, { error: 'validation_error', message: 'bad Accept-Language' });
|
|
82
|
+
if (typeof header === 'string' && !n.malformed && n.firstRaw !== undefined && n.used === 'en' && !header.toLowerCase().startsWith('en') && this.defect === 'unsupported-406') {
|
|
83
|
+
return this.send(res, 406, { error: 'not_acceptable', message: 'locale not supported' });
|
|
84
|
+
}
|
|
85
|
+
const localized = n.used !== 'en';
|
|
86
|
+
let cl: string | undefined = typeof header === 'string' ? n.used : undefined;
|
|
87
|
+
if (this.defect === 'content-language-lies' && n.firstRaw !== undefined) cl = n.firstRaw;
|
|
88
|
+
if (this.defect === 'content-language-garbage' && cl !== undefined) cl = 'en_US!';
|
|
89
|
+
|
|
90
|
+
let id: string;
|
|
91
|
+
try { id = unprojectBoundId(m[1]!); } catch { return this.send(res, 400, { error: 'validation_error', message: 'bad id projection' }); }
|
|
92
|
+
const tenant = id.includes('/') ? id.split('/')[0] : OWN_TENANT;
|
|
93
|
+
const foreign = tenant !== OWN_TENANT;
|
|
94
|
+
const code = this.defect === 'code-localized' && localized ? (foreign ? 'inquilino_distinto' : 'no_encontrado') : (foreign ? 'id_tenant_mismatch' : 'not_found');
|
|
95
|
+
const details: Record<string, unknown> = this.defect === 'details-keys-localized' && localized ? { recurso: 'run' } : { resource: 'run' };
|
|
96
|
+
if (localized) details['locale'] = this.defect === 'details-locale-garbage' ? 'spanish!' : n.used;
|
|
97
|
+
return this.send(res, foreign ? 403 : 404, { error: code, message: MESSAGES[n.used] ?? MESSAGES['en'], details }, cl === undefined ? {} : { 'content-language': cl });
|
|
98
|
+
}
|
|
99
|
+
}
|