@openwop/openwop-conformance 2.45.15 → 2.45.17
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 +33 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +1335 -14
- package/scenario-majors.json +104 -2
- package/schemas/CORPUS-STAMP.json +13 -13
- package/src/lib/artifact-schema-url-witness.ts +83 -0
- package/src/lib/dispatch-witness.ts +212 -0
- package/src/lib/family-advert-witness.ts +204 -5
- package/src/lib/fixture-run-observer.ts +97 -0
- package/src/lib/prompt-library-witness.ts +11 -2
- package/src/lib/provider-usage-witness.ts +63 -0
- package/src/lib/run-graph-double.ts +144 -0
- package/src/lib/subworkflow-witness.ts +192 -0
- 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-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-discovery-etag.test.ts +86 -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-export-bundle-static.test.ts +108 -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-library-advertisement.test.ts +40 -0
- package/src/scenarios/v2-prompt-list-and-fetch.test.ts +4 -2
- 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-snapshot-etag.test.ts +35 -22
- 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
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The advertisement-shape witness
|
|
3
|
-
* `envelopes`, `limits` (the three envelope caps), `modelCapabilities`
|
|
4
|
-
* `aiProviders`.
|
|
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.
|
|
5
12
|
*
|
|
6
13
|
* A claims-check family is witnessed by what the host says about itself, so
|
|
7
14
|
* every leg here reads the discovery record alone. Each leg checks one facet
|
|
@@ -13,7 +20,13 @@
|
|
|
13
20
|
* Not here, on purpose:
|
|
14
21
|
* - `limits.maxRunDurationMs` / `maxLoopIterations` — `run-bounds-witness.ts`;
|
|
15
22
|
* - every runtime rule of these families (retry routing, refusal, substitution,
|
|
16
|
-
* cap enforcement
|
|
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.
|
|
17
30
|
*
|
|
18
31
|
* Every leg is pure: `(profile, discovery) → outcome`. A family the host does
|
|
19
32
|
* not advertise, or a facet it does not carry, is `inapplicable` — never a
|
|
@@ -50,7 +63,15 @@ function onFamily(profile: MajorProfile, discovery: unknown, family: string, leg
|
|
|
50
63
|
}
|
|
51
64
|
|
|
52
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, …). */
|
|
53
|
-
export
|
|
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 {
|
|
54
75
|
return onFamily(profile, discovery, family, (rec) => observed([bySchema(`/properties/${family}`, rec, doc, `the ${family} record`)]));
|
|
55
76
|
}
|
|
56
77
|
|
|
@@ -210,3 +231,181 @@ export const aiProvidersPromptPrefixCacheLeg = (p: MajorProfile, d: unknown): Ad
|
|
|
210
231
|
if (v === undefined) return inapplicable('the host does not advertise aiProviders.promptPrefixCache');
|
|
211
232
|
return observed([bySchema(`${AP}/promptPrefixCache`, v, AP_DOC, 'aiProviders.promptPrefixCache (a boolean at v2, not the v1 object)')]);
|
|
212
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);
|
|
@@ -152,7 +152,15 @@ export async function fetchLeg(profile: MajorProfile, a: PromptAdverts, validate
|
|
|
152
152
|
] };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* Leg (advisory): an ETag, when sent, revalidates to `304`. v2 binds the host
|
|
157
|
+
* to nothing here: `getPromptTemplate` SHOULD send an ETag (host-services.md
|
|
158
|
+
* §prompts), and the `304` MUST in headers.md §Request headers covers only the
|
|
159
|
+
* discovery document and the run snapshot. So the leg passes on a `304` and
|
|
160
|
+
* records `inapplicable` otherwise; it is never failed (the
|
|
161
|
+
* `tool-catalog-projection` advisory precedent). Until 2.45.17 it failed on
|
|
162
|
+
* RFC 9110 alone, which no v2 rule imports for this read.
|
|
163
|
+
*/
|
|
156
164
|
export async function etagLeg(profile: MajorProfile, a: PromptAdverts): Promise<PromptOutcome> {
|
|
157
165
|
const g = endpointsGate(a); if (g) return g;
|
|
158
166
|
const t = await aTemplate(a);
|
|
@@ -161,7 +169,8 @@ export async function etagLeg(profile: MajorProfile, a: PromptAdverts): Promise<
|
|
|
161
169
|
const etag = first.headers.get('etag');
|
|
162
170
|
if (first.status !== 200 || etag === null || etag.length === 0) return { kind: 'skip', disposition: 'inapplicable', reason: `getPromptTemplate sent no ETag (status ${first.status}) — a SHOULD, so there is nothing to revalidate` };
|
|
163
171
|
const again = await driver.get(tplPath(profile, a, t.templateId), { headers: { 'If-None-Match': etag } });
|
|
164
|
-
return { kind: '
|
|
172
|
+
if (again.status !== 304) return { kind: 'skip', disposition: 'inapplicable', reason: `advisory SHOULD not met: If-None-Match with the current ETag answered ${again.status}, not 304 — v2 binds 304 only on discovery and the run snapshot (headers.md), so this is recorded, never failed` };
|
|
173
|
+
return { kind: 'observed', findings: [f(true, `${DOC_LIB} (SHOULD); RFC 9110 §13.1.2`, 'If-None-Match with the current ETag answers 304')] };
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
/** Leg: an unknown template id is `404` with the error envelope. */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `provider.usage` emission witness (`events.md` §`providerUsage`): "A
|
|
3
|
+
* host advertising `providerUsage` MUST emit exactly one `provider.usage` per
|
|
4
|
+
* LLM provider invocation, before that node's `node.completed`."
|
|
5
|
+
*
|
|
6
|
+
* Driven on `conformance-context-budget-live` — a supervisor on the host's LIVE
|
|
7
|
+
* model (the fixture's catalog entry forbids a mock), so a completed run made
|
|
8
|
+
* at least one provider invocation. From outside, the number of invocations
|
|
9
|
+
* is unknowable, so "exactly one per invocation" is NOT asserted; what is:
|
|
10
|
+
* emitted at least one `provider.usage` is on the log;
|
|
11
|
+
* ordering each one precedes a `node.completed` of the node it names (or,
|
|
12
|
+
* when it names none, some `node.completed`);
|
|
13
|
+
* payload each payload validates against the v2 `providerUsage` def (whose
|
|
14
|
+
* closed shape also keeps credential refs and prompt text out).
|
|
15
|
+
*
|
|
16
|
+
* {@link driveUsage} observes and asserts nothing; {@link judgeUsage} is pure,
|
|
17
|
+
* proven in `provider-usage-witness.test.ts`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isFixtureAdvertised } from './fixtures.js';
|
|
21
|
+
import type { MajorProfile } from './major-profile.js';
|
|
22
|
+
import { finding, observed, observeRun, skip, type ObservedRun, type ObserveOpts, type Outcome, type Skip, type Validate } from './fixture-run-observer.js';
|
|
23
|
+
|
|
24
|
+
export const LIVE_FIXTURE = 'conformance-context-budget-live';
|
|
25
|
+
const DOC = 'events.md §providerUsage';
|
|
26
|
+
|
|
27
|
+
export interface UsageObservation { readonly run: ObservedRun; readonly usageType: string }
|
|
28
|
+
|
|
29
|
+
/** The `providerUsage` record, the live fixture, and this major's event name. */
|
|
30
|
+
export function usageGate(profile: MajorProfile, doc: unknown): Skip | { usageType: string } {
|
|
31
|
+
if (profile.family(doc, 'providerUsage') === null) return skip('inapplicable', 'the host does not advertise providerUsage');
|
|
32
|
+
if (!isFixtureAdvertised(LIVE_FIXTURE)) return skip('inapplicable', `fixture ${LIVE_FIXTURE} is not advertised — the fixture is the opt-in (a host whose supervisor has no live model must not advertise it)`);
|
|
33
|
+
const usageType = profile.eventType('provider.usage');
|
|
34
|
+
if (usageType === undefined) return skip('blocked', 'the event codemap is not on disk in this layout — the suite will not guess an event name');
|
|
35
|
+
return { usageType };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Run the live fixture. Asserts nothing. */
|
|
39
|
+
export async function driveUsage(profile: MajorProfile, doc: unknown, opts: ObserveOpts = {}): Promise<Skip | UsageObservation> {
|
|
40
|
+
const g = usageGate(profile, doc);
|
|
41
|
+
if ('kind' in g) return g;
|
|
42
|
+
return { run: await observeRun(profile, LIVE_FIXTURE, { timeoutMs: 300_000, pollMs: 1000, ...opts }), ...g };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Emission, ordering and payload shape. Pure. */
|
|
46
|
+
export function judgeUsage(o: UsageObservation, validate: Validate): Outcome {
|
|
47
|
+
const usage = o.run.events.filter((e) => e.type === o.usageType);
|
|
48
|
+
const completions = o.run.events.filter((e) => e.type === 'node.completed');
|
|
49
|
+
if (o.run.createStatus !== 201 && o.run.createStatus !== 202) {
|
|
50
|
+
return observed([finding(false, 'runs.md createRun', `createRun of ${LIVE_FIXTURE} MUST be accepted (got ${o.run.createStatus})`)]);
|
|
51
|
+
}
|
|
52
|
+
if (usage.length === 0 && o.run.terminalStatus !== 'completed') {
|
|
53
|
+
return skip('blocked', `the live run ended ${String(o.run.terminalStatus)} with no ${o.usageType} — no provider invocation is shown to have happened`);
|
|
54
|
+
}
|
|
55
|
+
return observed([
|
|
56
|
+
finding(usage.length > 0, DOC, `a completed run of a live-model supervisor MUST carry at least one ${o.usageType} (got none)`),
|
|
57
|
+
...usage.map((u) => {
|
|
58
|
+
const after = completions.some((c) => c.sequence > u.sequence && (u.nodeId === undefined || c.nodeId === u.nodeId));
|
|
59
|
+
return finding(after, DOC, `${o.usageType} (seq ${u.sequence}${u.nodeId === undefined ? '' : `, node ${u.nodeId}`}) MUST precede that node's node.completed`);
|
|
60
|
+
}),
|
|
61
|
+
...usage.map((u) => { const v = validate(u.payload); return finding(v.ok, 'schemas run-event-payloads providerUsage', `every ${o.usageType} payload MUST validate (seq ${u.sequence}): ${v.errors}`); }),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run-graph double: the smallest HTTP server the wave-2 fixture witnesses
|
|
3
|
+
* (sub-workflow linkage and input mapping, dispatch input mapping, the
|
|
4
|
+
* dispatch loop, provider usage) and the artifact schema-URL witness can be
|
|
5
|
+
* proven against, in both directions.
|
|
6
|
+
*
|
|
7
|
+
* `ScratchHost` plays one run per create. These witnesses follow a parent run
|
|
8
|
+
* to the child it started, read the child's snapshot (`variables`, `inputs`)
|
|
9
|
+
* and its `getRunAncestry` answer, and fetch paths outside the run surface,
|
|
10
|
+
* so this double lets a case script a parent that spawns children, and answer
|
|
11
|
+
* any other GET.
|
|
12
|
+
*
|
|
13
|
+
* It is a test double, not a host: it executes nothing, and nothing it does is
|
|
14
|
+
* evidence about any implementation. Ids are tenant-bound (`<tenant>/<opaque>`,
|
|
15
|
+
* `ids.schema.json#/$defs/runId`) and accepted `~`-projected or percent-encoded.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
19
|
+
import type { AddressInfo } from 'node:net';
|
|
20
|
+
import { unprojectBoundId } from './bound-id.js';
|
|
21
|
+
|
|
22
|
+
export interface DoubleEvent {
|
|
23
|
+
readonly type: string;
|
|
24
|
+
/** The envelope's top-level `nodeId`. */
|
|
25
|
+
readonly nodeId?: string | undefined;
|
|
26
|
+
readonly payload?: Record<string, unknown> | undefined;
|
|
27
|
+
}
|
|
28
|
+
export interface DoubleRun {
|
|
29
|
+
readonly workflowId?: string;
|
|
30
|
+
readonly status: string;
|
|
31
|
+
readonly variables?: Record<string, unknown> | undefined;
|
|
32
|
+
readonly inputs?: Record<string, unknown> | undefined;
|
|
33
|
+
readonly events?: readonly DoubleEvent[];
|
|
34
|
+
/** The `GET /runs/{runId}/ancestry` answer, given the run's own id. Unset: 404. */
|
|
35
|
+
readonly ancestry?: ((self: string) => { readonly status: number; readonly body: unknown }) | undefined;
|
|
36
|
+
}
|
|
37
|
+
export interface RawAnswer { readonly status: number; readonly headers?: Readonly<Record<string, string>>; readonly body: string }
|
|
38
|
+
export interface DoubleOptions {
|
|
39
|
+
readonly discovery: Readonly<Record<string, unknown>>;
|
|
40
|
+
/**
|
|
41
|
+
* The run a create makes. `spawn` stores a further run (a child) and returns
|
|
42
|
+
* its id; `self` is the id the created run will carry. Unset: every create is 404.
|
|
43
|
+
*/
|
|
44
|
+
readonly onCreate?: ((workflowId: string, spawn: (run: DoubleRun) => string, self: string) => DoubleRun | undefined) | undefined;
|
|
45
|
+
/** Any other GET, by path. `undefined` is 404. */
|
|
46
|
+
readonly onGet?: ((path: string) => RawAnswer | undefined) | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DOUBLE_TENANT = 'tenant-a';
|
|
50
|
+
|
|
51
|
+
export class RunGraphDouble {
|
|
52
|
+
private server: Server | undefined;
|
|
53
|
+
private readonly runs = new Map<string, DoubleRun>();
|
|
54
|
+
private seq = 0;
|
|
55
|
+
url = '';
|
|
56
|
+
|
|
57
|
+
constructor(private opts: DoubleOptions) {}
|
|
58
|
+
|
|
59
|
+
/** Swap behaviour between cases. The address stays the same, since the driver caches its base URL per file. */
|
|
60
|
+
reconfigure(next: Partial<DoubleOptions>): void {
|
|
61
|
+
this.opts = { ...this.opts, ...next };
|
|
62
|
+
this.runs.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get discovery(): Readonly<Record<string, unknown>> { return this.opts.discovery; }
|
|
66
|
+
|
|
67
|
+
async start(): Promise<string> {
|
|
68
|
+
this.server = createServer((req, res) => { void this.handle(req, res); });
|
|
69
|
+
await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
|
|
70
|
+
this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
|
|
71
|
+
return this.url;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async stop(): Promise<void> {
|
|
75
|
+
const s = this.server;
|
|
76
|
+
if (s === undefined) return;
|
|
77
|
+
s.closeAllConnections();
|
|
78
|
+
await new Promise<void>((r) => s.close(() => r()));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private mint(): string { return `${DOUBLE_TENANT}/run-${String(++this.seq).padStart(16, '0')}`; }
|
|
82
|
+
|
|
83
|
+
private send(res: ServerResponse, status: number, body: unknown, headers: Readonly<Record<string, string>> = {}): void {
|
|
84
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
85
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async body(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
89
|
+
const chunks: Buffer[] = [];
|
|
90
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
91
|
+
try {
|
|
92
|
+
const v: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
93
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
94
|
+
} catch { return {}; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
98
|
+
const path = (req.url ?? '/').split('?')[0] ?? '/';
|
|
99
|
+
if (req.method === 'GET' && path === '/.well-known/openwop') return this.send(res, 200, this.opts.discovery);
|
|
100
|
+
|
|
101
|
+
if (req.method === 'POST' && path === '/runs') {
|
|
102
|
+
const workflowId = String((await this.body(req))['workflowId'] ?? '');
|
|
103
|
+
const self = this.mint();
|
|
104
|
+
const run = this.opts.onCreate?.(workflowId, (child) => { const id = this.mint(); this.runs.set(id, child); return id; }, self);
|
|
105
|
+
if (run === undefined) return this.send(res, 404, { error: 'not_found', message: `no workflow ${workflowId}` });
|
|
106
|
+
this.runs.set(self, { workflowId, ...run });
|
|
107
|
+
return this.send(res, 201, { runId: self, status: 'running' });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const m = /^\/runs\/([^/]+)(\/events\/poll|\/ancestry)?$/.exec(path);
|
|
111
|
+
if (req.method === 'GET' && m) {
|
|
112
|
+
const seg = m[1] as string;
|
|
113
|
+
const id = seg.includes('~') ? unprojectBoundId(seg) : decodeURIComponent(seg);
|
|
114
|
+
const run = this.runs.get(id);
|
|
115
|
+
if (run === undefined) return this.send(res, 404, { error: 'not_found', message: 'no such run' });
|
|
116
|
+
if (m[2] === '/ancestry') {
|
|
117
|
+
const a = run.ancestry?.(id);
|
|
118
|
+
return a === undefined ? this.send(res, 404, { error: 'not_found', message: 'ancestry not served' }) : this.send(res, a.status, a.body);
|
|
119
|
+
}
|
|
120
|
+
if (m[2] === '/events/poll') {
|
|
121
|
+
const events = (run.events ?? []).map((e, i) => ({
|
|
122
|
+
eventId: `e${i + 1}`, runId: id, type: e.type, ...(e.nodeId === undefined ? {} : { nodeId: e.nodeId }),
|
|
123
|
+
payload: e.payload ?? {}, sequence: i + 1, schemaVersion: 2, timestamp: new Date(0).toISOString(),
|
|
124
|
+
}));
|
|
125
|
+
return this.send(res, 200, { events });
|
|
126
|
+
}
|
|
127
|
+
return this.send(res, 200, {
|
|
128
|
+
runId: id, workflowId: run.workflowId ?? 'double', status: run.status,
|
|
129
|
+
...(run.variables === undefined ? {} : { variables: run.variables }),
|
|
130
|
+
...(run.inputs === undefined ? {} : { inputs: run.inputs }),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (req.method === 'GET') {
|
|
135
|
+
const raw = this.opts.onGet?.(path);
|
|
136
|
+
if (raw !== undefined) {
|
|
137
|
+
res.writeHead(raw.status, { ...(raw.headers ?? {}) });
|
|
138
|
+
res.end(raw.body);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return this.send(res, 404, { error: 'not_found', message: 'no such route' });
|
|
143
|
+
}
|
|
144
|
+
}
|