@openwop/openwop 1.0.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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Run-status + error-code helpers — additive utilities over the wire-format
3
+ * `RunStatus` union, HTTP error envelopes, and run-level error shape
4
+ * declared in `types.ts`.
5
+ *
6
+ * **Why these live here.** `types.ts` declares the canonical wire shapes
7
+ * (mirroring `api/openapi.yaml` + `schemas/run-snapshot.schema.json`).
8
+ * This module adds the constants + predicates SDK consumers need to act
9
+ * on those shapes without redefining them locally. Pulling them into the
10
+ * SDK makes the protocol vocabulary single-sourced for application,
11
+ * integration, and test code.
12
+ *
13
+ * **Forward-compatibility design.** The spec's `RunStatus` enum is
14
+ * intentionally narrower than what host engines may emit: the schema
15
+ * declares *"future statuses MAY be added; readers SHOULD treat unknown
16
+ * values as terminal-unknown rather than throw"*. So `isTerminalRunStatus`
17
+ * accepts `string` and uses a **negative-set** check — anything NOT in
18
+ * the small known-non-terminal set is treated as terminal. This keeps
19
+ * the helper correct against:
20
+ *
21
+ * - the canonical 8-member spec union (`pending` / `running` / `paused`
22
+ * / `waiting-approval` / `waiting-input` / `completed` / `failed` /
23
+ * `cancelled`)
24
+ * - host extensions like `'planned'`, `'executing'`, `'waiting-external'`,
25
+ * `'timed-out'`, `'interrupted'` which the OpenWOP engine emits
26
+ * - any future spec additions before the SDK ships an updated minor
27
+ *
28
+ * @module @openwop/openwop/run-helpers
29
+ */
30
+
31
+ import type { RunStatus } from './types.js';
32
+
33
+ // ─── Run statuses ────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Run statuses considered active — the run MAY still transition. A reader
37
+ * who does not recognize a status string MUST treat it as terminal-unknown
38
+ * (per the spec's forward-compat clause). This list is the narrow,
39
+ * spec-stable enumeration used to derive that decision.
40
+ */
41
+ export const ACTIVE_RUN_STATUSES = [
42
+ 'pending',
43
+ 'running',
44
+ 'paused',
45
+ 'waiting-approval',
46
+ 'waiting-input',
47
+ ] as const;
48
+
49
+ /**
50
+ * Spec-known terminal statuses. Hosts MAY emit additional terminal values
51
+ * (e.g., `'timed-out'`, `'interrupted'`); use {@link isTerminalRunStatus}
52
+ * for forward-compatible checks instead of literal-set membership.
53
+ */
54
+ export const TERMINAL_RUN_STATUSES = [
55
+ 'completed',
56
+ 'failed',
57
+ 'cancelled',
58
+ ] as const;
59
+
60
+ export type ActiveRunStatus = (typeof ACTIVE_RUN_STATUSES)[number];
61
+ export type TerminalRunStatus = (typeof TERMINAL_RUN_STATUSES)[number];
62
+
63
+ /**
64
+ * Returns true if the status indicates the run will not transition further.
65
+ *
66
+ * Implemented as a negative check against {@link ACTIVE_RUN_STATUSES}: any
67
+ * value NOT in the spec's known-active set is treated as terminal. This
68
+ * implements the schema's forward-compat clause — a host that emits a
69
+ * status the SDK doesn't know about is assumed to be reporting a terminal
70
+ * state, NOT that the run is still active. The alternative (positive check
71
+ * against {@link TERMINAL_RUN_STATUSES}) would loop polling forever on any
72
+ * unknown value, which is the documented worst-case the clause prevents.
73
+ *
74
+ * Accepts `string` (not just `RunStatus`) so callers can pass values from
75
+ * extended host emissions without casting.
76
+ */
77
+ export function isTerminalRunStatus(status: RunStatus | string): boolean {
78
+ return !(ACTIVE_RUN_STATUSES as readonly string[]).includes(status);
79
+ }
80
+
81
+ // ─── HTTP error-envelope codes ───────────────────────────────────────────
82
+
83
+ /**
84
+ * Canonical REST/MCP error-envelope codes from `auth.md`,
85
+ * `rest-endpoints.md`, and adjacent v1 specs. The HTTP envelope's `error`
86
+ * field remains string-typed for forward compatibility, but this list
87
+ * gives SDK consumers a stable set for common branching.
88
+ *
89
+ * Codes here describe request/transport failures. Run execution failures
90
+ * live in `RUN_ERROR_CODES` below as `RunSnapshot.error.code`.
91
+ */
92
+ export const HTTP_ERROR_CODES = [
93
+ // Auth / access
94
+ 'unauthenticated',
95
+ 'forbidden',
96
+ 'key_expired',
97
+ 'key_revoked',
98
+
99
+ // Request / routing
100
+ 'validation_error',
101
+ 'not_found',
102
+ 'rate_limited',
103
+
104
+ // Idempotency / run creation conflicts
105
+ 'run_already_active',
106
+ 'idempotency_in_flight',
107
+ 'idempotency_key_mismatch',
108
+
109
+ // Streaming / protocol negotiation
110
+ 'unsupported_stream_mode',
111
+ 'force_engine_version_forbidden',
112
+ 'mock_provider_forbidden',
113
+
114
+ // Capability / credential negotiation
115
+ 'capability_not_provided',
116
+ 'credential_required',
117
+ 'credential_forbidden',
118
+ 'credential_unavailable',
119
+
120
+ // HITL / interrupt callbacks
121
+ 'interrupt_not_found',
122
+ 'approval_token_invalid',
123
+ 'approval_token_expired',
124
+ 'approval_token_consumed',
125
+
126
+ // Generic server failure
127
+ 'internal_error',
128
+ ] as const;
129
+
130
+ export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
131
+
132
+ /**
133
+ * Type guard that narrows a string to a known canonical HTTP error code.
134
+ * Returns false for host extensions and future spec additions; callers
135
+ * should still render a fallback from the envelope's `message`.
136
+ */
137
+ export function isHttpErrorCode(value: unknown): value is HttpErrorCode {
138
+ return typeof value === 'string' && (HTTP_ERROR_CODES as readonly string[]).includes(value);
139
+ }
140
+
141
+ // ─── Run error codes ─────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Run-document error codes — stable identifiers used in
145
+ * `RunSnapshot.error.code` when a run reaches `failed`. These are distinct
146
+ * from the HTTP-level `ErrorEnvelope.error` codes above: a request can fail
147
+ * with `unauthenticated` before a run exists, while a run can fail later
148
+ * with `node_execution_failed` or `recursion_limit_exceeded`.
149
+ *
150
+ * Add a new code here when adding a new run failure mode that crosses the
151
+ * network boundary. Do NOT add codes for purely internal engine errors.
152
+ *
153
+ * **Drift risk note.** This list is canonical for SDK consumers but is
154
+ * NOT yet pinned by `conformance/src/scenarios/errors.test.ts` against
155
+ * host emission. A future conformance addition (tracked separately)
156
+ * SHOULD compare host-emitted error codes against this set and fail on
157
+ * unrecognized values. Until that lands, drift between this list + the
158
+ * engine's actual emission set is detectable only by manual review.
159
+ */
160
+ export const RUN_ERROR_CODES = [
161
+ // Authorization / access
162
+ 'auth_required',
163
+ 'forbidden',
164
+ 'workspace_not_found',
165
+
166
+ // Run-state conflicts
167
+ 'run_already_active',
168
+ 'run_not_found',
169
+ 'run_terminal',
170
+ 'engine_version_mismatch',
171
+
172
+ // Validation
173
+ 'invalid_workflow_definition',
174
+ 'invalid_trigger_input',
175
+ 'node_type_not_found',
176
+ 'config_validation_failed',
177
+
178
+ // Quota / budget
179
+ 'token_budget_exceeded',
180
+ 'concurrent_run_limit_reached',
181
+ 'rate_limited',
182
+
183
+ // Execution
184
+ 'node_timeout',
185
+ 'global_timeout',
186
+ 'node_execution_failed',
187
+ 'external_call_failed',
188
+ 'recursion_limit_exceeded',
189
+ 'capability_not_provided',
190
+
191
+ // Approval
192
+ 'approval_timeout',
193
+ 'approval_token_invalid',
194
+ 'approval_token_expired',
195
+ 'approval_token_consumed',
196
+
197
+ // Persistence
198
+ 'persistence_failed',
199
+ 'doc_budget_exceeded',
200
+ ] as const;
201
+
202
+ export type RunErrorCode = (typeof RUN_ERROR_CODES)[number];
203
+
204
+ /**
205
+ * Type guard that narrows a string to a known {@link RunErrorCode}.
206
+ * Returns false for unknown / malformed values rather than throwing —
207
+ * SDK consumers usually want to display a fallback for unknown codes
208
+ * rather than crash the render pipeline.
209
+ */
210
+ export function isRunErrorCode(value: unknown): value is RunErrorCode {
211
+ return typeof value === 'string' && (RUN_ERROR_CODES as readonly string[]).includes(value);
212
+ }
213
+
214
+ /**
215
+ * Run-level error shape — the structured `error` field on a `failed` run
216
+ * document. Distinct from the HTTP-level {@link import('./types.js').ErrorEnvelope}:
217
+ *
218
+ * - `RunError` lives on the run document; describes WHY the run failed.
219
+ * `code` is from the typed {@link RunErrorCode} vocabulary.
220
+ * - `ErrorEnvelope` lives on HTTP error responses; describes WHY the
221
+ * request failed. `error` is a free string code (often from
222
+ * {@link HTTP_ERROR_CODES}, but not type-pinned, to permit host
223
+ * extensions and future protocol additions).
224
+ *
225
+ * The shapes share `message` and `details` but diverge on the code field
226
+ * name (`code` vs `error`) by design — they represent different layers.
227
+ */
228
+ export interface RunError {
229
+ code: RunErrorCode;
230
+ message: string;
231
+ /** Optional node-id where the error originated. */
232
+ nodeId?: string;
233
+ /** Optional structured context — implementations MAY extend. */
234
+ details?: Record<string, unknown>;
235
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * SSE consumer for `GET /v1/runs/{runId}/events`. Async-iterable shape so
3
+ * consumers can write `for await (const event of client.runs.events(...))`.
4
+ *
5
+ * Implementation parses event:/data:/id: lines per RFC 8895. Native fetch +
6
+ * ReadableStream — zero third-party deps.
7
+ *
8
+ * Designed to be cancellable: pass an AbortSignal via options, or break out
9
+ * of the for-await loop and the underlying connection is torn down on the
10
+ * next tick.
11
+ */
12
+
13
+ import type { RunEventDoc, StreamMode } from './types.js';
14
+
15
+ export interface EventsStreamOptions {
16
+ /**
17
+ * Single mode (e.g., 'updates') OR array of modes (S4 mixed-mode,
18
+ * e.g., ['updates', 'messages']). Arrays serialize to a comma-
19
+ * separated `?streamMode=updates,messages` query.
20
+ */
21
+ readonly streamMode?: StreamMode | readonly StreamMode[];
22
+ readonly lastEventId?: string;
23
+ readonly signal?: AbortSignal;
24
+ /**
25
+ * S3 batching hint. When set, server batches events for up to N ms;
26
+ * the SDK transparently flattens batched arrays back into individual
27
+ * RunEventDoc yields, so consumers see the same per-event surface as
28
+ * unbuffered streams. Range 0..5000.
29
+ */
30
+ readonly bufferMs?: number;
31
+ }
32
+
33
+ export interface EventsStreamContext {
34
+ readonly baseUrl: string;
35
+ readonly apiKey: string;
36
+ }
37
+
38
+ export async function* streamEvents(
39
+ ctx: EventsStreamContext,
40
+ runId: string,
41
+ opts: EventsStreamOptions = {},
42
+ ): AsyncGenerator<RunEventDoc, void, void> {
43
+ const params = new URLSearchParams();
44
+ if (opts.streamMode) {
45
+ const modeParam: string =
46
+ typeof opts.streamMode === 'string' ? opts.streamMode : opts.streamMode.join(',');
47
+ params.set('streamMode', modeParam);
48
+ }
49
+ if (opts.bufferMs !== undefined) {
50
+ params.set('bufferMs', String(opts.bufferMs));
51
+ }
52
+ const qs = params.toString();
53
+ const url = `${ctx.baseUrl}/v1/runs/${encodeURIComponent(runId)}/events${qs ? `?${qs}` : ''}`;
54
+
55
+ const headers: Record<string, string> = {
56
+ Accept: 'text/event-stream',
57
+ Authorization: `Bearer ${ctx.apiKey}`,
58
+ 'Cache-Control': 'no-cache',
59
+ };
60
+ if (opts.lastEventId) {
61
+ headers['Last-Event-ID'] = opts.lastEventId;
62
+ }
63
+
64
+ const internalAbort = new AbortController();
65
+ const externalSignal = opts.signal;
66
+ if (externalSignal) {
67
+ if (externalSignal.aborted) internalAbort.abort();
68
+ else externalSignal.addEventListener('abort', () => internalAbort.abort(), { once: true });
69
+ }
70
+
71
+ const res = await fetch(url, { method: 'GET', headers, signal: internalAbort.signal });
72
+ if (!res.ok || res.body === null) {
73
+ throw new Error(`SSE subscribe failed: HTTP ${res.status}`);
74
+ }
75
+
76
+ const reader = res.body.getReader();
77
+ const decoder = new TextDecoder('utf-8');
78
+ let buffer = '';
79
+ let pendingEvent = 'message';
80
+ let pendingData: string[] = [];
81
+ let pendingId: string | null = null;
82
+
83
+ /**
84
+ * Flush the buffered event. Returns an array of RunEventDoc:
85
+ * - 0 elements when the buffer is empty or non-JSON (skip).
86
+ * - 1 element for a normal `event: <type>` event.
87
+ * - N elements when the server batched per S3 — `event: batch` with
88
+ * `data:` as a JSON array of RunEventDoc.
89
+ */
90
+ const flushAndYield = (): RunEventDoc[] => {
91
+ if (pendingData.length === 0) {
92
+ pendingEvent = 'message';
93
+ pendingId = null;
94
+ return [];
95
+ }
96
+ const dataStr = pendingData.join('\n');
97
+ const eventType = pendingEvent;
98
+ pendingEvent = 'message';
99
+ pendingData = [];
100
+ pendingId = null;
101
+ try {
102
+ const parsed = JSON.parse(dataStr) as unknown;
103
+ // S3 batched envelope — `event: batch` carries an array of events.
104
+ if (eventType === 'batch' && Array.isArray(parsed)) {
105
+ return parsed as RunEventDoc[];
106
+ }
107
+ // Normal single-event payload.
108
+ return [parsed as RunEventDoc];
109
+ } catch {
110
+ // Skip non-JSON events (keep-alive payloads, vendor extensions).
111
+ return [];
112
+ }
113
+ };
114
+
115
+ try {
116
+ while (true) {
117
+ const { done, value } = await reader.read();
118
+ if (done) break;
119
+ buffer += decoder.decode(value, { stream: true });
120
+ let nlIdx: number;
121
+ while ((nlIdx = buffer.indexOf('\n')) !== -1) {
122
+ const rawLine = buffer.slice(0, nlIdx).replace(/\r$/, '');
123
+ buffer = buffer.slice(nlIdx + 1);
124
+
125
+ if (rawLine === '') {
126
+ for (const event of flushAndYield()) yield event;
127
+ continue;
128
+ }
129
+ if (rawLine.startsWith(':')) continue; // keep-alive comment
130
+
131
+ const colon = rawLine.indexOf(':');
132
+ const field = colon === -1 ? rawLine : rawLine.slice(0, colon);
133
+ const valueRaw = colon === -1 ? '' : rawLine.slice(colon + 1);
134
+ const fieldValue = valueRaw.startsWith(' ') ? valueRaw.slice(1) : valueRaw;
135
+
136
+ switch (field) {
137
+ case 'event':
138
+ pendingEvent = fieldValue;
139
+ break;
140
+ case 'data':
141
+ pendingData.push(fieldValue);
142
+ break;
143
+ case 'id':
144
+ pendingId = fieldValue;
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ // Flush any final unterminated event.
152
+ for (const final of flushAndYield()) yield final;
153
+ } finally {
154
+ try {
155
+ reader.releaseLock();
156
+ } catch {
157
+ // best-effort
158
+ }
159
+ if (!internalAbort.signal.aborted) internalAbort.abort();
160
+ }
161
+
162
+ // Reference pendingEvent/pendingId so the linter doesn't flag them as
163
+ // unused; they're consumed via flushAndYield's closure but TS can't see
164
+ // that across a generator boundary.
165
+ void pendingEvent;
166
+ void pendingId;
167
+ }
package/src/types.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Request/response types for the openwop REST surface.
3
+ *
4
+ * Mirrors `api/openapi.yaml` and the JSON Schemas in
5
+ * `schemas/`. Hand-authored rather than codegen'd — see
6
+ * SDK README §rationale.
7
+ *
8
+ * Forward-compat: types use `string` (not narrow unions) for fields whose
9
+ * spec'd values may grow over time (status enums, event types, error codes).
10
+ * Consumers wanting exhaustive narrowing should `as const` their checks
11
+ * rather than relying on the SDK to refuse unknown values.
12
+ */
13
+
14
+ /** Run statuses per `RunSnapshot.status` in OpenAPI. */
15
+ export type RunStatus =
16
+ | 'pending'
17
+ | 'running'
18
+ | 'paused'
19
+ | 'waiting-approval'
20
+ | 'waiting-input'
21
+ | 'completed'
22
+ | 'failed'
23
+ | 'cancelled';
24
+
25
+ export interface Capabilities {
26
+ protocolVersion: string;
27
+ supportedEnvelopes: readonly string[];
28
+ schemaVersions: Record<string, number>;
29
+ limits: {
30
+ clarificationRounds: number;
31
+ schemaRounds: number;
32
+ envelopesPerTurn: number;
33
+ maxNodeExecutions?: number;
34
+ };
35
+ extensions?: Record<string, unknown>;
36
+ // Network-handshake superset (all `(future)` fields per capabilities.md)
37
+ implementation?: { name?: string; version?: string; vendor?: string };
38
+ engineVersion?: number;
39
+ eventLogSchemaVersion?: number;
40
+ supportedTransports?: readonly ('rest' | 'mcp' | 'a2a' | 'grpc')[];
41
+ configurable?: Record<string, unknown>;
42
+ observability?: Record<string, unknown>;
43
+ minClientVersion?: string;
44
+ }
45
+
46
+ export interface RunSnapshot {
47
+ runId: string;
48
+ workflowId: string;
49
+ status: RunStatus;
50
+ currentNodeId?: string;
51
+ startedAt?: string;
52
+ completedAt?: string;
53
+ nodeStates?: Record<string, unknown>;
54
+ variables?: Record<string, unknown>;
55
+ channels?: Record<string, unknown>;
56
+ error?: { code?: string; message?: string };
57
+ }
58
+
59
+ /**
60
+ * Per-run parameter overlay carried in `RunOptions.configurable`. Reserved
61
+ * keys are typed; unknown keys are passed through verbatim. See
62
+ * `run-options.md`.
63
+ */
64
+ export interface RunConfigurable {
65
+ /** Override the per-run node-execution ceiling. Clamped server-side. */
66
+ recursionLimit?: number;
67
+ /** Override AI model for nodes that consume `ctx.config.configurable.model`. */
68
+ model?: string;
69
+ /** Override AI temperature (server SHOULD enforce 0..2). */
70
+ temperature?: number;
71
+ /** Override AI max-tokens cap. */
72
+ maxTokens?: number;
73
+ /** Per-prompt-ID variant override map. */
74
+ promptOverrides?: Record<string, string>;
75
+ /** Implementation-specific extensions; passed through verbatim. */
76
+ [key: string]: unknown;
77
+ }
78
+
79
+ export interface CreateRunRequest {
80
+ workflowId: string;
81
+ inputs?: Record<string, unknown>;
82
+ tenantId?: string;
83
+ scopeId?: string;
84
+ callbackUrl?: string;
85
+ configurable?: RunConfigurable;
86
+ tags?: readonly string[];
87
+ metadata?: Record<string, unknown>;
88
+ }
89
+
90
+ export interface CreateRunResponse {
91
+ runId: string;
92
+ status: RunStatus;
93
+ eventsUrl: string;
94
+ statusUrl?: string;
95
+ }
96
+
97
+ export interface CancelRunRequest {
98
+ reason?: string;
99
+ }
100
+
101
+ export interface CancelRunResponse {
102
+ runId: string;
103
+ status: 'cancelled' | 'cancelling';
104
+ }
105
+
106
+ export interface ForkRunRequest {
107
+ fromSeq: number;
108
+ mode: 'replay' | 'branch';
109
+ runOptionsOverlay?: Record<string, unknown>;
110
+ }
111
+
112
+ export interface ForkRunResponse {
113
+ runId: string;
114
+ sourceRunId: string;
115
+ fromSeq?: number;
116
+ mode: 'replay' | 'branch';
117
+ status: RunStatus;
118
+ eventsUrl: string;
119
+ }
120
+
121
+ export interface ResolveInterruptRequest {
122
+ resumeValue: unknown;
123
+ }
124
+
125
+ export interface ResolveInterruptResponse {
126
+ runId: string;
127
+ nodeId: string;
128
+ status: RunStatus;
129
+ }
130
+
131
+ /**
132
+ * Token-scoped interrupt inspection response — mirrors `suspend-request.schema.json`
133
+ * (the `InterruptPayload` shape).
134
+ */
135
+ export interface InterruptByTokenInspection {
136
+ kind: 'approval' | 'clarification' | 'external-event' | 'custom';
137
+ key: string;
138
+ resumeSchema?: Record<string, unknown>;
139
+ timeoutMs?: number;
140
+ data: unknown;
141
+ }
142
+
143
+ export interface ResolveInterruptByTokenResponse {
144
+ // Server-defined shape (openapi declares `type: object`); kept as
145
+ // unknown-typed object so SDK consumers narrow per implementation.
146
+ [key: string]: unknown;
147
+ }
148
+
149
+ export interface PollEventsResponse {
150
+ events: readonly RunEventDoc[];
151
+ isComplete: boolean;
152
+ }
153
+
154
+ /** Mirror of `run-event.schema.json` — top-level shape only. */
155
+ export interface RunEventDoc {
156
+ eventId: string;
157
+ runId: string;
158
+ nodeId?: string;
159
+ type: string; // RunEventType — string-typed for forward compat
160
+ payload: unknown;
161
+ timestamp: string;
162
+ sequence: number;
163
+ schemaVersion?: number;
164
+ engineVersion?: string;
165
+ causationId?: string;
166
+ }
167
+
168
+ export interface ErrorEnvelope {
169
+ error: string;
170
+ message: string;
171
+ details?: Record<string, unknown>;
172
+ }
173
+
174
+ export type StreamMode = 'values' | 'updates' | 'messages' | 'debug';
175
+
176
+ /**
177
+ * Thrown when the server returns a non-2xx response. Carries the original
178
+ * status, parsed error envelope (if available), the raw response text,
179
+ * and any `traceparent` the server returned (per
180
+ * `observability.md` §Trace context propagation —
181
+ * "Clients SHOULD display the trace ID in error messages so operators
182
+ * can search backend traces").
183
+ */
184
+ export class WopError extends Error {
185
+ readonly status: number;
186
+ readonly envelope: ErrorEnvelope | undefined;
187
+ readonly rawText: string;
188
+ /** W3C `traceparent` from the response headers, when present. */
189
+ readonly traceparent: string | undefined;
190
+ /** 32-hex-char trace ID extracted from `traceparent`, when parseable. */
191
+ readonly traceId: string | undefined;
192
+
193
+ constructor(
194
+ status: number,
195
+ rawText: string,
196
+ envelope: ErrorEnvelope | undefined,
197
+ traceparent: string | undefined,
198
+ ) {
199
+ const traceId = traceparent ? extractTraceId(traceparent) : undefined;
200
+ const baseMessage = envelope?.message ?? `openwop request failed: HTTP ${status}`;
201
+ const messageWithTrace = traceId ? `${baseMessage} (trace=${traceId})` : baseMessage;
202
+ super(messageWithTrace);
203
+ this.name = 'WopError';
204
+ this.status = status;
205
+ this.rawText = rawText;
206
+ this.envelope = envelope;
207
+ this.traceparent = traceparent;
208
+ this.traceId = traceId;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Extract the 32-hex trace ID from a W3C traceparent header. Format:
214
+ * `00-<32-hex>-<16-hex>-<2-hex>`. Returns undefined for malformed
215
+ * input — never throws (errors during error construction would be
216
+ * truly miserable).
217
+ */
218
+ function extractTraceId(traceparent: string): string | undefined {
219
+ const parts = traceparent.split('-');
220
+ if (parts.length < 3) return undefined;
221
+ const traceId = parts[1];
222
+ if (!traceId || !/^[0-9a-f]{32}$/i.test(traceId)) return undefined;
223
+ return traceId;
224
+ }