@lotics/cli 0.50.0 → 0.51.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.
- package/dist/app_commands.d.ts +11 -0
- package/dist/app_commands.js +21 -2
- package/dist/app_commands.test.js +23 -1
- package/dist/client.d.ts +10 -0
- package/dist/client.js +17 -0
- package/dist/dev/server.js +51 -0
- package/dist/dev/wrapper_page.d.ts +2 -0
- package/dist/dev/wrapper_page.js +54 -1
- package/dist/src/cli.js +149 -5
- package/dist/starter_template.js +23 -2
- package/package.json +1 -1
package/dist/app_commands.d.ts
CHANGED
|
@@ -63,6 +63,17 @@ export declare function stampPulledManifest(projectDir: string, args: {
|
|
|
63
63
|
queries: Record<string, AppQueryDeclaration>;
|
|
64
64
|
agents: Record<string, AppAgentDeclaration>;
|
|
65
65
|
}): void;
|
|
66
|
+
/**
|
|
67
|
+
* A filesystem-safe SINGLE directory segment from an app's free-form display
|
|
68
|
+
* name, for the default create/pull target. App names may contain "/" (and
|
|
69
|
+
* other path-hostile characters); used verbatim as a directory, a name like
|
|
70
|
+
* "Nhập/Xuất Cont" splits into NESTED folders ("Nhập" → "Xuất Cont") — and a
|
|
71
|
+
* deploy then packages that nested copy into the source archive, so each later
|
|
72
|
+
* pull re-extracts and compounds it. Neutralize path separators + reserved
|
|
73
|
+
* characters to one segment, keeping spaces and unicode so the folder stays
|
|
74
|
+
* recognizable. An explicit `targetPath` still overrides this entirely.
|
|
75
|
+
*/
|
|
76
|
+
export declare function appDirName(name: string): string;
|
|
66
77
|
/**
|
|
67
78
|
* `lotics app create <name> [path]`
|
|
68
79
|
*
|
package/dist/app_commands.js
CHANGED
|
@@ -149,6 +149,25 @@ async function downloadToFile(url, destPath) {
|
|
|
149
149
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
150
150
|
fs.writeFileSync(destPath, buffer);
|
|
151
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* A filesystem-safe SINGLE directory segment from an app's free-form display
|
|
154
|
+
* name, for the default create/pull target. App names may contain "/" (and
|
|
155
|
+
* other path-hostile characters); used verbatim as a directory, a name like
|
|
156
|
+
* "Nhập/Xuất Cont" splits into NESTED folders ("Nhập" → "Xuất Cont") — and a
|
|
157
|
+
* deploy then packages that nested copy into the source archive, so each later
|
|
158
|
+
* pull re-extracts and compounds it. Neutralize path separators + reserved
|
|
159
|
+
* characters to one segment, keeping spaces and unicode so the folder stays
|
|
160
|
+
* recognizable. An explicit `targetPath` still overrides this entirely.
|
|
161
|
+
*/
|
|
162
|
+
export function appDirName(name) {
|
|
163
|
+
const cleaned = name
|
|
164
|
+
.replace(/[/\\]+/g, "-") // path separators — the core bug
|
|
165
|
+
.replace(/[<>:"|?*]/g, "") // other filesystem-reserved characters
|
|
166
|
+
.replace(/\s+/g, " ") // collapse whitespace runs
|
|
167
|
+
.trim()
|
|
168
|
+
.replace(/^[.\s-]+|[.\s-]+$/g, ""); // no leading/trailing dot/space/dash → never "."/".."/hidden/blank
|
|
169
|
+
return cleaned || "app";
|
|
170
|
+
}
|
|
152
171
|
/**
|
|
153
172
|
* `lotics app create <name> [path]`
|
|
154
173
|
*
|
|
@@ -164,7 +183,7 @@ async function downloadToFile(url, destPath) {
|
|
|
164
183
|
* with CLI releases. (Server-side build is the v2 path.)
|
|
165
184
|
*/
|
|
166
185
|
export async function appCreate(client, args) {
|
|
167
|
-
const targetPath = path.resolve(args.targetPath ?? args.name);
|
|
186
|
+
const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
|
|
168
187
|
if (fs.existsSync(targetPath)) {
|
|
169
188
|
const entries = fs.readdirSync(targetPath);
|
|
170
189
|
if (entries.length > 0) {
|
|
@@ -259,7 +278,7 @@ export async function appPull(client, args) {
|
|
|
259
278
|
}
|
|
260
279
|
const version = await client.getAppVersion(app.id, app.current_version_id);
|
|
261
280
|
const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
|
|
262
|
-
const targetPath = path.resolve(args.targetPath ?? app.name);
|
|
281
|
+
const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
|
|
263
282
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
264
283
|
// Download to a temp file because `tar -xz` reads from a real path.
|
|
265
284
|
const tmpFile = path.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { stampPulledManifest, undeclaredCapabilities } from "./app_commands.js";
|
|
5
|
+
import { stampPulledManifest, undeclaredCapabilities, appDirName } from "./app_commands.js";
|
|
6
6
|
/**
|
|
7
7
|
* `appPull` reads workflows from the live App row (server response), NOT from
|
|
8
8
|
* the manifest embedded in the extracted source archive. The frozen archive
|
|
@@ -135,3 +135,25 @@ describe("undeclaredCapabilities", () => {
|
|
|
135
135
|
expect(undeclaredCapabilities(`await deleteComment(id);`, {})).toEqual(["comments"]);
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
|
+
describe("appDirName", () => {
|
|
139
|
+
it("collapses a slash in the name to a single segment (the create/pull nesting bug)", () => {
|
|
140
|
+
// A "/" in the name used to split the create/pull target into nested folders
|
|
141
|
+
// ("Nhập" → "Xuất Cont") and compound on each pull.
|
|
142
|
+
expect(appDirName("Nhập/Xuất Cont")).toBe("Nhập-Xuất Cont");
|
|
143
|
+
expect(appDirName("a/b/c")).toBe("a-b-c");
|
|
144
|
+
expect(appDirName("a\\b")).toBe("a-b");
|
|
145
|
+
});
|
|
146
|
+
it("leaves an ordinary name untouched (no regression for the common case)", () => {
|
|
147
|
+
expect(appDirName("Sales Tracker")).toBe("Sales Tracker");
|
|
148
|
+
expect(appDirName("crm")).toBe("crm");
|
|
149
|
+
});
|
|
150
|
+
it("strips reserved characters and collapses whitespace", () => {
|
|
151
|
+
expect(appDirName('Report: "Q1" <draft>')).toBe("Report Q1 draft");
|
|
152
|
+
});
|
|
153
|
+
it("never yields a traversal, hidden, or empty directory", () => {
|
|
154
|
+
expect(appDirName("../etc")).toBe("etc"); // "/"→"-", then leading dot/dash run stripped
|
|
155
|
+
expect(appDirName(".hidden")).toBe("hidden");
|
|
156
|
+
expect(appDirName(" ")).toBe("app");
|
|
157
|
+
expect(appDirName("///")).toBe("app"); // separators → "-", a dir of only "-" falls back
|
|
158
|
+
});
|
|
159
|
+
});
|
package/dist/client.d.ts
CHANGED
|
@@ -216,6 +216,16 @@ export declare class LoticsClient {
|
|
|
216
216
|
* Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
|
|
217
217
|
*/
|
|
218
218
|
appWorkflow(app_id: string, alias: string, inputs: unknown): Promise<unknown>;
|
|
219
|
+
/**
|
|
220
|
+
* Open a streaming agent run and return the RAW streamed `Response` (the
|
|
221
|
+
* caller reads `res.body`). Unlike `request`, this does not buffer/parse the
|
|
222
|
+
* body — it's the SSE stream the `lotics app dev` harness proxies to the
|
|
223
|
+
* iframe. Mirrors POST /v1/apps/{app_id}/agents/{alias}/runs.
|
|
224
|
+
*/
|
|
225
|
+
appAgentRunStream(app_id: string, alias: string, body: {
|
|
226
|
+
session_id: string;
|
|
227
|
+
input: Record<string, unknown>;
|
|
228
|
+
}, signal?: AbortSignal): Promise<Response>;
|
|
219
229
|
/**
|
|
220
230
|
* Mint a presigned URL for uploading a file into an app. Mirrors
|
|
221
231
|
* POST /v1/apps/{app_id}/files/upload-url.
|
package/dist/client.js
CHANGED
|
@@ -202,6 +202,23 @@ export class LoticsClient {
|
|
|
202
202
|
async appWorkflow(app_id, alias, inputs) {
|
|
203
203
|
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`, { inputs });
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Open a streaming agent run and return the RAW streamed `Response` (the
|
|
207
|
+
* caller reads `res.body`). Unlike `request`, this does not buffer/parse the
|
|
208
|
+
* body — it's the SSE stream the `lotics app dev` harness proxies to the
|
|
209
|
+
* iframe. Mirrors POST /v1/apps/{app_id}/agents/{alias}/runs.
|
|
210
|
+
*/
|
|
211
|
+
async appAgentRunStream(app_id, alias, body, signal) {
|
|
212
|
+
const res = await fetch(`${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/agents/${encodeURIComponent(alias)}/runs`, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { ...this.buildHeaders(), "Content-Type": "application/json" },
|
|
215
|
+
body: JSON.stringify(body),
|
|
216
|
+
signal,
|
|
217
|
+
});
|
|
218
|
+
if (!res.ok)
|
|
219
|
+
await this.throwResponseError(res);
|
|
220
|
+
return res;
|
|
221
|
+
}
|
|
205
222
|
/**
|
|
206
223
|
* Mint a presigned URL for uploading a file into an app. Mirrors
|
|
207
224
|
* POST /v1/apps/{app_id}/files/upload-url.
|
package/dist/dev/server.js
CHANGED
|
@@ -116,6 +116,57 @@ export async function startDevServer(args) {
|
|
|
116
116
|
}
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
|
+
// Streaming agent run — the one op /_rpc can't carry (its response is a
|
|
120
|
+
// stream, not a single JSON value). Open the run with the CLI key and pipe
|
|
121
|
+
// the SSE body straight back to the wrapper page, which forwards chunks to
|
|
122
|
+
// the iframe. A wrapper disconnect (the app aborted) aborts the upstream.
|
|
123
|
+
if (req.method === "POST" && url === "/_agent_run") {
|
|
124
|
+
// Abort the upstream run only when the CLIENT disconnects — keyed on the
|
|
125
|
+
// RESPONSE closing before we finished writing it. `req.on("close")` is
|
|
126
|
+
// wrong here: in modern Node it fires as soon as the request BODY stream
|
|
127
|
+
// ends (right after readJson consumes it), which would abort every run the
|
|
128
|
+
// instant it started — a silent empty 200, no chunks.
|
|
129
|
+
const ac = new AbortController();
|
|
130
|
+
res.on("close", () => {
|
|
131
|
+
if (!res.writableEnded)
|
|
132
|
+
ac.abort();
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
const body = await readJson(req);
|
|
136
|
+
const p = (body.payload ?? {});
|
|
137
|
+
if (typeof p.alias !== "string" || typeof p.session_id !== "string") {
|
|
138
|
+
throw new Error("agentRun payload must include `alias` and `session_id`");
|
|
139
|
+
}
|
|
140
|
+
const upstream = await args.client.appAgentRunStream(body.app_id, p.alias, { session_id: p.session_id, input: p.input ?? {} }, ac.signal);
|
|
141
|
+
if (!upstream.body)
|
|
142
|
+
throw new Error("agent run returned no stream body");
|
|
143
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
|
|
144
|
+
const reader = upstream.body.getReader();
|
|
145
|
+
for (;;) {
|
|
146
|
+
const { value, done } = await reader.read();
|
|
147
|
+
if (done)
|
|
148
|
+
break;
|
|
149
|
+
res.write(Buffer.from(value));
|
|
150
|
+
}
|
|
151
|
+
res.end();
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (ac.signal.aborted) {
|
|
155
|
+
res.end();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
+
process.stderr.write(`[agent_run] ERROR ${message}\n`);
|
|
160
|
+
if (!res.headersSent) {
|
|
161
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
162
|
+
res.end(JSON.stringify({ message }));
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
res.end();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
119
170
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
120
171
|
res.end("Not Found");
|
|
121
172
|
});
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* Protocol matches `frontend/features/app_ui/app_iframe_host.tsx` exactly:
|
|
21
21
|
* iframe → wrapper: { id: number, op: string, payload: unknown }
|
|
22
22
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
23
|
+
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
24
|
+
* the iframe aborts with { id, type: "abort" }.
|
|
23
25
|
*/
|
|
24
26
|
export interface WrapperPageArgs {
|
|
25
27
|
app_name: string;
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* Protocol matches `frontend/features/app_ui/app_iframe_host.tsx` exactly:
|
|
21
21
|
* iframe → wrapper: { id: number, op: string, payload: unknown }
|
|
22
22
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
23
|
+
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
24
|
+
* the iframe aborts with { id, type: "abort" }.
|
|
23
25
|
*/
|
|
24
26
|
export function buildWrapperPage(args) {
|
|
25
27
|
const { app_name, app_id, workspace_id, vite_url, api_url } = args;
|
|
@@ -161,10 +163,61 @@ export function buildWrapperPage(args) {
|
|
|
161
163
|
return undefined;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
// Streaming agent runs (op "agentRun"): the response is a stream, so it
|
|
167
|
+
// can't use rpc()'s single JSON round-trip. POST /_agent_run, read the SSE
|
|
168
|
+
// body, and forward chunks to the iframe as the SDK's bridged streaming
|
|
169
|
+
// protocol expects (stream-chunk* -> stream-end). Mirrors the production
|
|
170
|
+
// host in frontend/features/app_ui/app_iframe_host.tsx.
|
|
171
|
+
const streamingRuns = {};
|
|
172
|
+
async function handleAgentRun(id, payload) {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
streamingRuns[id] = controller;
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch("/_agent_run", {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "content-type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ app_id: APP_ID, op: "agentRun", payload: payload }),
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok || !res.body) {
|
|
183
|
+
let detail = await res.text().catch(function () { return ""; });
|
|
184
|
+
try { detail = JSON.parse(detail).message || detail; } catch (_) {}
|
|
185
|
+
throw new Error(detail || ("HTTP " + res.status));
|
|
186
|
+
}
|
|
187
|
+
const reader = res.body.getReader();
|
|
188
|
+
const decoder = new TextDecoder();
|
|
189
|
+
for (;;) {
|
|
190
|
+
const r = await reader.read();
|
|
191
|
+
if (r.done) break;
|
|
192
|
+
iframe.contentWindow.postMessage(
|
|
193
|
+
{ id: id, type: "stream-chunk", chunk: decoder.decode(r.value, { stream: true }) },
|
|
194
|
+
VITE_ORIGIN
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const tail = decoder.decode();
|
|
198
|
+
if (tail) iframe.contentWindow.postMessage({ id: id, type: "stream-chunk", chunk: tail }, VITE_ORIGIN);
|
|
199
|
+
iframe.contentWindow.postMessage({ id: id, type: "stream-end" }, VITE_ORIGIN);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (controller.signal.aborted) return;
|
|
202
|
+
const message = err && err.message ? err.message : String(err);
|
|
203
|
+
iframe.contentWindow.postMessage({ id: id, type: "error", message: message }, VITE_ORIGIN);
|
|
204
|
+
} finally {
|
|
205
|
+
delete streamingRuns[id];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
164
209
|
window.addEventListener("message", async function (event) {
|
|
165
210
|
if (event.source !== iframe.contentWindow || event.origin !== VITE_ORIGIN) return;
|
|
166
211
|
const msg = event.data;
|
|
167
|
-
if (!msg || typeof msg.id !== "number"
|
|
212
|
+
if (!msg || typeof msg.id !== "number") return;
|
|
213
|
+
// Abort an in-flight streaming run (control message, carries no op).
|
|
214
|
+
if (msg.type === "abort") {
|
|
215
|
+
if (streamingRuns[msg.id]) { streamingRuns[msg.id].abort(); delete streamingRuns[msg.id]; }
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (typeof msg.op !== "string") return;
|
|
219
|
+
// Streaming agent run — many messages back, not a single result.
|
|
220
|
+
if (msg.op === "agentRun") { handleAgentRun(msg.id, msg.payload); return; }
|
|
168
221
|
const startedAt = performance.now();
|
|
169
222
|
try {
|
|
170
223
|
const data = msg.op === "upload"
|
package/dist/src/cli.js
CHANGED
|
@@ -29791,6 +29791,25 @@ var LoticsClient = class {
|
|
|
29791
29791
|
{ inputs }
|
|
29792
29792
|
);
|
|
29793
29793
|
}
|
|
29794
|
+
/**
|
|
29795
|
+
* Open a streaming agent run and return the RAW streamed `Response` (the
|
|
29796
|
+
* caller reads `res.body`). Unlike `request`, this does not buffer/parse the
|
|
29797
|
+
* body — it's the SSE stream the `lotics app dev` harness proxies to the
|
|
29798
|
+
* iframe. Mirrors POST /v1/apps/{app_id}/agents/{alias}/runs.
|
|
29799
|
+
*/
|
|
29800
|
+
async appAgentRunStream(app_id, alias, body, signal) {
|
|
29801
|
+
const res = await fetch(
|
|
29802
|
+
`${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/agents/${encodeURIComponent(alias)}/runs`,
|
|
29803
|
+
{
|
|
29804
|
+
method: "POST",
|
|
29805
|
+
headers: { ...this.buildHeaders(), "Content-Type": "application/json" },
|
|
29806
|
+
body: JSON.stringify(body),
|
|
29807
|
+
signal
|
|
29808
|
+
}
|
|
29809
|
+
);
|
|
29810
|
+
if (!res.ok) await this.throwResponseError(res);
|
|
29811
|
+
return res;
|
|
29812
|
+
}
|
|
29794
29813
|
/**
|
|
29795
29814
|
* Mint a presigned URL for uploading a file into an app. Mirrors
|
|
29796
29815
|
* POST /v1/apps/{app_id}/files/upload-url.
|
|
@@ -30304,7 +30323,12 @@ function buildStarterTemplate(args) {
|
|
|
30304
30323
|
// Restrict tsc to the project's own sources. tsgo follows
|
|
30305
30324
|
// transitive imports into node_modules — strict rules
|
|
30306
30325
|
// (noUnusedLocals etc.) would fire on @lotics/ui's source.
|
|
30307
|
-
|
|
30326
|
+
// `.lotics/**/*` (not bare `.lotics`): TypeScript's include glob walk
|
|
30327
|
+
// SKIPS dot-directories, so a bare `.lotics` silently loads zero of the
|
|
30328
|
+
// generated `.d.ts` augmentations and typed useWorkflow/useQuery/useAgentRun
|
|
30329
|
+
// fall to `unknown`. The explicit glob makes the dot-dir the non-wildcard
|
|
30330
|
+
// base, which IS read. (GAP-38.)
|
|
30331
|
+
include: ["src", ".lotics/**/*"],
|
|
30308
30332
|
exclude: ["node_modules"]
|
|
30309
30333
|
},
|
|
30310
30334
|
null,
|
|
@@ -30434,7 +30458,23 @@ export default defineConfig({
|
|
|
30434
30458
|
optimizer: {
|
|
30435
30459
|
web: {
|
|
30436
30460
|
enabled: true,
|
|
30437
|
-
|
|
30461
|
+
// react + react-dom + the @testing-library renderer must ride in the
|
|
30462
|
+
// SAME optimized chunk as react-native-web, so a rendered tree and the
|
|
30463
|
+
// renderer share ONE react instance. Without this, a test that renders a
|
|
30464
|
+
// non-trivial App tree hits a null hooks dispatcher ("Cannot read
|
|
30465
|
+
// properties of null (reading 'useState')") the moment it mounts.
|
|
30466
|
+
include: [
|
|
30467
|
+
"react",
|
|
30468
|
+
"react-dom",
|
|
30469
|
+
"react-dom/client",
|
|
30470
|
+
"react-dom/test-utils",
|
|
30471
|
+
"react-native",
|
|
30472
|
+
"react-native-web",
|
|
30473
|
+
"react-native-svg",
|
|
30474
|
+
"@react-native-picker/picker",
|
|
30475
|
+
"@testing-library/react",
|
|
30476
|
+
"@testing-library/dom",
|
|
30477
|
+
],
|
|
30438
30478
|
esbuildOptions: {
|
|
30439
30479
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
30440
30480
|
},
|
|
@@ -31007,10 +31047,61 @@ function buildWrapperPage(args) {
|
|
|
31007
31047
|
return undefined;
|
|
31008
31048
|
}
|
|
31009
31049
|
|
|
31050
|
+
// Streaming agent runs (op "agentRun"): the response is a stream, so it
|
|
31051
|
+
// can't use rpc()'s single JSON round-trip. POST /_agent_run, read the SSE
|
|
31052
|
+
// body, and forward chunks to the iframe as the SDK's bridged streaming
|
|
31053
|
+
// protocol expects (stream-chunk* -> stream-end). Mirrors the production
|
|
31054
|
+
// host in frontend/features/app_ui/app_iframe_host.tsx.
|
|
31055
|
+
const streamingRuns = {};
|
|
31056
|
+
async function handleAgentRun(id, payload) {
|
|
31057
|
+
const controller = new AbortController();
|
|
31058
|
+
streamingRuns[id] = controller;
|
|
31059
|
+
try {
|
|
31060
|
+
const res = await fetch("/_agent_run", {
|
|
31061
|
+
method: "POST",
|
|
31062
|
+
headers: { "content-type": "application/json" },
|
|
31063
|
+
body: JSON.stringify({ app_id: APP_ID, op: "agentRun", payload: payload }),
|
|
31064
|
+
signal: controller.signal,
|
|
31065
|
+
});
|
|
31066
|
+
if (!res.ok || !res.body) {
|
|
31067
|
+
let detail = await res.text().catch(function () { return ""; });
|
|
31068
|
+
try { detail = JSON.parse(detail).message || detail; } catch (_) {}
|
|
31069
|
+
throw new Error(detail || ("HTTP " + res.status));
|
|
31070
|
+
}
|
|
31071
|
+
const reader = res.body.getReader();
|
|
31072
|
+
const decoder = new TextDecoder();
|
|
31073
|
+
for (;;) {
|
|
31074
|
+
const r = await reader.read();
|
|
31075
|
+
if (r.done) break;
|
|
31076
|
+
iframe.contentWindow.postMessage(
|
|
31077
|
+
{ id: id, type: "stream-chunk", chunk: decoder.decode(r.value, { stream: true }) },
|
|
31078
|
+
VITE_ORIGIN
|
|
31079
|
+
);
|
|
31080
|
+
}
|
|
31081
|
+
const tail = decoder.decode();
|
|
31082
|
+
if (tail) iframe.contentWindow.postMessage({ id: id, type: "stream-chunk", chunk: tail }, VITE_ORIGIN);
|
|
31083
|
+
iframe.contentWindow.postMessage({ id: id, type: "stream-end" }, VITE_ORIGIN);
|
|
31084
|
+
} catch (err) {
|
|
31085
|
+
if (controller.signal.aborted) return;
|
|
31086
|
+
const message = err && err.message ? err.message : String(err);
|
|
31087
|
+
iframe.contentWindow.postMessage({ id: id, type: "error", message: message }, VITE_ORIGIN);
|
|
31088
|
+
} finally {
|
|
31089
|
+
delete streamingRuns[id];
|
|
31090
|
+
}
|
|
31091
|
+
}
|
|
31092
|
+
|
|
31010
31093
|
window.addEventListener("message", async function (event) {
|
|
31011
31094
|
if (event.source !== iframe.contentWindow || event.origin !== VITE_ORIGIN) return;
|
|
31012
31095
|
const msg = event.data;
|
|
31013
|
-
if (!msg || typeof msg.id !== "number"
|
|
31096
|
+
if (!msg || typeof msg.id !== "number") return;
|
|
31097
|
+
// Abort an in-flight streaming run (control message, carries no op).
|
|
31098
|
+
if (msg.type === "abort") {
|
|
31099
|
+
if (streamingRuns[msg.id]) { streamingRuns[msg.id].abort(); delete streamingRuns[msg.id]; }
|
|
31100
|
+
return;
|
|
31101
|
+
}
|
|
31102
|
+
if (typeof msg.op !== "string") return;
|
|
31103
|
+
// Streaming agent run \u2014 many messages back, not a single result.
|
|
31104
|
+
if (msg.op === "agentRun") { handleAgentRun(msg.id, msg.payload); return; }
|
|
31014
31105
|
const startedAt = performance.now();
|
|
31015
31106
|
try {
|
|
31016
31107
|
const data = msg.op === "upload"
|
|
@@ -31126,6 +31217,49 @@ async function startDevServer(args) {
|
|
|
31126
31217
|
}
|
|
31127
31218
|
return;
|
|
31128
31219
|
}
|
|
31220
|
+
if (req.method === "POST" && url === "/_agent_run") {
|
|
31221
|
+
const ac = new AbortController();
|
|
31222
|
+
res.on("close", () => {
|
|
31223
|
+
if (!res.writableEnded) ac.abort();
|
|
31224
|
+
});
|
|
31225
|
+
try {
|
|
31226
|
+
const body = await readJson(req);
|
|
31227
|
+
const p = body.payload ?? {};
|
|
31228
|
+
if (typeof p.alias !== "string" || typeof p.session_id !== "string") {
|
|
31229
|
+
throw new Error("agentRun payload must include `alias` and `session_id`");
|
|
31230
|
+
}
|
|
31231
|
+
const upstream = await args.client.appAgentRunStream(
|
|
31232
|
+
body.app_id,
|
|
31233
|
+
p.alias,
|
|
31234
|
+
{ session_id: p.session_id, input: p.input ?? {} },
|
|
31235
|
+
ac.signal
|
|
31236
|
+
);
|
|
31237
|
+
if (!upstream.body) throw new Error("agent run returned no stream body");
|
|
31238
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
|
|
31239
|
+
const reader = upstream.body.getReader();
|
|
31240
|
+
for (; ; ) {
|
|
31241
|
+
const { value, done } = await reader.read();
|
|
31242
|
+
if (done) break;
|
|
31243
|
+
res.write(Buffer.from(value));
|
|
31244
|
+
}
|
|
31245
|
+
res.end();
|
|
31246
|
+
} catch (err2) {
|
|
31247
|
+
if (ac.signal.aborted) {
|
|
31248
|
+
res.end();
|
|
31249
|
+
return;
|
|
31250
|
+
}
|
|
31251
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
31252
|
+
process.stderr.write(`[agent_run] ERROR ${message}
|
|
31253
|
+
`);
|
|
31254
|
+
if (!res.headersSent) {
|
|
31255
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
31256
|
+
res.end(JSON.stringify({ message }));
|
|
31257
|
+
} else {
|
|
31258
|
+
res.end();
|
|
31259
|
+
}
|
|
31260
|
+
}
|
|
31261
|
+
return;
|
|
31262
|
+
}
|
|
31129
31263
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
31130
31264
|
res.end("Not Found");
|
|
31131
31265
|
});
|
|
@@ -31543,8 +31677,12 @@ async function downloadToFile(url, destPath) {
|
|
|
31543
31677
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
31544
31678
|
fs3.writeFileSync(destPath, buffer);
|
|
31545
31679
|
}
|
|
31680
|
+
function appDirName(name) {
|
|
31681
|
+
const cleaned = name.replace(/[/\\]+/g, "-").replace(/[<>:"|?*]/g, "").replace(/\s+/g, " ").trim().replace(/^[.\s-]+|[.\s-]+$/g, "");
|
|
31682
|
+
return cleaned || "app";
|
|
31683
|
+
}
|
|
31546
31684
|
async function appCreate(client, args) {
|
|
31547
|
-
const targetPath = path4.resolve(args.targetPath ?? args.name);
|
|
31685
|
+
const targetPath = path4.resolve(args.targetPath ?? appDirName(args.name));
|
|
31548
31686
|
if (fs3.existsSync(targetPath)) {
|
|
31549
31687
|
const entries = fs3.readdirSync(targetPath);
|
|
31550
31688
|
if (entries.length > 0) {
|
|
@@ -31605,7 +31743,7 @@ async function appPull(client, args) {
|
|
|
31605
31743
|
}
|
|
31606
31744
|
const version = await client.getAppVersion(app.id, app.current_version_id);
|
|
31607
31745
|
const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
|
|
31608
|
-
const targetPath = path4.resolve(args.targetPath ?? app.name);
|
|
31746
|
+
const targetPath = path4.resolve(args.targetPath ?? appDirName(app.name));
|
|
31609
31747
|
fs3.mkdirSync(targetPath, { recursive: true });
|
|
31610
31748
|
const tmpFile = path4.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
|
|
31611
31749
|
console.error(`Downloading source archive...`);
|
|
@@ -38289,6 +38427,12 @@ function emptyStyleResolver() {
|
|
|
38289
38427
|
var xmlParser3 = new XMLParser({
|
|
38290
38428
|
ignoreAttributes: false,
|
|
38291
38429
|
attributeNamePrefix: "@_",
|
|
38430
|
+
// Shared-string and workbook text is textual BY DEFINITION (tax IDs, phone
|
|
38431
|
+
// numbers, account codes, sheet/defined names). fast-xml-parser's default tag
|
|
38432
|
+
// coercion would parse `<t>0312345678</t>` as the number 312345678 — silently
|
|
38433
|
+
// dropping the leading zero before we can read it. Cell `<v>` numerics are
|
|
38434
|
+
// parsed deliberately (parseFloat) in ooxml_sheet.ts, so this never affects them.
|
|
38435
|
+
parseTagValue: false,
|
|
38292
38436
|
trimValues: false,
|
|
38293
38437
|
processEntities: false,
|
|
38294
38438
|
isArray: (tagName) => tagName === "sheet" || tagName === "si" || tagName === "r" || tagName === "Relationship" || tagName === "definedName"
|
package/dist/starter_template.js
CHANGED
|
@@ -126,7 +126,12 @@ export function buildStarterTemplate(args) {
|
|
|
126
126
|
// Restrict tsc to the project's own sources. tsgo follows
|
|
127
127
|
// transitive imports into node_modules — strict rules
|
|
128
128
|
// (noUnusedLocals etc.) would fire on @lotics/ui's source.
|
|
129
|
-
|
|
129
|
+
// `.lotics/**/*` (not bare `.lotics`): TypeScript's include glob walk
|
|
130
|
+
// SKIPS dot-directories, so a bare `.lotics` silently loads zero of the
|
|
131
|
+
// generated `.d.ts` augmentations and typed useWorkflow/useQuery/useAgentRun
|
|
132
|
+
// fall to `unknown`. The explicit glob makes the dot-dir the non-wildcard
|
|
133
|
+
// base, which IS read. (GAP-38.)
|
|
134
|
+
include: ["src", ".lotics/**/*"],
|
|
130
135
|
exclude: ["node_modules"],
|
|
131
136
|
}, null, 2) + "\n",
|
|
132
137
|
},
|
|
@@ -253,7 +258,23 @@ export default defineConfig({
|
|
|
253
258
|
optimizer: {
|
|
254
259
|
web: {
|
|
255
260
|
enabled: true,
|
|
256
|
-
|
|
261
|
+
// react + react-dom + the @testing-library renderer must ride in the
|
|
262
|
+
// SAME optimized chunk as react-native-web, so a rendered tree and the
|
|
263
|
+
// renderer share ONE react instance. Without this, a test that renders a
|
|
264
|
+
// non-trivial App tree hits a null hooks dispatcher ("Cannot read
|
|
265
|
+
// properties of null (reading 'useState')") the moment it mounts.
|
|
266
|
+
include: [
|
|
267
|
+
"react",
|
|
268
|
+
"react-dom",
|
|
269
|
+
"react-dom/client",
|
|
270
|
+
"react-dom/test-utils",
|
|
271
|
+
"react-native",
|
|
272
|
+
"react-native-web",
|
|
273
|
+
"react-native-svg",
|
|
274
|
+
"@react-native-picker/picker",
|
|
275
|
+
"@testing-library/react",
|
|
276
|
+
"@testing-library/dom",
|
|
277
|
+
],
|
|
257
278
|
esbuildOptions: {
|
|
258
279
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
259
280
|
},
|