@lotics/cli 0.185.0 → 0.187.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.
@@ -1,914 +1,1055 @@
1
- import { transportErrorMessage } from "@lotics/shared/transport_error";
1
+ // ../shared/src/transport_error.ts
2
+ function gatewayErrorMessage(status) {
3
+ if (status === 524) {
4
+ return "The request took too long to finish (gateway timeout). It may still be running \u2014 check back in a moment, or try again.";
5
+ }
6
+ if (status >= 500) {
7
+ return "The service is temporarily unavailable. Please try again shortly.";
8
+ }
9
+ return "The service returned an unexpected response. Please try again.";
10
+ }
11
+ function transportErrorMessage(status, parsed) {
12
+ const jsonMessage = parsed && typeof parsed.message === "string" ? parsed.message : null;
13
+ if (jsonMessage === null) return gatewayErrorMessage(status);
14
+ const authored = typeof parsed.code === "string" && parsed.code.length > 0;
15
+ return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
16
+ }
17
+
18
+ // ../shared/src/multipart_parts.ts
19
+ var DEFAULT_MAX_RETRIES = 5;
20
+ var MAX_RETRY_DELAY_MS = 15e3;
21
+ var TIMEOUT_BASE_MS = 3e4;
22
+ var TIMEOUT_PER_MB_MS = 15e3;
23
+ function partTimeoutMs(partSizeBytes) {
24
+ return TIMEOUT_BASE_MS + Math.ceil(partSizeBytes / (1024 * 1024)) * TIMEOUT_PER_MB_MS;
25
+ }
26
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+ var PermanentPartError = class extends Error {
28
+ };
29
+ function isRetryableStatus(status) {
30
+ return status >= 500 || status === 408 || status === 429;
31
+ }
32
+ function partErrorFor(partNumber, status) {
33
+ const message = `part ${partNumber} rejected by storage (${status})`;
34
+ return isRetryableStatus(status) ? new Error(message) : new PermanentPartError(message);
35
+ }
36
+ async function uploadParts(options) {
37
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
38
+ const concurrency = Math.max(1, options.concurrency ?? 1);
39
+ const done = [...options.alreadyUploaded ?? []];
40
+ const finished = new Set(done.map((part) => part.part_number));
41
+ const queue = options.parts.filter((part) => !finished.has(part.part_number));
42
+ const uploadOne = async (part) => {
43
+ const bytes = options.partBytes(part);
44
+ for (let attempt = 1; ; attempt += 1) {
45
+ options.signal?.throwIfAborted();
46
+ try {
47
+ const timeout = AbortSignal.timeout(partTimeoutMs(bytes));
48
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
49
+ const etag = await options.putPart(part, await options.readPart(part), signal);
50
+ if (!etag) {
51
+ throw new PermanentPartError(`part ${part.part_number} stored without an ETag`);
52
+ }
53
+ return { part_number: part.part_number, etag };
54
+ } catch (error) {
55
+ if (options.signal?.aborted) throw error;
56
+ if (error instanceof PermanentPartError) throw error;
57
+ if (attempt >= maxRetries) {
58
+ options.onExhausted?.({ partNumber: part.part_number, attempts: attempt, error });
59
+ throw error;
60
+ }
61
+ options.onRetry?.({ partNumber: part.part_number, attempt, error });
62
+ const base = 1e3 * 2 ** (attempt - 1);
63
+ await sleep(Math.min(base + Math.random() * base * 0.5, MAX_RETRY_DELAY_MS));
64
+ }
65
+ }
66
+ };
67
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
68
+ for (let next = queue.shift(); next !== void 0; next = queue.shift()) {
69
+ const part = await uploadOne(next);
70
+ done.push(part);
71
+ options.onPartUploaded?.(part);
72
+ }
73
+ });
74
+ await Promise.all(workers);
75
+ return done.sort((a, b) => a.part_number - b.part_number);
76
+ }
77
+
78
+ // src/client.ts
2
79
  import crypto from "node:crypto";
3
80
  import fs from "node:fs";
4
81
  import path from "node:path";
