@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.
- package/README.md +4 -4
- package/dist/src/cli.js +3043 -2432
- package/dist/src/client.d.ts +1 -0
- package/dist/src/client.js +1032 -891
- package/docs/building_an_app.md +3 -1
- package/docs/cli_reference.md +4 -4
- package/package.json +1 -1
- package/dist/src/invocation.js +0 -142
package/dist/src/client.js
CHANGED
|
@@ -1,914 +1,1055 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
138
|
+
const ext = path.extname(filename).toLowerCase();
|
|
139
|
+
return MIME_MAP[ext] ?? "application/octet-stream";
|
|
75
140
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
130
165
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
+
};
|