@devosurf/tesser-sdk 0.1.0-alpha.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/LICENSE +202 -0
- package/README.md +36 -0
- package/llms.txt +90 -0
- package/package.json +23 -0
- package/src/automation.ts +258 -0
- package/src/connector/index.ts +529 -0
- package/src/errors.ts +46 -0
- package/src/events.ts +25 -0
- package/src/harnesses.ts +76 -0
- package/src/index.ts +75 -0
- package/src/internal/client.ts +66 -0
- package/src/internal/codec.ts +135 -0
- package/src/internal/duration.ts +57 -0
- package/src/internal/harnesses.ts +46 -0
- package/src/internal/http.ts +179 -0
- package/src/internal/index.ts +51 -0
- package/src/internal/manifest.ts +398 -0
- package/src/internal/operators.ts +287 -0
- package/src/internal/retry.ts +57 -0
- package/src/internal/standard-schema.ts +92 -0
- package/src/internal/webhook-verify.ts +49 -0
- package/src/operators.ts +181 -0
- package/src/triggers.ts +79 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @devosurf/tesser-sdk — the authoring surface. One durable-function primitive; durability comes
|
|
2
|
+
// ONLY from ctx.step() (ADR-0002). Deliberately small and stable: absorb churn in the
|
|
3
|
+
// runtime, never here. Connector AUTHORING lives at @devosurf/tesser-sdk/connector.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
defineAutomation,
|
|
7
|
+
secret,
|
|
8
|
+
type AutomationDef,
|
|
9
|
+
type Ctx,
|
|
10
|
+
type Schema,
|
|
11
|
+
type Serializable,
|
|
12
|
+
type Connector,
|
|
13
|
+
type ClientOf,
|
|
14
|
+
type SecretDef,
|
|
15
|
+
type ConnectionMap,
|
|
16
|
+
type SecretMap,
|
|
17
|
+
type Connections,
|
|
18
|
+
type Secrets,
|
|
19
|
+
type RetryPolicy,
|
|
20
|
+
type StepOpts,
|
|
21
|
+
type Logger,
|
|
22
|
+
type WebhookRequest,
|
|
23
|
+
} from "./automation.js";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
defineOperator,
|
|
27
|
+
model,
|
|
28
|
+
type AutomationBudget,
|
|
29
|
+
type ModelBudget,
|
|
30
|
+
type ModelDef,
|
|
31
|
+
type ModelMap,
|
|
32
|
+
type ModelSettingsV1,
|
|
33
|
+
type ModelUsage,
|
|
34
|
+
type NormalizedModelRequest,
|
|
35
|
+
type NormalizedModelResponse,
|
|
36
|
+
type ModelMessage,
|
|
37
|
+
type ModelToolCall,
|
|
38
|
+
type ModelToolDescriptor,
|
|
39
|
+
type OperatorDef,
|
|
40
|
+
type OperatorMap,
|
|
41
|
+
type OperatorInput,
|
|
42
|
+
type OperatorOutput,
|
|
43
|
+
type Operators,
|
|
44
|
+
} from "./operators.js";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
harness,
|
|
48
|
+
type HarnessArtifact,
|
|
49
|
+
type HarnessBudget,
|
|
50
|
+
type HarnessDef,
|
|
51
|
+
type HarnessMap,
|
|
52
|
+
type HarnessRunRequest,
|
|
53
|
+
type HarnessRunResult,
|
|
54
|
+
type Harnesses,
|
|
55
|
+
} from "./harnesses.js";
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
onWebhook,
|
|
59
|
+
onSchedule,
|
|
60
|
+
onEvent,
|
|
61
|
+
type Trigger,
|
|
62
|
+
type WebhookTrigger,
|
|
63
|
+
type ScheduleTrigger,
|
|
64
|
+
type EventTrigger,
|
|
65
|
+
type ConnectorTrigger,
|
|
66
|
+
} from "./triggers.js";
|
|
67
|
+
|
|
68
|
+
export { defineEvent, type EventDefinition } from "./events.js";
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
RetryableError,
|
|
72
|
+
TerminalError,
|
|
73
|
+
isRetryableError,
|
|
74
|
+
isTerminalError,
|
|
75
|
+
} from "./errors.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Builds the typed client tree handed to automations as ctx.connections.<name> and runs a
|
|
2
|
+
// single Action with input/output validation. The runtime (server: broker-authed http;
|
|
3
|
+
// testing: mocks/cassettes) supplies the invoke/ctx — the SDK owns the walking and the
|
|
4
|
+
// validation so both sides behave identically.
|
|
5
|
+
|
|
6
|
+
import type { ActionCtx, ActionsTree, AnyAction, ConnectorInstance, TriggersDecl } from "../connector/index.js";
|
|
7
|
+
import { isAction } from "../connector/index.js";
|
|
8
|
+
import { validateSchema } from "./standard-schema.js";
|
|
9
|
+
|
|
10
|
+
export type InvokeAction = (
|
|
11
|
+
actionPath: string[],
|
|
12
|
+
def: AnyAction,
|
|
13
|
+
input: unknown,
|
|
14
|
+
) => Promise<unknown>;
|
|
15
|
+
|
|
16
|
+
export function buildConnectorClient(
|
|
17
|
+
connector: ConnectorInstance<ActionsTree, TriggersDecl>,
|
|
18
|
+
invoke: InvokeAction,
|
|
19
|
+
): unknown {
|
|
20
|
+
function walk(tree: ActionsTree, path: string[]): Record<string, unknown> {
|
|
21
|
+
const node: Record<string, unknown> = {};
|
|
22
|
+
for (const [key, child] of Object.entries(tree)) {
|
|
23
|
+
const childPath = [...path, key];
|
|
24
|
+
if (isAction(child)) {
|
|
25
|
+
node[key] = (input: unknown) => invoke(childPath, child, input);
|
|
26
|
+
} else {
|
|
27
|
+
node[key] = walk(child as ActionsTree, childPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Object.freeze(node);
|
|
31
|
+
}
|
|
32
|
+
return walk(connector.__connector.actions ?? {}, []);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve an action by dotted path (used by webhook registrars and MCP exposure). */
|
|
36
|
+
export function actionAtPath(
|
|
37
|
+
connector: ConnectorInstance<ActionsTree, TriggersDecl>,
|
|
38
|
+
path: string[],
|
|
39
|
+
): AnyAction | undefined {
|
|
40
|
+
let node: ActionsTree | AnyAction | undefined = connector.__connector.actions ?? {};
|
|
41
|
+
for (const key of path) {
|
|
42
|
+
if (node === undefined || isAction(node)) return undefined;
|
|
43
|
+
node = (node as ActionsTree)[key];
|
|
44
|
+
}
|
|
45
|
+
return node !== undefined && isAction(node) ? node : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Validate input → run → validate OUR mapped output (never the raw provider response). */
|
|
49
|
+
export async function runAction(
|
|
50
|
+
def: AnyAction,
|
|
51
|
+
ctx: ActionCtx,
|
|
52
|
+
rawInput: unknown,
|
|
53
|
+
what: string,
|
|
54
|
+
): Promise<unknown> {
|
|
55
|
+
const input = await validateSchema(def.input, rawInput ?? {}, `${what} input`);
|
|
56
|
+
const result = await def.run(ctx, input);
|
|
57
|
+
return validateSchema(def.output, result, `${what} output`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Derived retry-safety (ADR-0012): reads retry; writes retry only with an idempotency
|
|
61
|
+
* key; explicit per-action override wins. */
|
|
62
|
+
export function isRetrySafe(def: AnyAction, connectorHasIdempotencyHeader: boolean): boolean {
|
|
63
|
+
if (def.retrySafe !== undefined) return def.retrySafe;
|
|
64
|
+
if (def.safety === "read") return true;
|
|
65
|
+
return connectorHasIdempotencyHeader;
|
|
66
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The structured codec carrying step results to/from the journal (ADR-0002).
|
|
2
|
+
// An invisible internal (ADR-0004): authors only ever see plain values round-trip.
|
|
3
|
+
//
|
|
4
|
+
// Wire shape is JSON. Non-JSON serializables are tagged envelopes: {"$t": kind, "v": ...}.
|
|
5
|
+
// A plain object that itself owns a "$t" key is wrapped as {"$t":"obj"} to stay unambiguous.
|
|
6
|
+
|
|
7
|
+
export type JsonValue =
|
|
8
|
+
| string
|
|
9
|
+
| number
|
|
10
|
+
| boolean
|
|
11
|
+
| null
|
|
12
|
+
| JsonValue[]
|
|
13
|
+
| { [key: string]: JsonValue };
|
|
14
|
+
|
|
15
|
+
export class NotSerializableError extends TypeError {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly path: string,
|
|
18
|
+
readonly kind: string,
|
|
19
|
+
) {
|
|
20
|
+
super(
|
|
21
|
+
`value at ${path} is not serializable (${kind}); step results are journaled and must be ` +
|
|
22
|
+
`plain data — string/number/boolean/null/bigint/Date/Array/Map/Set/Uint8Array/plain object`,
|
|
23
|
+
);
|
|
24
|
+
this.name = "NotSerializableError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function describe(value: unknown): string {
|
|
29
|
+
if (typeof value === "function") return "a function";
|
|
30
|
+
if (typeof value === "symbol") return "a symbol";
|
|
31
|
+
if (value instanceof Promise) return "a Promise — await it inside the step";
|
|
32
|
+
const proto = Object.getPrototypeOf(value);
|
|
33
|
+
const ctor = proto?.constructor?.name;
|
|
34
|
+
return ctor ? `a ${ctor} instance — a live handle` : "an exotic object";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function encodeJournal(value: unknown, path = "$"): JsonValue {
|
|
38
|
+
if (value === undefined) return { $t: "undef" };
|
|
39
|
+
if (value === null) return null;
|
|
40
|
+
const t = typeof value;
|
|
41
|
+
if (t === "string" || t === "boolean") return value as string | boolean;
|
|
42
|
+
if (t === "number") {
|
|
43
|
+
const n = value as number;
|
|
44
|
+
if (Number.isFinite(n)) return n;
|
|
45
|
+
return { $t: "num", v: String(n) }; // NaN / Infinity / -Infinity
|
|
46
|
+
}
|
|
47
|
+
if (t === "bigint") return { $t: "bigint", v: (value as bigint).toString() };
|
|
48
|
+
if (t === "function" || t === "symbol") throw new NotSerializableError(path, describe(value));
|
|
49
|
+
if (value instanceof Date) {
|
|
50
|
+
const ms = value.getTime();
|
|
51
|
+
return { $t: "date", v: Number.isNaN(ms) ? null : value.toISOString() };
|
|
52
|
+
}
|
|
53
|
+
if (value instanceof Map) {
|
|
54
|
+
return {
|
|
55
|
+
$t: "map",
|
|
56
|
+
v: [...value.entries()].map(([k, v], i) => [
|
|
57
|
+
encodeJournal(k, `${path}.<key ${i}>`),
|
|
58
|
+
encodeJournal(v, `${path}.<entry ${i}>`),
|
|
59
|
+
]) as JsonValue,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (value instanceof Set) {
|
|
63
|
+
return {
|
|
64
|
+
$t: "set",
|
|
65
|
+
v: [...value.values()].map((v, i) => encodeJournal(v, `${path}.<item ${i}>`)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (value instanceof Uint8Array) {
|
|
69
|
+
return { $t: "bytes", v: Buffer.from(value).toString("base64") };
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return value.map((v, i) => encodeJournal(v, `${path}[${i}]`));
|
|
73
|
+
}
|
|
74
|
+
if (t === "object") {
|
|
75
|
+
const proto = Object.getPrototypeOf(value);
|
|
76
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
77
|
+
throw new NotSerializableError(path, describe(value));
|
|
78
|
+
}
|
|
79
|
+
const obj = value as Record<string, unknown>;
|
|
80
|
+
const out: Record<string, JsonValue> = {};
|
|
81
|
+
for (const key of Object.keys(obj)) {
|
|
82
|
+
out[key] = encodeJournal(obj[key], `${path}.${key}`);
|
|
83
|
+
}
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(obj, "$t")) return { $t: "obj", v: out };
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
throw new NotSerializableError(path, describe(value));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function decodeJournal(json: JsonValue): unknown {
|
|
91
|
+
if (json === null || typeof json !== "object") return json;
|
|
92
|
+
if (Array.isArray(json)) return json.map(decodeJournal);
|
|
93
|
+
const tag = (json as Record<string, JsonValue>)["$t"];
|
|
94
|
+
if (tag === undefined) {
|
|
95
|
+
const out: Record<string, unknown> = {};
|
|
96
|
+
for (const key of Object.keys(json)) out[key] = decodeJournal((json as never)[key]);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
const v = (json as Record<string, JsonValue>)["v"];
|
|
100
|
+
switch (tag) {
|
|
101
|
+
case "undef":
|
|
102
|
+
return undefined;
|
|
103
|
+
case "num":
|
|
104
|
+
return Number(v);
|
|
105
|
+
case "bigint":
|
|
106
|
+
return BigInt(v as string);
|
|
107
|
+
case "date":
|
|
108
|
+
return v === null ? new Date(NaN) : new Date(v as string);
|
|
109
|
+
case "map":
|
|
110
|
+
return new Map((v as JsonValue[]).map((e) => {
|
|
111
|
+
const [k, val] = e as [JsonValue, JsonValue];
|
|
112
|
+
return [decodeJournal(k), decodeJournal(val)] as const;
|
|
113
|
+
}));
|
|
114
|
+
case "set":
|
|
115
|
+
return new Set((v as JsonValue[]).map(decodeJournal));
|
|
116
|
+
case "bytes":
|
|
117
|
+
return new Uint8Array(Buffer.from(v as string, "base64"));
|
|
118
|
+
case "obj": {
|
|
119
|
+
const out: Record<string, unknown> = {};
|
|
120
|
+
const inner = v as Record<string, JsonValue>;
|
|
121
|
+
for (const key of Object.keys(inner)) out[key] = decodeJournal(inner[key] as JsonValue);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
throw new TypeError(`journal codec: unknown tag "${String(tag)}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Stable JSON string for journal storage / content hashing (sorted object keys). */
|
|
130
|
+
export function stableStringify(json: JsonValue): string {
|
|
131
|
+
if (json === null || typeof json !== "object") return JSON.stringify(json);
|
|
132
|
+
if (Array.isArray(json)) return `[${json.map(stableStringify).join(",")}]`;
|
|
133
|
+
const keys = Object.keys(json).sort();
|
|
134
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((json as never)[k])}`).join(",")}}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Duration strings ("30s", "1h30m", "250ms", "2d") used across step timeouts, retry
|
|
2
|
+
// backoff, sleep, and trigger cadence. An invisible internal.
|
|
3
|
+
|
|
4
|
+
const UNIT_MS: Record<string, number> = {
|
|
5
|
+
ms: 1,
|
|
6
|
+
s: 1000,
|
|
7
|
+
m: 60_000,
|
|
8
|
+
h: 3_600_000,
|
|
9
|
+
d: 86_400_000,
|
|
10
|
+
w: 604_800_000,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const PART = /(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/g;
|
|
14
|
+
|
|
15
|
+
export function parseDuration(input: string | number, what = "duration"): number {
|
|
16
|
+
if (typeof input === "number") {
|
|
17
|
+
if (!Number.isFinite(input) || input < 0) {
|
|
18
|
+
throw new TypeError(`${what}: expected a non-negative number of ms, got ${input}`);
|
|
19
|
+
}
|
|
20
|
+
return input;
|
|
21
|
+
}
|
|
22
|
+
const s = input.trim();
|
|
23
|
+
let total = 0;
|
|
24
|
+
let matchedLen = 0;
|
|
25
|
+
for (const m of s.matchAll(PART)) {
|
|
26
|
+
total += Number(m[1]) * (UNIT_MS[m[2] as string] as number);
|
|
27
|
+
matchedLen += (m[0] as string).length;
|
|
28
|
+
}
|
|
29
|
+
if (matchedLen === 0 || matchedLen !== s.replace(/\s+/g, "").length) {
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
`${what}: "${input}" is not a duration — use forms like "250ms", "30s", "5m", "1h30m", "2d"`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return Math.round(total);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatDuration(ms: number): string {
|
|
38
|
+
if (ms < 1000) return `${ms}ms`;
|
|
39
|
+
const units: Array<[string, number]> = [
|
|
40
|
+
["w", UNIT_MS["w"] as number],
|
|
41
|
+
["d", UNIT_MS["d"] as number],
|
|
42
|
+
["h", UNIT_MS["h"] as number],
|
|
43
|
+
["m", UNIT_MS["m"] as number],
|
|
44
|
+
["s", 1000],
|
|
45
|
+
];
|
|
46
|
+
let rest = ms;
|
|
47
|
+
let out = "";
|
|
48
|
+
for (const [unit, size] of units) {
|
|
49
|
+
const n = Math.floor(rest / size);
|
|
50
|
+
if (n > 0) {
|
|
51
|
+
out += `${n}${unit}`;
|
|
52
|
+
rest -= n * size;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (rest > 0) out += `${rest}ms`;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { TerminalError } from "../errors.js";
|
|
2
|
+
import type { AutomationDef, Ctx, Serializable } from "../automation.js";
|
|
3
|
+
import type { HarnessDef, HarnessRunRequest, HarnessRunResult, Harnesses } from "../harnesses.js";
|
|
4
|
+
import { encodeJournal, decodeJournal } from "./codec.js";
|
|
5
|
+
import { validateSchema } from "./standard-schema.js";
|
|
6
|
+
|
|
7
|
+
export interface HarnessCallInfo {
|
|
8
|
+
automationId: string;
|
|
9
|
+
harnessKey: string;
|
|
10
|
+
harness: HarnessDef;
|
|
11
|
+
request: HarnessRunRequest<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type HarnessCaller = (info: HarnessCallInfo) => Promise<HarnessRunResult<unknown>>;
|
|
15
|
+
|
|
16
|
+
export function buildHarnesses(
|
|
17
|
+
def: AutomationDef<any, any, any, any, any, any, any>,
|
|
18
|
+
_ctx: Ctx<any, any, any, any>,
|
|
19
|
+
callHarness: HarnessCaller,
|
|
20
|
+
): Harnesses<any> {
|
|
21
|
+
const out: Record<string, { run: (request: HarnessRunRequest<unknown>) => Promise<HarnessRunResult<unknown>> }> = {};
|
|
22
|
+
const harnesses = (def.harnesses ?? {}) as Record<string, HarnessDef>;
|
|
23
|
+
for (const [harnessKey, h] of Object.entries(harnesses)) {
|
|
24
|
+
out[harnessKey] = {
|
|
25
|
+
run: async (request) => {
|
|
26
|
+
if (!request || typeof request.prompt !== "string" || request.prompt.length === 0) {
|
|
27
|
+
throw new TerminalError(`harness.${harnessKey}: prompt is required`);
|
|
28
|
+
}
|
|
29
|
+
if (!request.output) throw new TerminalError(`harness.${harnessKey}: output schema is required`);
|
|
30
|
+
const raw = await callHarness({ automationId: def.id, harnessKey, harness: h, request });
|
|
31
|
+
const serial = toSerializable(raw, `harness.${harnessKey} result`) as unknown as HarnessRunResult<unknown>;
|
|
32
|
+
const output = await validateSchema(request.output, serial.output, `harness.${harnessKey} output`);
|
|
33
|
+
return { ...serial, output: toSerializable(output, `harness.${harnessKey} output`) as never };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return Object.freeze(out) as unknown as Harnesses<any>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toSerializable(value: unknown, what: string): Serializable {
|
|
41
|
+
try {
|
|
42
|
+
return decodeJournal(encodeJournal(value)) as Serializable;
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new TerminalError(`${what} is not serializable`, { cause });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The pre-authed connector HTTP client (ADR-0012). The SDK owns the *mechanics* —
|
|
2
|
+
// URL/base-URL handling, JSON encoding, status→Retryable/Terminal classification,
|
|
3
|
+
// Retry-After, refresh-once on 401 — while the credential itself enters only through
|
|
4
|
+
// the runtime-supplied `applyAuth` hook at call time (the broker boundary, ADR-0004).
|
|
5
|
+
|
|
6
|
+
import { RetryableError, TerminalError } from "../errors.js";
|
|
7
|
+
|
|
8
|
+
export interface HttpRequestOptions {
|
|
9
|
+
method?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
/** JSON-encoded unless `body` is a string / Uint8Array / URLSearchParams / FormData. */
|
|
14
|
+
body?: unknown;
|
|
15
|
+
/** Return the raw Response instead of the parsed body. */
|
|
16
|
+
raw?: boolean;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProviderHttpError {
|
|
21
|
+
status: number;
|
|
22
|
+
bodySnippet: string;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Classification override, per action (ADR-0012). Return an Error to throw it as-is. */
|
|
27
|
+
export type ClassifyError = (
|
|
28
|
+
err: ProviderHttpError,
|
|
29
|
+
) => "retry" | "terminal" | Error | undefined;
|
|
30
|
+
|
|
31
|
+
export interface TesserHttpConfig {
|
|
32
|
+
baseUrl?: string;
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
defaultHeaders?: Record<string, string>;
|
|
35
|
+
/** Inject the credential into the outbound request. Supplied by the runtime, never by authors. */
|
|
36
|
+
applyAuth?: (req: { url: URL; headers: Headers }) => void | Promise<void>;
|
|
37
|
+
/** Broker refresh-once hook: called on 401; return true to retry the call once. */
|
|
38
|
+
onUnauthorized?: () => Promise<boolean>;
|
|
39
|
+
classifyError?: ClassifyError;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TesserHttp {
|
|
44
|
+
get(path: string, opts?: HttpRequestOptions): Promise<any>;
|
|
45
|
+
delete(path: string, opts?: HttpRequestOptions): Promise<any>;
|
|
46
|
+
post(path: string, body?: unknown, opts?: HttpRequestOptions): Promise<any>;
|
|
47
|
+
put(path: string, body?: unknown, opts?: HttpRequestOptions): Promise<any>;
|
|
48
|
+
patch(path: string, body?: unknown, opts?: HttpRequestOptions): Promise<any>;
|
|
49
|
+
request(opts: HttpRequestOptions & { path: string }): Promise<any>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseRetryAfter(value: string | null): number | undefined {
|
|
53
|
+
if (!value) return undefined;
|
|
54
|
+
const secs = Number(value);
|
|
55
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
|
56
|
+
const date = Date.parse(value);
|
|
57
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
62
|
+
|
|
63
|
+
export function classifyStatus(status: number): "retry" | "terminal" | "ok" | "unauthorized" {
|
|
64
|
+
if (status >= 200 && status < 300) return "ok";
|
|
65
|
+
if (status === 401) return "unauthorized";
|
|
66
|
+
if (RETRYABLE_STATUS.has(status) || status >= 500) return "retry";
|
|
67
|
+
return "terminal";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createHttpClient(config: TesserHttpConfig): TesserHttp {
|
|
71
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
72
|
+
|
|
73
|
+
function buildUrl(path: string, query?: HttpRequestOptions["query"]): URL {
|
|
74
|
+
let url: URL;
|
|
75
|
+
if (/^https?:\/\//.test(path)) {
|
|
76
|
+
url = new URL(path);
|
|
77
|
+
} else {
|
|
78
|
+
if (!config.baseUrl) {
|
|
79
|
+
throw new TerminalError(`http: relative path "${path}" but the connector has no baseUrl`);
|
|
80
|
+
}
|
|
81
|
+
const base = config.baseUrl.endsWith("/") ? config.baseUrl : config.baseUrl + "/";
|
|
82
|
+
url = new URL(path.startsWith("/") ? path.slice(1) : path, base);
|
|
83
|
+
}
|
|
84
|
+
if (query) {
|
|
85
|
+
for (const [k, v] of Object.entries(query)) {
|
|
86
|
+
if (v !== undefined) url.searchParams.set(k, String(v));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function exec(opts: HttpRequestOptions & { path: string }, isRetryAfter401 = false): Promise<any> {
|
|
93
|
+
const url = buildUrl(opts.path, opts.query);
|
|
94
|
+
const headers = new Headers(config.defaultHeaders);
|
|
95
|
+
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
96
|
+
if (opts.headers) for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
97
|
+
|
|
98
|
+
type FetchBody = NonNullable<Parameters<typeof fetch>[1]> extends { body?: infer B }
|
|
99
|
+
? B
|
|
100
|
+
: never;
|
|
101
|
+
let body: FetchBody | undefined;
|
|
102
|
+
if (opts.body !== undefined) {
|
|
103
|
+
if (
|
|
104
|
+
typeof opts.body === "string" ||
|
|
105
|
+
opts.body instanceof Uint8Array ||
|
|
106
|
+
opts.body instanceof URLSearchParams ||
|
|
107
|
+
(typeof FormData !== "undefined" && opts.body instanceof FormData)
|
|
108
|
+
) {
|
|
109
|
+
body = opts.body as FetchBody;
|
|
110
|
+
} else {
|
|
111
|
+
body = JSON.stringify(opts.body);
|
|
112
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await config.applyAuth?.({ url, headers });
|
|
117
|
+
|
|
118
|
+
const timeoutMs = opts.timeoutMs ?? config.timeoutMs ?? 30_000;
|
|
119
|
+
let res: Response;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetchImpl(url, {
|
|
122
|
+
method: opts.method ?? "GET",
|
|
123
|
+
headers,
|
|
124
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
125
|
+
...(body !== undefined ? { body } : {}),
|
|
126
|
+
});
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
throw new RetryableError(`http: network failure for ${opts.method ?? "GET"} ${url.host}${url.pathname}`, {
|
|
129
|
+
cause,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// raw mode: the caller owns status handling entirely (e.g. the generic http connector).
|
|
134
|
+
if (opts.raw) return res;
|
|
135
|
+
|
|
136
|
+
const kind = classifyStatus(res.status);
|
|
137
|
+
if (kind === "ok") {
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
if (text.length === 0) return null;
|
|
140
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
141
|
+
if (contentType.includes("json")) return JSON.parse(text);
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (kind === "unauthorized" && !isRetryAfter401 && config.onUnauthorized) {
|
|
150
|
+
const refreshed = await config.onUnauthorized();
|
|
151
|
+
if (refreshed) return exec(opts, true);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const bodySnippet = (await res.text().catch(() => "")).slice(0, 600);
|
|
155
|
+
const providerErr: ProviderHttpError = { status: res.status, bodySnippet, url: url.toString() };
|
|
156
|
+
const overridden = config.classifyError?.(providerErr);
|
|
157
|
+
if (overridden instanceof Error) throw overridden;
|
|
158
|
+
const finalKind = overridden ?? (kind === "unauthorized" ? "terminal" : kind);
|
|
159
|
+
|
|
160
|
+
const message = `provider responded ${res.status} for ${opts.method ?? "GET"} ${url.pathname}${
|
|
161
|
+
bodySnippet ? ` — ${bodySnippet}` : ""
|
|
162
|
+
}`;
|
|
163
|
+
if (finalKind === "retry") {
|
|
164
|
+
throw new RetryableError(message, {
|
|
165
|
+
retryAfterMs: parseRetryAfter(res.headers.get("retry-after")),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
throw new TerminalError(message);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
get: (path, opts) => exec({ ...opts, path, method: "GET" }),
|
|
173
|
+
delete: (path, opts) => exec({ ...opts, path, method: "DELETE" }),
|
|
174
|
+
post: (path, body, opts) => exec({ ...opts, path, body, method: "POST" }),
|
|
175
|
+
put: (path, body, opts) => exec({ ...opts, path, body, method: "PUT" }),
|
|
176
|
+
patch: (path, body, opts) => exec({ ...opts, path, body, method: "PATCH" }),
|
|
177
|
+
request: (opts) => exec(opts),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @devosurf/tesser-sdk/internal — machinery shared by the runtime, the testing harness, the CLI
|
|
2
|
+
// and codegen. NOT part of the authoring surface; absorb churn here, never in the facade.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
encodeJournal,
|
|
6
|
+
decodeJournal,
|
|
7
|
+
stableStringify,
|
|
8
|
+
NotSerializableError,
|
|
9
|
+
type JsonValue,
|
|
10
|
+
} from "./codec.js";
|
|
11
|
+
export { parseDuration, formatDuration } from "./duration.js";
|
|
12
|
+
export {
|
|
13
|
+
validateSchema,
|
|
14
|
+
isSchema,
|
|
15
|
+
SchemaValidationError,
|
|
16
|
+
type StandardSchemaV1,
|
|
17
|
+
type ValidationIssue,
|
|
18
|
+
} from "./standard-schema.js";
|
|
19
|
+
export {
|
|
20
|
+
createHttpClient,
|
|
21
|
+
classifyStatus,
|
|
22
|
+
type TesserHttp,
|
|
23
|
+
type TesserHttpConfig,
|
|
24
|
+
type HttpRequestOptions,
|
|
25
|
+
type ClassifyError,
|
|
26
|
+
type ProviderHttpError,
|
|
27
|
+
} from "./http.js";
|
|
28
|
+
export { verifyInboundEvent } from "./webhook-verify.js";
|
|
29
|
+
export {
|
|
30
|
+
resolveRetryPolicy,
|
|
31
|
+
nextRetryDelayMs,
|
|
32
|
+
DEFAULT_STEP_RETRY,
|
|
33
|
+
type ResolvedRetryPolicy,
|
|
34
|
+
} from "./retry.js";
|
|
35
|
+
export {
|
|
36
|
+
buildConnectorClient,
|
|
37
|
+
actionAtPath,
|
|
38
|
+
runAction,
|
|
39
|
+
isRetrySafe,
|
|
40
|
+
type InvokeAction,
|
|
41
|
+
} from "./client.js";
|
|
42
|
+
export {
|
|
43
|
+
extractAutomationManifest,
|
|
44
|
+
extractConnectorManifest,
|
|
45
|
+
ManifestError,
|
|
46
|
+
type AutomationManifest,
|
|
47
|
+
type ConnectorManifest,
|
|
48
|
+
type TriggerManifest,
|
|
49
|
+
} from "./manifest.js";
|
|
50
|
+
export { buildOperators, toolName, type ModelCaller, type ModelCallInfo } from "./operators.js";
|
|
51
|
+
export { buildHarnesses, type HarnessCaller, type HarnessCallInfo } from "./harnesses.js";
|