@tuvl/client 0.0.1 → 2026.2.1-beta.2
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/README.md +384 -3
- package/dist/index.d.mts +569 -0
- package/dist/index.d.ts +569 -0
- package/dist/index.js +849 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +840 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -12
- package/index.js +0 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/grpc.ts
|
|
14
|
+
var grpc_exports = {};
|
|
15
|
+
__export(grpc_exports, {
|
|
16
|
+
openGrpcStream: () => openGrpcStream
|
|
17
|
+
});
|
|
18
|
+
async function* openGrpcStream(options) {
|
|
19
|
+
let GrpcWebFetchTransport;
|
|
20
|
+
try {
|
|
21
|
+
const mod = await import('@protobuf-ts/grpcweb-transport');
|
|
22
|
+
GrpcWebFetchTransport = mod.GrpcWebFetchTransport;
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"@tuvl/client: gRPC mode requires @protobuf-ts/grpcweb-transport. Install it with: npm i @protobuf-ts/grpcweb-transport @protobuf-ts/runtime-rpc"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const { BinaryWriter, BinaryReader, WireType } = await import('@protobuf-ts/runtime');
|
|
29
|
+
function encodeRunRequest(workflowName, payloadJson, tokenFallback) {
|
|
30
|
+
const writer = new BinaryWriter();
|
|
31
|
+
writer.tag(1, WireType.LengthDelimited).string(workflowName);
|
|
32
|
+
writer.tag(2, WireType.LengthDelimited).string(payloadJson);
|
|
33
|
+
if (tokenFallback) {
|
|
34
|
+
writer.tag(3, WireType.LengthDelimited).string(tokenFallback);
|
|
35
|
+
}
|
|
36
|
+
return writer.finish();
|
|
37
|
+
}
|
|
38
|
+
function decodeStepEvent(bytes) {
|
|
39
|
+
const reader = new BinaryReader(bytes);
|
|
40
|
+
let eventType = "";
|
|
41
|
+
let stepId = "";
|
|
42
|
+
let kind = "";
|
|
43
|
+
let signal = "";
|
|
44
|
+
let snapshotJson = "";
|
|
45
|
+
let durationMs = 0;
|
|
46
|
+
let errorDetail = "";
|
|
47
|
+
while (reader.pos < reader.len) {
|
|
48
|
+
const [fieldNo, wireType] = reader.tag();
|
|
49
|
+
switch (fieldNo) {
|
|
50
|
+
case 1:
|
|
51
|
+
eventType = reader.string();
|
|
52
|
+
break;
|
|
53
|
+
case 2:
|
|
54
|
+
stepId = reader.string();
|
|
55
|
+
break;
|
|
56
|
+
case 3:
|
|
57
|
+
kind = reader.string();
|
|
58
|
+
break;
|
|
59
|
+
case 4:
|
|
60
|
+
signal = reader.string();
|
|
61
|
+
break;
|
|
62
|
+
case 5:
|
|
63
|
+
snapshotJson = reader.string();
|
|
64
|
+
break;
|
|
65
|
+
case 6:
|
|
66
|
+
durationMs = reader.float();
|
|
67
|
+
break;
|
|
68
|
+
case 7:
|
|
69
|
+
errorDetail = reader.string();
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
reader.skip(wireType);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let snapshot = {};
|
|
76
|
+
if (snapshotJson) {
|
|
77
|
+
try {
|
|
78
|
+
snapshot = JSON.parse(snapshotJson);
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const et = eventType || "step";
|
|
83
|
+
return {
|
|
84
|
+
event_type: et,
|
|
85
|
+
step_id: stepId,
|
|
86
|
+
kind,
|
|
87
|
+
signal,
|
|
88
|
+
snapshot,
|
|
89
|
+
duration_ms: durationMs,
|
|
90
|
+
error_detail: errorDetail || void 0
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const transport = new GrpcWebFetchTransport({
|
|
94
|
+
baseUrl: options.baseUrl,
|
|
95
|
+
fetchInit: options.token ? { headers: { Authorization: `Bearer ${options.token}` } } : void 0
|
|
96
|
+
});
|
|
97
|
+
const methodDescriptor = {
|
|
98
|
+
name: "/tuvl.v1.ExecutionService/RunWorkflow",
|
|
99
|
+
clientStreaming: false,
|
|
100
|
+
serverStreaming: true,
|
|
101
|
+
I: { create: () => ({}) },
|
|
102
|
+
O: { create: () => ({}) },
|
|
103
|
+
options: {}
|
|
104
|
+
};
|
|
105
|
+
const requestBytes = encodeRunRequest(
|
|
106
|
+
options.workflowName,
|
|
107
|
+
options.payloadJson,
|
|
108
|
+
options.tokenFallback ?? ""
|
|
109
|
+
);
|
|
110
|
+
const call = transport.serverStreaming(methodDescriptor, requestBytes, {
|
|
111
|
+
abort: options.signal
|
|
112
|
+
});
|
|
113
|
+
for await (const responseBytes of call.responses) {
|
|
114
|
+
const event = decodeStepEvent(responseBytes);
|
|
115
|
+
yield event;
|
|
116
|
+
if (event.event_type === "done" || event.event_type === "error" || event.event_type === "suspended") {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
var init_grpc = __esm({
|
|
122
|
+
"src/grpc.ts"() {
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// src/crud.ts
|
|
127
|
+
var CrudClient = class {
|
|
128
|
+
constructor(transport, modelName) {
|
|
129
|
+
this.transport = transport;
|
|
130
|
+
this.basePath = `/models/${modelName.toLowerCase()}`;
|
|
131
|
+
}
|
|
132
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
133
|
+
/** Build query string from list options. */
|
|
134
|
+
_buildQuery(options) {
|
|
135
|
+
const params = new URLSearchParams();
|
|
136
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
137
|
+
if (options.offset !== void 0) params.set("offset", String(options.offset));
|
|
138
|
+
if (options.filters) {
|
|
139
|
+
for (const [key, value] of Object.entries(options.filters)) {
|
|
140
|
+
params.set(`filter[${key}]`, value);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (options.include && options.include.length > 0) {
|
|
144
|
+
params.set("include", options.include.join(","));
|
|
145
|
+
}
|
|
146
|
+
const qs = params.toString();
|
|
147
|
+
return qs ? `?${qs}` : "";
|
|
148
|
+
}
|
|
149
|
+
/** Build ?include= query string for get-by-id calls. */
|
|
150
|
+
_buildGetQuery(options) {
|
|
151
|
+
if (!options.include || options.include.length === 0) return "";
|
|
152
|
+
const params = new URLSearchParams({ include: options.include.join(",") });
|
|
153
|
+
return `?${params.toString()}`;
|
|
154
|
+
}
|
|
155
|
+
// ── Public CRUD methods ──────────────────────────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* GET /models/{model}/
|
|
158
|
+
* Returns all records matching the given filters (server default: up to 100).
|
|
159
|
+
*/
|
|
160
|
+
async list(options = {}) {
|
|
161
|
+
const qs = this._buildQuery(options);
|
|
162
|
+
return this.transport.get(`${this.basePath}/${qs}`, {
|
|
163
|
+
token: options.token,
|
|
164
|
+
signal: options.signal
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* GET /models/{model}/{id}
|
|
169
|
+
* Returns a single record by UUID, optionally with embedded relations.
|
|
170
|
+
*/
|
|
171
|
+
async get(id, options = {}) {
|
|
172
|
+
const qs = this._buildGetQuery(options);
|
|
173
|
+
return this.transport.get(`${this.basePath}/${encodeURIComponent(id)}${qs}`, {
|
|
174
|
+
token: options.token,
|
|
175
|
+
signal: options.signal
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* POST /models/{model}/
|
|
180
|
+
* Creates a new record and returns the created resource (HTTP 201).
|
|
181
|
+
*/
|
|
182
|
+
async create(body, options = {}) {
|
|
183
|
+
return this.transport.post(`${this.basePath}/`, body, {
|
|
184
|
+
token: options.token,
|
|
185
|
+
signal: options.signal
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* PATCH /models/{model}/{id}
|
|
190
|
+
* Partially updates a record (only fields present in `body` are changed).
|
|
191
|
+
*/
|
|
192
|
+
async update(id, body, options = {}) {
|
|
193
|
+
return this.transport.patch(
|
|
194
|
+
`${this.basePath}/${encodeURIComponent(id)}`,
|
|
195
|
+
body,
|
|
196
|
+
{ token: options.token, signal: options.signal }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* DELETE /models/{model}/{id}
|
|
201
|
+
* Deletes a record (HTTP 204). Throws if the record is not found.
|
|
202
|
+
*/
|
|
203
|
+
async delete(id, options = {}) {
|
|
204
|
+
return this.transport.delete(`${this.basePath}/${encodeURIComponent(id)}`, {
|
|
205
|
+
token: options.token,
|
|
206
|
+
signal: options.signal
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/sse.ts
|
|
212
|
+
async function* parseSseStream(response, signal) {
|
|
213
|
+
if (!response.body) {
|
|
214
|
+
throw new Error("SSE response has no body");
|
|
215
|
+
}
|
|
216
|
+
const reader = response.body.getReader();
|
|
217
|
+
const decoder = new TextDecoder("utf-8");
|
|
218
|
+
let buffer = "";
|
|
219
|
+
let currentEventType = "message";
|
|
220
|
+
let currentDataLines = [];
|
|
221
|
+
const flush = function* () {
|
|
222
|
+
const dataStr = currentDataLines.join("\n");
|
|
223
|
+
const eventType = currentEventType;
|
|
224
|
+
currentDataLines = [];
|
|
225
|
+
currentEventType = "message";
|
|
226
|
+
if (!dataStr) return;
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(dataStr);
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
234
|
+
const frame = { ...parsed, event_type: eventType };
|
|
235
|
+
yield frame;
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
while (true) {
|
|
239
|
+
if (signal?.aborted) break;
|
|
240
|
+
const { done, value } = await reader.read();
|
|
241
|
+
if (done) break;
|
|
242
|
+
buffer += decoder.decode(value, { stream: true });
|
|
243
|
+
const lines = buffer.split("\n");
|
|
244
|
+
buffer = lines.pop() ?? "";
|
|
245
|
+
for (const rawLine of lines) {
|
|
246
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
247
|
+
if (line.startsWith("event:")) {
|
|
248
|
+
currentEventType = line.slice(6).trim();
|
|
249
|
+
} else if (line.startsWith("data:")) {
|
|
250
|
+
currentDataLines.push(line.slice(5).trim());
|
|
251
|
+
} else if (line === "") {
|
|
252
|
+
for (const frame of flush()) {
|
|
253
|
+
yield frame;
|
|
254
|
+
if (frame.event_type === "done" || frame.event_type === "error" || frame.event_type === "suspended") {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const frame of flush()) {
|
|
262
|
+
yield frame;
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
reader.releaseLock();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/transport.ts
|
|
270
|
+
var Transport = class {
|
|
271
|
+
constructor(options) {
|
|
272
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
273
|
+
this.defaultToken = options.defaultToken;
|
|
274
|
+
}
|
|
275
|
+
/** Update the default token (e.g. after refresh). */
|
|
276
|
+
setToken(token) {
|
|
277
|
+
this.defaultToken = token;
|
|
278
|
+
}
|
|
279
|
+
/** Build the Authorization header value, or undefined if no token. */
|
|
280
|
+
authHeader(overrideToken) {
|
|
281
|
+
const tok = overrideToken ?? this.defaultToken;
|
|
282
|
+
return tok ? `Bearer ${tok}` : void 0;
|
|
283
|
+
}
|
|
284
|
+
/** Perform a plain JSON POST and return the parsed response body. */
|
|
285
|
+
async post(path, body, options) {
|
|
286
|
+
const headers = {
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
Accept: "application/json"
|
|
289
|
+
};
|
|
290
|
+
const auth = this.authHeader(options?.token);
|
|
291
|
+
if (auth) headers["Authorization"] = auth;
|
|
292
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers,
|
|
295
|
+
body: JSON.stringify(body),
|
|
296
|
+
signal: options?.signal
|
|
297
|
+
});
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
const text = await response.text();
|
|
300
|
+
throw new Error(`tuvl POST ${path} \u2192 HTTP ${response.status}: ${text}`);
|
|
301
|
+
}
|
|
302
|
+
return response.json();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Open an SSE-capable stream and return the raw Response.
|
|
306
|
+
* Callers are responsible for reading `response.body`.
|
|
307
|
+
*/
|
|
308
|
+
async postStream(path, body, options) {
|
|
309
|
+
const headers = {
|
|
310
|
+
"Content-Type": "application/json",
|
|
311
|
+
Accept: "text/event-stream",
|
|
312
|
+
"Cache-Control": "no-cache"
|
|
313
|
+
};
|
|
314
|
+
const auth = this.authHeader(options?.token);
|
|
315
|
+
if (auth) headers["Authorization"] = auth;
|
|
316
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers,
|
|
319
|
+
body: JSON.stringify(body),
|
|
320
|
+
signal: options?.signal
|
|
321
|
+
});
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
const text = await response.text();
|
|
324
|
+
throw new Error(`tuvl SSE ${path} \u2192 HTTP ${response.status}: ${text}`);
|
|
325
|
+
}
|
|
326
|
+
return response;
|
|
327
|
+
}
|
|
328
|
+
/** Simple GET returning parsed JSON. */
|
|
329
|
+
async get(path, options) {
|
|
330
|
+
const headers = { Accept: "application/json" };
|
|
331
|
+
const auth = this.authHeader(options?.token);
|
|
332
|
+
if (auth) headers["Authorization"] = auth;
|
|
333
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
334
|
+
method: "GET",
|
|
335
|
+
headers,
|
|
336
|
+
signal: options?.signal
|
|
337
|
+
});
|
|
338
|
+
if (!response.ok) {
|
|
339
|
+
const text = await response.text();
|
|
340
|
+
throw new Error(`tuvl GET ${path} \u2192 HTTP ${response.status}: ${text}`);
|
|
341
|
+
}
|
|
342
|
+
return response.json();
|
|
343
|
+
}
|
|
344
|
+
/** Perform a JSON PATCH and return the parsed response body. */
|
|
345
|
+
async patch(path, body, options) {
|
|
346
|
+
const headers = {
|
|
347
|
+
"Content-Type": "application/json",
|
|
348
|
+
Accept: "application/json"
|
|
349
|
+
};
|
|
350
|
+
const auth = this.authHeader(options?.token);
|
|
351
|
+
if (auth) headers["Authorization"] = auth;
|
|
352
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
353
|
+
method: "PATCH",
|
|
354
|
+
headers,
|
|
355
|
+
body: JSON.stringify(body),
|
|
356
|
+
signal: options?.signal
|
|
357
|
+
});
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
const text = await response.text();
|
|
360
|
+
throw new Error(`tuvl PATCH ${path} \u2192 HTTP ${response.status}: ${text}`);
|
|
361
|
+
}
|
|
362
|
+
return response.json();
|
|
363
|
+
}
|
|
364
|
+
/** Perform a DELETE request. Expects a 204 No Content response. */
|
|
365
|
+
async delete(path, options) {
|
|
366
|
+
const headers = {};
|
|
367
|
+
const auth = this.authHeader(options?.token);
|
|
368
|
+
if (auth) headers["Authorization"] = auth;
|
|
369
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
370
|
+
method: "DELETE",
|
|
371
|
+
headers,
|
|
372
|
+
signal: options?.signal
|
|
373
|
+
});
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
const text = await response.text();
|
|
376
|
+
throw new Error(`tuvl DELETE ${path} \u2192 HTTP ${response.status}: ${text}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// src/types.ts
|
|
382
|
+
var TuvlWorkflowError = class extends Error {
|
|
383
|
+
constructor(message, data, error) {
|
|
384
|
+
super(message);
|
|
385
|
+
this.name = "TuvlWorkflowError";
|
|
386
|
+
this.data = data;
|
|
387
|
+
this.error = error;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
var TuvlWorkflowSuspendedError = class extends Error {
|
|
391
|
+
constructor(suspended) {
|
|
392
|
+
super(`tuvl workflow suspended at step '${suspended.paused_step_id ?? "?"}'`);
|
|
393
|
+
this.name = "TuvlWorkflowSuspendedError";
|
|
394
|
+
this.suspended = suspended;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/client.ts
|
|
399
|
+
var TuvlClient = class {
|
|
400
|
+
constructor(options) {
|
|
401
|
+
this.manifestCache = /* @__PURE__ */ new Map();
|
|
402
|
+
this.transport = new Transport({
|
|
403
|
+
baseUrl: options.baseUrl,
|
|
404
|
+
defaultToken: options.token
|
|
405
|
+
});
|
|
406
|
+
this.manifestCacheTtl = options.manifestCacheTtl ?? 6e4;
|
|
407
|
+
}
|
|
408
|
+
/** Update the default auth token (e.g. after token refresh). */
|
|
409
|
+
setToken(token) {
|
|
410
|
+
this.transport.setToken(token);
|
|
411
|
+
}
|
|
412
|
+
/** Expose the base URL for callers that need raw access (e.g. gRPC). */
|
|
413
|
+
get baseUrl() {
|
|
414
|
+
return this.transport.baseUrl;
|
|
415
|
+
}
|
|
416
|
+
// ── Manifest ──────────────────────────────────────────────────────────────
|
|
417
|
+
/** Fetch (and cache) the manifest for a single workflow. */
|
|
418
|
+
async getManifest(workflowName, options) {
|
|
419
|
+
const cached = this.manifestCache.get(workflowName);
|
|
420
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
421
|
+
return cached.manifest;
|
|
422
|
+
}
|
|
423
|
+
const manifest = await this.transport.get(
|
|
424
|
+
`/api/_system/workflows/${encodeURIComponent(workflowName)}`,
|
|
425
|
+
options
|
|
426
|
+
);
|
|
427
|
+
if (this.manifestCacheTtl > 0) {
|
|
428
|
+
this.manifestCache.set(workflowName, {
|
|
429
|
+
manifest,
|
|
430
|
+
expiresAt: Date.now() + this.manifestCacheTtl
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return manifest;
|
|
434
|
+
}
|
|
435
|
+
/** Fetch manifests for all registered workflows. */
|
|
436
|
+
async listWorkflows(options) {
|
|
437
|
+
return this.transport.get("/api/_system/workflows", options);
|
|
438
|
+
}
|
|
439
|
+
/** Invalidate the cached manifest for a workflow (or all if no name given). */
|
|
440
|
+
invalidateManifest(workflowName) {
|
|
441
|
+
if (workflowName) {
|
|
442
|
+
this.manifestCache.delete(workflowName);
|
|
443
|
+
} else {
|
|
444
|
+
this.manifestCache.clear();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ── Execute ───────────────────────────────────────────────────────────────
|
|
448
|
+
/**
|
|
449
|
+
* Execute a workflow and return its `data` payload.
|
|
450
|
+
*
|
|
451
|
+
* Transport selection:
|
|
452
|
+
* 1. mode="grpc" → gRPC-Web (always, regardless of onProgress)
|
|
453
|
+
* 2. mode="sse" → SSE stream
|
|
454
|
+
* 3. onProgress provided → SSE if workflow has_slow_steps, else REST
|
|
455
|
+
* 4. default → REST
|
|
456
|
+
*
|
|
457
|
+
* @throws {TuvlWorkflowError} when the workflow returns success=false
|
|
458
|
+
* @throws {TuvlWorkflowSuspendedError} when the workflow suspends at HITL
|
|
459
|
+
* @throws {Error} on transport / engine failures
|
|
460
|
+
*/
|
|
461
|
+
async execute(workflowName, options = {}) {
|
|
462
|
+
const { payload = {}, onProgress, onSuspended, mode, token, signal } = options;
|
|
463
|
+
const manifest = await this.getManifest(workflowName, { token, signal });
|
|
464
|
+
const useSse = mode === "sse" || mode !== "rest" && mode !== "grpc" && !!onProgress && manifest.has_slow_steps;
|
|
465
|
+
if (mode === "grpc") {
|
|
466
|
+
return this._executeGrpc(manifest, payload, onProgress, onSuspended, token, signal);
|
|
467
|
+
}
|
|
468
|
+
if (useSse) {
|
|
469
|
+
return this._executeSse(manifest, payload, onProgress, onSuspended, token, signal);
|
|
470
|
+
}
|
|
471
|
+
const envelope = await this.transport.post(
|
|
472
|
+
manifest.trigger_path,
|
|
473
|
+
payload,
|
|
474
|
+
{ token, signal }
|
|
475
|
+
);
|
|
476
|
+
return this._unwrapRest(envelope, manifest.name);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Execute a specific version of a workflow via `/{version}/run/{name}`.
|
|
480
|
+
*
|
|
481
|
+
* Useful when you want to pin to a particular schema_version without
|
|
482
|
+
* relying on the currently-active default trigger path.
|
|
483
|
+
*
|
|
484
|
+
* @throws {TuvlWorkflowError} when the workflow returns success=false
|
|
485
|
+
* @throws {TuvlWorkflowSuspendedError} when the workflow suspends at HITL
|
|
486
|
+
* @throws {Error} on transport / engine failures
|
|
487
|
+
*/
|
|
488
|
+
async executeVersioned(workflowName, version, options = {}) {
|
|
489
|
+
const { payload = {}, onProgress, onSuspended, mode, token, signal } = options;
|
|
490
|
+
const triggerPath = `/${version}/run/${workflowName}`;
|
|
491
|
+
let cachedManifest;
|
|
492
|
+
try {
|
|
493
|
+
cachedManifest = await this.getManifest(workflowName, { token, signal });
|
|
494
|
+
} catch {
|
|
495
|
+
}
|
|
496
|
+
const syntheticManifest = {
|
|
497
|
+
name: workflowName,
|
|
498
|
+
trigger_path: triggerPath,
|
|
499
|
+
trigger_method: "POST",
|
|
500
|
+
has_slow_steps: cachedManifest?.has_slow_steps ?? false,
|
|
501
|
+
slow_kinds_present: cachedManifest?.slow_kinds_present ?? [],
|
|
502
|
+
required_scope: cachedManifest?.required_scope ?? null,
|
|
503
|
+
required_group: cachedManifest?.required_group ?? null,
|
|
504
|
+
steps: cachedManifest?.steps ?? []
|
|
505
|
+
};
|
|
506
|
+
const useSse = mode === "sse" || mode !== "rest" && mode !== "grpc" && !!onProgress && syntheticManifest.has_slow_steps;
|
|
507
|
+
if (mode === "grpc") {
|
|
508
|
+
return this._executeGrpc(
|
|
509
|
+
syntheticManifest,
|
|
510
|
+
payload,
|
|
511
|
+
onProgress,
|
|
512
|
+
onSuspended,
|
|
513
|
+
token,
|
|
514
|
+
signal
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
if (useSse) {
|
|
518
|
+
return this._executeSse(
|
|
519
|
+
syntheticManifest,
|
|
520
|
+
payload,
|
|
521
|
+
onProgress,
|
|
522
|
+
onSuspended,
|
|
523
|
+
token,
|
|
524
|
+
signal
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const envelope = await this.transport.post(
|
|
528
|
+
triggerPath,
|
|
529
|
+
payload,
|
|
530
|
+
{ token, signal }
|
|
531
|
+
);
|
|
532
|
+
return this._unwrapRest(envelope, workflowName);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Resume a HITL-suspended workflow instance.
|
|
536
|
+
*
|
|
537
|
+
* After the workflow throws `TuvlWorkflowSuspendedError`, capture the
|
|
538
|
+
* `event.instance_id` from the suspended event and pass it here along with
|
|
539
|
+
* the human-provided data to continue execution.
|
|
540
|
+
*
|
|
541
|
+
* @throws {TuvlWorkflowError} when the resumed workflow returns success=false
|
|
542
|
+
* @throws {TuvlWorkflowSuspendedError} when the workflow suspends again at another HITL step
|
|
543
|
+
* @throws {Error} on transport / engine failures
|
|
544
|
+
*/
|
|
545
|
+
async resumeWorkflow(options) {
|
|
546
|
+
const { instanceId, humanInput = {}, onProgress, onSuspended, mode, token, signal } = options;
|
|
547
|
+
const body = {
|
|
548
|
+
instance_id: instanceId,
|
|
549
|
+
human_input: humanInput
|
|
550
|
+
};
|
|
551
|
+
if (mode === "sse" || mode !== "rest" && !!onProgress) {
|
|
552
|
+
const syntheticManifest = {
|
|
553
|
+
name: `(resumed:${instanceId})`,
|
|
554
|
+
trigger_path: "/api/workflows/resume",
|
|
555
|
+
trigger_method: "POST",
|
|
556
|
+
has_slow_steps: true,
|
|
557
|
+
slow_kinds_present: [],
|
|
558
|
+
required_scope: null,
|
|
559
|
+
required_group: null,
|
|
560
|
+
steps: []
|
|
561
|
+
};
|
|
562
|
+
return this._executeSse(
|
|
563
|
+
syntheticManifest,
|
|
564
|
+
body,
|
|
565
|
+
onProgress,
|
|
566
|
+
onSuspended,
|
|
567
|
+
token,
|
|
568
|
+
signal
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
const envelope = await this.transport.post(
|
|
572
|
+
"/api/workflows/resume",
|
|
573
|
+
body,
|
|
574
|
+
{ token, signal }
|
|
575
|
+
);
|
|
576
|
+
return this._unwrapRest(envelope, `(resumed:${instanceId})`);
|
|
577
|
+
}
|
|
578
|
+
// ── SSE transport ─────────────────────────────────────────────────────────
|
|
579
|
+
async _executeSse(manifest, payload, onProgress, onSuspended, token, signal) {
|
|
580
|
+
const response = await this.transport.postStream(manifest.trigger_path, payload, {
|
|
581
|
+
token,
|
|
582
|
+
signal
|
|
583
|
+
});
|
|
584
|
+
let done;
|
|
585
|
+
for await (const frame of parseSseStream(response, signal)) {
|
|
586
|
+
switch (frame.event_type) {
|
|
587
|
+
case "step":
|
|
588
|
+
onProgress?.(frame);
|
|
589
|
+
break;
|
|
590
|
+
case "done":
|
|
591
|
+
done = frame;
|
|
592
|
+
break;
|
|
593
|
+
case "suspended":
|
|
594
|
+
onSuspended?.(frame);
|
|
595
|
+
throw new TuvlWorkflowSuspendedError(frame);
|
|
596
|
+
case "error": {
|
|
597
|
+
const err = frame;
|
|
598
|
+
throw new Error(
|
|
599
|
+
`tuvl workflow '${manifest.name}' engine error: ${err.message}` + (err.details ? ` \u2014 ${err.details}` : "")
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (!done) {
|
|
605
|
+
throw new Error(`tuvl SSE stream for '${manifest.name}' ended without a done event`);
|
|
606
|
+
}
|
|
607
|
+
if (!done.success) {
|
|
608
|
+
throw new TuvlWorkflowError(
|
|
609
|
+
`tuvl workflow '${manifest.name}' failed`,
|
|
610
|
+
done.data,
|
|
611
|
+
done.error
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return done.data;
|
|
615
|
+
}
|
|
616
|
+
// ── gRPC-Web transport ────────────────────────────────────────────────────
|
|
617
|
+
async _executeGrpc(manifest, payload, onProgress, onSuspended, token, signal) {
|
|
618
|
+
const { openGrpcStream: openGrpcStream2 } = await Promise.resolve().then(() => (init_grpc(), grpc_exports));
|
|
619
|
+
let finalSnapshot;
|
|
620
|
+
for await (const event of openGrpcStream2({
|
|
621
|
+
baseUrl: this.baseUrl,
|
|
622
|
+
workflowName: manifest.name,
|
|
623
|
+
payloadJson: JSON.stringify(payload),
|
|
624
|
+
token,
|
|
625
|
+
signal
|
|
626
|
+
})) {
|
|
627
|
+
switch (event.event_type) {
|
|
628
|
+
case "step":
|
|
629
|
+
onProgress?.(event);
|
|
630
|
+
break;
|
|
631
|
+
case "done":
|
|
632
|
+
finalSnapshot = event.snapshot;
|
|
633
|
+
break;
|
|
634
|
+
case "suspended": {
|
|
635
|
+
const snap = event.snapshot;
|
|
636
|
+
const suspended = {
|
|
637
|
+
event_type: "suspended",
|
|
638
|
+
paused_step_id: event.step_id,
|
|
639
|
+
...snap
|
|
640
|
+
};
|
|
641
|
+
onSuspended?.(suspended);
|
|
642
|
+
throw new TuvlWorkflowSuspendedError(suspended);
|
|
643
|
+
}
|
|
644
|
+
case "error":
|
|
645
|
+
throw new Error(
|
|
646
|
+
`tuvl workflow '${manifest.name}' gRPC error: ${event.error_detail ?? "unknown"}`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (finalSnapshot === void 0) {
|
|
651
|
+
throw new Error(`tuvl gRPC stream for '${manifest.name}' ended without a done event`);
|
|
652
|
+
}
|
|
653
|
+
if ("_last_error" in finalSnapshot) {
|
|
654
|
+
throw new TuvlWorkflowError(
|
|
655
|
+
`tuvl workflow '${manifest.name}' failed`,
|
|
656
|
+
finalSnapshot,
|
|
657
|
+
finalSnapshot._last_error ?? null
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if ("_response" in finalSnapshot) {
|
|
661
|
+
return finalSnapshot._response;
|
|
662
|
+
}
|
|
663
|
+
return finalSnapshot;
|
|
664
|
+
}
|
|
665
|
+
// ── REST envelope ─────────────────────────────────────────────────────────
|
|
666
|
+
_unwrapRest(envelope, workflowName) {
|
|
667
|
+
if (!envelope || typeof envelope !== "object") {
|
|
668
|
+
return envelope;
|
|
669
|
+
}
|
|
670
|
+
if (envelope.success === false) {
|
|
671
|
+
throw new TuvlWorkflowError(
|
|
672
|
+
`tuvl workflow '${workflowName}' failed`,
|
|
673
|
+
envelope.data,
|
|
674
|
+
envelope.error
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return envelope.data ?? envelope;
|
|
678
|
+
}
|
|
679
|
+
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
680
|
+
/**
|
|
681
|
+
* Return a typed CRUD client for a tuvl model endpoint.
|
|
682
|
+
*
|
|
683
|
+
* The returned client targets `/models/{modelName}/` and shares the
|
|
684
|
+
* same transport (base URL + auth token) as this TuvlClient instance.
|
|
685
|
+
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```ts
|
|
688
|
+
* // List all candidates
|
|
689
|
+
* const all = await client.crud("candidate").list();
|
|
690
|
+
*
|
|
691
|
+
* // With filters + embedded relations
|
|
692
|
+
* const results = await client.crud("candidate").list({
|
|
693
|
+
* filters: { stage: "screening" },
|
|
694
|
+
* include: ["posting"],
|
|
695
|
+
* limit: 50,
|
|
696
|
+
* });
|
|
697
|
+
*
|
|
698
|
+
* // Get one record
|
|
699
|
+
* const c = await client.crud("candidate").get("uuid-here");
|
|
700
|
+
*
|
|
701
|
+
* // Strongly-typed variant
|
|
702
|
+
* const c2 = await client
|
|
703
|
+
* .crud<CandidateRead, CandidateCreate>("candidate")
|
|
704
|
+
* .create({ name: "Alice", email: "alice@example.com" });
|
|
705
|
+
* ```
|
|
706
|
+
*/
|
|
707
|
+
crud(modelName) {
|
|
708
|
+
return new CrudClient(this.transport, modelName);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// src/auth.ts
|
|
713
|
+
var TuvlAuth = class {
|
|
714
|
+
constructor(options) {
|
|
715
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Decode the current token server-side and return the user's identity,
|
|
719
|
+
* role memberships (`groups`), and permission scopes.
|
|
720
|
+
*
|
|
721
|
+
* Biscuit tokens are protobuf-encoded and cannot be decoded in pure JS,
|
|
722
|
+
* so this is the correct way to read "who is logged in" and "what can
|
|
723
|
+
* they do" from the TypeScript SDK.
|
|
724
|
+
*
|
|
725
|
+
* @throws if the token is invalid, expired, or revoked.
|
|
726
|
+
*/
|
|
727
|
+
async getMe(token) {
|
|
728
|
+
const response = await fetch(`${this.baseUrl}/auth/me`, {
|
|
729
|
+
method: "GET",
|
|
730
|
+
headers: {
|
|
731
|
+
Authorization: `Bearer ${token}`,
|
|
732
|
+
Accept: "application/json"
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
if (!response.ok) {
|
|
736
|
+
const text = await response.text();
|
|
737
|
+
throw new Error(`tuvl getMe failed (HTTP ${response.status}): ${text}`);
|
|
738
|
+
}
|
|
739
|
+
return response.json();
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Exchange an email + password for a Biscuit bearer token.
|
|
743
|
+
*
|
|
744
|
+
* Calls `POST /auth/token` with an `application/x-www-form-urlencoded`
|
|
745
|
+
* body (OAuth2 password-grant). The `username` field must be the user's
|
|
746
|
+
* email address.
|
|
747
|
+
*/
|
|
748
|
+
async loginWithPassword(email, password) {
|
|
749
|
+
const body = new URLSearchParams({ username: email, password });
|
|
750
|
+
const response = await fetch(`${this.baseUrl}/auth/token`, {
|
|
751
|
+
method: "POST",
|
|
752
|
+
headers: {
|
|
753
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
754
|
+
Accept: "application/json"
|
|
755
|
+
},
|
|
756
|
+
body
|
|
757
|
+
});
|
|
758
|
+
if (!response.ok) {
|
|
759
|
+
const text = await response.text();
|
|
760
|
+
throw new Error(`tuvl login failed (HTTP ${response.status}): ${text}`);
|
|
761
|
+
}
|
|
762
|
+
return response.json();
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Create the first superadmin user (one-time IAM bootstrap).
|
|
766
|
+
*
|
|
767
|
+
* Calls `POST /auth/bootstrap`. Returns 409 on every subsequent call
|
|
768
|
+
* once any user exists.
|
|
769
|
+
*/
|
|
770
|
+
async bootstrap(req) {
|
|
771
|
+
const response = await fetch(`${this.baseUrl}/auth/bootstrap`, {
|
|
772
|
+
method: "POST",
|
|
773
|
+
headers: {
|
|
774
|
+
"Content-Type": "application/json",
|
|
775
|
+
Accept: "application/json"
|
|
776
|
+
},
|
|
777
|
+
body: JSON.stringify(req)
|
|
778
|
+
});
|
|
779
|
+
if (!response.ok) {
|
|
780
|
+
const text = await response.text();
|
|
781
|
+
throw new Error(`tuvl bootstrap failed (HTTP ${response.status}): ${text}`);
|
|
782
|
+
}
|
|
783
|
+
return response.json();
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Return the URL the browser should navigate to in order to start an
|
|
787
|
+
* OAuth2 login flow for the given provider.
|
|
788
|
+
*
|
|
789
|
+
* Supported built-ins: `"google"`, `"github"`, `"microsoft"`. Any
|
|
790
|
+
* provider configured in the project's `federation/` directory also
|
|
791
|
+
* works.
|
|
792
|
+
*
|
|
793
|
+
* After the OAuth dance the server either:
|
|
794
|
+
* - redirects to `TUVL_OAUTH_UI_REDIRECT_URL?token=<biscuit_b64>`, OR
|
|
795
|
+
* - returns a JSON `{access_token, token_type}` body (CLI / server flows).
|
|
796
|
+
*/
|
|
797
|
+
getOAuthLoginUrl(provider) {
|
|
798
|
+
return `${this.baseUrl}/auth/oauth/${encodeURIComponent(provider)}/start`;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Exchange a valid (non-expired, non-revoked) token for a fresh one.
|
|
802
|
+
* The old token is immediately blacklisted — discard it after this call.
|
|
803
|
+
*/
|
|
804
|
+
async refresh(token) {
|
|
805
|
+
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
|
806
|
+
method: "POST",
|
|
807
|
+
headers: {
|
|
808
|
+
Authorization: `Bearer ${token}`,
|
|
809
|
+
Accept: "application/json"
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
if (!response.ok) {
|
|
813
|
+
const text = await response.text();
|
|
814
|
+
throw new Error(`tuvl token refresh failed (HTTP ${response.status}): ${text}`);
|
|
815
|
+
}
|
|
816
|
+
return response.json();
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Revoke the given token (logout). After this the token is blacklisted
|
|
820
|
+
* across all workers that share a Redis instance. Resolves silently on
|
|
821
|
+
* success (HTTP 204).
|
|
822
|
+
*/
|
|
823
|
+
async logout(token) {
|
|
824
|
+
const response = await fetch(`${this.baseUrl}/auth/logout`, {
|
|
825
|
+
method: "POST",
|
|
826
|
+
headers: {
|
|
827
|
+
Authorization: `Bearer ${token}`
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
if (!response.ok && response.status !== 204) {
|
|
831
|
+
const text = await response.text();
|
|
832
|
+
throw new Error(`tuvl logout failed (HTTP ${response.status}): ${text}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// src/index.ts
|
|
838
|
+
init_grpc();
|
|
839
|
+
|
|
840
|
+
exports.CrudClient = CrudClient;
|
|
841
|
+
exports.Transport = Transport;
|
|
842
|
+
exports.TuvlAuth = TuvlAuth;
|
|
843
|
+
exports.TuvlClient = TuvlClient;
|
|
844
|
+
exports.TuvlWorkflowError = TuvlWorkflowError;
|
|
845
|
+
exports.TuvlWorkflowSuspendedError = TuvlWorkflowSuspendedError;
|
|
846
|
+
exports.openGrpcStream = openGrpcStream;
|
|
847
|
+
exports.parseSseStream = parseSseStream;
|
|
848
|
+
//# sourceMappingURL=index.js.map
|
|
849
|
+
//# sourceMappingURL=index.js.map
|