@elysra/tungsten-runtime 0.0.0-bootstrap.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,301 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Classification of HTTP responses and lost requests into results and
4
+ * diagnostic envelopes (planning/06 "Diagnostic error envelope",
5
+ * "Unknown outcome", planning/04 "Remediation table resolution").
6
+ */
7
+ import { categoryForStatus, diagnostic, GENERIC_REMEDIATION, isCategory, isRetryable } from "./envelope.js";
8
+ import { hasReplayProtection } from "./idempotency.js";
9
+ import { getPath, isRecord } from "./util.js";
10
+ /** Headers that carry a request id, in lookup order. */
11
+ const REQUEST_ID_HEADERS = ["x-request-id", "request-id", "x-correlation-id", "x-amzn-requestid", "cf-ray"];
12
+ export function requestIdOf(headers) {
13
+ for (const name of REQUEST_ID_HEADERS) {
14
+ const value = headers[name];
15
+ if (value)
16
+ return value.slice(0, 200);
17
+ }
18
+ return null;
19
+ }
20
+ /** The media type without parameters, lowercased; "" when absent. */
21
+ export function mediaTypeOf(headers) {
22
+ return (headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
23
+ }
24
+ export function isJsonMedia(media) {
25
+ return media === "application/json" || media.endsWith("+json") || media === "text/json";
26
+ }
27
+ const JSONL_MEDIA = new Set(["application/jsonl", "application/x-jsonl", "application/ndjson", "application/x-ndjson"]);
28
+ /** JSON Lines media types: one JSON value per line. */
29
+ export function isJsonlMedia(media) {
30
+ return JSONL_MEDIA.has(media);
31
+ }
32
+ /** The response descriptor matching a status: exact, then `NXX`, then `default`. */
33
+ export function matchResponse(responses, status) {
34
+ const list = Array.isArray(responses) ? responses.filter(isRecord) : [];
35
+ return (list.find((r) => r.status === status) ??
36
+ list.find((r) => typeof r.status === "string" && r.status === `${Math.floor(status / 100)}XX`) ??
37
+ list.find((r) => r.status === "default"));
38
+ }
39
+ export function decodeBody(bytes, headers, declared) {
40
+ const announced = mediaTypeOf(headers);
41
+ const media = announced || (declared ?? "").toLowerCase();
42
+ const text = () => new TextDecoder().decode(bytes);
43
+ // An empty body is no body, unless the server says it is JSON Lines: then it is no lines.
44
+ if (bytes.byteLength === 0 && !isJsonlMedia(announced))
45
+ return { value: undefined, json: false, invalidJson: false, empty: true };
46
+ if (isJsonlMedia(announced || media.split(";")[0].trim())) {
47
+ const raw = text();
48
+ const values = [];
49
+ const lines = raw.split("\n");
50
+ for (const [index, line] of lines.entries()) {
51
+ if (line.trim() === "")
52
+ continue;
53
+ try {
54
+ values.push(JSON.parse(line));
55
+ }
56
+ catch {
57
+ return { value: raw, json: false, invalidJson: true, empty: false, jsonl: true, badLine: index + 1 };
58
+ }
59
+ }
60
+ return { value: values, json: false, invalidJson: false, empty: false, jsonl: true };
61
+ }
62
+ if (isJsonMedia(media)) {
63
+ const raw = text();
64
+ try {
65
+ return { value: JSON.parse(raw), json: true, invalidJson: false, empty: false };
66
+ }
67
+ catch {
68
+ return { value: raw, json: false, invalidJson: true, empty: false };
69
+ }
70
+ }
71
+ if (media === "" || media.startsWith("text/") || media === "application/problem+xml" || media.endsWith("+xml") || media === "application/xml") {
72
+ const raw = text();
73
+ if (media === "" && /^\s*[[{]/.test(raw)) {
74
+ try {
75
+ return { value: JSON.parse(raw), json: true, invalidJson: false, empty: false };
76
+ }
77
+ catch {
78
+ // not JSON after all; keep the text
79
+ }
80
+ }
81
+ return { value: raw, json: false, invalidJson: false, empty: false };
82
+ }
83
+ return { value: bytes, json: false, invalidJson: false, empty: false };
84
+ }
85
+ const MESSAGE_FIELDS = ["message", "error.message", "detail", "error_description", "title", "error"];
86
+ function serverMessage(body) {
87
+ for (const field of MESSAGE_FIELDS) {
88
+ const value = getPath(body, field);
89
+ if (typeof value === "string" && value.trim() !== "")
90
+ return value.trim().slice(0, 200);
91
+ }
92
+ return null;
93
+ }
94
+ function errorCode(op, body) {
95
+ const field = op.errorCodeField;
96
+ if (!field)
97
+ return null;
98
+ const value = getPath(body, field);
99
+ if (typeof value === "string" && value !== "")
100
+ return value.slice(0, 200);
101
+ if (typeof value === "number" && Number.isFinite(value))
102
+ return String(value);
103
+ return null;
104
+ }
105
+ function remediationEntry(table, code) {
106
+ if (code === null || !isRecord(table))
107
+ return undefined;
108
+ const entry = Object.prototype.hasOwnProperty.call(table, code) ? table[code] : undefined;
109
+ return isRecord(entry) ? entry : undefined;
110
+ }
111
+ /** Whether the verification hook's arguments can be built without the
112
+ * call's response: after a lost or ambiguous answer there is no success
113
+ * body, so a hook whose args reference `$response` cannot be called. */
114
+ export function verifyCallable(verify) {
115
+ const refersToResponse = (node, depth) => {
116
+ if (depth > 32)
117
+ return true;
118
+ if (typeof node === "string")
119
+ return node === "$response" || node.startsWith("$response.");
120
+ if (Array.isArray(node))
121
+ return node.some((item) => refersToResponse(item, depth + 1));
122
+ if (isRecord(node))
123
+ return Object.values(node).some((item) => refersToResponse(item, depth + 1));
124
+ return false;
125
+ };
126
+ return isRecord(verify) && !refersToResponse(verify.args, 0);
127
+ }
128
+ /** What resolves an unknown outcome: the verification hook when it can be
129
+ * called without the lost response, else repeating the call under its
130
+ * replay protection (the same key, or the identical body), which answers
131
+ * the original result instead of applying the effect twice. Null when
132
+ * nothing can resolve it safely. */
133
+ function nextActionHint(ctx) {
134
+ const verify = ctx.op.agent.verify;
135
+ if (verify && typeof verify.operation === "string" && verifyCallable(verify)) {
136
+ const keys = isRecord(verify.args) ? Object.keys(verify.args) : [];
137
+ const withArgs = keys.length > 0 ? ` with ${keys.join(", ")}` : "";
138
+ return `Call ${verify.operation}${withArgs} to check whether ${ctx.op.id} took effect before doing anything else.`;
139
+ }
140
+ if (ctx.op.agent.idempotency.policy === "content_identity") {
141
+ return `Call ${ctx.op.id} again with the identical body; the body is its own identity, so the server answers the original result instead of applying it twice.`;
142
+ }
143
+ if (ctx.key !== null) {
144
+ return `Call ${ctx.op.id} again with the same ${ctx.keyHeader} value and identical arguments; the server answers the original result instead of applying it twice.`;
145
+ }
146
+ return null;
147
+ }
148
+ /** The change an operation makes, in words, for remediation text: its
149
+ * summary when it has one (`the change "Rotate an endpoint's signing
150
+ * secret"`). */
151
+ export function changeOf(op) {
152
+ const summary = typeof op.summary === "string" ? op.summary.trim().replace(/\.$/, "") : "";
153
+ return summary === "" ? `the change ${op.id} makes` : `the change ${JSON.stringify(summary)}`;
154
+ }
155
+ /** OUTCOME_UNKNOWN for a mutation whose effect cannot be known. Without
156
+ * replay protection (no key, no identity body) a repeat can apply the
157
+ * effect twice, so the envelope never offers one: `retryable` is
158
+ * `after_remediation` (check the state first), not `same_key_only`, and
159
+ * the remediation and `next_action` say what to check before repeating:
160
+ * `ctx.check` when the client found a read that shows it, else the
161
+ * operation's own change in words. */
162
+ export function outcomeUnknown(ctx, cause, fields = {}) {
163
+ const op = ctx.op;
164
+ let rule;
165
+ let hint = null;
166
+ if (op.agent.idempotency.policy === "content_identity") {
167
+ rule = "If you retry, resend the identical bytes only; the body is its own identity.";
168
+ }
169
+ else if (ctx.key !== null) {
170
+ rule = `If you retry, reuse the SAME ${ctx.keyHeader} value; a new key can apply the effect twice.`;
171
+ }
172
+ else if (ctx.check) {
173
+ // What to verify, specific to the operation: its verification hook, or
174
+ // a read of the resource it changes.
175
+ const { call, shows } = ctx.check;
176
+ rule = `This operation has no idempotency key, so repeating it can apply the effect twice. Before calling ${op.id} again, call ${call} and check ${shows}: if it does, ${op.id} took effect and must not be repeated; call it again only if it did not.`;
177
+ hint = `Call ${call} and check ${shows}; call ${op.id} again only if it did not take effect.`;
178
+ }
179
+ else {
180
+ const change = changeOf(op);
181
+ rule = `This operation has no idempotency key and no registered way to verify it, so ${op.id} may have taken effect and a repeat can apply the effect twice: do not retry it blindly. Check the outcome by other means if you can (look for ${change}); if you cannot, ask whoever owns the task before calling it again.`;
182
+ hint = `Do not retry ${op.id} blindly: it may have taken effect. Check for ${change} if you can; call ${op.id} again only if it is not there, or after the task owner accepts that it may apply twice.`;
183
+ }
184
+ return diagnostic(op.id, "OUTCOME_UNKNOWN", {
185
+ remediation: `${cause} The server may or may not have applied ${op.id}. ${rule}`,
186
+ retryable: hasReplayProtection(op, ctx.key) ? "same_key_only" : "after_remediation",
187
+ next_action: fields.next_action ?? hint ?? nextActionHint(ctx),
188
+ http_status: fields.http_status ?? null,
189
+ code: fields.code ?? null,
190
+ request_id: fields.request_id ?? null,
191
+ retry_after_ms: fields.retry_after_ms ?? null,
192
+ trace: { attempts: ctx.attempts },
193
+ });
194
+ }
195
+ /** Categories that say the request was not applied, or that a repeat is
196
+ * pointless: a manifest entry with one of them (or with `retryable`
197
+ * `never` / `after_remediation`) is a definite answer even for a status
198
+ * that is otherwise ambiguous. */
199
+ const DEFINITE_CATEGORIES = new Set([
200
+ "VALIDATION_FAILED",
201
+ "MALFORMED_REQUEST",
202
+ "REQUEST_TOO_LARGE",
203
+ "AUTH_FAILED",
204
+ "NOT_FOUND",
205
+ "CONFLICT",
206
+ "PRECONDITION_FAILED",
207
+ "RATE_LIMITED",
208
+ "GATE_DISABLED",
209
+ ]);
210
+ function definite(entry) {
211
+ if (!entry)
212
+ return false;
213
+ if (entry.retryable === "never" || entry.retryable === "after_remediation")
214
+ return true;
215
+ return typeof entry.category === "string" && DEFINITE_CATEGORIES.has(entry.category);
216
+ }
217
+ export function isMutation(op) {
218
+ return op.agent.safety !== "read_only";
219
+ }
220
+ /** The classification of an HTTP error status. */
221
+ export function classifyError(ctx, status, headers, decoded, retryAfterMs) {
222
+ const { api, op } = ctx;
223
+ const requestId = requestIdOf(headers);
224
+ const base = { http_status: status, request_id: requestId, retry_after_ms: retryAfterMs, trace: { attempts: ctx.attempts } };
225
+ const code = decoded.json ? errorCode(op, decoded.value) : null;
226
+ // A disabled gate answers its status without the API's error document
227
+ // (ZROtext: the route is not mounted, so axum's bare 404). An answer
228
+ // with an error code comes from the mounted route, so it is classified
229
+ // by its code like any other error (an unknown id is NOT_FOUND).
230
+ if (op.status.kind === "gated" && status === op.status.disabledStatus && code === null) {
231
+ const gates = isRecord(api.gates) ? api.gates : {};
232
+ const text = typeof gates[op.status.envVar] === "string" ? gates[op.status.envVar] : null;
233
+ return diagnostic(op.id, "GATE_DISABLED", {
234
+ ...base,
235
+ code,
236
+ remediation: text ??
237
+ `This deployment disables ${op.id} because ${op.status.envVar} is off (HTTP ${status}). It is a deployment setting, not a missing resource; do not retry.`,
238
+ retryable: "never",
239
+ });
240
+ }
241
+ const entry = remediationEntry(op.agent.remediation, code) ?? remediationEntry(api.errorCodes, code);
242
+ const media = decoded.empty ? "none" : mediaTypeOf(headers) || "none";
243
+ const nonJson = decoded.json
244
+ ? undefined
245
+ : (Array.isArray(api.nonJson) ? api.nonJson : []).find((n) => isRecord(n) && n.status === status && (n.media === media || (n.media === "none" && decoded.empty)));
246
+ const declared = matchResponse(op.responses, status);
247
+ // On a mutation, a status listed as ambiguous, a response declared
248
+ // ambiguous, and any 5xx leave the outcome unknown (a gateway's 502/504
249
+ // or an intermediary's 500 says nothing about whether the origin
250
+ // committed), unless the manifest's entry for the answer's code or
251
+ // non-JSON shape says it is a definite rejection (billing_pending,
252
+ // retryable: never).
253
+ const ambiguous = (Array.isArray(api.ambiguousStatuses) && api.ambiguousStatuses.includes(status)) ||
254
+ declared?.kind === "ambiguous" ||
255
+ (status >= 500 && status <= 599);
256
+ if (ambiguous && isMutation(op) && !definite(entry ?? (isRecord(nonJson) ? nonJson : undefined))) {
257
+ // The manifest's text for this answer still explains it.
258
+ const said = entry && typeof entry.text === "string" ? entry.text : nonJson && typeof nonJson.text === "string" ? nonJson.text : null;
259
+ return outcomeUnknown(ctx, `HTTP ${status} leaves the outcome of this call unknown.${said ? ` ${said}` : ""}`, {
260
+ ...base,
261
+ code,
262
+ next_action: typeof entry?.next_action === "string" ? entry.next_action : null,
263
+ });
264
+ }
265
+ let category;
266
+ let retryable;
267
+ let text = null;
268
+ let nextAction = null;
269
+ if (entry) {
270
+ category = isCategory(entry.category) ? entry.category : categoryForStatus(status, decoded.json);
271
+ retryable = isRetryable(entry.retryable) ? entry.retryable : undefined;
272
+ text = typeof entry.text === "string" ? entry.text : null;
273
+ nextAction = typeof entry.next_action === "string" ? entry.next_action : null;
274
+ }
275
+ else if (nonJson && isCategory(nonJson.category)) {
276
+ category = nonJson.category;
277
+ retryable = isRetryable(nonJson.retryable) ? nonJson.retryable : undefined;
278
+ text = typeof nonJson.text === "string" ? nonJson.text : null;
279
+ }
280
+ else {
281
+ category = categoryForStatus(status, decoded.json);
282
+ }
283
+ if (category === "OUTCOME_UNKNOWN" && !isMutation(op)) {
284
+ // A read has no effect whose outcome could be unknown: the answer only
285
+ // says the service did not respond in time.
286
+ category = "UPSTREAM_UNAVAILABLE";
287
+ if (retryable === undefined || retryable === "same_key_only")
288
+ retryable = "after_delay";
289
+ }
290
+ if (text === null) {
291
+ const said = decoded.json ? serverMessage(decoded.value) : null;
292
+ text = `${GENERIC_REMEDIATION[category]}${said ? ` Server message: ${JSON.stringify(said)}` : ""}`;
293
+ }
294
+ return diagnostic(op.id, category, {
295
+ ...base,
296
+ code,
297
+ remediation: text,
298
+ ...(retryable ? { retryable } : {}),
299
+ next_action: nextAction,
300
+ });
301
+ }
@@ -0,0 +1,49 @@
1
+ import { OAuthFlow } from "./oauth.js";
2
+ import { EventStream } from "./streams.js";
3
+ import type { ApiDescriptor, CallOptions, ClientCoreApi, ClientCoreExtensions, ClientOptions, MacroDescriptor, OperationDescriptor, Page, Predicate, PreviewResult, Result } from "./types.js";
4
+ /**
5
+ * The runtime core used by generated clients. One instance per client:
6
+ * it owns the idempotency store, the confirmation-token key, the OAuth2
7
+ * token cache and the registry of operations resolvable by id.
8
+ */
9
+ export declare class ClientCore implements ClientCoreApi, ClientCoreExtensions {
10
+ #private;
11
+ readonly api: ApiDescriptor;
12
+ readonly options: ClientOptions;
13
+ constructor(api: ApiDescriptor, options?: ClientOptions);
14
+ /** Make operations resolvable by id for verification hooks, endpoint
15
+ * previews and macro steps. Invalid entries are ignored. */
16
+ register(...ops: OperationDescriptor[]): void;
17
+ /** A registered operation by id. */
18
+ operation(id: string): OperationDescriptor | undefined;
19
+ /** Validate, authenticate, apply idempotency and confirmation rules,
20
+ * send with retries and classify the response. Never throws. */
21
+ call<T>(op: OperationDescriptor, args: Record<string, unknown>, opts?: CallOptions): Promise<Result<T>>;
22
+ /** Run the operation's preview mode: local rendering (no network), a
23
+ * dry-run header, or a preview endpoint. Mutating operations get a
24
+ * confirmation token bound to these exact arguments for five minutes. */
25
+ preview(op: OperationDescriptor, args: Record<string, unknown>, opts?: CallOptions): Promise<Result<PreviewResult>>;
26
+ /** Iterate pages (cursor, offset, page number or `Link` header); stops
27
+ * after the last page or after yielding the first error. */
28
+ pages<T>(op: OperationDescriptor, args: Record<string, unknown>, opts?: CallOptions): AsyncIterable<Result<Page<T>>>;
29
+ /** Iterate the events of the operation's event stream: one item per
30
+ * server-sent event, decoded from JSON, then the end of the stream or a
31
+ * final failure item. Never throws. */
32
+ stream<T>(op: OperationDescriptor, args: Record<string, unknown>, opts?: CallOptions): EventStream<T>;
33
+ /** Call a read operation until `until` holds on its body or the budget
34
+ * runs out (`timedOut: true` with the last result). */
35
+ poll<T>(op: OperationDescriptor, args: Record<string, unknown>, until: Predicate, intervalMs: number, budgetMs: number, opts?: CallOptions): Promise<Result<T> & {
36
+ timedOut?: boolean;
37
+ }>;
38
+ /** Run a compiled macro: steps in order, each step's success body bound
39
+ * to its `as` name; returns the evaluated output, or the first failing
40
+ * step's envelope (its remediation names the steps already completed). */
41
+ runMacro<T>(macro: MacroDescriptor, input: Record<string, unknown>, opts?: CallOptions): Promise<Result<T>>;
42
+ /** Preview a macro: no request is sent. Validates and renders the first
43
+ * step, lists the effects of every step, and returns a confirmation token
44
+ * bound to the macro and this exact input unless the macro is read-only. */
45
+ previewMacro(macro: MacroDescriptor, input: Record<string, unknown>, opts?: CallOptions): Promise<Result<PreviewResult>>;
46
+ /** The OAuth2 authorization-code helpers of the scheme `scheme`: PKCE, the
47
+ * authorization URL, the code exchange and refresh. */
48
+ oauth(scheme: string): OAuthFlow;
49
+ }