5
- import { getInvocation } from "./invocation.js";
6
- /**
7
- * The filename a `Content-Disposition` names, preferring RFC 5987 `filename*`.
8
- *
9
- * The server always sends BOTH: `filename*=UTF-8''…` carrying the real name,
10
- * and a plain `filename=` whose non-ASCII characters are replaced with `_`
11
- * because HTTP header values are Latin-1. Reading only the plain one turned
12
- * every accented name into underscores on the way to disk — `GIẤY YÊU CẦU.doc`
13
- * saved as `GI_Y Y_U C_U.doc` — even though the correct bytes were in the same
14
- * header. The `*` form wins whenever it parses; the plain form is the fallback
15
- * it was always meant to be.
16
- */
82
+
83
+ // src/invocation.ts
84
+ var invocation = null;
85
+ function getInvocation() {
86
+ return invocation;
87
+ }
88
+ var IDLE_GAP_MS = 30 * 60 * 1e3;
89
+
90
+ // src/client.ts
17
91
  function parseContentDispositionFilename(disposition) {
18
- const extended = disposition.match(/filename\*=\s*([^']*)'[^']*'([^;\n]+)/i);
19
- if (extended) {
20
- try {
21
- return decodeURIComponent(extended[2].trim());
22
- }
23
- catch {
24
- // A malformed percent-escape means this parameter is unusable, not that
25
- // the whole header is — fall through to the ASCII form below.
26
- }
92
+ const extended = disposition.match(/filename\*=\s*([^']*)'[^']*'([^;\n]+)/i);
93
+ if (extended) {
94
+ try {
95
+ return decodeURIComponent(extended[2].trim());
96
+ } catch {
27
97
  }
28
- const plain = disposition.match(/filename=\s*"?([^";\n]+)"?/i);
29
- return plain ? plain[1].trim() : null;
98
+ }
99
+ const plain = disposition.match(/filename=\s*"?([^";\n]+)"?/i);
100
+ return plain ? plain[1].trim() : null;
30
101
  }
31
102
  function findAvailableFilename(dir, filename, reserved) {
32
- // `reserved` tracks absolute paths claimed by in-flight downloads in the same
33
- // batch required for parallel callers because the file may not be on disk
34
- // yet when a peer call races to pick a name.
35
- const isTaken = (name) => {
36
- const full = path.join(dir, name);
37
- if (reserved?.has(full))
38
- return true;
39
- return fs.existsSync(full);
40
- };
41
- const claim = (name) => {
42
- reserved?.add(path.join(dir, name));
43
- return name;
44
- };
45
- if (!isTaken(filename))
46
- return claim(filename);
47
- const lastDot = filename.lastIndexOf(".");
48
- const base = lastDot > 0 ? filename.slice(0, lastDot) : filename;
49
- const ext = lastDot > 0 ? filename.slice(lastDot) : "";
50
- let n = 2;
51
- while (isTaken(`${base}_${n}${ext}`))
52
- n++;
53
- return claim(`${base}_${n}${ext}`);
103
+ const isTaken = (name) => {
104
+ const full = path.join(dir, name);
105
+ if (reserved?.has(full)) return true;
106
+ return fs.existsSync(full);
107
+ };
108
+ const claim = (name) => {
109
+ reserved?.add(path.join(dir, name));
110
+ return name;
111
+ };
112
+ if (!isTaken(filename)) return claim(filename);
113
+ const lastDot = filename.lastIndexOf(".");
114
+ const base = lastDot > 0 ? filename.slice(0, lastDot) : filename;
115
+ const ext = lastDot > 0 ? filename.slice(lastDot) : "";
116
+ let n = 2;
117
+ while (isTaken(`${base}_${n}${ext}`)) n++;
118
+ return claim(`${base}_${n}${ext}`);
54
119
  }
55
- const MIME_MAP = {
56
- ".jpg": "image/jpeg",
57
- ".jpeg": "image/jpeg",
58
- ".png": "image/png",
59
- ".gif": "image/gif",
60
- ".webp": "image/webp",
61
- ".svg": "image/svg+xml",
62
- ".pdf": "application/pdf",
63
- ".csv": "text/csv",
64
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
65
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
66
- ".txt": "text/plain",
67
- ".json": "application/json",
68
- ".html": "text/html",
69
- ".xml": "application/xml",
70
- ".zip": "application/zip",
120
+ var MIME_MAP = {
121
+ ".jpg": "image/jpeg",
122
+ ".jpeg": "image/jpeg",
123
+ ".png": "image/png",
124
+ ".gif": "image/gif",
125
+ ".webp": "image/webp",
126
+ ".svg": "image/svg+xml",
127
+ ".pdf": "application/pdf",
128
+ ".csv": "text/csv",
129
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
130
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
131
+ ".txt": "text/plain",
132
+ ".json": "application/json",
133
+ ".html": "text/html",
134
+ ".xml": "application/xml",
135
+ ".zip": "application/zip"
71
136
  };
72
137
  function getMimeType(filename) {
73
- const ext = path.extname(filename).toLowerCase();
74
- return MIME_MAP[ext] ?? "application/octet-stream";
138
+ const ext = path.extname(filename).toLowerCase();
139
+ return MIME_MAP[ext] ?? "application/octet-stream";
75
140
  }
76
- export const API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai";
77
- /**
78
- * Where a person goes when the CLI cannot finish the job — the one case being a
79
- * returning user on a machine that holds no key, since every route back in
80
- * needs one. Held beside the API base so the pair is read together, and named
81
- * once rather than inlined at each message that has to point somewhere.
82
- */
83
- export const WEB_APP_URL = "https://lotics.ai";
84
- /**
85
- * The starters Lotics publishes, fetched with no credential.
86
- *
87
- * A plain function rather than a `LoticsClient` method because there is no key
88
- * to build a client around — and that is the point of the endpoint. "Is there a
89
- * starter for what I do, or should I build?" is decided before an account
90
- * exists, so needing one to ask means signing up to find out the answer was no.
91
- */
92
- export async function fetchOfficialStarters() {
93
- // Bounded, because this is the FIRST command a new user runs and a hang with
94
- // nothing on screen is the worst version of the failure. `config.ts` bounds
95
- // its own unattended fetch for the same reason; `docs/network_reliability.md`
96
- // is a log of connections from our users' networks degrading rather than
97
- // refusing, which is the shape that hangs.
98
- let response;
99
- try {
100
- response = await fetch(`${API_BASE_URL}/v1/starters/official`, {
101
- signal: AbortSignal.timeout(SHELF_FETCH_TIMEOUT_MS),
102
- });
103
- }
104
- catch (error) {
105
- // Nothing answered. This is the only branch where "check your connection"
106
- // is true — a status code below means the server replied and the network is
107
- // demonstrably fine.
108
- throw new Error(`Could not reach Lotics to list the starters (${error instanceof Error ? error.message : String(error)}). ` +
109
- `Check your connection, or browse ${WEB_APP_URL}/docs/cli.`);
110
- }
111
- if (!response.ok) {
112
- throw new Error(`Lotics answered ${response.status} listing the starters. ` +
113
- `If this keeps happening, browse ${WEB_APP_URL}/docs/cli.`);
114
- }
115
- return (await response.json());
141
+ var MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024;
142
+ var API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai";
143
+ var WEB_APP_URL = "https://lotics.ai";
144
+ async function fetchOfficialStarters() {
145
+ let response;
146
+ try {
147
+ response = await fetch(`${API_BASE_URL}/v1/starters/official`, {
148
+ signal: AbortSignal.timeout(SHELF_FETCH_TIMEOUT_MS)
149
+ });
150
+ } catch (error) {
151
+ throw new Error(
152
+ `Could not reach Lotics to list the starters (${error instanceof Error ? error.message : String(error)}). Check your connection, or browse ${WEB_APP_URL}/docs/cli.`
153
+ );
154
+ }
155
+ if (!response.ok) {
156
+ throw new Error(
157
+ `Lotics answered ${response.status} listing the starters. If this keeps happening, browse ${WEB_APP_URL}/docs/cli.`
158
+ );
159
+ }
160
+ return await response.json();
116
161
  }
117
- /** Long enough for a slow link, short enough that a dead one still says so. */
118
- const SHELF_FETCH_TIMEOUT_MS = 10_000;
119
- /**
120
- * A short id for one request, short because a person has to read it back to us.
121
- *
122
- * Not `generateLocalId` from `@lotics/shared`: this file is the UNBUNDLED
123
- * library entry, so every bare specifier it imports has to resolve from a
124
- * package whose `dependencies` are empty. `node:crypto` is a builtin and costs
125
- * nothing, and the server treats the header as opaque — it logs whatever
126
- * arrives, so matching its id ALPHABET buys nothing.
127
- */
162
+ var SHELF_FETCH_TIMEOUT_MS = 1e4;
128
163
  function newRequestId() {
129
- return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
164
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
130
165
  }
131
- export class LoticsClient {
132
- apiKey;
133
- workspaceId;
134
- /** The active "View as" target member id, if any. Read-only after
135
- * construction — surfaced so `lotics app dev` can show it in the banner. */
136
- viewAsMemberId;
137
- /** API URL the client is configured against. Read-only after construction.
138
- * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
139
- * shows it in the banner). */
140
- baseUrl;
141
- constructor(options) {
142
- this.apiKey = options.apiKey;
143
- this.workspaceId = options.workspaceId;
144
- this.viewAsMemberId = options.viewAsMemberId;
145
- this.baseUrl = API_BASE_URL;
146
- }
147
- /**
148
- * The id is appended to the MESSAGE rather than carried on a field, because
149
- * the only thing that reliably reaches a person is what got printed: the CLI's
150
- * top-level handler writes `error.message` to stderr, and the telemetry tail
151
- * captures the same bytes. A field would have to be read by every one of ~60
152
- * exit sites to be worth anything.
153
- *
154
- * `requestId` is optional because one caller legitimately has none — a
155
- * presigned CDN download is not a request of ours and appears in none of our
156
- * logs. Every call against the API passes the id its own headers carry.
157
- */
158
- async throwResponseError(response, requestId) {
159
- const text = await response.text();
160
- let message;
161
- try {
162
- const json = JSON.parse(text);
163
- message = json.message ?? text;
164
- }
165
- catch {
166
- message = text;
167
- }
168
- const trace = requestId === undefined ? "" : `\n\n Request id: ${requestId} — quote this to Lotics support.`;
169
- throw new Error(`${response.status}: ${message}${trace}`);
170
- }
171
- /**
172
- * The backend's `log()` middleware registers `user-agent`,
173
- * `x-posthog-session-id` and `x-request-id` onto the per-request Logger, so
174
- * they ride EVERY log line that request emits the validation 400, the tool
175
- * error, the timing. Sending them is therefore the whole of the correlation
176
- * work: it turns an anonymous API-key request into "`app workflow set`, from
177
- * cli 0.117.0, the fourth command of this session".
178
- *
179
- * The request id is minted HERE, with the other headers, so it cannot reach
180
- * some paths and not others: several commands build their own transport around
181
- * these headers, and an id threaded only through `request` would leave
182
- * `app deploy`, `uploadFiles` and every workflow call unfindable.
183
- */
184
- buildHeaders() {
185
- const invocation = getInvocation();
186
- const headers = {
187
- "Authorization": `Bearer ${this.apiKey}`,
188
- // Unversioned when the SDK is used as a library — there is no CLI process
189
- // whose version to name, and a wrong one is worse than none.
190
- "user-agent": invocation?.userAgent ?? "lotics-cli",
191
- };
192
- if (this.workspaceId) {
193
- headers["x-workspace-id"] = this.workspaceId;
194
- }
195
- if (this.viewAsMemberId) {
196
- headers["x-view-as-member-id"] = this.viewAsMemberId;
197
- }
198
- if (invocation) {
199
- headers["x-lotics-cli-command"] = invocation.command;
200
- if (invocation.session !== null) {
201
- headers["x-posthog-session-id"] = invocation.session;
202
- }
203
- }
204
- // Unconditional, unlike the session header: this describes the request
205
- // rather than tracking anyone, and it is what makes a failure findable.
206
- headers["x-request-id"] = newRequestId();
207
- return headers;
208
- }
209
- async request(method, path, body) {
210
- const url = `${this.baseUrl}${path}`;
211
- const headers = this.buildHeaders();
212
- const init = { method, headers };
213
- if (body !== undefined) {
214
- headers["Content-Type"] = "application/json";
215
- init.body = JSON.stringify(body);
216
- }
217
- const response = await fetch(url, init);
218
- if (!response.ok)
219
- await this.throwResponseError(response, headers["x-request-id"]);
166
+ var LoticsClient = class {
167
+ apiKey;
168
+ workspaceId;
169
+ /** The active "View as" target member id, if any. Read-only after
170
+ * construction — surfaced so `lotics app dev` can show it in the banner. */
171
+ viewAsMemberId;
172
+ /** API URL the client is configured against. Read-only after construction.
173
+ * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
174
+ * shows it in the banner). */
175
+ baseUrl;
176
+ constructor(options) {
177
+ this.apiKey = options.apiKey;
178
+ this.workspaceId = options.workspaceId;
179
+ this.viewAsMemberId = options.viewAsMemberId;
180
+ this.baseUrl = API_BASE_URL;
181
+ }
182
+ /**
183
+ * The id is appended to the MESSAGE rather than carried on a field, because
184
+ * the only thing that reliably reaches a person is what got printed: the CLI's
185
+ * top-level handler writes `error.message` to stderr, and the telemetry tail
186
+ * captures the same bytes. A field would have to be read by every one of ~60
187
+ * exit sites to be worth anything.
188
+ *
189
+ * `requestId` is optional because one caller legitimately has none — a
190
+ * presigned CDN download is not a request of ours and appears in none of our
191
+ * logs. Every call against the API passes the id its own headers carry.
192
+ */
193
+ async throwResponseError(response, requestId) {
194
+ const text = await response.text();
195
+ let message;
196
+ try {
197
+ const json = JSON.parse(text);
198
+ message = json.message ?? text;
199
+ } catch {
200
+ message = text;
201
+ }
202
+ const trace = requestId === void 0 ? "" : `
203
+
204
+ Request id: ${requestId} \u2014 quote this to Lotics support.`;
205
+ throw new Error(`${response.status}: ${message}${trace}`);
206
+ }
207
+ /**
208
+ * The backend's `log()` middleware registers `user-agent`,
209
+ * `x-posthog-session-id` and `x-request-id` onto the per-request Logger, so
210
+ * they ride EVERY log line that request emits — the validation 400, the tool
211
+ * error, the timing. Sending them is therefore the whole of the correlation
212
+ * work: it turns an anonymous API-key request into "`app workflow set`, from
213
+ * cli 0.117.0, the fourth command of this session".
214
+ *
215
+ * The request id is minted HERE, with the other headers, so it cannot reach
216
+ * some paths and not others: several commands build their own transport around
217
+ * these headers, and an id threaded only through `request` would leave
218
+ * `app deploy`, `uploadFiles` and every workflow call unfindable.
219
+ */
220
+ buildHeaders() {
221
+ const invocation2 = getInvocation();
222
+ const headers = {
223
+ "Authorization": `Bearer ${this.apiKey}`,
224
+ // Unversioned when the SDK is used as a library there is no CLI process
225
+ // whose version to name, and a wrong one is worse than none.
226
+ "user-agent": invocation2?.userAgent ?? "lotics-cli"
227
+ };
228
+ if (this.workspaceId) {
229
+ headers["x-workspace-id"] = this.workspaceId;
230
+ }
231
+ if (this.viewAsMemberId) {
232
+ headers["x-view-as-member-id"] = this.viewAsMemberId;
233
+ }
234
+ if (invocation2) {
235
+ headers["x-lotics-cli-command"] = invocation2.command;
236
+ if (invocation2.session !== null) {
237
+ headers["x-posthog-session-id"] = invocation2.session;
238
+ }
239
+ }
240
+ headers["x-request-id"] = newRequestId();
241
+ return headers;
242
+ }
243
+ async request(method, path2, body) {
244
+ const url = `${this.baseUrl}${path2}`;
245
+ const headers = this.buildHeaders();
246
+ const init = { method, headers };
247
+ if (body !== void 0) {
248
+ headers["Content-Type"] = "application/json";
249
+ init.body = JSON.stringify(body);
250
+ }
251
+ const response = await fetch(url, init);
252
+ if (!response.ok) await this.throwResponseError(response, headers["x-request-id"]);
253
+ return response.json();
254
+ }
255
+ async whoami() {
256
+ return this.request("GET", "/v1/cli/whoami");
257
+ }
258
+ /**
259
+ * File a hand-written report. Unlike a telemetry flush, the headers this
260
+ * stamps are CORRECT: the invocation making the request is the one the report
261
+ * is about, so the courier and the cargo are the same session.
262
+ */
263
+ async sendReport(body) {
264
+ return this.request("POST", "/v1/cli/report", body);
265
+ }
266
+ setWorkspaceId(id) {
267
+ this.workspaceId = id;
268
+ }
269
+ /** The workspace id the client targets (the `x-workspace-id` header), if resolved. */
270
+ getWorkspaceId() {
271
+ return this.workspaceId;
272
+ }
273
+ async listWorkspaces() {
274
+ return this.request("GET", "/v1/workspaces");
275
+ }
276
+ async createWorkspace(body) {
277
+ return this.request("POST", "/v1/workspaces", body);
278
+ }
279
+ /** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
280
+ * target from the request's workspace, never a path id. */
281
+ async updateWorkspace(body) {
282
+ return this.request("PATCH", "/v1/workspace", body);
283
+ }
284
+ async deleteWorkspace(id) {
285
+ return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}`);
286
+ }
287
+ async login(body) {
288
+ return this.request("POST", "/v1/cli/login", body ?? {});
289
+ }
290
+ async listTools() {
291
+ return this.request("GET", "/v1/tools");
292
+ }
293
+ async getTool(name) {
294
+ return this.request("GET", `/v1/tools/${encodeURIComponent(name)}`);
295
+ }
296
+ async execute(tool, args, options) {
297
+ const body = { tool, args, format: options?.format ?? "text" };
298
+ if (options?.timeoutMs) {
299
+ const controller = new AbortController();
300
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
301
+ try {
302
+ const url = `${this.baseUrl}/v1/tools/execute`;
303
+ const headers = { ...this.buildHeaders(), "Content-Type": "application/json" };
304
+ const response = await fetch(url, {
305
+ method: "POST",
306
+ headers,
307
+ body: JSON.stringify(body),
308
+ signal: controller.signal
309
+ });
310
+ if (!response.ok) await this.throwResponseError(response, headers["x-request-id"]);
220
311
  return response.json();
221
- }
222
- async whoami() {
223
- return this.request("GET", "/v1/cli/whoami");
224
- }
225
- /**
226
- * File a hand-written report. Unlike a telemetry flush, the headers this
227
- * stamps are CORRECT: the invocation making the request is the one the report
228
- * is about, so the courier and the cargo are the same session.
229
- */
230
- async sendReport(body) {
231
- return this.request("POST", "/v1/cli/report", body);
232
- }
233
- setWorkspaceId(id) {
234
- this.workspaceId = id;
235
- }
236
- /** The workspace id the client targets (the `x-workspace-id` header), if resolved. */
237
- getWorkspaceId() {
238
- return this.workspaceId;
239
- }
240
- async listWorkspaces() {
241
- return this.request("GET", "/v1/workspaces");
242
- }
243
- async createWorkspace(body) {
244
- return this.request("POST", "/v1/workspaces", body);
245
- }
246
- /** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
247
- * target from the request's workspace, never a path id. */
248
- async updateWorkspace(body) {
249
- return this.request("PATCH", "/v1/workspace", body);
250
- }
251
- async deleteWorkspace(id) {
252
- return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}`);
253
- }
254
- async login(body) {
255
- return this.request("POST", "/v1/cli/login", body ?? {});
256
- }
257
- async listTools() {
258
- return this.request("GET", "/v1/tools");
259
- }
260
- async getTool(name) {
261
- return this.request("GET", `/v1/tools/${encodeURIComponent(name)}`);
262
- }
263
- async execute(tool, args, options) {
264
- const body = { tool, args, format: options?.format ?? "text" };
265
- if (options?.timeoutMs) {
266
- const controller = new AbortController();
267
- const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
268
- try {
269
- const url = `${this.baseUrl}/v1/tools/execute`;
270
- const headers = { ...this.buildHeaders(), "Content-Type": "application/json" };
271
- const response = await fetch(url, {
272
- method: "POST",
273
- headers,
274
- body: JSON.stringify(body),
275
- signal: controller.signal,
276
- });
277
- if (!response.ok)
278
- await this.throwResponseError(response, headers["x-request-id"]);
279
- return response.json();
280
- }
281
- catch (error) {
282
- if (error instanceof Error && error.name === "AbortError") {
283
- throw new Error(`Tool execution timed out after ${options.timeoutMs}ms`);
284
- }
285
- throw error;
286
- }
287
- finally {
288
- clearTimeout(timeout);
289
- }
312
+ } catch (error) {
313
+ if (error instanceof Error && error.name === "AbortError") {
314
+ throw new Error(`Tool execution timed out after ${options.timeoutMs}ms`);
290
315
  }
291
- return this.request("POST", "/v1/tools/execute", body);
292
- }
293
- // --- Knowledge docs ---
294
- /**
295
- * Fetch one knowledge doc with its hydrated `content` — the single content-read
296
- * path for a non-sandbox client (the `list_knowledge` tool returns metadata
297
- * only, and the sandbox-staging read path is unavailable here). Works for both
298
- * the file-model and legacy parked-column rows. Mirrors GET /v1/knowledge_docs/{id}.
299
- */
300
- async getKnowledgeDoc(knowledge_doc_id) {
301
- return this.request("GET", `/v1/knowledge_docs/${encodeURIComponent(knowledge_doc_id)}`);
302
- }
303
- /**
304
- * Every doc's metadata — REST rather than the `list_knowledge` tool, because
305
- * this is the surface a PERSON manages the corpus from and the tool is bound
306
- * to what the assistant may browse. That distinction is the whole point of
307
- * `include_hidden`: a hidden doc is out of the assistant's corpus by design,
308
- * and someone still has to be able to find it in order to put it back.
309
- */
310
- async listKnowledgeDocs(opts) {
311
- const qs = opts?.includeHidden ? "?include_hidden=true" : "";
312
- return this.request("GET", `/v1/knowledge_docs${qs}`);
313
- }
314
- /** Apply one metadata change to a set of docs. Tags are an add/remove DIFF —
315
- * see the endpoint's own note on why a replacement is the wrong shape here. */
316
- async bulkUpdateKnowledgeDocs(body) {
317
- return this.request("PATCH", "/v1/knowledge_docs", body);
318
- }
319
- // --- Apps ---
320
- async getApp(app_id) {
321
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}`);
322
- }
323
- async createApp(body) {
324
- return this.request("POST", "/v1/apps", body);
325
- }
326
- /**
327
- * Take a starter off the shelf, or `undo` to put it back (backs `opctl
328
- * starter unpublish`). It hides from non-owning orgs and can no longer be
329
- * copied; copies already made are unaffected — they never linked back.
330
- * Owner-org admin-only.
331
- */
332
- async unpublishStarter(starter_id, body) {
333
- return this.request("POST", `/v1/starters/${encodeURIComponent(starter_id)}/unpublish`, body);
334
- }
335
- /**
336
- * Edit a starter's registry listing — the name and description a stranger
337
- * reads before copying, and what every copy's app row is created from.
338
- *
339
- * A version is an immutable snapshot; the listing is not. Omit a field to
340
- * leave it, pass `description: null` to clear it. Owner-org admin-only.
341
- */
342
- async editStarterListing(starter_id, body) {
343
- return this.request("POST", `/v1/starters/${encodeURIComponent(starter_id)}/listing`, body);
344
- }
345
- // --- Starters (registry reads + copies) ---
346
- // Authoring is server-side, through the publish job (`requestStarterPublish`).
347
- // There is no client-side create-starter / upload-bundle path.
348
- /**
349
- * The starters this organization can copy — Lotics-reviewed ones plus its own,
350
- * never a catalogue of everything published. The server returns exactly what
351
- * instantiate would accept, so the list cannot offer a refusal. Admin-only.
352
- */
353
- async listStarters() {
354
- return this.request("GET", "/v1/starters");
355
- }
356
- /**
357
- * Copy a starter into the current workspace.
358
- *
359
- * Server-side this scaffolds the schema, creates the templates, docs and
360
- * sample records, creates every app the starter carries and deploys each
361
- * from its prebuilt dist — no build anywhere. `apps` reports each deploy;
362
- * one that failed carries its `error` and the copy is complete around it.
363
- * Admin-only.
364
- */
365
- async instantiateStarter(starter_id, body) {
366
- return this.request("POST", `/v1/starters/${encodeURIComponent(starter_id)}/instantiate`, body);
367
- }
368
- /**
369
- * Which starter this app is the origin of. 404 when it has published none.
370
- *
371
- * The app row carries no pin, so this is the only app→starter direction there
372
- * is — provenance lives on the published version. Admin-only.
373
- */
374
- async getAppOriginStarter(app_id) {
375
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/origin-starter`);
376
- }
377
- /**
378
- * Capture live records from this workspace as a starter's sample data.
379
- *
380
- * The alias-keyed shape is produced SERVER-side, because the contract alias
381
- * space is minted by extract and exists nowhere a project can read it. Pure
382
- * read — the caller writes the returned files into the project and reviews
383
- * them, which matters: these rows are copied verbatim into every workspace
384
- * that takes the starter. Admin-only.
385
- */
386
- async captureStarterFixtures(app_id, opts = {}) {
387
- const params = new URLSearchParams();
388
- if (opts.entities !== undefined && opts.entities.length > 0) {
389
- params.set("entities", opts.entities.join(","));
316
+ throw error;
317
+ } finally {
318
+ clearTimeout(timeout);
319
+ }
320
+ }
321
+ return this.request("POST", "/v1/tools/execute", body);
322
+ }
323
+ // --- Knowledge docs ---
324
+ /**
325
+ * Fetch one knowledge doc with its hydrated `content` — the single content-read
326
+ * path for a non-sandbox client (the `list_knowledge` tool returns metadata
327
+ * only, and the sandbox-staging read path is unavailable here). Works for both
328
+ * the file-model and legacy parked-column rows. Mirrors GET /v1/knowledge_docs/{id}.
329
+ */
330
+ async getKnowledgeDoc(knowledge_doc_id) {
331
+ return this.request("GET", `/v1/knowledge_docs/${encodeURIComponent(knowledge_doc_id)}`);
332
+ }
333
+ /**
334
+ * Every doc's metadata — REST rather than the `list_knowledge` tool, because
335
+ * this is the surface a PERSON manages the corpus from and the tool is bound
336
+ * to what the assistant may browse. That distinction is the whole point of
337
+ * `include_hidden`: a hidden doc is out of the assistant's corpus by design,
338
+ * and someone still has to be able to find it in order to put it back.
339
+ */
340
+ async listKnowledgeDocs(opts) {
341
+ const qs = opts?.includeHidden ? "?include_hidden=true" : "";
342
+ return this.request("GET", `/v1/knowledge_docs${qs}`);
343
+ }
344
+ /** Apply one metadata change to a set of docs. Tags are an add/remove DIFF —
345
+ * see the endpoint's own note on why a replacement is the wrong shape here. */
346
+ async bulkUpdateKnowledgeDocs(body) {
347
+ return this.request("PATCH", "/v1/knowledge_docs", body);
348
+ }
349
+ // --- Apps ---
350
+ async getApp(app_id) {
351
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}`);
352
+ }
353
+ async createApp(body) {
354
+ return this.request("POST", "/v1/apps", body);
355
+ }
356
+ /**
357
+ * Take a starter off the shelf, or `undo` to put it back (backs `opctl
358
+ * starter unpublish`). It hides from non-owning orgs and can no longer be
359
+ * copied; copies already made are unaffected — they never linked back.
360
+ * Owner-org admin-only.
361
+ */
362
+ async unpublishStarter(starter_id, body) {
363
+ return this.request("POST", `/v1/starters/${encodeURIComponent(starter_id)}/unpublish`, body);
364
+ }
365
+ /**
366
+ * Edit a starter's registry listing — the name and description a stranger
367
+ * reads before copying, and what every copy's app row is created from.
368
+ *
369
+ * A version is an immutable snapshot; the listing is not. Omit a field to
370
+ * leave it, pass `description: null` to clear it. Owner-org admin-only.
371
+ */
372
+ async editStarterListing(starter_id, body) {
373
+ return this.request("POST", `/v1/starters/${encodeURIComponent(starter_id)}/listing`, body);
374
+ }
375
+ // --- Starters (registry reads + copies) ---
376
+ // Authoring is server-side, through the publish job (`requestStarterPublish`).
377
+ // There is no client-side create-starter / upload-bundle path.
378
+ /**
379
+ * The starters this organization can copy — Lotics-reviewed ones plus its own,
380
+ * never a catalogue of everything published. The server returns exactly what
381
+ * instantiate would accept, so the list cannot offer a refusal. Admin-only.
382
+ */
383
+ async listStarters() {
384
+ return this.request("GET", "/v1/starters");
385
+ }
386
+ /**
387
+ * Copy a starter into the current workspace.
388
+ *
389
+ * Server-side this scaffolds the schema, creates the templates, docs and
390
+ * sample records, creates every app the starter carries and deploys each
391
+ * from its prebuilt dist — no build anywhere. `apps` reports each deploy;
392
+ * one that failed carries its `error` and the copy is complete around it.
393
+ * Admin-only.
394
+ */
395
+ async instantiateStarter(starter_id, body) {
396
+ return this.request(
397
+ "POST",
398
+ `/v1/starters/${encodeURIComponent(starter_id)}/instantiate`,
399
+ body
400
+ );
401
+ }
402
+ /**
403
+ * Which starter this app is the origin of. 404 when it has published none.
404
+ *
405
+ * The app row carries no pin, so this is the only app→starter direction there
406
+ * is provenance lives on the published version. Admin-only.
407
+ */
408
+ async getAppOriginStarter(app_id) {
409
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/origin-starter`);
410
+ }
411
+ /**
412
+ * Capture live records from this workspace as a starter's sample data.
413
+ *
414
+ * The alias-keyed shape is produced SERVER-side, because the contract alias
415
+ * space is minted by extract and exists nowhere a project can read it. Pure
416
+ * read — the caller writes the returned files into the project and reviews
417
+ * them, which matters: these rows are copied verbatim into every workspace
418
+ * that takes the starter. Admin-only.
419
+ */
420
+ async captureStarterFixtures(app_id, opts = {}) {
421
+ const params = new URLSearchParams();
422
+ if (opts.entities !== void 0 && opts.entities.length > 0) {
423
+ params.set("entities", opts.entities.join(","));
424
+ }
425
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
426
+ const qs = params.toString();
427
+ return this.request(
428
+ "GET",
429
+ `/v1/apps/${encodeURIComponent(app_id)}/fixtures-capture${qs ? `?${qs}` : ""}`
430
+ );
431
+ }
432
+ /**
433
+ * Fetch a registry starter's metadata (`latest_version` and the Lotics-backed
434
+ * `is_official` trust badge). Admin-only; cross-tenant by id.
435
+ */
436
+ async getStarter(starter_id) {
437
+ return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}`);
438
+ }
439
+ /** Version history newest-first (no contract payloads) — backs `opctl starter show`. Admin-only. */
440
+ async listStarterVersions(starter_id) {
441
+ return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}/versions`);
442
+ }
443
+ /**
444
+ * One version's contract — what a copy of it is supposed to produce. The
445
+ * history above omits it (heavy per row); this is the read that carries it,
446
+ * so a check compares a copy against the declaration itself rather than
447
+ * against a description of it. Admin-only; cross-tenant by id like
448
+ * `getStarter`.
449
+ */
450
+ async getStarterVersion(starter_id, version) {
451
+ return this.request(
452
+ "GET",
453
+ `/v1/starters/${encodeURIComponent(starter_id)}/versions/${encodeURIComponent(String(version))}`
454
+ );
455
+ }
456
+ /**
457
+ * Workspace-wide dangling-reference sweep — active app/workflow artifacts
458
+ * whose prefixed schema ids no longer resolve. Backs
459
+ * `lotics workspace doctor`. Admin-only.
460
+ */
461
+ async getWorkspaceDanglingReferences() {
462
+ return this.request("GET", "/v1/workspaces/dangling-references");
463
+ }
464
+ // --- Starter publishing (the authoring verbs; copying is `instantiateStarter`) ---
465
+ /**
466
+ * Preview publishing a set of this workspace's apps as one starter version —
467
+ * the GET behind `opctl starter publish` (no `--yes`). The server runs the
468
+ * same extraction the publish runs and reports which starter it would
469
+ * release into (null: it would mint one), the next version, the aliases a
470
+ * first publish can still rename, the diff against the current version, the
471
+ * knowledge delta, and the findings (an `error` blocks the publish). No
472
+ * writes. Admin-only.
473
+ */
474
+ async previewStarterPublish(opts) {
475
+ const params = new URLSearchParams();
476
+ params.set("app_ids", opts.app_ids.join(","));
477
+ if (opts.knowledge_doc_ids !== void 0) params.set("knowledge_doc_ids", opts.knowledge_doc_ids.join(","));
478
+ if (opts.renames !== void 0 && opts.renames.length > 0) params.set("renames", JSON.stringify(opts.renames));
479
+ if (opts.name !== void 0) params.set("name", opts.name);
480
+ if (opts.description !== void 0) params.set("description", opts.description);
481
+ if (opts.icon !== void 0) params.set("icon", opts.icon);
482
+ if (opts.color !== void 0) params.set("color", opts.color);
483
+ return this.request("GET", `/v1/starters/publish-preview?${params.toString()}`);
484
+ }
485
+ /**
486
+ * Publish this workspace's apps as a starter version — a JOB, because every
487
+ * app is built once against sentinel field keys and eleven builds outlast a
488
+ * request. Everything a request can refuse is refused here with nothing
489
+ * written: a blocking finding or another publish still running for this
490
+ * org (409), a missing deploy or a bad declaration (400). The response is
491
+ * the job to poll with `getStarterPublish`. Admin-only.
492
+ */
493
+ async requestStarterPublish(body) {
494
+ const { color, ...rest } = body;
495
+ return this.request("POST", "/v1/starters/publishes", {
496
+ ...rest,
497
+ ...color !== void 0 ? { theme: { color } } : {}
498
+ });
499
+ }
500
+ /** The state of a publish: which app is building, and the version once every dist is in. */
501
+ async getStarterPublish(publish_id) {
502
+ return this.request("GET", `/v1/starters/publishes/${encodeURIComponent(publish_id)}`);
503
+ }
504
+ /**
505
+ * Resolve the display name + fields (incl. select options) of the given tables
506
+ * — the schema `lotics app codegen` turns into the runtime `.lotics/app_fields.ts`
507
+ * alias maps. One `get_table` call per id (the tool surface has no batch
508
+ * variant); a missing/inaccessible table is dropped rather than throwing, so a
509
+ * stale id in the scope set never fails codegen.
510
+ */
511
+ async getWorkspaceSchema(tableIds) {
512
+ const tables = await Promise.all(
513
+ tableIds.map(async (table_id) => {
514
+ const res = await this.execute("get_table", { table_id });
515
+ if (res.error || res.result === null || typeof res.result !== "object") return null;
516
+ const table = res.result;
517
+ if (typeof table.id !== "string" || typeof table.name !== "string" || !Array.isArray(table.fields)) {
518
+ return null;
390
519
  }
391
- if (opts.limit !== undefined)
392
- params.set("limit", String(opts.limit));
393
- const qs = params.toString();
394
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/fixtures-capture${qs ? `?${qs}` : ""}`);
395
- }
396
- /**
397
- * Fetch a registry starter's metadata (`latest_version` and the Lotics-backed
398
- * `is_official` trust badge). Admin-only; cross-tenant by id.
399
- */
400
- async getStarter(starter_id) {
401
- return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}`);
402
- }
403
- /** Version history newest-first (no contract payloads) — backs `opctl starter show`. Admin-only. */
404
- async listStarterVersions(starter_id) {
405
- return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}/versions`);
406
- }
407
- /**
408
- * One version's contract — what a copy of it is supposed to produce. The
409
- * history above omits it (heavy per row); this is the read that carries it,
410
- * so a check compares a copy against the declaration itself rather than
411
- * against a description of it. Admin-only; cross-tenant by id like
412
- * `getStarter`.
413
- */
414
- async getStarterVersion(starter_id, version) {
415
- return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}/versions/${encodeURIComponent(String(version))}`);
416
- }
417
- /**
418
- * Workspace-wide dangling-reference sweep — active app/workflow artifacts
419
- * whose prefixed schema ids no longer resolve. Backs
420
- * `lotics workspace doctor`. Admin-only.
421
- */
422
- async getWorkspaceDanglingReferences() {
423
- return this.request("GET", "/v1/workspaces/dangling-references");
424
- }
425
- // --- Starter publishing (the authoring verbs; copying is `instantiateStarter`) ---
426
- /**
427
- * Preview publishing a set of this workspace's apps as one starter version —
428
- * the GET behind `opctl starter publish` (no `--yes`). The server runs the
429
- * same extraction the publish runs and reports which starter it would
430
- * release into (null: it would mint one), the next version, the aliases a
431
- * first publish can still rename, the diff against the current version, the
432
- * knowledge delta, and the findings (an `error` blocks the publish). No
433
- * writes. Admin-only.
434
- */
435
- async previewStarterPublish(opts) {
436
- const params = new URLSearchParams();
437
- params.set("app_ids", opts.app_ids.join(","));
438
- if (opts.knowledge_doc_ids !== undefined)
439
- params.set("knowledge_doc_ids", opts.knowledge_doc_ids.join(","));
440
- if (opts.renames !== undefined && opts.renames.length > 0)
441
- params.set("renames", JSON.stringify(opts.renames));
442
- if (opts.name !== undefined)
443
- params.set("name", opts.name);
444
- if (opts.description !== undefined)
445
- params.set("description", opts.description);
446
- if (opts.icon !== undefined)
447
- params.set("icon", opts.icon);
448
- if (opts.color !== undefined)
449
- params.set("color", opts.color);
450
- return this.request("GET", `/v1/starters/publish-preview?${params.toString()}`);
451
- }
452
- /**
453
- * Publish this workspace's apps as a starter version — a JOB, because every
454
- * app is built once against sentinel field keys and eleven builds outlast a
455
- * request. Everything a request can refuse is refused here with nothing
456
- * written: a blocking finding or another publish still running for this
457
- * org (409), a missing deploy or a bad declaration (400). The response is
458
- * the job to poll with `getStarterPublish`. Admin-only.
459
- */
460
- async requestStarterPublish(body) {
461
- const { color, ...rest } = body;
462
- return this.request("POST", "/v1/starters/publishes", {
463
- ...rest,
464
- ...(color !== undefined ? { theme: { color } } : {}),
520
+ const fields = table.fields.flatMap((field) => {
521
+ if (field === null || typeof field !== "object") return [];
522
+ const f = field;
523
+ if (typeof f.key !== "string" || typeof f.name !== "string" || typeof f.type !== "string") return [];
524
+ const options = Array.isArray(f.options) ? f.options.flatMap((opt) => {
525
+ if (opt === null || typeof opt !== "object") return [];
526
+ const o = opt;
527
+ return typeof o.key === "string" && typeof o.name === "string" ? [{ id: o.key, label: o.name }] : [];
528
+ }) : void 0;
529
+ return [{ id: f.key, name: f.name, type: f.type, ...options && options.length > 0 ? { options } : {} }];
465
530
  });
531
+ return { id: table.id, name: table.name, fields };
532
+ })
533
+ );
534
+ return tables.filter((t) => t !== null);
535
+ }
536
+ /**
537
+ * Rename an app's public subdomain — its `<slug>.lotics.app` address.
538
+ * Mirrors PUT /v1/apps/{app_id}/subdomain. The old subdomain stops
539
+ * resolving once the change lands.
540
+ */
541
+ async setAppSubdomain(app_id, public_subdomain) {
542
+ return this.request(
543
+ "PUT",
544
+ `/v1/apps/${encodeURIComponent(app_id)}/subdomain`,
545
+ { public_subdomain }
546
+ );
547
+ }
548
+ async getAppVersion(app_id, version_id) {
549
+ return this.request(
550
+ "GET",
551
+ `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}`
552
+ );
553
+ }
554
+ async getAppVersionSourceUrl(app_id, version_id) {
555
+ const result = await this.request(
556
+ "GET",
557
+ `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}/source`
558
+ );
559
+ return result.url;
560
+ }
561
+ /** Deploy history for an app — newest first. Backs `lotics app versions`. */
562
+ async listAppVersions(app_id, opts) {
563
+ const qs = new URLSearchParams();
564
+ if (opts?.limit != null) qs.set("limit", String(opts.limit));
565
+ if (opts?.offset != null) qs.set("offset", String(opts.offset));
566
+ const suffix = qs.toString() ? `?${qs.toString()}` : "";
567
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions${suffix}`);
568
+ }
569
+ // ── App iframe RPC endpoints ──────────────────────────────────────────────
570
+ // These mirror the two ops handled by frontend/features/app_ui/app_iframe_host.tsx.
571
+ // The deployed iframe sends postMessage to the parent frontend, which calls
572
+ // these same endpoints via the user's session cookie. `lotics app dev`
573
+ // forwards the iframe's postMessage to these methods using the CLI's API key.
574
+ /**
575
+ * Run a named query declared in the app's manifest, scoped to the app's IAM
576
+ * principal. Mirrors POST /v1/apps/{app_id}/query.
577
+ */
578
+ async appQuery(app_id, body) {
579
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/query`, body);
580
+ }
581
+ async appMembers(app_id, group_id) {
582
+ const qs = group_id ? `?group_id=${encodeURIComponent(group_id)}` : "";
583
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/members${qs}`);
584
+ }
585
+ /**
586
+ * Resolve the full option set (key, label, color) of a named query's select
587
+ * columns — the picker companion to `appQuery`. Mirrors
588
+ * POST /v1/apps/{app_id}/field-options.
589
+ */
590
+ async appFieldOptions(app_id, alias) {
591
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/field-options`, { alias });
592
+ }
593
+ /**
594
+ * Execute a workflow by alias declared in package.json#lotics.workflows.
595
+ * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
596
+ */
597
+ async appWorkflow(app_id, alias, inputs) {
598
+ const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`;
599
+ const headers = this.buildHeaders();
600
+ headers["Content-Type"] = "application/json";
601
+ let response;
602
+ try {
603
+ response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ inputs }) });
604
+ } catch (err) {
605
+ return { status: "error", message: err instanceof Error ? err.message : "The workflow request failed." };
606
+ }
607
+ const text = await response.text();
608
+ let parsed = null;
609
+ if (text) {
610
+ try {
611
+ parsed = JSON.parse(text);
612
+ } catch {
613
+ }
614
+ }
615
+ if (response.ok) return parsed ?? {};
616
+ return { status: "error", message: transportErrorMessage(response.status, parsed) };
617
+ }
618
+ /**
619
+ * Bind (create or replace) an app workflow by alias via the `set_app_workflow`
620
+ * tool — the SINGLE author of `apps.workflows` + the workflow row. `source` is
621
+ * the verbatim JS-subset body (no `on({...})` trigger). `inputs`/`outputs` are
622
+ * the typed schemas declared in `package.json#lotics.workflows.<alias>`. The
623
+ * server re-verifies the body and echoes the bound `outputs` (declared, else
624
+ * DERIVED from `return({ data })`), so the CLI can show the author what shape
625
+ * `result.data` will carry. Wraps the tool rather than a bespoke endpoint so
626
+ * the file flow stays a convenience over the existing single-author contract.
627
+ */
628
+ async setAppWorkflow(app_id, alias, body) {
629
+ return this.execute("set_app_workflow", {
630
+ app_id,
631
+ alias,
632
+ source: body.source,
633
+ ...body.inputs ? { inputs: body.inputs } : {},
634
+ ...body.outputs ? { outputs: body.outputs } : {},
635
+ ...body.name ? { name: body.name } : {},
636
+ ...body.description ? { description: body.description } : {},
637
+ ...body.expected_body_sha ? { expected_body_sha: body.expected_body_sha } : {}
638
+ });
639
+ }
640
+ /**
641
+ * Bind (create or replace) an app query by alias via the `set_app_query` tool
642
+ * — the deploy-free authoring path for `apps.queries`, parallel to
643
+ * `setAppWorkflow`. `declaration` is the `{ ast, params? }` from
644
+ * `package.json#lotics.queries.<alias>`. The server validates it exactly as a
645
+ * deploy validates the manifest. Note: `apps.queries` is manifest-authoritative,
646
+ * so the next `lotics app deploy` overwrites this from the manifest.
647
+ */
648
+ async setAppQuery(app_id, alias, declaration, expected_sha) {
649
+ return this.execute("set_app_query", {
650
+ app_id,
651
+ alias,
652
+ declaration,
653
+ ...expected_sha ? { expected_sha } : {}
654
+ });
655
+ }
656
+ /**
657
+ * Bind (create or replace) an app agent by alias via the `set_app_agent` tool
658
+ * — the deploy-free authoring path for `apps.agents`, parallel to
659
+ * `setAppWorkflow`/`setAppQuery`.
660
+ *
661
+ * `set_app_agent` REPLACES the whole declaration, so this takes the whole
662
+ * declaration. `lotics app agent set` is the caller that assembles it (prose
663
+ * from `src/agents/<alias>.md`, typed fields from the manifest) precisely so
664
+ * no caller has to remember that a partial payload silently drops
665
+ * `instructions`, `outputs` and the model pin.
666
+ */
667
+ async setAppAgent(app_id, alias, patch) {
668
+ return this.execute("set_app_agent", { app_id, alias, ...patch });
669
+ }
670
+ /**
671
+ * Fetch one app workflow's faithful source + bound input/output schemas via
672
+ * `get_app_workflow`. `source` is the JS-subset body re-rendered from the
673
+ * persisted step tree (incl. the `return({ data })` clause, opaque field/option
674
+ * keys) — the exact text `lotics app workflow set` would push back. Feeds
675
+ * `lotics app pull`, which writes it to `src/workflows/<alias>.ts`.
676
+ */
677
+ async getAppWorkflow(app_id, alias) {
678
+ return this.execute("get_app_workflow", { app_id, alias });
679
+ }
680
+ /**
681
+ * Fetch the server-generated workspace `.d.ts` + the wrapper envelope that
682
+ * make a `src/workflows/<alias>.ts` body locally typecheckable (GAP-59).
683
+ * The server is the single source of the type model — the CLI never
684
+ * re-implements it. `envelope_prefix`/`envelope_suffix` are the exact
685
+ * `async function __workflow(): …` wrapper the server compiles inside, so the
686
+ * local typecheck mirrors the set-time verdict. Mirrors
687
+ * POST /v1/apps/{app_id}/workflows/{alias}/dts.
688
+ *
689
+ * `declaration` (the manifest's `{ inputs?, outputs? }`) is posted as the body
690
+ * `{ declaration }` ONLY when the alias isn't `set` on the server yet — the
691
+ * server then synthesizes the dts from the declared schemas instead of 400ing
692
+ * "no workflow alias". A registered alias needs no declaration (the server's
693
+ * own bound contract wins), so the field is omitted in that case.
694
+ */
695
+ async getAppWorkflowDts(app_id, alias, declaration) {
696
+ return this.request(
697
+ "POST",
698
+ `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/dts`,
699
+ declaration ? { declaration } : void 0
700
+ );
701
+ }
702
+ /**
703
+ * Open a streaming agent run and return the RAW streamed `Response` (the
704
+ * caller reads `res.body`). Unlike `request`, this does not buffer/parse the
705
+ * body — it's the SSE stream the `lotics app dev` harness proxies to the
706
+ * iframe. Mirrors POST /v1/apps/{app_id}/agents/{alias}/runs.
707
+ */
708
+ async appAgentRunStream(app_id, alias, body, signal) {
709
+ const headers = { ...this.buildHeaders(), "Content-Type": "application/json" };
710
+ const res = await fetch(
711
+ `${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/agents/${encodeURIComponent(alias)}/runs`,
712
+ { method: "POST", headers, body: JSON.stringify(body), signal }
713
+ );
714
+ if (!res.ok) await this.throwResponseError(res, headers["x-request-id"]);
715
+ return res;
716
+ }
717
+ /**
718
+ * Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
719
+ * pending `ask_user_choice` — returns the RAW streamed continuation `Response`,
720
+ * exactly like `appAgentRunStream`. Mirrors
721
+ * POST /v1/apps/{app_id}/agent-runs/{run_id}/continue.
722
+ */
723
+ async appAgentRunContinueStream(app_id, run_id, body, signal) {
724
+ const headers = { ...this.buildHeaders(), "Content-Type": "application/json" };
725
+ const res = await fetch(
726
+ `${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/agent-runs/${encodeURIComponent(run_id)}/continue`,
727
+ { method: "POST", headers, body: JSON.stringify(body), signal }
728
+ );
729
+ if (!res.ok) await this.throwResponseError(res, headers["x-request-id"]);
730
+ return res;
731
+ }
732
+ /**
733
+ * A session's app-agent run history, oldest-first (the run just started is the
734
+ * last, and its exact id is on the stream response's `x-app-agent-run-id`
735
+ * header). Transcript excluded; structured `output`/`input` included. Mirrors
736
+ * GET /v1/apps/{app_id}/agent-runs.
737
+ */
738
+ async listAgentRuns(app_id, session_id) {
739
+ return this.request(
740
+ "GET",
741
+ `/v1/apps/${encodeURIComponent(app_id)}/agent-runs?session_id=${encodeURIComponent(session_id)}`
742
+ );
743
+ }
744
+ /**
745
+ * A single run by id — the poll read a client follows after its stream drops
746
+ * (a parked `awaiting_input` row carries `pending_interactive` so the question
747
+ * survives reconnection). Mirrors GET /v1/apps/{app_id}/agent-runs/{run_id}.
748
+ */
749
+ async getAgentRun(app_id, run_id) {
750
+ return this.request(
751
+ "GET",
752
+ `/v1/apps/${encodeURIComponent(app_id)}/agent-runs/${encodeURIComponent(run_id)}`
753
+ );
754
+ }
755
+ /**
756
+ * Request cancellation of an in-flight (or parked) run. Mirrors
757
+ * POST /v1/apps/{app_id}/agent-runs/{run_id}/cancel.
758
+ */
759
+ async cancelAgentRun(app_id, run_id) {
760
+ return this.request(
761
+ "POST",
762
+ `/v1/apps/${encodeURIComponent(app_id)}/agent-runs/${encodeURIComponent(run_id)}/cancel`
763
+ );
764
+ }
765
+ /**
766
+ * Mint a presigned URL for uploading a file into an app. Mirrors
767
+ * POST /v1/apps/{app_id}/files/upload-url.
768
+ */
769
+ async appRequestFileUpload(app_id, body) {
770
+ return this.request(
771
+ "POST",
772
+ `/v1/apps/${encodeURIComponent(app_id)}/files/upload-url`,
773
+ body
774
+ );
775
+ }
776
+ /**
777
+ * Finalize a presigned upload once the bytes are in storage. Mirrors
778
+ * POST /v1/apps/{app_id}/files/complete.
779
+ */
780
+ async appCompleteFileUpload(app_id, body) {
781
+ return this.request(
782
+ "POST",
783
+ `/v1/apps/${encodeURIComponent(app_id)}/files/complete`,
784
+ body
785
+ );
786
+ }
787
+ // App-scoped record comments — the `lotics app dev` loop forwards the iframe's
788
+ // `comments.*` ops to these (production routes them through the iframe host).
789
+ // App authority + tenant floor are enforced server-side; these are thin.
790
+ async appGetRecordComments(app_id, record_id) {
791
+ return this.request(
792
+ "GET",
793
+ `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments`
794
+ );
795
+ }
796
+ async appCreateRecordComment(app_id, record_id, body) {
797
+ return this.request(
798
+ "POST",
799
+ `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments`,
800
+ body
801
+ );
802
+ }
803
+ async appUpdateRecordComment(app_id, record_id, comment_id, body) {
804
+ return this.request(
805
+ "PATCH",
806
+ `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments/${encodeURIComponent(comment_id)}`,
807
+ body
808
+ );
809
+ }
810
+ async appDeleteRecordComment(app_id, record_id, comment_id) {
811
+ await this.request(
812
+ "DELETE",
813
+ `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments/${encodeURIComponent(comment_id)}`
814
+ );
815
+ }
816
+ async appGetTableCommentCounts(app_id, table_id) {
817
+ return this.request(
818
+ "GET",
819
+ `/v1/apps/${encodeURIComponent(app_id)}/tables/${encodeURIComponent(table_id)}/comment-counts`
820
+ );
821
+ }
822
+ async deployAppVersion(args) {
823
+ const formData = new FormData();
824
+ formData.append(
825
+ "source",
826
+ new Blob([new Uint8Array(args.source_archive)], { type: "application/gzip" }),
827
+ "source.tar.gz"
828
+ );
829
+ formData.append(
830
+ "dist",
831
+ new Blob([new Uint8Array(args.dist_archive)], { type: "application/gzip" }),
832
+ "dist.tar.gz"
833
+ );
834
+ if (args.prev_version_id) {
835
+ formData.append("prev_version_id", args.prev_version_id);
836
+ }
837
+ if (args.message) {
838
+ formData.append("message", args.message);
839
+ }
840
+ formData.append("queries", JSON.stringify(args.queries ?? {}));
841
+ if (args.capabilities !== void 0) {
842
+ formData.append("capabilities", JSON.stringify(args.capabilities));
843
+ }
844
+ formData.append("workflow_aliases", JSON.stringify(args.workflow_aliases ?? []));
845
+ formData.append("agent_aliases", JSON.stringify(args.agent_aliases ?? []));
846
+ formData.append("query_aliases", JSON.stringify(args.query_aliases ?? []));
847
+ const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
848
+ const headers = this.buildHeaders();
849
+ const response = await fetch(url, { method: "POST", headers, body: formData });
850
+ if (response.status === 409) {
851
+ const conflict = await response.json();
852
+ const err = new Error(conflict.message ?? "Deploy conflict \u2014 pull required");
853
+ err.code = "VERSION_CONFLICT";
854
+ err.current_version_id = conflict.current_version_id ?? null;
855
+ throw err;
466
856
  }
467
- /** The state of a publish: which app is building, and the version once every dist is in. */
468
- async getStarterPublish(publish_id) {
469
- return this.request("GET", `/v1/starters/publishes/${encodeURIComponent(publish_id)}`);
470
- }
471
- /**
472
- * Resolve the display name + fields (incl. select options) of the given tables
473
- * — the schema `lotics app codegen` turns into the runtime `.lotics/app_fields.ts`
474
- * alias maps. One `get_table` call per id (the tool surface has no batch
475
- * variant); a missing/inaccessible table is dropped rather than throwing, so a
476
- * stale id in the scope set never fails codegen.
477
- */
478
- async getWorkspaceSchema(tableIds) {
479
- const tables = await Promise.all(tableIds.map(async (table_id) => {
480
- // JSON (the default) — `res.result` is the structured `get_table` output
481
- // already; `text` would only also run `toModelOutput` per table for
482
- // nothing. The field id is `key` and an option's id is `key` / label is
483
- // `name` (the `TableField` / select-option schema shapes).
484
- const res = await this.execute("get_table", { table_id });
485
- if (res.error || res.result === null || typeof res.result !== "object")
486
- return null;
487
- const table = res.result;
488
- if (typeof table.id !== "string" || typeof table.name !== "string" || !Array.isArray(table.fields)) {
489
- return null;
490
- }
491
- const fields = table.fields.flatMap((field) => {
492
- if (field === null || typeof field !== "object")
493
- return [];
494
- const f = field;
495
- if (typeof f.key !== "string" || typeof f.name !== "string" || typeof f.type !== "string")
496
- return [];
497
- const options = Array.isArray(f.options)
498
- ? f.options.flatMap((opt) => {
499
- if (opt === null || typeof opt !== "object")
500
- return [];
501
- const o = opt;
502
- return typeof o.key === "string" && typeof o.name === "string"
503
- ? [{ id: o.key, label: o.name }]
504
- : [];
505
- })
506
- : undefined;
507
- return [{ id: f.key, name: f.name, type: f.type, ...(options && options.length > 0 ? { options } : {}) }];
508
- });
509
- return { id: table.id, name: table.name, fields };
510
- }));
511
- return tables.filter((t) => t !== null);
512
- }
513
- /**
514
- * Rename an app's public subdomain — its `<slug>.lotics.app` address.
515
- * Mirrors PUT /v1/apps/{app_id}/subdomain. The old subdomain stops
516
- * resolving once the change lands.
517
- */
518
- async setAppSubdomain(app_id, public_subdomain) {
519
- return this.request("PUT", `/v1/apps/${encodeURIComponent(app_id)}/subdomain`, { public_subdomain });
520
- }
521
- async getAppVersion(app_id, version_id) {
522
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}`);
523
- }
524
- async getAppVersionSourceUrl(app_id, version_id) {
525
- const result = await this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}/source`);
526
- return result.url;
527
- }
528
- /** Deploy history for an app — newest first. Backs `lotics app versions`. */
529
- async listAppVersions(app_id, opts) {
530
- const qs = new URLSearchParams();
531
- if (opts?.limit != null)
532
- qs.set("limit", String(opts.limit));
533
- if (opts?.offset != null)
534
- qs.set("offset", String(opts.offset));
535
- const suffix = qs.toString() ? `?${qs.toString()}` : "";
536
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions${suffix}`);
537
- }
538
- // ── App iframe RPC endpoints ──────────────────────────────────────────────
539
- // These mirror the two ops handled by frontend/features/app_ui/app_iframe_host.tsx.
540
- // The deployed iframe sends postMessage to the parent frontend, which calls
541
- // these same endpoints via the user's session cookie. `lotics app dev`
542
- // forwards the iframe's postMessage to these methods using the CLI's API key.
543
- /**
544
- * Run a named query declared in the app's manifest, scoped to the app's IAM
545
- * principal. Mirrors POST /v1/apps/{app_id}/query.
546
- */
547
- async appQuery(app_id, body) {
548
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/query`, body);
549
- }
550
- async appMembers(app_id, group_id) {
551
- const qs = group_id ? `?group_id=${encodeURIComponent(group_id)}` : "";
552
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/members${qs}`);
553
- }
554
- /**
555
- * Resolve the full option set (key, label, color) of a named query's select
556
- * columns — the picker companion to `appQuery`. Mirrors
557
- * POST /v1/apps/{app_id}/field-options.
558
- */
559
- async appFieldOptions(app_id, alias) {
560
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/field-options`, { alias });
561
- }
562
- /**
563
- * Execute a workflow by alias declared in package.json#lotics.workflows.
564
- * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
565
- */
566
- async appWorkflow(app_id, alias, inputs) {
567
- // Its own transport (not the generic `request`, whose `throwResponseError`
568
- // shape is the CLI contract elsewhere): a transport/gateway failure resolves
569
- // to a `WorkflowResult` error `{ status, message }` — never a thrown HTML
570
- // body — so the dev RPC bridge forwards `{status:"error"}` to the iframe,
571
- // matching the deployed standalone SDK (`@lotics/app-sdk` standaloneWorkflow).
572
- const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`;
573
- const headers = this.buildHeaders();
574
- headers["Content-Type"] = "application/json";
575
- let response;
576
- try {
577
- response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ inputs }) });
578
- }
579
- catch (err) {
580
- return { status: "error", message: err instanceof Error ? err.message : "The workflow request failed." };
581
- }
582
- const text = await response.text();
583
- let parsed = null;
584
- if (text) {
585
- try {
586
- parsed = JSON.parse(text);
587
- }
588
- catch {
589
- // non-JSON body (e.g. a gateway HTML error page) — never echoed
590
- }
591
- }
592
- if (response.ok)
593
- return parsed ?? {};
594
- return { status: "error", message: transportErrorMessage(response.status, parsed) };
595
- }
596
- /**
597
- * Bind (create or replace) an app workflow by alias via the `set_app_workflow`
598
- * tool — the SINGLE author of `apps.workflows` + the workflow row. `source` is
599
- * the verbatim JS-subset body (no `on({...})` trigger). `inputs`/`outputs` are
600
- * the typed schemas declared in `package.json#lotics.workflows.<alias>`. The
601
- * server re-verifies the body and echoes the bound `outputs` (declared, else
602
- * DERIVED from `return({ data })`), so the CLI can show the author what shape
603
- * `result.data` will carry. Wraps the tool rather than a bespoke endpoint so
604
- * the file flow stays a convenience over the existing single-author contract.
605
- */
606
- async setAppWorkflow(app_id, alias, body) {
607
- return this.execute("set_app_workflow", {
608
- app_id,
609
- alias,
610
- source: body.source,
611
- ...(body.inputs ? { inputs: body.inputs } : {}),
612
- ...(body.outputs ? { outputs: body.outputs } : {}),
613
- ...(body.name ? { name: body.name } : {}),
614
- ...(body.description ? { description: body.description } : {}),
615
- ...(body.expected_body_sha ? { expected_body_sha: body.expected_body_sha } : {}),
616
- });
857
+ if (!response.ok) {
858
+ await this.throwResponseError(response, headers["x-request-id"]);
617
859
  }
618
- /**
619
- * Bind (create or replace) an app query by alias via the `set_app_query` tool
620
- * the deploy-free authoring path for `apps.queries`, parallel to
621
- * `setAppWorkflow`. `declaration` is the `{ ast, params? }` from
622
- * `package.json#lotics.queries.<alias>`. The server validates it exactly as a
623
- * deploy validates the manifest. Note: `apps.queries` is manifest-authoritative,
624
- * so the next `lotics app deploy` overwrites this from the manifest.
625
- */
626
- async setAppQuery(app_id, alias, declaration,
627
- /** The fingerprint this edit was based on — makes the write conditional. */
628
- expected_sha) {
629
- return this.execute("set_app_query", {
630
- app_id,
631
- alias,
632
- declaration,
633
- ...(expected_sha ? { expected_sha } : {}),
860
+ return response.json();
861
+ }
862
+ async downloadFile(url, outputPath) {
863
+ const response = await fetch(url);
864
+ if (!response.ok) {
865
+ throw new Error(`${response.status}: Failed to download file`);
866
+ }
867
+ const buffer = Buffer.from(await response.arrayBuffer());
868
+ const absolutePath = path.resolve(outputPath);
869
+ await fs.promises.mkdir(path.dirname(absolutePath), { recursive: true });
870
+ await fs.promises.writeFile(absolutePath, buffer);
871
+ return absolutePath;
872
+ }
873
+ async downloadFileById(fileId, outputDir, options) {
874
+ const signedHeaders = this.buildHeaders();
875
+ const signedUrlRes = await fetch(
876
+ `${this.baseUrl}/v1/files/${encodeURIComponent(fileId)}/signed_url`,
877
+ { headers: signedHeaders }
878
+ );
879
+ if (!signedUrlRes.ok) {
880
+ await this.throwResponseError(signedUrlRes, signedHeaders["x-request-id"]);
881
+ }
882
+ const { url } = await signedUrlRes.json();
883
+ const response = await fetch(url);
884
+ if (!response.ok) await this.throwResponseError(response);
885
+ const disposition = response.headers.get("content-disposition") ?? "";
886
+ const originalFilename = parseContentDispositionFilename(disposition) ?? fileId;
887
+ const buffer = Buffer.from(await response.arrayBuffer());
888
+ const dir = outputDir ? path.resolve(outputDir) : process.cwd();
889
+ await fs.promises.mkdir(dir, { recursive: true });
890
+ const filename = findAvailableFilename(dir, originalFilename, options?.reserved);
891
+ const absolutePath = path.join(dir, filename);
892
+ await fs.promises.writeFile(absolutePath, buffer);
893
+ return { path: absolutePath, filename };
894
+ }
895
+ async downloadRecordFiles(recordId, fieldKey, outputDir) {
896
+ const result = await this.execute("get_record", { record_id: recordId }, { format: "text" });
897
+ if (result.error) throw new Error(result.error);
898
+ const record = result.result;
899
+ const files = record?.data?.[fieldKey];
900
+ if (!Array.isArray(files)) {
901
+ throw new Error(`Field ${fieldKey} on ${recordId} is not a file field or has no value`);
902
+ }
903
+ const fileIds = files.map((f, i) => {
904
+ if (typeof f !== "object" || f === null || !("id" in f) || typeof f.id !== "string") {
905
+ throw new Error(`Invalid file entry [${i}] in ${recordId}.${fieldKey}: ${JSON.stringify(f)}`);
906
+ }
907
+ return f.id;
908
+ });
909
+ const reserved = /* @__PURE__ */ new Set();
910
+ return Promise.all(
911
+ fileIds.map(async (fileId) => {
912
+ const res = await this.downloadFileById(fileId, outputDir, { reserved });
913
+ return { ...res, file_id: fileId };
914
+ })
915
+ );
916
+ }
917
+ async uploadFiles(filePaths, options) {
918
+ const small = [];
919
+ const large = [];
920
+ for (let i = 0; i < filePaths.length; i++) {
921
+ const absolutePath = path.resolve(filePaths[i]);
922
+ const filename = options?.filenames?.[i] ?? path.basename(absolutePath);
923
+ const { size } = await fs.promises.stat(absolutePath);
924
+ if (size >= MULTIPART_THRESHOLD_BYTES) {
925
+ large.push({ absolutePath, filename, size });
926
+ } else {
927
+ small.push({ bytes: await fs.promises.readFile(absolutePath), filename });
928
+ }
929
+ }
930
+ const files = [];
931
+ const errors = [];
932
+ if (small.length > 0) {
933
+ const result = await this.uploadFileBytes(small);
934
+ files.push(...result.files);
935
+ errors.push(...result.errors);
936
+ }
937
+ for (const item of large) {
938
+ try {
939
+ files.push(await this.uploadLargeFile(item));
940
+ } catch (error) {
941
+ errors.push({
942
+ filename: item.filename,
943
+ error: error instanceof Error ? error.message : String(error)
634
944
  });
635
- }
636
- /**
637
- * Bind (create or replace) an app agent by alias via the `set_app_agent` tool
638
- * — the deploy-free authoring path for `apps.agents`, parallel to
639
- * `setAppWorkflow`/`setAppQuery`.
640
- *
641
- * `set_app_agent` REPLACES the whole declaration, so this takes the whole
642
- * declaration. `lotics app agent set` is the caller that assembles it (prose
643
- * from `src/agents/<alias>.md`, typed fields from the manifest) precisely so
644
- * no caller has to remember that a partial payload silently drops
645
- * `instructions`, `outputs` and the model pin.
646
- */
647
- async setAppAgent(app_id, alias,
648
- /** ONLY the fields being changed. The server merges against the stored
649
- * declaration, so nothing a caller omits is lost and a field the server adds
650
- * later needs no change here. Pass `null` to clear an optional field. */
651
- patch) {
652
- return this.execute("set_app_agent", { app_id, alias, ...patch });
653
- }
654
- /**
655
- * Fetch one app workflow's faithful source + bound input/output schemas via
656
- * `get_app_workflow`. `source` is the JS-subset body re-rendered from the
657
- * persisted step tree (incl. the `return({ data })` clause, opaque field/option
658
- * keys) the exact text `lotics app workflow set` would push back. Feeds
659
- * `lotics app pull`, which writes it to `src/workflows/<alias>.ts`.
660
- */
661
- async getAppWorkflow(app_id, alias) {
662
- return this.execute("get_app_workflow", { app_id, alias });
663
- }
664
- /**
665
- * Fetch the server-generated workspace `.d.ts` + the wrapper envelope that
666
- * make a `src/workflows/<alias>.ts` body locally typecheckable (GAP-59).
667
- * The server is the single source of the type model — the CLI never
668
- * re-implements it. `envelope_prefix`/`envelope_suffix` are the exact
669
- * `async function __workflow(): …` wrapper the server compiles inside, so the
670
- * local typecheck mirrors the set-time verdict. Mirrors
671
- * POST /v1/apps/{app_id}/workflows/{alias}/dts.
672
- *
673
- * `declaration` (the manifest's `{ inputs?, outputs? }`) is posted as the body
674
- * `{ declaration }` ONLY when the alias isn't `set` on the server yet — the
675
- * server then synthesizes the dts from the declared schemas instead of 400ing
676
- * "no workflow alias". A registered alias needs no declaration (the server's
677
- * own bound contract wins), so the field is omitted in that case.
678
- */
679
- async getAppWorkflowDts(app_id, alias, declaration) {
680
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/dts`, declaration ? { declaration } : undefined);
681
- }
682
- /**
683
- * Open a streaming agent run and return the RAW streamed `Response` (the
684
- * caller reads `res.body`). Unlike `request`, this does not buffer/parse the
685
- * body it's the SSE stream the `lotics app dev` harness proxies to the
686
- * iframe. Mirrors POST /v1/apps/{app_id}/agents/{alias}/runs.
687
- */
688
- async appAgentRunStream(app_id, alias, body, signal) {
689
- const headers = { ...this.buildHeaders(), "Content-Type": "application/json" };
690
- const res = await fetch(`${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/agents/${encodeURIComponent(alias)}/runs`, { method: "POST", headers, body: JSON.stringify(body), signal });
691
- if (!res.ok)
692
- await this.throwResponseError(res, headers["x-request-id"]);
693
- return res;
694
- }
695
- /**
696
- * Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
697
- * pending `ask_user_choice` — returns the RAW streamed continuation `Response`,
698
- * exactly like `appAgentRunStream`. Mirrors
699
- * POST /v1/apps/{app_id}/agent-runs/{run_id}/continue.
700
- */
701
- async appAgentRunContinueStream(app_id, run_id, body, signal) {
702
- const headers = { ...this.buildHeaders(), "Content-Type": "application/json" };
703
- const res = await fetch(`${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/agent-runs/${encodeURIComponent(run_id)}/continue`, { method: "POST", headers, body: JSON.stringify(body), signal });
704
- if (!res.ok)
705
- await this.throwResponseError(res, headers["x-request-id"]);
706
- return res;
707
- }
708
- /**
709
- * A session's app-agent run history, oldest-first (the run just started is the
710
- * last, and its exact id is on the stream response's `x-app-agent-run-id`
711
- * header). Transcript excluded; structured `output`/`input` included. Mirrors
712
- * GET /v1/apps/{app_id}/agent-runs.
713
- */
714
- async listAgentRuns(app_id, session_id) {
715
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/agent-runs?session_id=${encodeURIComponent(session_id)}`);
716
- }
717
- /**
718
- * A single run by id — the poll read a client follows after its stream drops
719
- * (a parked `awaiting_input` row carries `pending_interactive` so the question
720
- * survives reconnection). Mirrors GET /v1/apps/{app_id}/agent-runs/{run_id}.
721
- */
722
- async getAgentRun(app_id, run_id) {
723
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/agent-runs/${encodeURIComponent(run_id)}`);
724
- }
725
- /**
726
- * Request cancellation of an in-flight (or parked) run. Mirrors
727
- * POST /v1/apps/{app_id}/agent-runs/{run_id}/cancel.
728
- */
729
- async cancelAgentRun(app_id, run_id) {
730
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/agent-runs/${encodeURIComponent(run_id)}/cancel`);
731
- }
732
- /**
733
- * Mint a presigned URL for uploading a file into an app. Mirrors
734
- * POST /v1/apps/{app_id}/files/upload-url.
735
- */
736
- async appRequestFileUpload(app_id, body) {
737
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/files/upload-url`, body);
738
- }
739
- /**
740
- * Finalize a presigned upload once the bytes are in storage. Mirrors
741
- * POST /v1/apps/{app_id}/files/complete.
742
- */
743
- async appCompleteFileUpload(app_id, body) {
744
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/files/complete`, body);
745
- }
746
- // App-scoped record comments — the `lotics app dev` loop forwards the iframe's
747
- // `comments.*` ops to these (production routes them through the iframe host).
748
- // App authority + tenant floor are enforced server-side; these are thin.
749
- async appGetRecordComments(app_id, record_id) {
750
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments`);
751
- }
752
- async appCreateRecordComment(app_id, record_id, body) {
753
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments`, body);
754
- }
755
- async appUpdateRecordComment(app_id, record_id, comment_id, body) {
756
- return this.request("PATCH", `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments/${encodeURIComponent(comment_id)}`, body);
757
- }
758
- async appDeleteRecordComment(app_id, record_id, comment_id) {
759
- await this.request("DELETE", `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(record_id)}/comments/${encodeURIComponent(comment_id)}`);
760
- }
761
- async appGetTableCommentCounts(app_id, table_id) {
762
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/tables/${encodeURIComponent(table_id)}/comment-counts`);
763
- }
764
- async deployAppVersion(args) {
765
- const formData = new FormData();
766
- // Wrap Buffers as Uint8Array views so the Blob constructor accepts them
767
- // (Node Buffer's `ArrayBufferLike` underlying buffer doesn't match the
768
- // BlobPart contract directly under strict TS).
769
- formData.append("source", new Blob([new Uint8Array(args.source_archive)], { type: "application/gzip" }), "source.tar.gz");
770
- formData.append("dist", new Blob([new Uint8Array(args.dist_archive)], { type: "application/gzip" }), "dist.tar.gz");
771
- if (args.prev_version_id) {
772
- formData.append("prev_version_id", args.prev_version_id);
773
- }
774
- if (args.message) {
775
- formData.append("message", args.message);
776
- }
777
- // Always send queries — empty object clears any previously-declared
778
- // named queries. Server validates each template. (Workflow bindings are
779
- // not sent: set_app_workflow / remove_app_workflow own apps.workflows.)
780
- formData.append("queries", JSON.stringify(args.queries ?? {}));
781
- // capabilities is manifest-authoritative — the caller always passes it
782
- // (`{}` when none declared), so a deploy turns off any capability the
783
- // manifest no longer declares. JSON-encoded.
784
- if (args.capabilities !== undefined) {
785
- formData.append("capabilities", JSON.stringify(args.capabilities));
786
- }
787
- // Always send the declared workflow aliases (empty array when none) so the
788
- // server records what the served version calls — the remove_app_workflow
789
- // guard reads this back. These are the manifest KEYS only, never bindings.
790
- formData.append("workflow_aliases", JSON.stringify(args.workflow_aliases ?? []));
791
- formData.append("agent_aliases", JSON.stringify(args.agent_aliases ?? []));
792
- formData.append("query_aliases", JSON.stringify(args.query_aliases ?? []));
793
- const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
794
- const headers = this.buildHeaders(); // no Content-Type — fetch sets multipart boundary
795
- const response = await fetch(url, { method: "POST", headers, body: formData });
796
- if (response.status === 409) {
797
- const conflict = (await response.json());
798
- const err = new Error(conflict.message ?? "Deploy conflict — pull required");
799
- err.code = "VERSION_CONFLICT";
800
- err.current_version_id =
801
- conflict.current_version_id ?? null;
802
- throw err;
803
- }
804
- if (!response.ok) {
805
- await this.throwResponseError(response, headers["x-request-id"]);
806
- }
807
- return response.json();
808
- }
809
- async downloadFile(url, outputPath) {
810
- const response = await fetch(url);
811
- if (!response.ok) {
812
- throw new Error(`${response.status}: Failed to download file`);
813
- }
814
- const buffer = Buffer.from(await response.arrayBuffer());
815
- const absolutePath = path.resolve(outputPath);
816
- await fs.promises.mkdir(path.dirname(absolutePath), { recursive: true });
817
- await fs.promises.writeFile(absolutePath, buffer);
818
- return absolutePath;
819
- }
820
- async downloadFileById(fileId, outputDir, options) {
821
- // Resolve a presigned URL (the file proxy is auth-gated and served by an
822
- // edge Worker that doesn't accept the API key). The signed_url endpoint
823
- // takes the API key and returns a short-lived, directly-fetchable URL
824
- // whose Content-Disposition carries the original filename.
825
- const signedHeaders = this.buildHeaders();
826
- const signedUrlRes = await fetch(`${this.baseUrl}/v1/files/${encodeURIComponent(fileId)}/signed_url`, { headers: signedHeaders });
827
- if (!signedUrlRes.ok) {
828
- await this.throwResponseError(signedUrlRes, signedHeaders["x-request-id"]);
829
- }
830
- const { url } = (await signedUrlRes.json());
831
- const response = await fetch(url);
832
- if (!response.ok)
833
- await this.throwResponseError(response);
834
- const disposition = response.headers.get("content-disposition") ?? "";
835
- const originalFilename = parseContentDispositionFilename(disposition) ?? fileId;
836
- const buffer = Buffer.from(await response.arrayBuffer());
837
- const dir = outputDir ? path.resolve(outputDir) : process.cwd();
838
- // The bytes are already fetched by this point, so a missing output directory
839
- // would throw away paid network work — and it threw `ENOENT … open
840
- // '<dir>/<file>'`, which the CLI renders as "File not found: <the file it was
841
- // creating>". That reads as a bad file id or a deleted attachment and sends
842
- // the reader to re-query the record. `-o` names a directory to write into;
843
- // creating it is what the flag means.
844
- await fs.promises.mkdir(dir, { recursive: true });
845
- const filename = findAvailableFilename(dir, originalFilename, options?.reserved);
846
- const absolutePath = path.join(dir, filename);
847
- await fs.promises.writeFile(absolutePath, buffer);
848
- return { path: absolutePath, filename };
849
- }
850
- async downloadRecordFiles(recordId, fieldKey, outputDir) {
851
- const result = await this.execute("get_record", { record_id: recordId }, { format: "text" });
852
- if (result.error)
853
- throw new Error(result.error);
854
- const record = result.result;
855
- const files = record?.data?.[fieldKey];
856
- if (!Array.isArray(files)) {
857
- throw new Error(`Field ${fieldKey} on ${recordId} is not a file field or has no value`);
858
- }
859
- const fileIds = files.map((f, i) => {
860
- if (typeof f !== "object" || f === null || !("id" in f) || typeof f.id !== "string") {
861
- throw new Error(`Invalid file entry [${i}] in ${recordId}.${fieldKey}: ${JSON.stringify(f)}`);
945
+ }
946
+ }
947
+ return { files, errors };
948
+ }
949
+ /**
950
+ * Store one file by handing its bytes straight to object storage.
951
+ *
952
+ * The API mints presigned part URLs and registers the row; the bytes go
953
+ * directly from disk to the bucket and never enter the API process. That is
954
+ * what makes the ceiling "what a workspace may store" rather than "what one
955
+ * request may cost the server" — the reason `POST /v1/files` and the ticketed
956
+ * upload are both capped far lower, and the reason this exists at all.
957
+ *
958
+ * Parts are read one at a time rather than streamed as a request body: a part
959
+ * is bounded (the server picks 5–10 MiB), a `fetch` body needs a length the
960
+ * bucket will check anyway, and reading a slice keeps peak memory at one part
961
+ * instead of one file.
962
+ *
963
+ * The PUT loop itself — retry, backoff, part timeout, ordering — is
964
+ * `@lotics/shared/multipart_parts`, shared with the browser client so the
965
+ * hardening cannot exist on one side only.
966
+ */
967
+ async uploadLargeFile(input) {
968
+ const mimeType = getMimeType(input.filename);
969
+ const init = await this.request("POST", "/v1/files/multipart/init", {
970
+ filename: input.filename,
971
+ mime_type: mimeType,
972
+ file_size: input.size
973
+ });
974
+ const partLength = (part) => Math.min(init.part_size, input.size - (part.part_number - 1) * init.part_size);
975
+ const handle = await fs.promises.open(input.absolutePath, "r");
976
+ let uploaded;
977
+ try {
978
+ uploaded = await uploadParts({
979
+ parts: init.parts,
980
+ partBytes: partLength,
981
+ // Sequential. Peak memory is then one part rather than one file, which
982
+ // is the property that lets this run against a 2 GiB upload at all;
983
+ // raising it trades that for wall-clock, and no upload has asked yet.
984
+ concurrency: 1,
985
+ putPart: async (part, body, signal) => {
986
+ const response = await fetch(part.url, { method: "PUT", body, signal });
987
+ if (!response.ok) throw partErrorFor(part.part_number, response.status);
988
+ return response.headers.get("etag") ?? "";
989
+ },
990
+ readPart: async (part) => {
991
+ const offset = (part.part_number - 1) * init.part_size;
992
+ const length = partLength(part);
993
+ const buffer = Buffer.alloc(length);
994
+ let filled = 0;
995
+ while (filled < length) {
996
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, offset + filled);
997
+ if (bytesRead === 0) {
998
+ throw new PermanentPartError(
999
+ `${input.filename} ended after ${offset + filled} bytes, short of the ${input.size} it reported \u2014 it changed while uploading`
1000
+ );
862
1001
  }
863
- return f.id;
864
- });
865
- const reserved = new Set();
866
- return Promise.all(fileIds.map(async (fileId) => {
867
- const res = await this.downloadFileById(fileId, outputDir, { reserved });
868
- return { ...res, file_id: fileId };
869
- }));
870
- }
871
- async uploadFiles(filePaths, options) {
872
- // Sequential, not `Promise.all`: `lotics upload <dir>` expands a directory to
873
- // every file in it, and reading them all at once opens one handle per file —
874
- // EMFILE on a large directory. The upload itself is a single request either
875
- // way, so concurrency here buys nothing.
876
- const items = [];
877
- for (let i = 0; i < filePaths.length; i++) {
878
- const absolutePath = path.resolve(filePaths[i]);
879
- items.push({
880
- bytes: await fs.promises.readFile(absolutePath),
881
- filename: options?.filenames?.[i] ?? path.basename(absolutePath),
882
- });
883
- }
884
- return this.uploadFileBytes(items);
885
- }
886
- /**
887
- * Store files from bytes the caller already holds — the path for a caller that
888
- * never had them on disk (an email attachment decoded in memory, a generated
889
- * document, a fetched URL). `uploadFiles` is this with a read in front, so
890
- * both routes hit one endpoint and one mime-derivation rule.
891
- */
892
- async uploadFileBytes(
893
- // `Uint8Array<ArrayBuffer>`, not a bare `Uint8Array`: `Blob` takes a
894
- // `BufferSource`, which excludes a `SharedArrayBuffer`-backed view. Every
895
- // real source here (`readFile`, `Buffer.concat`, `response.arrayBuffer()`)
896
- // is already ArrayBuffer-backed, so the narrower type states the
897
- // requirement rather than casting it away at the call site.
898
- items) {
899
- const formData = new FormData();
900
- for (const item of items) {
901
- const mimeType = item.mimeType ?? getMimeType(item.filename);
902
- // `Blob` copies a BufferSource by the VIEW's offset+length, so passing a
903
- // pooled `Buffer` from `readFile` straight in is correct — no defensive
904
- // re-copy, which would double peak memory on every upload.
905
- formData.append("file", new Blob([item.bytes], { type: mimeType }), item.filename);
1002
+ filled += bytesRead;
1003
+ }
1004
+ return buffer;
906
1005
  }
907
- const url = `${this.baseUrl}/v1/files`;
908
- const headers = this.buildHeaders();
909
- const response = await fetch(url, { method: "POST", headers, body: formData });
910
- if (!response.ok)
911
- await this.throwResponseError(response, headers["x-request-id"]);
912
- return response.json();
913
- }
914
- }
1006
+ });
1007
+ } catch (error) {
1008
+ await this.request("POST", "/v1/files/multipart/abort", {
1009
+ upload_id: init.upload_id,
1010
+ file_storage_key: init.file_storage_key
1011
+ }).catch(() => {
1012
+ });
1013
+ throw error;
1014
+ } finally {
1015
+ await handle.close();
1016
+ }
1017
+ const result = await this.request("POST", "/v1/files/multipart/complete", {
1018
+ file_id: init.file_id,
1019
+ upload_id: init.upload_id,
1020
+ file_storage_key: init.file_storage_key,
1021
+ filename: input.filename,
1022
+ mime_type: mimeType,
1023
+ parts: uploaded
1024
+ });
1025
+ const stored = result.files[0];
1026
+ if (!stored) {
1027
+ throw new Error(result.errors[0]?.error ?? "upload completed but stored no file");
1028
+ }
1029
+ return stored;
1030
+ }
1031
+ /**
1032
+ * Store files from bytes the caller already holds — the path for a caller that
1033
+ * never had them on disk (an email attachment decoded in memory, a generated
1034
+ * document, a fetched URL). `uploadFiles` is this with a read in front, so
1035
+ * both routes hit one endpoint and one mime-derivation rule.
1036
+ */
1037
+ async uploadFileBytes(items) {
1038
+ const formData = new FormData();
1039
+ for (const item of items) {
1040
+ const mimeType = item.mimeType ?? getMimeType(item.filename);
1041
+ formData.append("file", new Blob([item.bytes], { type: mimeType }), item.filename);
1042
+ }
1043
+ const url = `${this.baseUrl}/v1/files`;
1044
+ const headers = this.buildHeaders();
1045
+ const response = await fetch(url, { method: "POST", headers, body: formData });
1046
+ if (!response.ok) await this.throwResponseError(response, headers["x-request-id"]);
1047
+ return response.json();
1048
+ }
1049
+ };
1050
+ export {
1051
+ API_BASE_URL,
1052
+ LoticsClient,
1053
+ WEB_APP_URL,
1054
+ fetchOfficialStarters
1055
+ };