@lotics/cli 0.55.0 → 0.57.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/dist/app_commands.d.ts +1 -1
- package/dist/client.d.ts +8 -0
- package/dist/client.js +59 -1
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +47 -0
- package/dist/dev/wrapper_page.d.ts +5 -3
- package/dist/dev/wrapper_page.js +33 -27
- package/dist/src/cli.js +1673 -1419
- package/dist/starter_template.js +16 -13
- package/package.json +1 -1
package/dist/app_commands.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export type AppQueryDeclaration = {
|
|
|
31
31
|
* `"alias": { instructions, tool_names, model_id, inputs?, outputs? }`
|
|
32
32
|
*
|
|
33
33
|
* A read-only reflection of the live App row (`apps.agents`, owned by
|
|
34
|
-
* `set_app_agent`), refreshed by `lotics app pull` and used only to codegen
|
|
34
|
+
* `set_app_agent` / `remove_app_agent`), refreshed by `lotics app pull` and used only to codegen
|
|
35
35
|
* `useAgentRun` typings. `inputs`/`outputs` drive the typed call site; the rest
|
|
36
36
|
* is carried for fidelity.
|
|
37
37
|
*/
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
3
|
+
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
|
4
|
+
* 5xx, or a JSON body without a `message` falls back to a body-free,
|
|
5
|
+
* status-derived message. `parsed` is the JSON.parse of the body, or `null`.
|
|
6
|
+
* In parity (by value, no shared dep) with `packages/app-sdk/src/rpc.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function transportErrorMessage(status: number, parsed: unknown): string;
|
|
1
9
|
/** One sort key forwarded to the app query RPC (wire shape of a `TableRecordSort` entry). */
|
|
2
10
|
export interface AppQuerySortKey {
|
|
3
11
|
field_key: string;
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* A user-facing message for a transport/gateway failure — derived from the HTTP
|
|
5
|
+
* status, never from the response body. A 524 (edge timeout on a long run), any
|
|
6
|
+
* 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw body. In
|
|
7
|
+
* parity (by value, no shared dep) with the published transport in
|
|
8
|
+
* `packages/app-sdk/src/rpc.ts`.
|
|
9
|
+
*/
|
|
10
|
+
function gatewayErrorMessage(status) {
|
|
11
|
+
if (status === 524) {
|
|
12
|
+
return "The request took too long to finish (gateway timeout). It may still be running — check back in a moment, or try again.";
|
|
13
|
+
}
|
|
14
|
+
if (status >= 500) {
|
|
15
|
+
return "The service is temporarily unavailable. Please try again shortly.";
|
|
16
|
+
}
|
|
17
|
+
return "The service returned an unexpected response. Please try again.";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
21
|
+
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
|
22
|
+
* 5xx, or a JSON body without a `message` falls back to a body-free,
|
|
23
|
+
* status-derived message. `parsed` is the JSON.parse of the body, or `null`.
|
|
24
|
+
* In parity (by value, no shared dep) with `packages/app-sdk/src/rpc.ts`.
|
|
25
|
+
*/
|
|
26
|
+
export function transportErrorMessage(status, parsed) {
|
|
27
|
+
const jsonMessage = parsed && typeof parsed.message === "string"
|
|
28
|
+
? parsed.message
|
|
29
|
+
: null;
|
|
30
|
+
return parsed === null || status >= 500 || jsonMessage === null
|
|
31
|
+
? gatewayErrorMessage(status)
|
|
32
|
+
: jsonMessage;
|
|
33
|
+
}
|
|
3
34
|
function findAvailableFilename(dir, filename, reserved) {
|
|
4
35
|
// `reserved` tracks absolute paths claimed by in-flight downloads in the same
|
|
5
36
|
// batch — required for parallel callers because the file may not be on disk
|
|
@@ -200,7 +231,34 @@ export class LoticsClient {
|
|
|
200
231
|
* Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
|
|
201
232
|
*/
|
|
202
233
|
async appWorkflow(app_id, alias, inputs) {
|
|
203
|
-
|
|
234
|
+
// Its own transport (not the generic `request`, whose `throwResponseError`
|
|
235
|
+
// shape is the CLI contract elsewhere): a transport/gateway failure resolves
|
|
236
|
+
// to a `WorkflowResult` error `{ status, message }` — never a thrown HTML
|
|
237
|
+
// body — so the dev RPC bridge forwards `{status:"error"}` to the iframe,
|
|
238
|
+
// matching the deployed standalone SDK (`@lotics/app-sdk` standaloneWorkflow).
|
|
239
|
+
const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`;
|
|
240
|
+
const headers = this.buildHeaders();
|
|
241
|
+
headers["Content-Type"] = "application/json";
|
|
242
|
+
let response;
|
|
243
|
+
try {
|
|
244
|
+
response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ inputs }) });
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
return { status: "error", message: err instanceof Error ? err.message : "The workflow request failed." };
|
|
248
|
+
}
|
|
249
|
+
const text = await response.text();
|
|
250
|
+
let parsed = null;
|
|
251
|
+
if (text) {
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(text);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// non-JSON body (e.g. a gateway HTML error page) — never echoed
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (response.ok)
|
|
260
|
+
return parsed ?? {};
|
|
261
|
+
return { status: "error", message: transportErrorMessage(response.status, parsed) };
|
|
204
262
|
}
|
|
205
263
|
/**
|
|
206
264
|
* Open a streaming agent run and return the RAW streamed `Response` (the
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { LoticsClient, transportErrorMessage } from "./client.js";
|
|
3
|
+
describe("transportErrorMessage", () => {
|
|
4
|
+
it("returns a body-free gateway message for a non-JSON 524 (never the HTML)", () => {
|
|
5
|
+
const msg = transportErrorMessage(524, null);
|
|
6
|
+
expect(msg).not.toMatch(/<html|<!DOCTYPE/i);
|
|
7
|
+
expect(msg.toLowerCase()).toContain("gateway timeout");
|
|
8
|
+
});
|
|
9
|
+
it("returns a friendly message for any 5xx, ignoring a JSON body", () => {
|
|
10
|
+
expect(transportErrorMessage(503, { message: "internal detail" })).not.toBe("internal detail");
|
|
11
|
+
expect(transportErrorMessage(503, { message: "internal detail" })).not.toMatch(/<html/i);
|
|
12
|
+
});
|
|
13
|
+
it("surfaces a genuine 4xx JSON error message verbatim", () => {
|
|
14
|
+
expect(transportErrorMessage(400, { message: "record_id is required" })).toBe("record_id is required");
|
|
15
|
+
});
|
|
16
|
+
it("never leaks a non-JSON 4xx body (WAF / redirect HTML)", () => {
|
|
17
|
+
expect(transportErrorMessage(403, null)).not.toMatch(/<html|<!DOCTYPE/i);
|
|
18
|
+
});
|
|
19
|
+
it("falls back to a friendly message for a JSON body with no message field", () => {
|
|
20
|
+
expect(transportErrorMessage(409, { error_code: "CONFLICT" })).toBeTruthy();
|
|
21
|
+
expect(transportErrorMessage(409, { error_code: "CONFLICT" })).not.toMatch(/<html/i);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("LoticsClient.appWorkflow", () => {
|
|
25
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
26
|
+
const client = new LoticsClient({ apiKey: "ltk_test", workspaceId: "wsp_test" });
|
|
27
|
+
it("normalizes a 524 HTML gateway response to a WorkflowResult error (no raw HTML)", async () => {
|
|
28
|
+
const html = "<!DOCTYPE html><html><head><title>error</title></head><body>524: A timeout occurred</body></html>";
|
|
29
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(html, { status: 524 })));
|
|
30
|
+
const result = (await client.appWorkflow("app_1", "wf", {}));
|
|
31
|
+
expect(result.status).toBe("error");
|
|
32
|
+
expect(result.message).not.toMatch(/<html|<!DOCTYPE/i);
|
|
33
|
+
});
|
|
34
|
+
it("passes a 200 workflow result through unchanged", async () => {
|
|
35
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ status: "success", data: { x: 1 } }), { status: 200 })));
|
|
36
|
+
const result = await client.appWorkflow("app_1", "wf", {});
|
|
37
|
+
expect(result).toEqual({ status: "success", data: { x: 1 } });
|
|
38
|
+
});
|
|
39
|
+
it("returns a WorkflowResult error (not a throw) on a network failure", async () => {
|
|
40
|
+
vi.stubGlobal("fetch", vi.fn(async () => {
|
|
41
|
+
throw new Error("ECONNREFUSED");
|
|
42
|
+
}));
|
|
43
|
+
const result = (await client.appWorkflow("app_1", "wf", {}));
|
|
44
|
+
expect(result.status).toBe("error");
|
|
45
|
+
expect(result.message).toContain("ECONNREFUSED");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
* urlState (useUrlState
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* urlState (useUrlState): the iframe reads/writes the wrapper's address bar via
|
|
28
|
+
* urlState.get/set; set writes in place (replaceState), and browser
|
|
29
|
+
* back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
|
+
* (In-app routing isn't here — the app owns the iframe's own url; see
|
|
31
|
+
* @lotics/app-sdk/router.)
|
|
30
32
|
*/
|
|
31
33
|
export interface WrapperPageArgs {
|
|
32
34
|
app_name: string;
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
* urlState (useUrlState
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* urlState (useUrlState): the iframe reads/writes the wrapper's address bar via
|
|
28
|
+
* urlState.get/set; set writes in place (replaceState), and browser
|
|
29
|
+
* back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
|
+
* (In-app routing isn't here — the app owns the iframe's own url; see
|
|
31
|
+
* @lotics/app-sdk/router.)
|
|
30
32
|
*/
|
|
31
33
|
export function buildWrapperPage(args) {
|
|
32
34
|
const { app_name, app_id, workspace_id, vite_url, api_url } = args;
|
|
@@ -78,9 +80,28 @@ export function buildWrapperPage(args) {
|
|
|
78
80
|
const iframe = document.getElementById("app");
|
|
79
81
|
|
|
80
82
|
// Pass the wrapper's own origin to the app via ?lotics_host= so the app
|
|
81
|
-
// SDK can origin-lock its postMessage bridge
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// SDK can origin-lock its postMessage bridge, and bake the saved screen
|
|
84
|
+
// (the wrapper url's _loc, kept current by AppRouter's mirror) into the src
|
|
85
|
+
// path so a refresh boots the app at that screen — mirrors the production
|
|
86
|
+
// host. _loc is honoured only when it resolves same-origin to the Vite
|
|
87
|
+
// server (a url param flowing into an iframe src is a redirect vector);
|
|
88
|
+
// anything else falls back to the app root.
|
|
89
|
+
const appSrc = new URL(VITE_URL);
|
|
90
|
+
const savedLoc = new URLSearchParams(window.location.search).get("_loc");
|
|
91
|
+
if (savedLoc) {
|
|
92
|
+
try {
|
|
93
|
+
const resolved = new URL(savedLoc, VITE_ORIGIN);
|
|
94
|
+
if (resolved.origin === VITE_ORIGIN) {
|
|
95
|
+
appSrc.pathname = resolved.pathname;
|
|
96
|
+
appSrc.search = resolved.search;
|
|
97
|
+
appSrc.hash = resolved.hash;
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// malformed _loc -> app root
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
appSrc.searchParams.set("lotics_host", window.location.origin);
|
|
104
|
+
iframe.src = appSrc.toString();
|
|
84
105
|
|
|
85
106
|
async function rpc(op, payload) {
|
|
86
107
|
const res = await fetch("/_rpc", {
|
|
@@ -185,7 +206,6 @@ export function buildWrapperPage(args) {
|
|
|
185
206
|
});
|
|
186
207
|
return out;
|
|
187
208
|
}
|
|
188
|
-
var lastPushAt = 0;
|
|
189
209
|
function handleUrlStateSet(payload) {
|
|
190
210
|
const params = (payload && payload.params) || {};
|
|
191
211
|
const sp = new URLSearchParams(window.location.search);
|
|
@@ -198,21 +218,9 @@ export function buildWrapperPage(args) {
|
|
|
198
218
|
});
|
|
199
219
|
const qs = sp.toString();
|
|
200
220
|
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
const now = Date.now();
|
|
205
|
-
if (payload && payload.push && now - lastPushAt > 100) {
|
|
206
|
-
lastPushAt = now;
|
|
207
|
-
window.history.pushState(null, "", url);
|
|
208
|
-
} else {
|
|
209
|
-
window.history.replaceState(null, "", url);
|
|
210
|
-
}
|
|
211
|
-
return undefined;
|
|
212
|
-
}
|
|
213
|
-
function handleUrlStateGo(payload) {
|
|
214
|
-
const delta = payload && payload.delta;
|
|
215
|
-
if (typeof delta === "number") window.history.go(delta);
|
|
221
|
+
// View-state writes in place — never a history entry. replaceState doesn't
|
|
222
|
+
// fire popstate, so no echo. Mirrors the production host.
|
|
223
|
+
window.history.replaceState(null, "", url);
|
|
216
224
|
return undefined;
|
|
217
225
|
}
|
|
218
226
|
|
|
@@ -281,8 +289,6 @@ export function buildWrapperPage(args) {
|
|
|
281
289
|
? readUrlParams()
|
|
282
290
|
: msg.op === "urlState.set"
|
|
283
291
|
? handleUrlStateSet(msg.payload)
|
|
284
|
-
: msg.op === "urlState.go"
|
|
285
|
-
? handleUrlStateGo(msg.payload)
|
|
286
292
|
: await rpc(msg.op, msg.payload);
|
|
287
293
|
const ms = Math.round(performance.now() - startedAt);
|
|
288
294
|
console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
|
|
@@ -300,9 +306,9 @@ export function buildWrapperPage(args) {
|
|
|
300
306
|
}
|
|
301
307
|
});
|
|
302
308
|
|
|
303
|
-
// Browser
|
|
304
|
-
//
|
|
305
|
-
//
|
|
309
|
+
// Browser back/forward → broadcast the new params so useUrlState re-hydrates
|
|
310
|
+
// (the app's own set writes use replaceState — no popstate — so there's no
|
|
311
|
+
// echo). Mirrors the production host.
|
|
306
312
|
window.addEventListener("popstate", function () {
|
|
307
313
|
iframe.contentWindow.postMessage(
|
|
308
314
|
{ type: "url-state", params: readUrlParams() },
|