@m6d/cortex-cli 0.17.0 → 0.19.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 +33 -0
- package/package.json +1 -1
- package/src/commands/apply.ts +28 -13
- package/src/commands/management.ts +17 -15
- package/src/commands/setup.ts +72 -60
- package/src/contracts/datasets/dataset-sql.ts +65 -28
- package/src/contracts/management/index.ts +169 -0
- package/src/contracts/management/procedures.generated.ts +5891 -0
- package/src/contracts/manifest/index.ts +199 -33
- package/src/contracts/manifest-file/index.ts +58 -6
- package/src/management/client.ts +17 -77
- package/src/scaffold/files.ts +2 -2
package/README.md
CHANGED
|
@@ -225,6 +225,39 @@ or any name such a collision decided (`ambiguous_tool`), since a path the
|
|
|
225
225
|
document adds or drops could move it to another endpoint. It also fails when an
|
|
226
226
|
operation's name is already a listed tool's or another source's.
|
|
227
227
|
|
|
228
|
+
A collection lists its documents. Each is inline `text`, a file `path`
|
|
229
|
+
relative to the manifest file, or a `url` Cortex reads on every apply, with no
|
|
230
|
+
credentials:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
collections: [
|
|
234
|
+
{
|
|
235
|
+
name: "Policies",
|
|
236
|
+
audience: { agents: ["support"] },
|
|
237
|
+
documents: [
|
|
238
|
+
{ name: "faq.md", text: "# FAQ\n\nReturns take 30 days." },
|
|
239
|
+
{ path: "./docs/returns.pdf" },
|
|
240
|
+
{ url: "https://shop.example.com/policies/shipping.html" },
|
|
241
|
+
],
|
|
242
|
+
// overridable: ["documents"],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A document's name sets its type: `.md`, `.txt`, `.html`, `.htm`, `.pdf` or
|
|
248
|
+
`.docx`. A path or URL document is named after its file unless `name` says
|
|
249
|
+
otherwise. Each is at most 10 MB. `cortex apply` reads paths and sends their
|
|
250
|
+
bytes, so a request carries every inline and path document again; Cortex
|
|
251
|
+
stores only what changed. A document with new bytes replaces the old one,
|
|
252
|
+
which keeps answering until the new one is processed, and one the manifest
|
|
253
|
+
drops is retired.
|
|
254
|
+
|
|
255
|
+
Listing `documents` makes the manifest own them: an apply retires a document
|
|
256
|
+
uploaded in the console, the first apply after upgrading included, unless
|
|
257
|
+
the collection offers `documents` in `overridable`. Offered, a document
|
|
258
|
+
uploaded or removed in Cortex stays so until the manifest changes that same
|
|
259
|
+
document, which `cortex apply` then asks about.
|
|
260
|
+
|
|
228
261
|
The manifest is checked against the contract before anything is sent, then
|
|
229
262
|
sent as written, so Cortex fills its own defaults. It goes to the
|
|
230
263
|
management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
|
package/package.json
CHANGED
package/src/commands/apply.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { relative, resolve } from "node:path";
|
|
|
4
4
|
import { Option } from "@commander-js/extra-typings";
|
|
5
5
|
import type { Command } from "@commander-js/extra-typings";
|
|
6
6
|
import { isRecord } from "@cortex/contracts/json";
|
|
7
|
+
import {
|
|
8
|
+
callManagement,
|
|
9
|
+
ManagementClientError,
|
|
10
|
+
managementPath,
|
|
11
|
+
} from "@cortex/contracts/management";
|
|
7
12
|
import {
|
|
8
13
|
appliedManifestSchema,
|
|
9
14
|
manifestApplyInputSchema,
|
|
@@ -12,7 +17,7 @@ import {
|
|
|
12
17
|
import { loadManifestFile } from "@cortex/contracts/manifest-file";
|
|
13
18
|
import { z } from "zod";
|
|
14
19
|
|
|
15
|
-
import {
|
|
20
|
+
import { managementTarget } from "@/management/client";
|
|
16
21
|
import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
|
|
17
22
|
import { askChoice, canAsk } from "@/ui/prompts";
|
|
18
23
|
import * as report from "@/ui/report";
|
|
@@ -137,7 +142,7 @@ export async function applyManifest(options: {
|
|
|
137
142
|
const { profile, dryRun } = options;
|
|
138
143
|
const answer = await sendManifest({
|
|
139
144
|
...options,
|
|
140
|
-
path: dryRun ? "
|
|
145
|
+
path: managementPath(dryRun ? "manifests.preview" : "manifests.sync"),
|
|
141
146
|
});
|
|
142
147
|
// The secrets are read apart from the changes: a sync has committed by
|
|
143
148
|
// now, and a summary this CLI is too old to read must not lose them.
|
|
@@ -179,14 +184,14 @@ async function sendManifest(options: {
|
|
|
179
184
|
const resolutions: Resolution = [];
|
|
180
185
|
for (;;) {
|
|
181
186
|
try {
|
|
182
|
-
return await callManagement(profile,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
resolutions,
|
|
187
|
+
return await callManagement(managementTarget(profile), {
|
|
188
|
+
method: "post",
|
|
189
|
+
path,
|
|
190
|
+
input: { ...manifest, prefer, resolutions },
|
|
186
191
|
});
|
|
187
192
|
} catch (error) {
|
|
188
193
|
const refused =
|
|
189
|
-
error instanceof
|
|
194
|
+
error instanceof ManagementClientError
|
|
190
195
|
? unresolvedSchema.safeParse(error.detail)
|
|
191
196
|
: undefined;
|
|
192
197
|
if (!refused?.success) throw error;
|
|
@@ -202,9 +207,14 @@ async function sendManifest(options: {
|
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
/** The field both sides changed, and its part when the field is judged part by part. */
|
|
211
|
+
function conflictField({ field, key }: Conflict) {
|
|
212
|
+
return key === undefined ? field : `${field} (${key})`;
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
/** How a conflict reads: its entry and the field both sides changed. */
|
|
206
|
-
function conflictLabel(
|
|
207
|
-
return `${kind} ${name}: ${
|
|
216
|
+
function conflictLabel(conflict: Conflict) {
|
|
217
|
+
return `${conflict.kind} ${conflict.name}: ${conflictField(conflict)} changed in both places`;
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
/** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
|
|
@@ -250,8 +260,9 @@ export function findManifest(path: string, command?: string) {
|
|
|
250
260
|
|
|
251
261
|
/**
|
|
252
262
|
* The file's default export, checked against the manifest contract here so
|
|
253
|
-
* a mistake is named before anything is sent, then sent as written
|
|
254
|
-
*
|
|
263
|
+
* a mistake is named before anything is sent, then sent as written, each
|
|
264
|
+
* document `path` read from beside the file: Cortex fills its own defaults,
|
|
265
|
+
* which may be newer than this CLI's.
|
|
255
266
|
*/
|
|
256
267
|
export async function loadManifest(path: string, verbose: boolean) {
|
|
257
268
|
const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
|
|
@@ -288,8 +299,12 @@ function printChanges(
|
|
|
288
299
|
rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
|
|
289
300
|
}
|
|
290
301
|
}
|
|
291
|
-
for (const
|
|
292
|
-
|
|
302
|
+
for (const conflict of conflicts) {
|
|
303
|
+
const { kind, name } = conflict;
|
|
304
|
+
rows.set(kind, [
|
|
305
|
+
...(rows.get(kind) ?? []),
|
|
306
|
+
`! ${name} ${conflictField(conflict)} changed in both places`,
|
|
307
|
+
]);
|
|
293
308
|
}
|
|
294
309
|
for (const [kind, lines] of rows) {
|
|
295
310
|
for (const [index, line] of lines.entries()) {
|
|
@@ -3,12 +3,13 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { type Command, Option } from "@commander-js/extra-typings";
|
|
4
4
|
import { canCarrySecret } from "@cortex/contracts/egress-policy";
|
|
5
5
|
import { isRecord } from "@cortex/contracts/json";
|
|
6
|
+
import { callManagement, ManagementClientError } from "@cortex/contracts/management";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
|
-
callManagement,
|
|
9
9
|
inputSchemaOf,
|
|
10
10
|
loadOpenApi,
|
|
11
|
-
|
|
11
|
+
managementClient,
|
|
12
|
+
managementTarget,
|
|
12
13
|
UnreachableError,
|
|
13
14
|
} from "@/management/client";
|
|
14
15
|
import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
|
|
@@ -134,14 +135,11 @@ async function login(given: string | undefined, name: string) {
|
|
|
134
135
|
*/
|
|
135
136
|
async function whoami(profile: { url: string; key: string }) {
|
|
136
137
|
try {
|
|
137
|
-
const me =
|
|
138
|
-
name: string;
|
|
139
|
-
fingerprint: string;
|
|
140
|
-
};
|
|
138
|
+
const me = await managementClient(profile).managementKeys.mine();
|
|
141
139
|
return `key "${me.name}" (${me.fingerprint})`;
|
|
142
140
|
} catch (error) {
|
|
143
|
-
if (error instanceof
|
|
144
|
-
if (error instanceof
|
|
141
|
+
if (error instanceof ManagementClientError && error.status === 401) return null;
|
|
142
|
+
if (error instanceof ManagementClientError) {
|
|
145
143
|
report.fail({
|
|
146
144
|
what: `${profile.url} answered ${error.status} ${error.code}: ${error.message}`,
|
|
147
145
|
});
|
|
@@ -323,7 +321,7 @@ function registerOperation(scope: {
|
|
|
323
321
|
const result = await callOrFail(profile, () =>
|
|
324
322
|
all
|
|
325
323
|
? walkPages(profile, method, path, body)
|
|
326
|
-
: callManagement(profile, method, path, body),
|
|
324
|
+
: callManagement(managementTarget(profile), { method, path, input: body }),
|
|
327
325
|
);
|
|
328
326
|
print(result, format);
|
|
329
327
|
if (publisher) {
|
|
@@ -427,7 +425,11 @@ async function publishAfterWrite(
|
|
|
427
425
|
}
|
|
428
426
|
if (publish) {
|
|
429
427
|
const published = await callOrFail(profile, () =>
|
|
430
|
-
callManagement(profile,
|
|
428
|
+
callManagement(managementTarget(profile), {
|
|
429
|
+
method: publisher.method,
|
|
430
|
+
path: publisher.path,
|
|
431
|
+
input: { id },
|
|
432
|
+
}),
|
|
431
433
|
);
|
|
432
434
|
options.note(`Published ${id}.`);
|
|
433
435
|
for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
|
|
@@ -454,7 +456,7 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
|
|
|
454
456
|
* rethrown.
|
|
455
457
|
*/
|
|
456
458
|
export function failCall(profile: { url: string }, error: unknown): never {
|
|
457
|
-
if (error instanceof
|
|
459
|
+
if (error instanceof ManagementClientError) {
|
|
458
460
|
report.fail({
|
|
459
461
|
what: `${error.status} ${error.code}: ${error.message}`,
|
|
460
462
|
blocks: [
|
|
@@ -605,10 +607,10 @@ export async function walkPages(
|
|
|
605
607
|
// Bounded: if Cortex reports more rows than it serves, the walk must not
|
|
606
608
|
// spin, and a listing the bound cannot cover is an error, never a short result.
|
|
607
609
|
for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
|
|
608
|
-
const page = (await callManagement(profile,
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
pageSize,
|
|
610
|
+
const page = (await callManagement(managementTarget(profile), {
|
|
611
|
+
method,
|
|
612
|
+
path,
|
|
613
|
+
input: { ...given, pageIndex, pageSize },
|
|
612
614
|
})) as { items?: unknown[]; count?: number; filteredCount?: number };
|
|
613
615
|
if (!Array.isArray(page.items)) return page;
|
|
614
616
|
items.push(...page.items);
|
package/src/commands/setup.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
normalizeHost,
|
|
13
13
|
scrubCredentialStrings,
|
|
14
14
|
} from "@cortex/contracts/egress-policy";
|
|
15
|
+
import { ManagementClientError, managementPath } from "@cortex/contracts/management";
|
|
15
16
|
import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH, SETTING_BOUNDS } from "@cortex/contracts/manifest";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
requireProfile,
|
|
30
31
|
walkPages,
|
|
31
32
|
} from "@/commands/management";
|
|
32
|
-
import {
|
|
33
|
+
import { managementClient, UnreachableError } from "@/management/client";
|
|
33
34
|
import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
|
|
34
35
|
import * as prompts from "@/ui/prompts";
|
|
35
36
|
import * as report from "@/ui/report";
|
|
@@ -154,7 +155,7 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
|
|
|
154
155
|
interactive,
|
|
155
156
|
command: "bun cortex apply --yes",
|
|
156
157
|
}).catch(function (error: unknown) {
|
|
157
|
-
if (error instanceof
|
|
158
|
+
if (error instanceof ManagementClientError && error.message === "chat_model_required") {
|
|
158
159
|
return undefined;
|
|
159
160
|
}
|
|
160
161
|
return failCall(profile, error);
|
|
@@ -233,12 +234,12 @@ async function waitForCortex(profile: Profile, wait: boolean) {
|
|
|
233
234
|
let announced = false;
|
|
234
235
|
for (;;) {
|
|
235
236
|
try {
|
|
236
|
-
await
|
|
237
|
+
await managementClient(profile).secrets.list({ pageSize: 1 });
|
|
237
238
|
return;
|
|
238
239
|
} catch (error) {
|
|
239
240
|
const starting =
|
|
240
241
|
error instanceof UnreachableError ||
|
|
241
|
-
(error instanceof
|
|
242
|
+
(error instanceof ManagementClientError && error.status >= 500);
|
|
242
243
|
if (!starting || Date.now() >= deadline) failCall(profile, error);
|
|
243
244
|
if (!announced) {
|
|
244
245
|
report.row("", `Waiting for ${profile.url}. A first start pulls the image.`);
|
|
@@ -249,24 +250,21 @@ async function waitForCortex(profile: Profile, wait: boolean) {
|
|
|
249
250
|
}
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
function call(profile: Profile, method: "get" | "post", path: string, input: unknown) {
|
|
253
|
-
return callOrFail(profile, () => callManagement(profile, method, path, input));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
253
|
/** The entry named exactly `name`, across every page; a listing's search matches any part of it. */
|
|
257
254
|
async function findByName<TItem extends typeof namedSchema>(
|
|
258
255
|
profile: Profile,
|
|
259
|
-
router:
|
|
256
|
+
router: "secrets" | "modelProviders" | "servers",
|
|
260
257
|
name: string,
|
|
261
258
|
item: TItem,
|
|
262
259
|
) {
|
|
263
|
-
const { items } = z
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
),
|
|
269
|
-
)
|
|
260
|
+
const { items } = z.object({ items: z.array(item) }).parse(
|
|
261
|
+
await callOrFail(profile, () =>
|
|
262
|
+
walkPages(profile, "get", managementPath(`${router}.list`), {
|
|
263
|
+
q: name,
|
|
264
|
+
pageSize: 100,
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
);
|
|
270
268
|
return items.find((entry) => entry.name === name);
|
|
271
269
|
}
|
|
272
270
|
|
|
@@ -284,7 +282,9 @@ function secretFor(owner: string, role: "api-key" | "server-key", value: string)
|
|
|
284
282
|
|
|
285
283
|
async function ensureSecret(profile: Profile, name: string, value: string) {
|
|
286
284
|
const found = await findByName(profile, "secrets", name, namedSchema);
|
|
287
|
-
if (!found)
|
|
285
|
+
if (!found) {
|
|
286
|
+
await callOrFail(profile, () => managementClient(profile).secrets.set({ name, value }));
|
|
287
|
+
}
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
/**
|
|
@@ -307,7 +307,7 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
|
|
|
307
307
|
* write.
|
|
308
308
|
*/
|
|
309
309
|
async function ensureProvider(profile: Profile, provider: Provider) {
|
|
310
|
-
const found = await findByName(profile, "
|
|
310
|
+
const found = await findByName(profile, "modelProviders", provider.name, providerRowSchema);
|
|
311
311
|
if (found && found.baseUrl !== provider.baseUrl) {
|
|
312
312
|
failCallsElsewhere(
|
|
313
313
|
`Provider ${provider.name}`,
|
|
@@ -330,13 +330,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
330
330
|
await ensureSecret(profile, secretName, provider.apiKey);
|
|
331
331
|
|
|
332
332
|
if (!found) {
|
|
333
|
-
await
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
333
|
+
await callOrFail(profile, () =>
|
|
334
|
+
managementClient(profile).modelProviders.create({
|
|
335
|
+
name: provider.name,
|
|
336
|
+
baseUrl: provider.baseUrl,
|
|
337
|
+
secretName,
|
|
338
|
+
models,
|
|
339
|
+
efforts: [],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
340
342
|
report.input("Provider", `${provider.name} · created`);
|
|
341
343
|
return;
|
|
342
344
|
}
|
|
@@ -344,13 +346,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
344
346
|
report.input("Provider", `${provider.name} · kept`);
|
|
345
347
|
return;
|
|
346
348
|
}
|
|
347
|
-
await
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
349
|
+
await callOrFail(profile, () =>
|
|
350
|
+
managementClient(profile).modelProviders.update({
|
|
351
|
+
id: found.id,
|
|
352
|
+
baseUrl: found.baseUrl,
|
|
353
|
+
secretName,
|
|
354
|
+
models,
|
|
355
|
+
efforts: found.efforts,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
354
358
|
report.input("Provider", `${provider.name} · updated`);
|
|
355
359
|
}
|
|
356
360
|
|
|
@@ -369,7 +373,11 @@ async function ensureServer(
|
|
|
369
373
|
const url = `${project.CORTEX_SERVER_URL}/cortex`;
|
|
370
374
|
const found = await findByName(profile, "servers", name, namedSchema);
|
|
371
375
|
const server = found
|
|
372
|
-
? serverSchema.parse(
|
|
376
|
+
? serverSchema.parse(
|
|
377
|
+
await callOrFail(profile, () =>
|
|
378
|
+
managementClient(profile).servers.get({ id: found.id }),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
373
381
|
: undefined;
|
|
374
382
|
if (server && server.url !== url) {
|
|
375
383
|
failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
|
|
@@ -384,15 +392,17 @@ async function ensureServer(
|
|
|
384
392
|
};
|
|
385
393
|
if (!server) {
|
|
386
394
|
const created = serverSchema.parse(
|
|
387
|
-
await
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
395
|
+
await callOrFail(profile, () =>
|
|
396
|
+
managementClient(profile).servers.create({
|
|
397
|
+
name,
|
|
398
|
+
description: "",
|
|
399
|
+
url,
|
|
400
|
+
keySecretName,
|
|
401
|
+
...model,
|
|
402
|
+
signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
|
|
403
|
+
triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
|
|
404
|
+
}),
|
|
405
|
+
),
|
|
396
406
|
);
|
|
397
407
|
report.input("Server", `${name} · created`);
|
|
398
408
|
return created;
|
|
@@ -404,22 +414,24 @@ async function ensureServer(
|
|
|
404
414
|
return server;
|
|
405
415
|
}
|
|
406
416
|
const updated = serverSchema.parse(
|
|
407
|
-
await
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
417
|
+
await callOrFail(profile, () =>
|
|
418
|
+
managementClient(profile).servers.update({
|
|
419
|
+
id: server.id,
|
|
420
|
+
name,
|
|
421
|
+
description: server.description ?? "",
|
|
422
|
+
url,
|
|
423
|
+
keySecretName,
|
|
424
|
+
...(takesModel
|
|
425
|
+
? model
|
|
426
|
+
: {
|
|
427
|
+
modelProvider: server.modelProvider,
|
|
428
|
+
modelName: server.modelName,
|
|
429
|
+
modelEffort: server.modelEffort,
|
|
430
|
+
}),
|
|
431
|
+
signInChecksPerMinute: server.signInChecksPerMinute,
|
|
432
|
+
triggerChecksPerMinute: server.triggerChecksPerMinute,
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
423
435
|
);
|
|
424
436
|
report.input("Server", `${name} · updated`);
|
|
425
437
|
return updated;
|
|
@@ -434,14 +446,14 @@ async function syncServer(profile: Profile, id: string, url: string) {
|
|
|
434
446
|
const deadline = Date.now() + SYNC_WAIT_MS;
|
|
435
447
|
for (;;) {
|
|
436
448
|
try {
|
|
437
|
-
await
|
|
449
|
+
await managementClient(profile).servers.sync({ id });
|
|
438
450
|
report.input("Synced", `${url}/cortex`);
|
|
439
451
|
return;
|
|
440
452
|
} catch (error) {
|
|
441
453
|
// Only a backend Cortex cannot reach yet is worth asking again; a
|
|
442
454
|
// refused manifest or blocked host fails the same way next time.
|
|
443
455
|
const starting =
|
|
444
|
-
error instanceof
|
|
456
|
+
error instanceof ManagementClientError && error.message === "upstream_failed";
|
|
445
457
|
if (starting && Date.now() < deadline) {
|
|
446
458
|
await Bun.sleep(POLL_MS);
|
|
447
459
|
continue;
|
|
@@ -57,8 +57,13 @@ type ParsedDatasetSql = {
|
|
|
57
57
|
/** A refusal the model can act on. */
|
|
58
58
|
export class DatasetSqlError extends Error {}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* `upper` is a word's keyword spelling. A quoted identifier and a word after a
|
|
62
|
+
* dot (`plans.from`) have none (`""`), so no keyword check matches them and
|
|
63
|
+
* they always read as names.
|
|
64
|
+
*/
|
|
60
65
|
type Token =
|
|
61
|
-
| { kind: "word"; value: string; upper: string
|
|
66
|
+
| { kind: "word"; value: string; upper: string }
|
|
62
67
|
| { kind: "string"; value: string }
|
|
63
68
|
| { kind: "number"; value: number }
|
|
64
69
|
| { kind: "punct"; value: string }
|
|
@@ -84,6 +89,14 @@ function stringLiteral(sql: string, start: number) {
|
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/** Whether `previous` ends a value, so a minus after it subtracts; after a keyword or an operator it signs the number. */
|
|
93
|
+
function endsValue(previous: Token | undefined) {
|
|
94
|
+
if (previous === undefined) return false;
|
|
95
|
+
if (previous.kind === "punct") return previous.value === ")";
|
|
96
|
+
if (previous.kind !== "word") return true;
|
|
97
|
+
return ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous);
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
/** Reads the token at `i`: a quoted identifier, a number (negative only where a value is expected), a word or punctuation. */
|
|
88
101
|
function lexeme(sql: string, i: number, previous: Token | undefined) {
|
|
89
102
|
const char = sql[i]!;
|
|
@@ -92,27 +105,13 @@ function lexeme(sql: string, i: number, previous: Token | undefined) {
|
|
|
92
105
|
if (close === -1) throw new DatasetSqlError("A quoted identifier is not closed.");
|
|
93
106
|
const value = sql.slice(i + 1, close);
|
|
94
107
|
return {
|
|
95
|
-
token: {
|
|
96
|
-
kind: "word",
|
|
97
|
-
value,
|
|
98
|
-
upper: value.toUpperCase(),
|
|
99
|
-
quoted: true,
|
|
100
|
-
} satisfies Token,
|
|
108
|
+
token: { kind: "word", value, upper: "" } satisfies Token,
|
|
101
109
|
next: close + 1,
|
|
102
110
|
};
|
|
103
111
|
}
|
|
104
112
|
const rest = sql.slice(i);
|
|
105
113
|
const number = /^-?\d+(\.\d+)?/.exec(rest);
|
|
106
|
-
|
|
107
|
-
const afterValue =
|
|
108
|
-
previous !== undefined &&
|
|
109
|
-
(previous.kind === "punct"
|
|
110
|
-
? previous.value === ")"
|
|
111
|
-
: previous.kind === "word"
|
|
112
|
-
? ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous)
|
|
113
|
-
: true);
|
|
114
|
-
const signIsOperator = char === "-" && afterValue;
|
|
115
|
-
if (number && !signIsOperator) {
|
|
114
|
+
if (number && !(char === "-" && endsValue(previous))) {
|
|
116
115
|
return {
|
|
117
116
|
token: { kind: "number", value: Number(number[0]) } satisfies Token,
|
|
118
117
|
next: i + number[0].length,
|
|
@@ -120,8 +119,13 @@ function lexeme(sql: string, i: number, previous: Token | undefined) {
|
|
|
120
119
|
}
|
|
121
120
|
const word = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
|
|
122
121
|
if (word) {
|
|
122
|
+
const qualified = previous?.kind === "punct" && previous.value === ".";
|
|
123
123
|
return {
|
|
124
|
-
token: {
|
|
124
|
+
token: {
|
|
125
|
+
kind: "word",
|
|
126
|
+
value: word[0],
|
|
127
|
+
upper: qualified ? "" : word[0].toUpperCase(),
|
|
128
|
+
} satisfies Token,
|
|
125
129
|
next: i + word[0].length,
|
|
126
130
|
};
|
|
127
131
|
}
|
|
@@ -338,12 +342,35 @@ const RESERVED = [
|
|
|
338
342
|
"END",
|
|
339
343
|
];
|
|
340
344
|
|
|
345
|
+
/** Whether a bare word reads as a keyword rather than a name. */
|
|
346
|
+
function isKeyword(upper: string) {
|
|
347
|
+
return RESERVED.includes(upper) || upper in REFUSED_CLAUSES;
|
|
348
|
+
}
|
|
349
|
+
|
|
341
350
|
function isName(token: Token): token is Token & { kind: "word" } {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
return token.kind === "word" && !isKeyword(token.upper);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Words that bare read as a value rather than a column. */
|
|
355
|
+
const VALUE_WORDS = ["TRUE", "FALSE", "CURRENT_DATE", "CURRENT_TIMESTAMP"];
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A column name as a query writes it: double quoted when bare it would read
|
|
359
|
+
* as a keyword or a value (`"from"`), so what the model is shown is what it
|
|
360
|
+
* can send.
|
|
361
|
+
*/
|
|
362
|
+
export function sqlColumnName(name: string) {
|
|
363
|
+
const upper = name.toUpperCase();
|
|
364
|
+
return isKeyword(upper) || VALUE_WORDS.includes(upper) ? `"${name}"` : name;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `message`, and for a keyword where a column belongs, how to write a column spelled like it. */
|
|
368
|
+
function keywordError(token: Token, message: string) {
|
|
369
|
+
const hint =
|
|
370
|
+
token.kind === "word" && isKeyword(token.upper)
|
|
371
|
+
? ` ${token.value} is a SQL keyword: write a column of that name as "${token.value}" or dataset.${token.value}.`
|
|
372
|
+
: "";
|
|
373
|
+
return new DatasetSqlError(`${message}${hint}`);
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
/** A unit written as `'month'`, `month` or `MONTH`. */
|
|
@@ -971,11 +998,21 @@ class Parser {
|
|
|
971
998
|
);
|
|
972
999
|
}
|
|
973
1000
|
const op = (operator.value === "<>" ? "!=" : operator.value) as DatasetFilterOp;
|
|
1001
|
+
const message = `"${shown} ${operator.value}" needs a value on the right.`;
|
|
974
1002
|
if (this.startsTerm()) {
|
|
975
|
-
return {
|
|
1003
|
+
return {
|
|
1004
|
+
kind: "cond",
|
|
1005
|
+
left,
|
|
1006
|
+
op,
|
|
1007
|
+
value: { term: this.term(message) },
|
|
1008
|
+
} satisfies Predicate;
|
|
1009
|
+
}
|
|
1010
|
+
// A keyword here may be a column spelled like one (`x > from`); NULL keeps its own refusal.
|
|
1011
|
+
const next = this.peek();
|
|
1012
|
+
if (next.kind === "word" && next.upper !== "NULL" && isKeyword(next.upper)) {
|
|
1013
|
+
throw keywordError(next, message);
|
|
976
1014
|
}
|
|
977
|
-
|
|
978
|
-
return { kind: "cond", left, op, value } satisfies Predicate;
|
|
1015
|
+
return { kind: "cond", left, op, value: this.literal(message) } satisfies Predicate;
|
|
979
1016
|
}
|
|
980
1017
|
|
|
981
1018
|
/** Whether the next tokens are a value expression rather than a literal: a name, `(`, CASE, or a number followed by an operator. */
|
|
@@ -1174,10 +1211,10 @@ class Parser {
|
|
|
1174
1211
|
return count.value;
|
|
1175
1212
|
}
|
|
1176
1213
|
|
|
1177
|
-
/** `a.b` as one reference; a bare keyword in reference position gets the caller's message. */
|
|
1214
|
+
/** `a.b` as one reference; a bare keyword in reference position gets the caller's message (`keywordError`). */
|
|
1178
1215
|
private reference(message: string) {
|
|
1179
1216
|
const token = this.peek();
|
|
1180
|
-
if (!isName(token)) throw
|
|
1217
|
+
if (!isName(token)) throw keywordError(token, message);
|
|
1181
1218
|
this.take();
|
|
1182
1219
|
let ref = token.value;
|
|
1183
1220
|
while (this.optionalPunct(".")) {
|