@openwop/openwop-conformance 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +293 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +2 -2
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +68 -0
- package/src/lib/env.ts +10 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +131 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +54 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay retention-expiry scenario per `spec/v1/replay.md` §"Retention
|
|
3
|
+
* and garbage collection."
|
|
4
|
+
*
|
|
5
|
+
* Verifies the normative `replay.md:246` requirement:
|
|
6
|
+
*
|
|
7
|
+
* > If the source run still exists but the event range needed for
|
|
8
|
+
* > `fromSeq` has expired, the host MUST reject the fork with
|
|
9
|
+
* > `410 Gone` or `422 Unprocessable Entity` using the canonical
|
|
10
|
+
* > error envelope. The error `details` SHOULD include `sourceRunId`,
|
|
11
|
+
* > `fromSeq`, and the retention boundary when known.
|
|
12
|
+
*
|
|
13
|
+
* Forcing expiry is environmental (hosts don't standardize a force-
|
|
14
|
+
* expire endpoint — that's the same RFC 0009 Q#1 surface area). The
|
|
15
|
+
* scenario reads two operator-supplied env vars:
|
|
16
|
+
*
|
|
17
|
+
* - `OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID` — runId of a run whose
|
|
18
|
+
* events past `fromSeq` are known-expired on the host under test.
|
|
19
|
+
* - `OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ` — the `fromSeq` to
|
|
20
|
+
* fork against (defaults to 0; pre-expired ranges typically
|
|
21
|
+
* start from the earliest event).
|
|
22
|
+
*
|
|
23
|
+
* When neither is supplied, the scenario asserts only that the
|
|
24
|
+
* `replay` capability advertisement is well-formed and that
|
|
25
|
+
* `retention` metadata (when present) types correctly. The
|
|
26
|
+
* 410/422 envelope assertion soft-skips.
|
|
27
|
+
*
|
|
28
|
+
* @see spec/v1/replay.md §"Retention and garbage collection"
|
|
29
|
+
* @see RFCS/0009-production-profile-conformance.md §C (parallel
|
|
30
|
+
* retention-expiry pattern for run snapshots)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
36
|
+
|
|
37
|
+
interface ReplayRetentionCaps {
|
|
38
|
+
windowSeconds?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ReplayCaps {
|
|
42
|
+
supported?: boolean;
|
|
43
|
+
modes?: string[];
|
|
44
|
+
retention?: ReplayRetentionCaps;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PROFILE = 'openwop-replay-fork';
|
|
48
|
+
|
|
49
|
+
async function readReplayCaps(): Promise<ReplayCaps | undefined> {
|
|
50
|
+
// Per existing replay-fork.test.ts convention: `replay` lives at the
|
|
51
|
+
// top level of the discovery body, not under capabilities.*.
|
|
52
|
+
const disco = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
53
|
+
if (disco.status !== 200) return undefined;
|
|
54
|
+
return (disco.json as { replay?: ReplayCaps }).replay;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isProfileAdvertised(replay: ReplayCaps | undefined): boolean {
|
|
58
|
+
return replay?.supported === true && Array.isArray(replay.modes) && replay.modes.length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('replay-retention-expiry: capability shape', () => {
|
|
62
|
+
it('host advertising replay surfaces well-formed retention metadata when present', async () => {
|
|
63
|
+
const replay = await readReplayCaps();
|
|
64
|
+
|
|
65
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
expect(replay?.supported, driver.describe(
|
|
70
|
+
'replay.md §"Retention and garbage collection"',
|
|
71
|
+
'replay.supported MUST be true when the host claims the openwop-replay-fork profile',
|
|
72
|
+
)).toBe(true);
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
Array.isArray(replay?.modes) && (replay?.modes?.length ?? 0) > 0,
|
|
76
|
+
driver.describe(
|
|
77
|
+
'profiles.md §`openwop-replay-fork`',
|
|
78
|
+
'replay.modes MUST be a non-empty array',
|
|
79
|
+
),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
|
|
82
|
+
// retention metadata is OPTIONAL per replay.md (the spec requires
|
|
83
|
+
// hosts document retention; it doesn't yet require advertising
|
|
84
|
+
// the window in discovery). When advertised, type strictly.
|
|
85
|
+
if (replay?.retention?.windowSeconds !== undefined) {
|
|
86
|
+
expect(
|
|
87
|
+
Number.isInteger(replay.retention.windowSeconds) &&
|
|
88
|
+
replay.retention.windowSeconds >= 0,
|
|
89
|
+
driver.describe(
|
|
90
|
+
'replay.md §"Retention and garbage collection"',
|
|
91
|
+
'replay.retention.windowSeconds MUST be a non-negative integer when advertised',
|
|
92
|
+
),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('replay-retention-expiry: 410/422 on expired-range fork', () => {
|
|
99
|
+
it('POST /v1/runs/{expiredRunId}:fork returns 410 or 422 with canonical envelope', async () => {
|
|
100
|
+
const replay = await readReplayCaps();
|
|
101
|
+
|
|
102
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const expiredRunId = process.env.OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID;
|
|
107
|
+
if (!expiredRunId) {
|
|
108
|
+
// eslint-disable-next-line no-console
|
|
109
|
+
console.warn(
|
|
110
|
+
'[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID not supplied; skipping envelope assertion (operator must produce a known-expired run id and pass it via env)',
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fromSeqEnv = process.env.OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ;
|
|
116
|
+
const fromSeq = fromSeqEnv ? Number.parseInt(fromSeqEnv, 10) : 0;
|
|
117
|
+
if (!Number.isFinite(fromSeq) || fromSeq < 0) {
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.warn(
|
|
120
|
+
`[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ=${String(fromSeqEnv)} is not a non-negative integer; skipping`,
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pick a mode the host advertises. Per replay.md the envelope
|
|
126
|
+
// assertion applies regardless of mode — replay and branch both
|
|
127
|
+
// depend on the source event log past fromSeq.
|
|
128
|
+
const mode = replay?.modes?.[0] ?? 'replay';
|
|
129
|
+
|
|
130
|
+
const res = await driver.post(
|
|
131
|
+
`/v1/runs/${encodeURIComponent(expiredRunId)}:fork`,
|
|
132
|
+
{ mode, fromSeq },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(
|
|
136
|
+
res.status === 410 || res.status === 422,
|
|
137
|
+
driver.describe(
|
|
138
|
+
'replay.md §"Retention and garbage collection"',
|
|
139
|
+
'fork against expired event range MUST return 410 Gone or 422 Unprocessable Entity',
|
|
140
|
+
),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
|
|
143
|
+
const body = res.json as {
|
|
144
|
+
error?: string;
|
|
145
|
+
message?: string;
|
|
146
|
+
details?: {
|
|
147
|
+
sourceRunId?: string;
|
|
148
|
+
fromSeq?: number;
|
|
149
|
+
retentionBoundary?: string | number;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(typeof body.error, driver.describe(
|
|
154
|
+
'replay.md §"Retention and garbage collection"',
|
|
155
|
+
'expired-fork response MUST use the canonical error envelope ({error, message, details?})',
|
|
156
|
+
)).toBe('string');
|
|
157
|
+
expect((body.error ?? '').length).toBeGreaterThan(0);
|
|
158
|
+
|
|
159
|
+
expect(typeof body.message).toBe('string');
|
|
160
|
+
expect((body.message ?? '').length).toBeGreaterThan(0);
|
|
161
|
+
|
|
162
|
+
// details.{sourceRunId, fromSeq, retentionBoundary} are SHOULD —
|
|
163
|
+
// soft-check when present, MUST NOT mismatch when present.
|
|
164
|
+
if (body.details?.sourceRunId !== undefined) {
|
|
165
|
+
expect(body.details.sourceRunId, driver.describe(
|
|
166
|
+
'replay.md §"Retention and garbage collection"',
|
|
167
|
+
'details.sourceRunId (when present) MUST match the runId in the request path',
|
|
168
|
+
)).toBe(expiredRunId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (body.details?.fromSeq !== undefined) {
|
|
172
|
+
expect(body.details.fromSeq, driver.describe(
|
|
173
|
+
'replay.md §"Retention and garbage collection"',
|
|
174
|
+
'details.fromSeq (when present) MUST match the fromSeq supplied in the request body',
|
|
175
|
+
)).toBe(fromSeq);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restart-during-run scenario per spec/v1/scale-profiles.md
|
|
3
|
+
* §"Replay semantics" + spec/v1/storage-adapters.md §"Claim acquisition."
|
|
4
|
+
*
|
|
5
|
+
* Distinct from `staleClaim.test.ts` (which exercises *cross-process*
|
|
6
|
+
* claim transfer where two host processes share a DB):
|
|
7
|
+
*
|
|
8
|
+
* - **staleClaim:** process A starts a run → SIGKILL → process B
|
|
9
|
+
* (different PID, different port, same DB) takes over after TTL.
|
|
10
|
+
* Models multi-host scale-out.
|
|
11
|
+
* - **restart-during-run** (this file): process A starts a run →
|
|
12
|
+
* SIGKILL → process A' (same port, same DB, fresh PID) takes
|
|
13
|
+
* over after TTL. Models the single-host crash + supervisor-
|
|
14
|
+
* restart pattern that most production deployments rely on.
|
|
15
|
+
*
|
|
16
|
+
* Both reduce to the same primitive — resume-on-startup picks up an
|
|
17
|
+
* orphaned claim — but the contract this scenario asserts is that the
|
|
18
|
+
* SECOND boot at the SAME port works, which is the more common
|
|
19
|
+
* production failure mode (a node-level supervisor like systemd, k8s,
|
|
20
|
+
* pm2 restarts the host process on crash).
|
|
21
|
+
*
|
|
22
|
+
* **`@multi-process`** — spawns child host processes via
|
|
23
|
+
* `child_process.spawn`. Opt-in via `OPENWOP_RUN_RESTART_DURING_RUN=1`.
|
|
24
|
+
*
|
|
25
|
+
* **`@timing-sensitive`** — relies on a short claim TTL.
|
|
26
|
+
*
|
|
27
|
+
* @see lib/multiProcess.ts — spawnHost helper
|
|
28
|
+
* @see examples/hosts/sqlite/src/server.ts — resume-on-startup
|
|
29
|
+
* @see spec/v1/production-profile.md §Durability (RFC 0009 — this
|
|
30
|
+
* scenario satisfies the supervisor-restart predicate when the
|
|
31
|
+
* host advertises capabilities.production.supported: true)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
35
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
36
|
+
import { tmpdir } from 'node:os';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { spawnHost, type SpawnedHost } from '../lib/multiProcess.js';
|
|
39
|
+
|
|
40
|
+
const HOST_PACKAGE_DIR =
|
|
41
|
+
process.env.OPENWOP_RESTART_DURING_RUN_HOST_DIR ?? 'examples/hosts/sqlite';
|
|
42
|
+
const RUN_THIS_SCENARIO = process.env.OPENWOP_RUN_RESTART_DURING_RUN === '1';
|
|
43
|
+
|
|
44
|
+
const APIKEY = 'openwop-restart-during-run';
|
|
45
|
+
const PORT = 4803;
|
|
46
|
+
const CLAIM_TTL_MS = 2000;
|
|
47
|
+
const HEARTBEAT_INTERVAL_MS = 500;
|
|
48
|
+
|
|
49
|
+
interface RunSnapshot {
|
|
50
|
+
status?: string;
|
|
51
|
+
runId?: string;
|
|
52
|
+
endedAt?: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function fetchSnapshot(
|
|
56
|
+
baseUrl: string,
|
|
57
|
+
apiKey: string,
|
|
58
|
+
runId: string,
|
|
59
|
+
): Promise<RunSnapshot> {
|
|
60
|
+
const res = await fetch(`${baseUrl}/v1/runs/${encodeURIComponent(runId)}`, {
|
|
61
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`GET /v1/runs/${runId} failed: ${res.status}`);
|
|
64
|
+
return (await res.json()) as RunSnapshot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let workdir: string | null = null;
|
|
68
|
+
const activeHosts: SpawnedHost[] = [];
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
for (const h of activeHosts.splice(0)) {
|
|
72
|
+
await h.shutdown().catch(() => h.kill().catch(() => undefined));
|
|
73
|
+
}
|
|
74
|
+
if (workdir) {
|
|
75
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
76
|
+
workdir = null;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe.skipIf(!RUN_THIS_SCENARIO)(
|
|
81
|
+
'restart-during-run: SIGKILL + same-port restart resumes orphaned run',
|
|
82
|
+
() => {
|
|
83
|
+
it(
|
|
84
|
+
'mid-run SIGKILL on the host process; restart at the same port + DB resumes to terminal',
|
|
85
|
+
async () => {
|
|
86
|
+
workdir = mkdtempSync(join(tmpdir(), 'openwop-restart-during-run-'));
|
|
87
|
+
const dbPath = join(workdir, 'openwop-host.sqlite');
|
|
88
|
+
|
|
89
|
+
// ── Boot host A (the one we'll kill).
|
|
90
|
+
const hostA = await spawnHost({
|
|
91
|
+
packageDir: HOST_PACKAGE_DIR,
|
|
92
|
+
port: PORT,
|
|
93
|
+
apiKey: APIKEY,
|
|
94
|
+
dbPath,
|
|
95
|
+
claimTtlMs: CLAIM_TTL_MS,
|
|
96
|
+
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
|
|
97
|
+
});
|
|
98
|
+
activeHosts.push(hostA);
|
|
99
|
+
await hostA.ready();
|
|
100
|
+
|
|
101
|
+
// Start a long-running run (uses conformance-cancellable so we
|
|
102
|
+
// have time between create and kill).
|
|
103
|
+
const create = await fetch(`${hostA.baseUrl}/v1/runs`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
Authorization: `Bearer ${APIKEY}`,
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
workflowId: 'conformance-cancellable',
|
|
111
|
+
inputs: { delaySeconds: 8 },
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
expect(create.status).toBe(201);
|
|
115
|
+
const runId = (await create.json() as { runId: string }).runId;
|
|
116
|
+
expect(runId).toMatch(/^run-/);
|
|
117
|
+
|
|
118
|
+
// Wait a beat so host A writes `run.started` and at least one
|
|
119
|
+
// node.started before we kill it.
|
|
120
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
121
|
+
|
|
122
|
+
// SIGKILL host A. Claim remains held in the DB until TTL
|
|
123
|
+
// expires.
|
|
124
|
+
await hostA.kill();
|
|
125
|
+
activeHosts.length = 0; // hostA is dead; don't try to shut down again
|
|
126
|
+
|
|
127
|
+
// Wait for the claim to go stale.
|
|
128
|
+
await new Promise((r) => setTimeout(r, CLAIM_TTL_MS + 500));
|
|
129
|
+
|
|
130
|
+
// ── Boot the replacement at the SAME port + SAME DB. This is
|
|
131
|
+
// the supervisor-restart pattern.
|
|
132
|
+
const hostA2 = await spawnHost({
|
|
133
|
+
packageDir: HOST_PACKAGE_DIR,
|
|
134
|
+
port: PORT,
|
|
135
|
+
apiKey: APIKEY,
|
|
136
|
+
dbPath,
|
|
137
|
+
claimTtlMs: CLAIM_TTL_MS,
|
|
138
|
+
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
|
|
139
|
+
});
|
|
140
|
+
activeHosts.push(hostA2);
|
|
141
|
+
await hostA2.ready();
|
|
142
|
+
|
|
143
|
+
// Poll until terminal. A successful restart-recovery means
|
|
144
|
+
// resume-on-startup re-acquired the claim and the executor
|
|
145
|
+
// completed the run.
|
|
146
|
+
const deadline = Date.now() + 30_000;
|
|
147
|
+
let terminal: RunSnapshot | null = null;
|
|
148
|
+
while (Date.now() < deadline) {
|
|
149
|
+
const snap = await fetchSnapshot(hostA2.baseUrl, APIKEY, runId);
|
|
150
|
+
if (
|
|
151
|
+
snap.status === 'completed' ||
|
|
152
|
+
snap.status === 'failed' ||
|
|
153
|
+
snap.status === 'cancelled'
|
|
154
|
+
) {
|
|
155
|
+
terminal = snap;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
expect(
|
|
162
|
+
terminal,
|
|
163
|
+
'run MUST reach terminal status on the restarted host within 30s',
|
|
164
|
+
).not.toBeNull();
|
|
165
|
+
expect(
|
|
166
|
+
terminal?.status,
|
|
167
|
+
'restart-recovery MUST drive the run to a non-pending terminal status',
|
|
168
|
+
).toMatch(/^(completed|failed|cancelled)$/);
|
|
169
|
+
// For the cancellable fixture, completed is the expected
|
|
170
|
+
// outcome — the workflow just exits the delay node and finishes.
|
|
171
|
+
expect(terminal?.status).toBe('completed');
|
|
172
|
+
expect(typeof terminal?.endedAt).toBe('string');
|
|
173
|
+
},
|
|
174
|
+
45_000,
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
);
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
FIXTURES_DIR,
|
|
46
46
|
FIXTURES_DOC_PATH,
|
|
47
47
|
GO_TYPES_PATH,
|
|
48
|
+
LAYOUT,
|
|
48
49
|
PYTHON_TYPES_PATH,
|
|
49
50
|
README_PATH,
|
|
50
51
|
SCENARIOS_DIR,
|
|
@@ -786,10 +787,13 @@ describe.skipIf(
|
|
|
786
787
|
)(
|
|
787
788
|
'spec-corpus: SDK HTTP error helpers match canonical REST vocabulary',
|
|
788
789
|
() => {
|
|
790
|
+
// describe.skipIf still evaluates the body for test registration; defaults guard against null
|
|
791
|
+
// dirname() when sources are missing under the published-tarball layout. it() blocks below are
|
|
792
|
+
// skipped at run time, so the path values are never actually read.
|
|
789
793
|
const sdkSources = {
|
|
790
|
-
typescript: TYPESCRIPT_RUN_HELPERS_PATH
|
|
791
|
-
python: PYTHON_TYPES_PATH
|
|
792
|
-
go: GO_TYPES_PATH
|
|
794
|
+
typescript: TYPESCRIPT_RUN_HELPERS_PATH ?? '.',
|
|
795
|
+
python: PYTHON_TYPES_PATH ?? '.',
|
|
796
|
+
go: GO_TYPES_PATH ?? '.',
|
|
793
797
|
};
|
|
794
798
|
const sdkReadmes = {
|
|
795
799
|
typescript: pathResolve(dirname(sdkSources.typescript), '..', 'README.md'),
|
|
@@ -1072,12 +1076,18 @@ describe.skipIf(V1_DIR === null)('spec-corpus: prose docs carry a Status: legend
|
|
|
1072
1076
|
});
|
|
1073
1077
|
|
|
1074
1078
|
describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README document index matches spec/v1', () => {
|
|
1075
|
-
|
|
1076
|
-
|
|
1079
|
+
// describe.skipIf skips test execution but still evaluates the describe callback at registration
|
|
1080
|
+
// time. Guard each side-effecting read against null so the body registers cleanly under the
|
|
1081
|
+
// published-tarball layout where V1_DIR / README_PATH resolve to null.
|
|
1082
|
+
const v1Dir = V1_DIR;
|
|
1083
|
+
const readmePath = README_PATH ?? '';
|
|
1077
1084
|
|
|
1078
|
-
const proseFiles =
|
|
1079
|
-
|
|
1080
|
-
|
|
1085
|
+
const proseFiles =
|
|
1086
|
+
v1Dir === null
|
|
1087
|
+
? []
|
|
1088
|
+
: readdirSync(v1Dir)
|
|
1089
|
+
.filter((f) => f.endsWith('.md'))
|
|
1090
|
+
.sort();
|
|
1081
1091
|
|
|
1082
1092
|
it('README Total count equals the number of spec/v1 prose docs', () => {
|
|
1083
1093
|
const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
|
|
@@ -1109,8 +1119,10 @@ describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README do
|
|
|
1109
1119
|
});
|
|
1110
1120
|
|
|
1111
1121
|
describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve', () => {
|
|
1112
|
-
|
|
1113
|
-
|
|
1122
|
+
// describe.skipIf skips test execution but still evaluates the body for registration; default
|
|
1123
|
+
// to '.' so dirname() never receives null in the published-tarball layout.
|
|
1124
|
+
const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
|
|
1125
|
+
const markdownFiles = README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot);
|
|
1114
1126
|
|
|
1115
1127
|
it('finds Markdown files to check', () => {
|
|
1116
1128
|
expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);
|
|
@@ -1132,6 +1144,9 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
|
|
|
1132
1144
|
}
|
|
1133
1145
|
|
|
1134
1146
|
const target = pathResolve(dirname(file), decoded);
|
|
1147
|
+
// Published-tarball layout: the conformance README references ../spec/v1/... and other paths
|
|
1148
|
+
// that resolve OUTSIDE the package boundary. Repo layout has the full tree available.
|
|
1149
|
+
if (LAYOUT === 'published' && !target.startsWith(repoRoot)) continue;
|
|
1135
1150
|
expect(
|
|
1136
1151
|
existsSync(target),
|
|
1137
1152
|
`${relFile} links to missing local target: ${link}`,
|
|
@@ -1142,21 +1157,29 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
|
|
|
1142
1157
|
});
|
|
1143
1158
|
|
|
1144
1159
|
describe.skipIf(README_PATH === null)('spec-corpus: public docs avoid private implementation breadcrumbs', () => {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
+
// describe.skipIf skips test execution but still evaluates the body for registration; guard each
|
|
1161
|
+
// path read against null/missing-dir so the body never throws under the published-tarball layout.
|
|
1162
|
+
const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
|
|
1163
|
+
const securityDir = join(repoRoot, 'SECURITY');
|
|
1164
|
+
const publicTextFiles =
|
|
1165
|
+
README_PATH === null
|
|
1166
|
+
? []
|
|
1167
|
+
: [
|
|
1168
|
+
README_PATH,
|
|
1169
|
+
join(repoRoot, 'QUICKSTART.md'),
|
|
1170
|
+
join(repoRoot, 'QUICKSTART-10MIN.md'),
|
|
1171
|
+
join(repoRoot, 'sdk', 'typescript', 'README.md'),
|
|
1172
|
+
join(repoRoot, 'sdk', 'python', 'README.md'),
|
|
1173
|
+
join(repoRoot, 'sdk', 'go', 'README.md'),
|
|
1174
|
+
...(CONFORMANCE_README_PATH ? [CONFORMANCE_README_PATH] : []),
|
|
1175
|
+
...(FIXTURES_DOC_PATH ? [FIXTURES_DOC_PATH] : []),
|
|
1176
|
+
...listTextFilesRecursive(join(repoRoot, 'examples'), new Set(['.md', 'package.json'])),
|
|
1177
|
+
...(existsSync(securityDir)
|
|
1178
|
+
? readdirSync(securityDir).filter((f) => f.endsWith('.md') || f.endsWith('.yaml')).map((f) => join(securityDir, f))
|
|
1179
|
+
: []),
|
|
1180
|
+
...(V1_DIR !== null ? ((v1Dir: string) => readdirSync(v1Dir).filter((f) => f.endsWith('.md')).map((f) => join(v1Dir, f)))(V1_DIR) : []),
|
|
1181
|
+
...readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.json')).map((f) => join(SCHEMAS_DIR, f)),
|
|
1182
|
+
].filter((path) => existsSync(path));
|
|
1160
1183
|
|
|
1161
1184
|
const banned = [
|
|
1162
1185
|
{ label: 'private workflow-runtime paths', pattern: /services\/workflow-runtime/ },
|
|
@@ -1231,7 +1254,12 @@ describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog
|
|
|
1231
1254
|
/^\|[^\n]*(PROPOSED|impl pending)[^\n]*\n/gm,
|
|
1232
1255
|
'',
|
|
1233
1256
|
);
|
|
1234
|
-
|
|
1257
|
+
// Match `conformance-<id>` only at a real fixture-id boundary —
|
|
1258
|
+
// require the preceding character to NOT be `[a-z0-9-]`, so that
|
|
1259
|
+
// longer strings like `openwop-conformance-canary-secret` do NOT
|
|
1260
|
+
// false-match `conformance-canary-secret` as a fixture id. The
|
|
1261
|
+
// negative lookbehind keeps the regex JS-compatible.
|
|
1262
|
+
const idRegex = /(?<![a-z0-9-])conformance-[a-z][a-z0-9-]*\b/g;
|
|
1235
1263
|
const cited = new Set<string>();
|
|
1236
1264
|
let m: RegExpExecArray | null;
|
|
1237
1265
|
while ((m = idRegex.exec(docWithoutProposed)) !== null) {
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
*
|
|
26
26
|
* @see lib/multiProcess.ts — spawnHost helper
|
|
27
27
|
* @see examples/hosts/sqlite/src/server.ts — heartbeat + resume
|
|
28
|
+
* @see spec/v1/production-profile.md §Durability (RFC 0009 — this
|
|
29
|
+
* scenario satisfies the durable-restart predicate when the
|
|
30
|
+
* host advertises capabilities.production.supported: true)
|
|
28
31
|
*/
|
|
29
32
|
|
|
30
33
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RFC 0008 §Conformance — scenario 6/6: ABI version mismatch.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* version is not in the host's advertised `abiVersions[]`. The host's
|
|
6
|
-
* loader MUST surface a recognizable `unsupported_abi_version` error
|
|
7
|
-
* (or equivalent) and MUST NOT silently dispatch to the pack's
|
|
8
|
-
* `openwop_node_invoke`.
|
|
4
|
+
* Two-part scenario per RFCS/0008-wasm-abi.md §H (ABI version handshake):
|
|
9
5
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* 1. **Discovery shape (observational, always-on)** — host advertises
|
|
7
|
+
* `capabilities.nodePackRuntimes.wasm.abiVersions[]` as a non-empty
|
|
8
|
+
* array of positive integers including v1.
|
|
9
|
+
* 2. **Positive path (loadedPacks-gated)** — when the host advertises
|
|
10
|
+
* `capabilities.nodePackRuntimes.wasm.loadedPacks[]` AND lists the
|
|
11
|
+
* well-behaved reference pack (proving the loader pipeline runs),
|
|
12
|
+
* the misbehaving-abi pack at `examples/packs/rust-misbehaving-abi/`
|
|
13
|
+
* — which declares `openwop_abi_version() == 999` — MUST NOT
|
|
14
|
+
* appear in `loadedPacks[]`. ABI 999 is outside any host's
|
|
15
|
+
* `abiVersions[]`, so a conformant host MUST reject the pack at
|
|
16
|
+
* load time per RFC 0008 §H.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
18
|
+
* The `loadedPacks[]` discovery field is the wire-observable signal:
|
|
19
|
+
* load-time rejection happens before any node-invoke surface, so the
|
|
20
|
+
* conformance suite needs a discovery-level affordance to verify the
|
|
21
|
+
* rejection took effect. Hosts that don't advertise `loadedPacks[]`
|
|
22
|
+
* soft-skip the positive path.
|
|
23
|
+
*
|
|
24
|
+
* @see RFCS/0008-wasm-abi.md §H (ABI version handshake)
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
import { describe, it, expect } from 'vitest';
|
|
19
28
|
import { driver } from '../lib/driver.js';
|
|
20
29
|
|
|
30
|
+
const MISBEHAVING_PACK_NAME = 'vendor.openwop.misbehaving-abi';
|
|
31
|
+
const WELL_BEHAVED_PACK_NAME = 'vendor.openwop.rust-hello';
|
|
32
|
+
|
|
21
33
|
describe('wasm-pack-abi-version-rejection: host advertises supported ABI versions', () => {
|
|
22
34
|
it('abiVersions[] contains positive integers; loader rejects unsupported versions', async () => {
|
|
23
35
|
const disco = await driver.get('/.well-known/openwop');
|
|
@@ -45,3 +57,48 @@ describe('wasm-pack-abi-version-rejection: host advertises supported ABI version
|
|
|
45
57
|
}
|
|
46
58
|
});
|
|
47
59
|
});
|
|
60
|
+
|
|
61
|
+
describe('wasm-pack-abi-version-rejection: positive path via misbehaving pack', () => {
|
|
62
|
+
it('misbehaving-abi pack (declares ABI 999) MUST NOT appear in loadedPacks[]', async () => {
|
|
63
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
64
|
+
const wasm =
|
|
65
|
+
(disco.json as {
|
|
66
|
+
capabilities?: {
|
|
67
|
+
nodePackRuntimes?: {
|
|
68
|
+
wasm?: { supported?: boolean; loadedPacks?: unknown };
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
}).capabilities?.nodePackRuntimes?.wasm;
|
|
72
|
+
|
|
73
|
+
if (!wasm?.supported) return;
|
|
74
|
+
|
|
75
|
+
if (!Array.isArray(wasm.loadedPacks)) {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.warn(
|
|
78
|
+
'[wasm-pack-abi-version-rejection] host does not advertise capabilities.nodePackRuntimes.wasm.loadedPacks[]; skipping positive path. ' +
|
|
79
|
+
'Hosts MAY advertise loadedPacks[] to surface ABI rejection observably (RFC 0008 §H + Track 7).',
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Soft-skip when the well-behaved reference pack isn't loaded. If
|
|
85
|
+
// loadedPacks[] doesn't include rust-hello, we can't distinguish
|
|
86
|
+
// "the loader pipeline runs but rejected misbehaving-abi" from
|
|
87
|
+
// "the loader doesn't run at all on this host."
|
|
88
|
+
if (!wasm.loadedPacks.includes(WELL_BEHAVED_PACK_NAME)) {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn(
|
|
91
|
+
`[wasm-pack-abi-version-rejection] reference pack ${WELL_BEHAVED_PACK_NAME} not in loadedPacks[]; ` +
|
|
92
|
+
'skipping positive path (build examples/packs/rust-hello first or run against a host that ships it).',
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Core assertion: ABI 999 pack MUST NOT be in loadedPacks[]
|
|
98
|
+
// regardless of filesystem state. The host's loader rejected it.
|
|
99
|
+
expect(wasm.loadedPacks.includes(MISBEHAVING_PACK_NAME), driver.describe(
|
|
100
|
+
'RFCS/0008-wasm-abi.md §H',
|
|
101
|
+
`host MUST reject packs whose declared ABI is not in abiVersions[]; ${MISBEHAVING_PACK_NAME} declares 999 and MUST NOT appear in loadedPacks[]`,
|
|
102
|
+
)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|