@elysra/tungsten-runtime 0.0.0-stage → 0.1.1

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,7 @@
1
+ /** Lifetime of a confirmation token. */
2
+ export declare const CONFIRMATION_TTL_MS: number;
3
+ /** SHA-256 of the canonical JSON of the arguments. */
4
+ export declare function argsDigest(args: unknown): Promise<string>;
5
+ export declare function issueToken(key: Uint8Array, operation: string, args: unknown, now: number): Promise<string>;
6
+ export type TokenCheck = "valid" | "expired" | "mismatch" | "malformed";
7
+ export declare function checkToken(key: Uint8Array, token: string, operation: string, args: unknown, now: number): Promise<TokenCheck>;
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Confirmation tokens (planning/06 "Preview and confirmation"):
4
+ * `tgc1.<expiry ms>.<base64url(HMAC-SHA-256(key, op id, args digest, expiry))>`.
5
+ * A token is valid only for the operation and the exact arguments it was
6
+ * issued for, until its expiry.
7
+ */
8
+ import { base64url, canonicalJson, hmacSha256, sha256Hex, timingSafeEqual } from "./util.js";
9
+ /** Lifetime of a confirmation token. */
10
+ export const CONFIRMATION_TTL_MS = 5 * 60 * 1000;
11
+ const PREFIX = "tgc1";
12
+ /** SHA-256 of the canonical JSON of the arguments. */
13
+ export async function argsDigest(args) {
14
+ return sha256Hex(canonicalJson(args));
15
+ }
16
+ async function signature(key, operation, digest, expiry) {
17
+ return base64url(await hmacSha256(key, `${operation}\n${digest}\n${expiry}`));
18
+ }
19
+ export async function issueToken(key, operation, args, now) {
20
+ const expiry = Math.floor(now) + CONFIRMATION_TTL_MS;
21
+ return `${PREFIX}.${expiry}.${await signature(key, operation, await argsDigest(args), expiry)}`;
22
+ }
23
+ export async function checkToken(key, token, operation, args, now) {
24
+ const match = /^tgc1\.(\d{1,16})\.([A-Za-z0-9_-]{43})$/.exec(token);
25
+ if (!match)
26
+ return "malformed";
27
+ const expiry = Number(match[1]);
28
+ const expected = await signature(key, operation, await argsDigest(args), expiry);
29
+ if (!timingSafeEqual(expected, match[2]))
30
+ return "mismatch";
31
+ return expiry > now ? "valid" : "expired";
32
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Identity helpers for generated code: they return their argument
3
+ * unchanged and exist so descriptor literals are checked against the
4
+ * contract types while keeping their literal types (`satisfies` without
5
+ * widening).
6
+ */
7
+ import type { ApiDescriptor, MacroDescriptor, OperationDescriptor } from "./types.js";
8
+ /** Declare one operation descriptor. */
9
+ export declare function defineOperation<const T extends OperationDescriptor>(op: T): T;
10
+ /** Declare the API descriptor of a client. */
11
+ export declare function defineApi<const T extends ApiDescriptor>(api: T): T;
12
+ /** Declare a compiled macro. */
13
+ export declare function defineMacro<const T extends MacroDescriptor>(macro: T): T;
package/dist/define.js ADDED
@@ -0,0 +1,12 @@
1
+ /** Declare one operation descriptor. */
2
+ export function defineOperation(op) {
3
+ return op;
4
+ }
5
+ /** Declare the API descriptor of a client. */
6
+ export function defineApi(api) {
7
+ return api;
8
+ }
9
+ /** Declare a compiled macro. */
10
+ export function defineMacro(macro) {
11
+ return macro;
12
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The diagnostic error envelope (planning/06): construction with stable
3
+ * field order, default `retryable` per category, status → category
4
+ * mapping and the generic remediation texts used when neither the
5
+ * operation nor the API has a specific entry.
6
+ */
7
+ import type { Category, Diagnostic, Retryable } from "./types.js";
8
+ /** Default `retryable` per category (planning/06 table). */
9
+ export declare const DEFAULT_RETRYABLE: Readonly<Record<Category, Retryable>>;
10
+ /** Remediation used when no manifest entry applies. */
11
+ export declare const GENERIC_REMEDIATION: Readonly<Record<Category, string>>;
12
+ export declare function isCategory(value: unknown): value is Category;
13
+ export declare function isRetryable(value: unknown): value is Retryable;
14
+ export type DiagnosticFields = Partial<Omit<Diagnostic, "status" | "category" | "operation">>;
15
+ /** Build an envelope with every field present, in the documented order. */
16
+ export declare function diagnostic(operation: string, category: Category, fields?: DiagnosticFields): Diagnostic;
17
+ /** Category of an HTTP error status when no manifest entry says otherwise. */
18
+ export declare function categoryForStatus(status: number, jsonBody: boolean): Category;
19
+ /** Replace every occurrence of the given secrets (four characters or
20
+ * more) in a text. */
21
+ export declare function scrubText(text: string, secrets: ReadonlySet<string>): string;
22
+ /** Replace every occurrence of the given secrets in the envelope's string
23
+ * fields. A last line of defence: no field is built from a secret. */
24
+ export declare function scrubDiagnostic(d: Diagnostic, secrets: ReadonlySet<string>): Diagnostic;
@@ -0,0 +1,152 @@
1
+ import { REDACTED } from "./util.js";
2
+ const CATEGORIES = [
3
+ "VALIDATION_FAILED",
4
+ "MALFORMED_REQUEST",
5
+ "REQUEST_TOO_LARGE",
6
+ "AUTH_FAILED",
7
+ "NOT_FOUND",
8
+ "CONFLICT",
9
+ "PRECONDITION_FAILED",
10
+ "RATE_LIMITED",
11
+ "UPSTREAM_UNAVAILABLE",
12
+ "OUTCOME_UNKNOWN",
13
+ "TRANSPORT_FAILED",
14
+ "CONFIRMATION_REQUIRED",
15
+ "GATE_DISABLED",
16
+ "UNEXPECTED_RESPONSE",
17
+ ];
18
+ const RETRYABLE_VALUES = ["never", "after_delay", "same_key_only", "after_remediation"];
19
+ /** Default `retryable` per category (planning/06 table). */
20
+ export const DEFAULT_RETRYABLE = {
21
+ VALIDATION_FAILED: "never",
22
+ MALFORMED_REQUEST: "never",
23
+ REQUEST_TOO_LARGE: "never",
24
+ AUTH_FAILED: "never",
25
+ NOT_FOUND: "never",
26
+ CONFLICT: "never",
27
+ PRECONDITION_FAILED: "never",
28
+ RATE_LIMITED: "after_delay",
29
+ UPSTREAM_UNAVAILABLE: "after_delay",
30
+ OUTCOME_UNKNOWN: "same_key_only",
31
+ TRANSPORT_FAILED: "after_delay",
32
+ CONFIRMATION_REQUIRED: "never",
33
+ GATE_DISABLED: "never",
34
+ UNEXPECTED_RESPONSE: "never",
35
+ };
36
+ /** Remediation used when no manifest entry applies. */
37
+ export const GENERIC_REMEDIATION = {
38
+ VALIDATION_FAILED: "The server rejected the arguments. Fix the parameter the API error names and call again; the same request fails the same way.",
39
+ MALFORMED_REQUEST: "The server could not parse the request. Check the format of path and query parameters (for example canonical UUIDs); do not retry unchanged.",
40
+ REQUEST_TOO_LARGE: "The request body is larger than the server accepts. Send a smaller body; do not retry unchanged.",
41
+ AUTH_FAILED: "The credential is missing, expired, revoked or lacks permission. Do not retry; fix the credential configured in ClientOptions.auth.",
42
+ NOT_FOUND: "The resource does not exist or is not visible to this credential. Check the identifiers; do not retry unchanged.",
43
+ CONFLICT: "The request conflicts with the resource's current state. Read the current state before deciding what to do; do not retry unchanged.",
44
+ PRECONDITION_FAILED: "A precondition of this operation is not met (account, billing or resource state). Resolve it first; do not retry unchanged.",
45
+ RATE_LIMITED: "Rate limited. Wait retry_after_ms (or a few seconds when it is null) before calling again.",
46
+ UPSTREAM_UNAVAILABLE: "The service is temporarily unavailable. Wait, then call again.",
47
+ OUTCOME_UNKNOWN: "The server may or may not have applied this call; do not retry it blindly. Check whether it took effect before doing anything else.",
48
+ TRANSPORT_FAILED: "The request could not be delivered (DNS, TLS or connection failure), so the server did not receive it. Check baseUrl and network access, then call again.",
49
+ CONFIRMATION_REQUIRED: "This operation needs confirmation: call preview(...) and pass its confirmation_token.",
50
+ GATE_DISABLED: "This operation is disabled on this deployment. It is a deployment setting; do not retry.",
51
+ UNEXPECTED_RESPONSE: "The server's response did not match the API description. Do not retry blindly; report it.",
52
+ };
53
+ export function isCategory(value) {
54
+ return typeof value === "string" && CATEGORIES.includes(value);
55
+ }
56
+ export function isRetryable(value) {
57
+ return typeof value === "string" && RETRYABLE_VALUES.includes(value);
58
+ }
59
+ /** Build an envelope with every field present, in the documented order. */
60
+ export function diagnostic(operation, category, fields = {}) {
61
+ return {
62
+ status: "error",
63
+ category,
64
+ operation,
65
+ http_status: fields.http_status ?? null,
66
+ code: fields.code ?? null,
67
+ failed_parameter: fields.failed_parameter ?? null,
68
+ received_value: fields.received_value === undefined ? null : fields.received_value,
69
+ expected: fields.expected ?? null,
70
+ remediation: fields.remediation ?? GENERIC_REMEDIATION[category],
71
+ retryable: fields.retryable ?? DEFAULT_RETRYABLE[category],
72
+ retry_after_ms: fields.retry_after_ms ?? null,
73
+ next_action: fields.next_action ?? null,
74
+ request_id: fields.request_id ?? null,
75
+ trace: { attempts: fields.trace?.attempts ?? 0 },
76
+ };
77
+ }
78
+ /** Category of an HTTP error status when no manifest entry says otherwise. */
79
+ export function categoryForStatus(status, jsonBody) {
80
+ switch (status) {
81
+ case 400:
82
+ return jsonBody ? "VALIDATION_FAILED" : "MALFORMED_REQUEST";
83
+ case 401:
84
+ case 403:
85
+ case 407:
86
+ return "AUTH_FAILED";
87
+ case 404:
88
+ case 410:
89
+ return "NOT_FOUND";
90
+ case 409:
91
+ return "CONFLICT";
92
+ case 402:
93
+ case 412:
94
+ case 428:
95
+ return "PRECONDITION_FAILED";
96
+ case 413:
97
+ return "REQUEST_TOO_LARGE";
98
+ case 422:
99
+ return "VALIDATION_FAILED";
100
+ case 429:
101
+ return "RATE_LIMITED";
102
+ case 408:
103
+ case 502:
104
+ case 503:
105
+ case 504:
106
+ return "UPSTREAM_UNAVAILABLE";
107
+ }
108
+ if (status >= 500 && status <= 599)
109
+ return "UPSTREAM_UNAVAILABLE";
110
+ if (status >= 400 && status <= 499)
111
+ return jsonBody ? "VALIDATION_FAILED" : "MALFORMED_REQUEST";
112
+ return "UNEXPECTED_RESPONSE";
113
+ }
114
+ /** Replace every occurrence of the given secrets (four characters or
115
+ * more) in a text. */
116
+ export function scrubText(text, secrets) {
117
+ let out = text;
118
+ for (const secret of secrets) {
119
+ if (secret.length >= 4 && out.includes(secret))
120
+ out = out.split(secret).join(REDACTED);
121
+ }
122
+ return out;
123
+ }
124
+ /** Replace every occurrence of the given secrets in the envelope's string
125
+ * fields. A last line of defence: no field is built from a secret. */
126
+ export function scrubDiagnostic(d, secrets) {
127
+ if (secrets.size === 0)
128
+ return d;
129
+ const scrub = (text) => scrubText(text, secrets);
130
+ const scrubValue = (value) => {
131
+ if (typeof value === "string")
132
+ return scrub(value);
133
+ if (value === null || typeof value !== "object")
134
+ return value;
135
+ try {
136
+ return JSON.parse(scrub(JSON.stringify(value)));
137
+ }
138
+ catch {
139
+ return REDACTED;
140
+ }
141
+ };
142
+ return {
143
+ ...d,
144
+ code: d.code === null ? null : scrub(d.code),
145
+ failed_parameter: d.failed_parameter === null ? null : scrub(d.failed_parameter),
146
+ received_value: scrubValue(d.received_value),
147
+ expected: d.expected === null ? null : scrub(d.expected),
148
+ remediation: scrub(d.remediation),
149
+ next_action: d.next_action === null ? null : scrub(d.next_action),
150
+ request_id: d.request_id === null ? null : scrub(d.request_id),
151
+ };
152
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Throwing ergonomics for people (planning/05: "a throwing client variant
3
+ * is exposed for human ergonomics, implemented in the runtime"). Agents
4
+ * should keep branching on `Result.ok`.
5
+ */
6
+ import type { Diagnostic, Result } from "./types.js";
7
+ /** An error carrying the diagnostic envelope of a failed call. */
8
+ export declare class TungstenError extends Error {
9
+ /** The envelope, exactly as the non-throwing API returns it. */
10
+ readonly diagnostic: Diagnostic;
11
+ /** The failed result's `partial`: what the call or macro already
12
+ * produced (it can hold values the API shows only once). */
13
+ readonly partial: unknown;
14
+ constructor(diagnostic: Diagnostic, partial?: unknown);
15
+ /** Serializes to the envelope, so logging the error logs the envelope
16
+ * (never `partial`, which can hold secrets). */
17
+ toJSON(): Diagnostic;
18
+ }
19
+ /** The value of a successful result; throws {@link TungstenError} otherwise. */
20
+ export declare function unwrap<T>(result: Result<T>): T;
21
+ type Unwrapped<R> = [R] extends [Result<infer V>] ? V : R;
22
+ type ThrowingReturn<R> = R extends Promise<infer P> ? Promise<Unwrapped<P>> : R extends AsyncIterable<infer Y> ? AsyncIterable<Unwrapped<Y>> : R;
23
+ type ThrowingCallable<T> = T extends (...args: infer A) => infer R ? (...args: A) => ThrowingReturn<R> : unknown;
24
+ /** `T` with every method returning `Promise<Result<V>>` turned into one
25
+ * returning `Promise<V>` (throwing {@link TungstenError}), and every
26
+ * `AsyncIterable<Result<V>>` into `AsyncIterable<V>`, recursively through
27
+ * nested resources. */
28
+ export type Throwing<T> = T extends object ? ThrowingCallable<T> & {
29
+ readonly [K in keyof T]: Throwing<T[K]>;
30
+ } : T;
31
+ /**
32
+ * A view of a generated client (or any object of resources) whose methods
33
+ * throw {@link TungstenError} instead of returning `{ok: false}`:
34
+ * `throwing(client).webhooks.list()` resolves to the body.
35
+ */
36
+ export declare function throwing<T extends object>(target: T): Throwing<T>;
37
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,75 @@
1
+ import { isRecord } from "./util.js";
2
+ /** An error carrying the diagnostic envelope of a failed call. */
3
+ export class TungstenError extends Error {
4
+ /** The envelope, exactly as the non-throwing API returns it. */
5
+ diagnostic;
6
+ /** The failed result's `partial`: what the call or macro already
7
+ * produced (it can hold values the API shows only once). */
8
+ partial;
9
+ constructor(diagnostic, partial) {
10
+ super(`${diagnostic.category} (${diagnostic.operation}): ${diagnostic.remediation}`);
11
+ this.name = "TungstenError";
12
+ this.diagnostic = diagnostic;
13
+ this.partial = partial;
14
+ }
15
+ /** Serializes to the envelope, so logging the error logs the envelope
16
+ * (never `partial`, which can hold secrets). */
17
+ toJSON() {
18
+ return this.diagnostic;
19
+ }
20
+ }
21
+ /** The value of a successful result; throws {@link TungstenError} otherwise. */
22
+ export function unwrap(result) {
23
+ if (result.ok)
24
+ return result.value;
25
+ throw new TungstenError(result.error, result.partial);
26
+ }
27
+ function isResult(value) {
28
+ return isRecord(value) && typeof value.ok === "boolean" && (value.ok ? "value" in value : isRecord(value.error));
29
+ }
30
+ function throwingIterable(iterable) {
31
+ return {
32
+ async *[Symbol.asyncIterator]() {
33
+ for await (const item of iterable)
34
+ yield isResult(item) ? unwrap(item) : item;
35
+ },
36
+ };
37
+ }
38
+ function convert(value) {
39
+ if (value instanceof Promise)
40
+ return value.then((resolved) => (isResult(resolved) ? unwrap(resolved) : resolved));
41
+ if (isRecord(value) && typeof value[Symbol.asyncIterator] === "function") {
42
+ return throwingIterable(value);
43
+ }
44
+ return value;
45
+ }
46
+ /**
47
+ * A view of a generated client (or any object of resources) whose methods
48
+ * throw {@link TungstenError} instead of returning `{ok: false}`:
49
+ * `throwing(client).webhooks.list()` resolves to the body.
50
+ */
51
+ export function throwing(target) {
52
+ const proxies = new WeakMap();
53
+ const originals = new WeakMap();
54
+ const wrap = (value) => {
55
+ const cached = proxies.get(value);
56
+ if (cached)
57
+ return cached;
58
+ const proxy = new Proxy(value, {
59
+ get(obj, prop, receiver) {
60
+ const inner = Reflect.get(obj, prop, receiver === proxy ? obj : receiver);
61
+ if (typeof inner === "function" && prop === "constructor")
62
+ return inner;
63
+ return (typeof inner === "object" && inner !== null) || typeof inner === "function" ? wrap(inner) : inner;
64
+ },
65
+ apply(fn, thisArg, args) {
66
+ const self = typeof thisArg === "object" && thisArg !== null ? (originals.get(thisArg) ?? thisArg) : thisArg;
67
+ return convert(Reflect.apply(fn, self, args));
68
+ },
69
+ });
70
+ proxies.set(value, proxy);
71
+ originals.set(proxy, value);
72
+ return proxy;
73
+ };
74
+ return wrap(target);
75
+ }
package/dist/expr.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** True when every entry of `predicate` holds on `value`. Malformed
2
+ * predicates never hold. */
3
+ export declare function evaluatePredicate(predicate: unknown, value: unknown): boolean;
4
+ /** Bindings an expression can reference, by name without `$`. */
5
+ export type Scope = Readonly<Record<string, unknown>>;
6
+ /** Resolve one `$name.path` reference; `undefined` when the name is unbound. */
7
+ export declare function resolveRef(ref: string, scope: Scope): unknown;
8
+ /** Evaluate an expression against `scope`. Unknown references are
9
+ * `undefined` (dropped from objects); malformed `expr` strings are `null`. */
10
+ export declare function evaluateExpr(expr: unknown, scope: Scope, depth?: number): unknown;
11
+ /** A JSON literal, also accepting single-quoted strings (`'a'`). */
12
+ /** The placeholder a dry evaluation shows for a result not produced yet. */
13
+ export declare function placeholder(name: string, path: string): string;
14
+ /** The placeholders in `value`, at any depth, in order of appearance. */
15
+ export declare function placeholdersIn(value: unknown, out?: string[], depth?: number): string[];
16
+ /** Whether `value` is a placeholder, or holds one at any depth. */
17
+ export declare function containsPlaceholder(value: unknown): boolean;
18
+ /** {@link evaluateExpr} for a dry run: references to the `pending` names
19
+ * (results of steps that have not run) become placeholders. */
20
+ export declare function evaluateDry(expr: unknown, scope: Scope, pending: ReadonlySet<string>, depth?: number): unknown;
21
+ /** A predicate in words, for remediation and preview text:
22
+ * `state in ["delivered", "failed"]`, `endpoints contains {...}`, `a = 1`,
23
+ * joined with "and"; "" for an empty predicate. */
24
+ export declare function describePredicate(predicate: unknown): string;
package/dist/expr.js ADDED
@@ -0,0 +1,198 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Predicates (verify `expect`/`terminal`, poll `until`) and the reference
4
+ * expressions used by verification argument mappings and macros.
5
+ *
6
+ * Predicate: field path → `{in: [...]}` | `{equals: x}` | `{contains: {...}}`;
7
+ * any other value is compared for equality. Every entry must hold.
8
+ *
9
+ * Expression: JSON in which a string starting with `$` is a reference
10
+ * resolved against a scope (`$input.a.b`, `$<name>.a`, `$response.x`,
11
+ * `$args.y`), an object `{expr: "<ref> in [..]" | "<ref> == x" | "<ref> != x"}`
12
+ * evaluates to a boolean, and anything else is a literal.
13
+ *
14
+ * A dry evaluation (macro previews) knows the input but not the results of
15
+ * the steps before: a reference to such a result evaluates to a placeholder
16
+ * string, `<from step NAME: path>` (`<from step NAME>` for the whole
17
+ * result), and so does an `expr` over one.
18
+ */
19
+ import { containsSubset, deepEqual, getPath, hasOwn, isRecord, splitPath } from "./util.js";
20
+ /** True when every entry of `predicate` holds on `value`. Malformed
21
+ * predicates never hold. */
22
+ export function evaluatePredicate(predicate, value) {
23
+ if (predicate === null || predicate === undefined)
24
+ return true;
25
+ if (!isRecord(predicate))
26
+ return false;
27
+ return Object.keys(predicate).every((path) => holds(predicate[path], getPath(value, path)));
28
+ }
29
+ function holds(test, actual) {
30
+ if (isRecord(test)) {
31
+ const keys = Object.keys(test);
32
+ if (keys.length === 1 && keys[0] === "in") {
33
+ return Array.isArray(test.in) && test.in.some((candidate) => deepEqual(candidate, actual));
34
+ }
35
+ if (keys.length === 1 && keys[0] === "equals")
36
+ return deepEqual(test.equals, actual);
37
+ if (keys.length === 1 && keys[0] === "contains") {
38
+ const pattern = test.contains;
39
+ if (typeof pattern === "string") {
40
+ return (typeof actual === "string" && actual.includes(pattern)) || (Array.isArray(actual) && actual.includes(pattern));
41
+ }
42
+ if (Array.isArray(actual) && !Array.isArray(pattern))
43
+ return actual.some((item) => containsSubset(item, pattern));
44
+ return containsSubset(actual, pattern);
45
+ }
46
+ }
47
+ return deepEqual(test, actual);
48
+ }
49
+ /** Resolve one `$name.path` reference; `undefined` when the name is unbound. */
50
+ export function resolveRef(ref, scope) {
51
+ const segments = splitPath(ref.slice(1));
52
+ const [name, ...rest] = segments;
53
+ if (name === undefined || !hasOwn(scope, name))
54
+ return undefined;
55
+ return getPath(scope[name], rest);
56
+ }
57
+ /** Evaluate an expression against `scope`. Unknown references are
58
+ * `undefined` (dropped from objects); malformed `expr` strings are `null`. */
59
+ export function evaluateExpr(expr, scope, depth = 0) {
60
+ if (depth > 64)
61
+ return null;
62
+ if (typeof expr === "string")
63
+ return expr.startsWith("$") ? resolveRef(expr, scope) : expr;
64
+ if (Array.isArray(expr))
65
+ return expr.map((item) => evaluateExpr(item, scope, depth + 1));
66
+ if (isRecord(expr)) {
67
+ const keys = Object.keys(expr);
68
+ if (keys.length === 1 && keys[0] === "expr" && typeof expr.expr === "string")
69
+ return evaluateBoolean(expr.expr, scope);
70
+ const out = {};
71
+ for (const key of keys) {
72
+ const value = evaluateExpr(expr[key], scope, depth + 1);
73
+ if (value !== undefined)
74
+ out[key] = value;
75
+ }
76
+ return out;
77
+ }
78
+ return expr;
79
+ }
80
+ /** A JSON literal, also accepting single-quoted strings (`'a'`). */
81
+ /** The placeholder a dry evaluation shows for a result not produced yet. */
82
+ export function placeholder(name, path) {
83
+ return path === "" ? `<from step ${name}>` : `<from step ${name}: ${path}>`;
84
+ }
85
+ const PLACEHOLDER = /^<from step [^<>]+>$/;
86
+ /** The placeholders in `value`, at any depth, in order of appearance. */
87
+ export function placeholdersIn(value, out = [], depth = 0) {
88
+ if (depth > 64)
89
+ return out;
90
+ if (typeof value === "string" && PLACEHOLDER.test(value))
91
+ out.push(value);
92
+ else if (Array.isArray(value))
93
+ for (const item of value)
94
+ placeholdersIn(item, out, depth + 1);
95
+ else if (isRecord(value))
96
+ for (const item of Object.values(value))
97
+ placeholdersIn(item, out, depth + 1);
98
+ return out;
99
+ }
100
+ /** Whether `value` is a placeholder, or holds one at any depth. */
101
+ export function containsPlaceholder(value) {
102
+ return placeholdersIn(value).length > 0;
103
+ }
104
+ /** The pending binding a `$name.path` reference names, with the path as
105
+ * written after the name; null when the name is not pending. */
106
+ function pendingRef(ref, pending) {
107
+ const name = splitPath(ref.slice(1))[0];
108
+ if (name === undefined || !pending.has(name) || !ref.startsWith(`$${name}`))
109
+ return null;
110
+ return { name, path: ref.slice(name.length + 1).replace(/^\./, "") };
111
+ }
112
+ /** {@link evaluateExpr} for a dry run: references to the `pending` names
113
+ * (results of steps that have not run) become placeholders. */
114
+ export function evaluateDry(expr, scope, pending, depth = 0) {
115
+ if (depth > 64)
116
+ return null;
117
+ if (typeof expr === "string") {
118
+ if (!expr.startsWith("$"))
119
+ return expr;
120
+ const ref = pendingRef(expr, pending);
121
+ return ref ? placeholder(ref.name, ref.path) : resolveRef(expr, scope);
122
+ }
123
+ if (Array.isArray(expr))
124
+ return expr.map((item) => evaluateDry(item, scope, pending, depth + 1));
125
+ if (isRecord(expr)) {
126
+ const keys = Object.keys(expr);
127
+ if (keys.length === 1 && keys[0] === "expr" && typeof expr.expr === "string") {
128
+ const source = expr.expr.trim();
129
+ const ref = pendingRef(/^\$[^\s=!]+/.exec(source)?.[0] ?? "", pending);
130
+ return ref ? placeholder(ref.name, source.slice(ref.name.length + 1).replace(/^\./, "")) : evaluateBoolean(source, scope);
131
+ }
132
+ const out = {};
133
+ for (const key of keys) {
134
+ const value = evaluateDry(expr[key], scope, pending, depth + 1);
135
+ if (value !== undefined)
136
+ out[key] = value;
137
+ }
138
+ return out;
139
+ }
140
+ return expr;
141
+ }
142
+ /** A predicate in words, for remediation and preview text:
143
+ * `state in ["delivered", "failed"]`, `endpoints contains {...}`, `a = 1`,
144
+ * joined with "and"; "" for an empty predicate. */
145
+ export function describePredicate(predicate) {
146
+ if (!isRecord(predicate))
147
+ return "";
148
+ const json = (value) => {
149
+ try {
150
+ return JSON.stringify(value) ?? String(value);
151
+ }
152
+ catch {
153
+ return String(value);
154
+ }
155
+ };
156
+ const parts = Object.keys(predicate).map((path) => {
157
+ const test = predicate[path];
158
+ if (isRecord(test) && Object.keys(test).length === 1) {
159
+ if (Array.isArray(test.in))
160
+ return `${path} in [${test.in.map(json).join(", ")}]`;
161
+ if ("equals" in test)
162
+ return `${path} = ${json(test.equals)}`;
163
+ if ("contains" in test)
164
+ return `${path} contains ${json(test.contains)}`;
165
+ }
166
+ return `${path} = ${json(test)}`;
167
+ });
168
+ return parts.join(" and ");
169
+ }
170
+ function parseLiteral(text) {
171
+ const trimmed = text.trim();
172
+ try {
173
+ return { ok: true, value: JSON.parse(trimmed) };
174
+ }
175
+ catch {
176
+ const normalized = trimmed.replace(/'((?:[^'\\]|\\.)*)'/g, (_m, inner) => JSON.stringify(inner.replace(/\\'/g, "'")));
177
+ try {
178
+ return { ok: true, value: JSON.parse(normalized) };
179
+ }
180
+ catch {
181
+ return { ok: false };
182
+ }
183
+ }
184
+ }
185
+ function evaluateBoolean(source, scope) {
186
+ const match = /^\s*(\$[^\s=!]+)\s+(in|==|!=)\s+([\s\S]+)$/.exec(source);
187
+ if (!match)
188
+ return null;
189
+ const [, ref, op, literalText] = match;
190
+ const literal = parseLiteral(literalText);
191
+ if (!literal.ok)
192
+ return null;
193
+ const actual = resolveRef(ref, scope);
194
+ if (op === "in")
195
+ return Array.isArray(literal.value) ? literal.value.some((v) => deepEqual(v, actual)) : null;
196
+ const equal = deepEqual(actual === undefined ? null : actual, literal.value);
197
+ return op === "==" ? equal : !equal;
198
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Idempotency (planning/04 "Idempotency policies", planning/06
3
+ * "Idempotency store"): the default in-memory store, key formats and the
4
+ * header an operation's key travels in.
5
+ */
6
+ import type { IdempotencyStore, OperationDescriptor } from "./types.js";
7
+ /**
8
+ * The default {@link IdempotencyStore}: keys live as long as the process.
9
+ * Use a persistent store (`FileIdempotencyStore` from
10
+ * `@elysra/tungsten-runtime/node`, or your own) when a key must survive a crash.
11
+ */
12
+ export declare class MemoryIdempotencyStore implements IdempotencyStore {
13
+ #private;
14
+ get(scope: string, logicalId: string): Promise<string | undefined>;
15
+ put(scope: string, logicalId: string, key: string): Promise<void>;
16
+ }
17
+ /** Human description of the key format a policy expects. */
18
+ export declare function keyFormatDescription(format: string | null): string;
19
+ /** Checks a caller-owned key against the policy's `format`. Returns the
20
+ * description of the expected format when the key does not match. */
21
+ export declare function checkKeyFormat(key: string, format: string | null): string | null;
22
+ /** The wire header of the operation's key: the policy's header, else the
23
+ * parameter with role `idempotency_key`, else `Idempotency-Key`. */
24
+ export declare function keyHeader(op: OperationDescriptor): string;
25
+ /** Whether the operation sends an idempotency key or an identity body, so
26
+ * resending it cannot apply its effect twice. */
27
+ export declare function hasReplayProtection(op: OperationDescriptor, key: string | null): boolean;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The default {@link IdempotencyStore}: keys live as long as the process.
3
+ * Use a persistent store (`FileIdempotencyStore` from
4
+ * `@elysra/tungsten-runtime/node`, or your own) when a key must survive a crash.
5
+ */
6
+ export class MemoryIdempotencyStore {
7
+ #keys = new Map();
8
+ async get(scope, logicalId) {
9
+ return this.#keys.get(`${scope}\u0000${logicalId}`);
10
+ }
11
+ async put(scope, logicalId, key) {
12
+ this.#keys.set(`${scope}\u0000${logicalId}`, key);
13
+ }
14
+ }
15
+ const UUID_ANY = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+ function formatKind(format) {
18
+ const f = (format ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
19
+ if (f === "uuidv4" || f === "uuid4")
20
+ return "uuid_v4";
21
+ return f === "uuid" ? "uuid" : "token";
22
+ }
23
+ /** Human description of the key format a policy expects. */
24
+ export function keyFormatDescription(format) {
25
+ switch (formatKind(format)) {
26
+ case "uuid_v4":
27
+ return "UUIDv4 string";
28
+ case "uuid":
29
+ return "UUID string";
30
+ case "token":
31
+ return "a non-empty printable ASCII string of at most 255 characters";
32
+ }
33
+ }
34
+ /** Checks a caller-owned key against the policy's `format`. Returns the
35
+ * description of the expected format when the key does not match. */
36
+ export function checkKeyFormat(key, format) {
37
+ const kind = formatKind(format);
38
+ const valid = kind === "uuid_v4" ? UUID_V4.test(key) : kind === "uuid" ? UUID_ANY.test(key) : key.length > 0 && key.length <= 255 && /^[\x21-\x7e]+$/.test(key);
39
+ return valid ? null : keyFormatDescription(format);
40
+ }
41
+ /** The wire header of the operation's key: the policy's header, else the
42
+ * parameter with role `idempotency_key`, else `Idempotency-Key`. */
43
+ export function keyHeader(op) {
44
+ const fromPolicy = op.agent.idempotency.header;
45
+ if (fromPolicy)
46
+ return fromPolicy;
47
+ const param = op.params.find((p) => p.role === "idempotency_key");
48
+ return param?.wire ?? "Idempotency-Key";
49
+ }
50
+ /** Whether the operation sends an idempotency key or an identity body, so
51
+ * resending it cannot apply its effect twice. */
52
+ export function hasReplayProtection(op, key) {
53
+ if (op.agent.idempotency.policy === "content_identity")
54
+ return true;
55
+ return key !== null;
56
+ }