@m6d/cortex-cli 0.14.0 → 0.16.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 +43 -19
- package/package.json +3 -13
- package/src/commands/apply.ts +171 -38
- package/src/commands/management.ts +73 -20
- package/src/commands/new.ts +8 -10
- package/src/commands/setup.ts +77 -37
- package/src/contracts/datasets/dataset-sql.ts +58 -7
- package/src/contracts/datasets/datasets.ts +220 -195
- package/src/contracts/egress-policy/scrub.ts +22 -10
- package/src/contracts/errors/index.ts +7 -0
- package/src/contracts/json/index.ts +45 -1
- package/src/contracts/manifest/index.ts +506 -134
- package/src/contracts/server/index.ts +45 -56
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +153 -18
- package/src/management/client.ts +79 -117
- package/src/management/profiles.ts +11 -22
- package/src/scaffold/files.ts +11 -9
- package/src/ui/prompts.ts +38 -8
- package/src/contracts/charts/chart-spec.ts +0 -217
- package/src/contracts/charts/index.ts +0 -8
- package/src/contracts/datasets/datasets.fixtures.json +0 -2849
- package/src/contracts/markdown/index.ts +0 -47
- package/src/contracts/pattern/index.ts +0 -276
- package/src/contracts/server/reserved-names.fixtures.json +0 -18
|
@@ -88,18 +88,22 @@ export function registerManagement(program: Program, argv: string[]) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* The
|
|
92
|
-
*
|
|
93
|
-
* when there is no terminal
|
|
91
|
+
* The URL is `--url`, or asked for on a terminal. The key is never a flag
|
|
92
|
+
* (argv is visible to other processes and lands in shell history): it is
|
|
93
|
+
* prompted for masked, or read from `CORTEX_KEY` when there is no terminal
|
|
94
|
+
* to ask.
|
|
94
95
|
*/
|
|
95
96
|
async function login(given: string | undefined, name: string) {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
"",
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
const typed =
|
|
98
|
+
given ??
|
|
99
|
+
(prompts.canAsk()
|
|
100
|
+
? await prompts.askValue("Cortex URL", "https://cortex.example.com")
|
|
101
|
+
: report.fail({
|
|
102
|
+
what: "No terminal to ask for the Cortex URL",
|
|
103
|
+
blocks: ["Pass it with --url."],
|
|
104
|
+
}));
|
|
105
|
+
const url = requireTls(typed.replace(/\/+$/, ""));
|
|
106
|
+
const key = prompts.canAsk()
|
|
103
107
|
? await prompts.askSecret("Management key")
|
|
104
108
|
: (process.env["CORTEX_KEY"] ??
|
|
105
109
|
report.fail({
|
|
@@ -248,7 +252,8 @@ function groupByRouter(document: OpenApiDocument) {
|
|
|
248
252
|
/**
|
|
249
253
|
* One procedure as one command: a positional for `id`, a typed flag per
|
|
250
254
|
* field, `--json` for a whole body, `--all` on paginated reads, `--publish`
|
|
251
|
-
* on writes to a publishable router
|
|
255
|
+
* on writes to a publishable router, `--yes` wherever a destructive
|
|
256
|
+
* procedure (`x-destructive`) would run.
|
|
252
257
|
*/
|
|
253
258
|
function registerOperation(scope: {
|
|
254
259
|
group: Command;
|
|
@@ -273,10 +278,13 @@ function registerOperation(scope: {
|
|
|
273
278
|
const publisher = publishWith === undefined ? undefined : byProcedure.get(publishWith);
|
|
274
279
|
const canPublish = publisher !== undefined;
|
|
275
280
|
const liveFields = operation["x-live-fields"] ?? [];
|
|
281
|
+
const destructive = operation["x-destructive"] === true;
|
|
282
|
+
const publishDestructive = publisher?.operation["x-destructive"] === true;
|
|
276
283
|
|
|
277
284
|
const command = declareCommand(group, name, operation.summary ?? "", flags, {
|
|
278
285
|
paginated,
|
|
279
286
|
canPublish,
|
|
287
|
+
confirms: destructive || publishDestructive,
|
|
280
288
|
});
|
|
281
289
|
|
|
282
290
|
command.action(async function (...args) {
|
|
@@ -285,11 +293,12 @@ function registerOperation(scope: {
|
|
|
285
293
|
json?: string;
|
|
286
294
|
all?: boolean;
|
|
287
295
|
publish?: boolean;
|
|
296
|
+
yes?: boolean;
|
|
288
297
|
};
|
|
289
298
|
// Commander passes the options object first when the command declares
|
|
290
299
|
// no argument, so only a string is the `[id]` positional.
|
|
291
300
|
const positional = typeof args[0] === "string" ? args[0] : undefined;
|
|
292
|
-
const { output: format, json, all, publish, ...given } = options;
|
|
301
|
+
const { output: format, json, all, publish, yes, ...given } = options;
|
|
293
302
|
const body = await collectInput(flags, given, json, positional, name);
|
|
294
303
|
// `-o json` promises stdout holds only the result; prose moves to stderr.
|
|
295
304
|
const note = format === "json" ? report.aside : report.note;
|
|
@@ -297,6 +306,20 @@ function registerOperation(scope: {
|
|
|
297
306
|
report.header(`cortex ${router} ${name}`);
|
|
298
307
|
report.input("Cortex", profile.url);
|
|
299
308
|
}
|
|
309
|
+
// One question before anything is sent, covering the publish too, so
|
|
310
|
+
// a no leaves no draft behind.
|
|
311
|
+
const confirming = [
|
|
312
|
+
...(destructive ? [`${router} ${name}`] : []),
|
|
313
|
+
...(publish === true && publishDestructive
|
|
314
|
+
? [publisher.operation.operationId.replace(".", " ")]
|
|
315
|
+
: []),
|
|
316
|
+
];
|
|
317
|
+
if (confirming.length > 0) {
|
|
318
|
+
await confirmDestructive(
|
|
319
|
+
`Running ${confirming.join(" and ")} on ${profile.url} replaces or removes state.`,
|
|
320
|
+
yes,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
300
323
|
const result = await callOrFail(profile, () =>
|
|
301
324
|
all
|
|
302
325
|
? walkPages(profile, method, path, body)
|
|
@@ -320,7 +343,7 @@ function declareCommand(
|
|
|
320
343
|
name: string,
|
|
321
344
|
summary: string,
|
|
322
345
|
flags: Flag[],
|
|
323
|
-
shape: { paginated: boolean; canPublish: boolean },
|
|
346
|
+
shape: { paginated: boolean; canPublish: boolean; confirms: boolean },
|
|
324
347
|
) {
|
|
325
348
|
const command = group
|
|
326
349
|
.command(name)
|
|
@@ -353,9 +376,34 @@ function declareCommand(
|
|
|
353
376
|
}
|
|
354
377
|
if (shape.paginated) command.option("--all", "Walk every page");
|
|
355
378
|
if (shape.canPublish) command.option("--publish", "Publish the entity right after saving");
|
|
379
|
+
if (shape.confirms)
|
|
380
|
+
command.option("--yes", "Skip the confirmation; required without a terminal");
|
|
356
381
|
return command;
|
|
357
382
|
}
|
|
358
383
|
|
|
384
|
+
/** What a publish went ahead despite, such as a prompt naming a tool without an @-mention. */
|
|
385
|
+
function warningsOf(published: unknown) {
|
|
386
|
+
return isRecord(published) && Array.isArray(published.warnings)
|
|
387
|
+
? published.warnings.filter((warning): warning is string => typeof warning === "string")
|
|
388
|
+
: [];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The go-ahead for a call that replaces or removes state, which `effect`
|
|
393
|
+
* names: `--yes`, or a yes on a terminal. With no terminal to ask, the run
|
|
394
|
+
* fails before anything is sent, so a script never hangs on the question.
|
|
395
|
+
* The hint does not echo argv, which may hold values that must not reach a
|
|
396
|
+
* CI log.
|
|
397
|
+
*/
|
|
398
|
+
export async function confirmDestructive(effect: string, yes: boolean | undefined) {
|
|
399
|
+
if (yes === true) return;
|
|
400
|
+
if (prompts.canAsk()) return prompts.askToProceed(`${effect} Continue?`);
|
|
401
|
+
report.fail({
|
|
402
|
+
what: "No terminal to confirm this call",
|
|
403
|
+
blocks: [effect, "Rerun the command with --yes to run it without asking."],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
359
407
|
/**
|
|
360
408
|
* A write to a publishable router is a draft: publish it now, or say how to.
|
|
361
409
|
* Fields Cortex reads live are already in effect and are named as such,
|
|
@@ -378,10 +426,11 @@ async function publishAfterWrite(
|
|
|
378
426
|
);
|
|
379
427
|
}
|
|
380
428
|
if (publish) {
|
|
381
|
-
await callOrFail(profile, () =>
|
|
429
|
+
const published = await callOrFail(profile, () =>
|
|
382
430
|
callManagement(profile, publisher.method, publisher.path, { id }),
|
|
383
431
|
);
|
|
384
432
|
options.note(`Published ${id}.`);
|
|
433
|
+
for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
|
|
385
434
|
} else {
|
|
386
435
|
options.note(
|
|
387
436
|
`${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
|
|
@@ -400,14 +449,18 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
|
|
|
400
449
|
|
|
401
450
|
/**
|
|
402
451
|
* A management refusal, or a Cortex that cannot be reached, is the
|
|
403
|
-
* run's outcome, in the error grammar
|
|
404
|
-
* anything else is a bug, and
|
|
452
|
+
* run's outcome, in the error grammar: the refusal's literal, the sentence
|
|
453
|
+
* Cortex words it in, then whatever it names; anything else is a bug, and
|
|
454
|
+
* rethrown.
|
|
405
455
|
*/
|
|
406
456
|
export function failCall(profile: { url: string }, error: unknown): never {
|
|
407
457
|
if (error instanceof ManagementError) {
|
|
408
458
|
report.fail({
|
|
409
459
|
what: `${error.status} ${error.code}: ${error.message}`,
|
|
410
|
-
blocks:
|
|
460
|
+
blocks: [
|
|
461
|
+
...(error.refusal === undefined ? [] : [error.refusal]),
|
|
462
|
+
...refusalBlocks(error.detail ?? {}),
|
|
463
|
+
],
|
|
411
464
|
});
|
|
412
465
|
}
|
|
413
466
|
if (error instanceof UnreachableError) {
|
|
@@ -451,8 +504,8 @@ function issueLine(issue: unknown) {
|
|
|
451
504
|
|
|
452
505
|
/**
|
|
453
506
|
* The body: `--json` first, flags layered over it, the positional as `id`.
|
|
454
|
-
* A required field left missing is asked for on a
|
|
455
|
-
* flag, or the positional, to add otherwise.
|
|
507
|
+
* A required field left missing is asked for on a terminal and named as
|
|
508
|
+
* the flag, or the positional, to add otherwise.
|
|
456
509
|
*/
|
|
457
510
|
async function collectInput(
|
|
458
511
|
flags: Flag[],
|
|
@@ -468,7 +521,7 @@ async function collectInput(
|
|
|
468
521
|
if (positional !== undefined) body.set("id", positional);
|
|
469
522
|
|
|
470
523
|
const missing = flags.filter((flag) => flag.required && body.get(flag.name) === undefined);
|
|
471
|
-
if (missing.length > 0 && !
|
|
524
|
+
if (missing.length > 0 && !prompts.canAsk()) {
|
|
472
525
|
const named = (flag: Flag) =>
|
|
473
526
|
flag.name === "id"
|
|
474
527
|
? "<id>"
|
package/src/commands/new.ts
CHANGED
|
@@ -9,7 +9,7 @@ import pkg from "../../package.json";
|
|
|
9
9
|
import { askProvider } from "@/commands/setup";
|
|
10
10
|
import { AGENT_SLUG, manifestIdOf, scaffoldFiles } from "@/scaffold/files";
|
|
11
11
|
import { allocatePorts, type Ports } from "@/scaffold/ports";
|
|
12
|
-
import { askConfirm, askName, intro, log, outro, runSteps } from "@/ui/prompts";
|
|
12
|
+
import { askConfirm, askName, canAsk, intro, log, outro, runSteps } from "@/ui/prompts";
|
|
13
13
|
import * as report from "@/ui/report";
|
|
14
14
|
|
|
15
15
|
/** npm's rule for an unscoped package name. The name is the directory too. */
|
|
@@ -46,9 +46,8 @@ type Options = { datasets?: true; setup?: boolean; yes?: true; verbose: boolean
|
|
|
46
46
|
async function scaffold(name: string | undefined, options: Options) {
|
|
47
47
|
const { verbose } = options;
|
|
48
48
|
|
|
49
|
-
// No
|
|
50
|
-
|
|
51
|
-
const interactive = process.stdin.isTTY === true && options.yes !== true;
|
|
49
|
+
// No terminal ⇒ never prompt and behave as `--yes`.
|
|
50
|
+
const interactive = canAsk() && options.yes !== true;
|
|
52
51
|
|
|
53
52
|
if (name === undefined && !interactive) missingName();
|
|
54
53
|
|
|
@@ -280,14 +279,13 @@ function isInsideWorkTree(target: string) {
|
|
|
280
279
|
}
|
|
281
280
|
|
|
282
281
|
function missingName(): never {
|
|
283
|
-
// No
|
|
284
|
-
// With
|
|
282
|
+
// No terminal ⇒ never prompt, so a missing required argument is a hard error.
|
|
283
|
+
// With one, this branch is only reached under `--yes`, which answers every
|
|
285
284
|
// question including the one that has no default.
|
|
286
285
|
report.fail({
|
|
287
|
-
what:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
: "cortex new needs a project name when stdin is not a TTY.",
|
|
286
|
+
what: canAsk()
|
|
287
|
+
? "cortex new needs a project name."
|
|
288
|
+
: "cortex new needs a project name when there is no terminal to ask on.",
|
|
291
289
|
blocks: [
|
|
292
290
|
"Pass it as an argument, and --yes to accept defaults for the rest:",
|
|
293
291
|
" cortex new my-app --yes",
|
package/src/commands/setup.ts
CHANGED
|
@@ -12,15 +12,23 @@ import {
|
|
|
12
12
|
normalizeHost,
|
|
13
13
|
scrubCredentialStrings,
|
|
14
14
|
} from "@cortex/contracts/egress-policy";
|
|
15
|
+
import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH } from "@cortex/contracts/manifest";
|
|
15
16
|
import { z } from "zod";
|
|
16
17
|
|
|
17
18
|
import {
|
|
18
19
|
applyManifest,
|
|
19
20
|
DEFAULT_MANIFEST,
|
|
20
21
|
findManifest,
|
|
22
|
+
loadManifest,
|
|
21
23
|
printTriggerSecrets,
|
|
22
24
|
} from "@/commands/apply";
|
|
23
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
callOrFail,
|
|
27
|
+
confirmDestructive,
|
|
28
|
+
failCall,
|
|
29
|
+
requireProfile,
|
|
30
|
+
walkPages,
|
|
31
|
+
} from "@/commands/management";
|
|
24
32
|
import { callManagement, ManagementError, UnreachableError } from "@/management/client";
|
|
25
33
|
import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
|
|
26
34
|
import * as prompts from "@/ui/prompts";
|
|
@@ -34,7 +42,7 @@ const POLL_MS = 2_000;
|
|
|
34
42
|
|
|
35
43
|
/** The backend as `cortex new` writes it into .env, which Bun loads from the directory it runs in. */
|
|
36
44
|
const projectSchema = z.object({
|
|
37
|
-
CORTEX_SERVER_NAME: z.string().min(1),
|
|
45
|
+
CORTEX_SERVER_NAME: z.string().min(1).max(NAME_MAX_LENGTH),
|
|
38
46
|
CORTEX_SERVER_URL: z.url(),
|
|
39
47
|
CORTEX_SERVER_KEY: z.string().min(1),
|
|
40
48
|
});
|
|
@@ -48,14 +56,16 @@ const ENV_SAFE = /^[^\s$#'"`\\]+$/;
|
|
|
48
56
|
|
|
49
57
|
/** The model provider in .env: all four values, or none. */
|
|
50
58
|
const providerFields = z.object({
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
MODEL_PROVIDER_NAME: z.string().min(1).max(NAME_MAX_LENGTH),
|
|
60
|
+
MODEL_PROVIDER_URL: z
|
|
61
|
+
.string()
|
|
62
|
+
.max(MODEL_PROVIDER_LIMITS.urlLength)
|
|
63
|
+
.superRefine(function (value, context) {
|
|
64
|
+
const problem = checkBaseUrl(value);
|
|
65
|
+
if (problem) context.addIssue({ code: "custom", message: problem });
|
|
66
|
+
}),
|
|
57
67
|
MODEL_PROVIDER_API_KEY: z.string().min(1),
|
|
58
|
-
MODEL_PROVIDER_MODEL: z.string().min(1).max(
|
|
68
|
+
MODEL_PROVIDER_MODEL: z.string().min(1).max(MODEL_PROVIDER_LIMITS.modelLength),
|
|
59
69
|
});
|
|
60
70
|
const providerEnvSchema = providerFields.transform((env) => ({
|
|
61
71
|
name: env.MODEL_PROVIDER_NAME,
|
|
@@ -94,8 +104,12 @@ const embeddingsSchema = z.object({
|
|
|
94
104
|
* creates what is missing and keeps what exists, so it runs again on every
|
|
95
105
|
* `bun run dev`. A provider or server under .env's name must call .env's URL,
|
|
96
106
|
* or setup leaves it alone and stops, so no key reaches a URL .env did not name. With no model provider in .env it asks for
|
|
97
|
-
* one on a terminal, and under `--yes` registers the server
|
|
98
|
-
*
|
|
107
|
+
* one on a terminal, and under `--yes` registers the server without a model
|
|
108
|
+
* and applies all the same, since an agent may name its own; when Cortex
|
|
109
|
+
* refuses an agent for want of one, setup says so and applies nothing. The
|
|
110
|
+
* apply deletes what the manifest no longer lists, so setup checks the
|
|
111
|
+
* manifest, then waits for a yes on a terminal or `--yes`, before it asks or
|
|
112
|
+
* writes anything else, as `cortex apply` does.
|
|
99
113
|
*/
|
|
100
114
|
export function registerSetup(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
101
115
|
const command = program
|
|
@@ -103,19 +117,26 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
|
|
|
103
117
|
.helpGroup("Project:")
|
|
104
118
|
.description("Register this project's backend and model in Cortex, then apply its manifest")
|
|
105
119
|
.option("--wait", "Wait for Cortex to come up first")
|
|
106
|
-
.option(
|
|
120
|
+
.option(
|
|
121
|
+
"--yes",
|
|
122
|
+
"Never ask: leave out what .env does not hold, and apply without confirming",
|
|
123
|
+
);
|
|
107
124
|
|
|
108
125
|
command.action(async function (options) {
|
|
109
126
|
const { verbose = false, profile: profileName } = program.opts();
|
|
110
127
|
const project = readProject();
|
|
111
128
|
const profile = requireProfile(profileName);
|
|
112
|
-
const
|
|
113
|
-
const interactive =
|
|
129
|
+
const manifest = await loadManifest(findManifest(DEFAULT_MANIFEST), verbose);
|
|
130
|
+
const interactive = prompts.canAsk() && options.yes !== true;
|
|
114
131
|
if (profileName === undefined) requireEnvCortex(profile.url);
|
|
115
132
|
|
|
116
133
|
report.header("cortex setup");
|
|
117
134
|
await waitForCortex(profile, options.wait === true);
|
|
118
135
|
report.input("Cortex", profile.url);
|
|
136
|
+
await confirmDestructive(
|
|
137
|
+
`Applying deletes what the manifest created on ${profile.url} and no longer lists.`,
|
|
138
|
+
options.yes,
|
|
139
|
+
);
|
|
119
140
|
|
|
120
141
|
const provider = readProvider() ?? (interactive ? await askAndSave() : undefined);
|
|
121
142
|
if (provider) await requireReachable(provider.baseUrl);
|
|
@@ -124,21 +145,26 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
|
|
|
124
145
|
const server = await ensureServer(profile, project, provider);
|
|
125
146
|
await syncServer(profile, server.id, project.CORTEX_SERVER_URL);
|
|
126
147
|
|
|
127
|
-
|
|
148
|
+
const applied = await applyManifest({
|
|
149
|
+
profile,
|
|
150
|
+
manifest,
|
|
151
|
+
dryRun: false,
|
|
152
|
+
interactive,
|
|
153
|
+
command: "bun cortex apply --yes",
|
|
154
|
+
}).catch(function (error: unknown) {
|
|
155
|
+
if (error instanceof ManagementError && error.message === "chat_model_required") {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return failCall(profile, error);
|
|
159
|
+
});
|
|
160
|
+
if (!applied) {
|
|
128
161
|
report.footer("no model yet, so nothing applied");
|
|
129
162
|
report.note("Run `bun cortex setup` in a terminal to add a model provider.");
|
|
130
163
|
return;
|
|
131
164
|
}
|
|
132
|
-
|
|
133
|
-
const { changes, triggerSecrets } = await applyManifest({
|
|
134
|
-
profile,
|
|
135
|
-
file,
|
|
136
|
-
dryRun: false,
|
|
137
|
-
verbose,
|
|
138
|
-
});
|
|
139
165
|
report.footer(`live · chat at ${profile.url}/playground`);
|
|
140
|
-
printTriggerSecrets(triggerSecrets);
|
|
141
|
-
if (Object.values(changes.create).flat().length > 0) printSignIn(profile.url);
|
|
166
|
+
printTriggerSecrets(applied.triggerSecrets);
|
|
167
|
+
if (Object.values(applied.changes.create).flat().length > 0) printSignIn(profile.url);
|
|
142
168
|
});
|
|
143
169
|
}
|
|
144
170
|
|
|
@@ -146,8 +172,9 @@ function readProject() {
|
|
|
146
172
|
const parsed = projectSchema.safeParse(process.env);
|
|
147
173
|
if (parsed.success) return parsed.data;
|
|
148
174
|
return report.fail({
|
|
149
|
-
what: "No project here
|
|
175
|
+
what: "No project here cortex setup can register",
|
|
150
176
|
blocks: [
|
|
177
|
+
z.prettifyError(parsed.error),
|
|
151
178
|
"It reads CORTEX_SERVER_NAME, CORTEX_SERVER_URL and CORTEX_SERVER_KEY from\n.env, which `cortex new` writes. Run it from that project's root.",
|
|
152
179
|
],
|
|
153
180
|
});
|
|
@@ -242,13 +269,15 @@ async function findByName<TItem extends typeof namedSchema>(
|
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
/**
|
|
245
|
-
* A secret's name for one value: its
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
272
|
+
* A secret's name for one value: its owner's name, what it is to the owner,
|
|
273
|
+
* and a short digest of the value, the owner's name cut so the whole is a
|
|
274
|
+
* name Cortex holds. A name then holds one value only, so a secret that
|
|
275
|
+
* exists is the value .env holds, and a changed key gets a secret of its own
|
|
276
|
+
* while every row presenting the old one keeps it.
|
|
249
277
|
*/
|
|
250
|
-
function secretFor(
|
|
251
|
-
|
|
278
|
+
function secretFor(owner: string, role: "api-key" | "server-key", value: string) {
|
|
279
|
+
const suffix = `-${role}-${createHash("sha256").update(value).digest("hex").slice(0, 8)}`;
|
|
280
|
+
return `${owner.slice(0, NAME_MAX_LENGTH - suffix.length)}${suffix}`;
|
|
252
281
|
}
|
|
253
282
|
|
|
254
283
|
async function ensureSecret(profile: Profile, name: string, value: string) {
|
|
@@ -271,7 +300,9 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
|
|
|
271
300
|
|
|
272
301
|
/**
|
|
273
302
|
* The provider, on its key's secret. One that exists takes a changed key and
|
|
274
|
-
* the .env model as well; one that calls another URL
|
|
303
|
+
* the .env model as well; one that calls another URL, or lists as many
|
|
304
|
+
* models as Cortex allows without the .env one, ends the run before any
|
|
305
|
+
* write.
|
|
275
306
|
*/
|
|
276
307
|
async function ensureProvider(profile: Profile, provider: Provider) {
|
|
277
308
|
const found = await findByName(profile, "model-providers", provider.name, providerRowSchema);
|
|
@@ -283,7 +314,17 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
283
314
|
"MODEL_PROVIDER_NAME",
|
|
284
315
|
);
|
|
285
316
|
}
|
|
286
|
-
|
|
317
|
+
// Added, never replaced: a model a server still runs on stays offered.
|
|
318
|
+
const models = [...new Set([...(found?.models ?? []), provider.model])];
|
|
319
|
+
if (models.length > MODEL_PROVIDER_LIMITS.models) {
|
|
320
|
+
report.fail({
|
|
321
|
+
what: `Provider ${provider.name} in Cortex lists ${MODEL_PROVIDER_LIMITS.models} models, not ${provider.model}`,
|
|
322
|
+
blocks: [
|
|
323
|
+
"Cortex lists no more on one provider. Remove one in the console, or set\nMODEL_PROVIDER_MODEL in .env to a model it lists.",
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const secretName = secretFor(provider.name, "api-key", provider.apiKey);
|
|
287
328
|
await ensureSecret(profile, secretName, provider.apiKey);
|
|
288
329
|
|
|
289
330
|
if (!found) {
|
|
@@ -291,7 +332,7 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
291
332
|
name: provider.name,
|
|
292
333
|
baseUrl: provider.baseUrl,
|
|
293
334
|
secretName,
|
|
294
|
-
models
|
|
335
|
+
models,
|
|
295
336
|
efforts: [],
|
|
296
337
|
});
|
|
297
338
|
report.input("Provider", `${provider.name} · created`);
|
|
@@ -305,8 +346,7 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
305
346
|
id: found.id,
|
|
306
347
|
baseUrl: found.baseUrl,
|
|
307
348
|
secretName,
|
|
308
|
-
|
|
309
|
-
models: [...new Set([...found.models, provider.model])],
|
|
349
|
+
models,
|
|
310
350
|
efforts: found.efforts,
|
|
311
351
|
});
|
|
312
352
|
report.input("Provider", `${provider.name} · updated`);
|
|
@@ -332,7 +372,7 @@ async function ensureServer(
|
|
|
332
372
|
if (server && server.url !== url) {
|
|
333
373
|
failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
|
|
334
374
|
}
|
|
335
|
-
const keySecretName = secretFor(
|
|
375
|
+
const keySecretName = secretFor(name, "server-key", project.CORTEX_SERVER_KEY);
|
|
336
376
|
await ensureSecret(profile, keySecretName, project.CORTEX_SERVER_KEY);
|
|
337
377
|
|
|
338
378
|
const model = {
|
|
@@ -15,7 +15,14 @@ import type {
|
|
|
15
15
|
DatasetQueryInput,
|
|
16
16
|
DatasetRelation,
|
|
17
17
|
} from "./datasets";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DATASET_BUCKETS,
|
|
20
|
+
DATASET_DATE_PARTS,
|
|
21
|
+
DATASET_REQUEST_MAX_BYTES,
|
|
22
|
+
describeOperand,
|
|
23
|
+
groupKeyName,
|
|
24
|
+
unknownColumnError,
|
|
25
|
+
} from "./datasets";
|
|
19
26
|
|
|
20
27
|
/*
|
|
21
28
|
* The SQL a model writes against a dataset, and how it becomes the wire
|
|
@@ -272,6 +279,15 @@ const WINDOW_PLACE =
|
|
|
272
279
|
"RANK() goes in QUALIFY (QUALIFY RANK() OVER (PARTITION BY a ORDER BY b DESC) <= n) or in a derived table filtered on its alias (FROM (SELECT ..., RANK() OVER (...) AS rn FROM ...) WHERE rn <= n).";
|
|
273
280
|
const INTERVAL_PLACE =
|
|
274
281
|
"INTERVAL only shifts CURRENT_DATE (CURRENT_DATE - INTERVAL '7 days'); compare the column against a date instead.";
|
|
282
|
+
const TOO_LARGE =
|
|
283
|
+
"The statement grows too large to send: a simple CASE repeats its subject in every WHEN, so a CASE nested in that subject multiplies. Compare a column instead, or ask in smaller queries.";
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The most tokens a statement may read, a simple CASE's subject counted again
|
|
287
|
+
* for every WHEN that repeats it. A token writes about a byte or more of the
|
|
288
|
+
* wire query, so a statement past this could never be sent.
|
|
289
|
+
*/
|
|
290
|
+
const EXPANDED_TOKENS_MAX = DATASET_REQUEST_MAX_BYTES;
|
|
275
291
|
|
|
276
292
|
const REFUSED_CLAUSES = {
|
|
277
293
|
UNION: "UNION is not supported: ask each dataset in its own query.",
|
|
@@ -396,6 +412,8 @@ function datePartTerm(unit: DatasetDatePart, of: Term) {
|
|
|
396
412
|
|
|
397
413
|
class Parser {
|
|
398
414
|
private position = 0;
|
|
415
|
+
/** Tokens read so far, a simple CASE's subject counted once per WHEN: how large the statement grows once written out. */
|
|
416
|
+
private expanded = 0;
|
|
399
417
|
|
|
400
418
|
public constructor(
|
|
401
419
|
private readonly tokens: Token[],
|
|
@@ -763,11 +781,15 @@ class Parser {
|
|
|
763
781
|
*/
|
|
764
782
|
private caseTerm() {
|
|
765
783
|
this.take();
|
|
784
|
+
const start = this.expanded;
|
|
766
785
|
const subject = this.peekWord("WHEN")
|
|
767
786
|
? undefined
|
|
768
787
|
: this.term("CASE is followed by WHEN, or by a value to compare.");
|
|
788
|
+
const subjectSize = this.expanded - start;
|
|
769
789
|
const branches: { when: Predicate[]; then: Term }[] = [];
|
|
770
790
|
while (this.optionalWord("WHEN")) {
|
|
791
|
+
// The wire has no simple CASE: every WHEN after the first writes the subject out again.
|
|
792
|
+
if (branches.length > 0) this.expand(subjectSize);
|
|
771
793
|
const when = subject ? [this.simpleCaseMatch(subject)] : this.predicates();
|
|
772
794
|
this.expectWord("THEN", "CASE WHEN condition needs THEN value.");
|
|
773
795
|
if (this.peekWord("NULL")) {
|
|
@@ -1224,8 +1246,14 @@ class Parser {
|
|
|
1224
1246
|
private take() {
|
|
1225
1247
|
const token = this.peek();
|
|
1226
1248
|
this.position += 1;
|
|
1249
|
+
this.expand(1);
|
|
1227
1250
|
return token;
|
|
1228
1251
|
}
|
|
1252
|
+
/** Counts `tokens` toward the statement's written-out size, refused the moment it passes what a request carries. */
|
|
1253
|
+
private expand(tokens: number) {
|
|
1254
|
+
this.expanded += tokens;
|
|
1255
|
+
if (this.expanded > EXPANDED_TOKENS_MAX) throw new DatasetSqlError(TOO_LARGE);
|
|
1256
|
+
}
|
|
1229
1257
|
private peekWord(upper: string) {
|
|
1230
1258
|
const token = this.peek();
|
|
1231
1259
|
return token.kind === "word" && token.upper === upper;
|
|
@@ -1328,11 +1356,16 @@ function unqualified(ref: string, table: string | undefined) {
|
|
|
1328
1356
|
return table !== undefined && ref.startsWith(`${table}.`) ? ref.slice(table.length + 1) : ref;
|
|
1329
1357
|
}
|
|
1330
1358
|
|
|
1331
|
-
/**
|
|
1359
|
+
/**
|
|
1360
|
+
* The outer list of a derived table: `*` keeps every inner value, names keep
|
|
1361
|
+
* those, each once since every listing writes the value out again; the
|
|
1362
|
+
* RANK() itself is never answered.
|
|
1363
|
+
*/
|
|
1332
1364
|
function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | undefined) {
|
|
1333
1365
|
const values = inner.filter((item) => item.kind !== "rankWindow");
|
|
1334
1366
|
if (outer.some((item) => item.kind === "star")) return values;
|
|
1335
1367
|
const windowNames = inner.flatMap((item) => (item.kind === "rankWindow" ? [item.alias] : []));
|
|
1368
|
+
const listed = new Set<SelectItem>();
|
|
1336
1369
|
const kept = outer.flatMap((item) => {
|
|
1337
1370
|
const name =
|
|
1338
1371
|
item.kind === "column" && item.alias === undefined
|
|
@@ -1346,6 +1379,12 @@ function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | unde
|
|
|
1346
1379
|
"The outer SELECT of a derived table lists names the inner SELECT answers, or *.",
|
|
1347
1380
|
);
|
|
1348
1381
|
}
|
|
1382
|
+
if (listed.has(found)) {
|
|
1383
|
+
throw new DatasetSqlError(
|
|
1384
|
+
`The outer SELECT lists "${name}" twice; list each value of the derived table once.`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
listed.add(found);
|
|
1349
1388
|
return [found];
|
|
1350
1389
|
});
|
|
1351
1390
|
const unnamed = values.flatMap((value) =>
|
|
@@ -1658,6 +1697,11 @@ class Sources {
|
|
|
1658
1697
|
`"${qualifier}" is a link table; read its columns from the datasets it links.`,
|
|
1659
1698
|
);
|
|
1660
1699
|
}
|
|
1700
|
+
// Checked here, where the query's own dataset name is known: the wire
|
|
1701
|
+
// reference names the relation path instead.
|
|
1702
|
+
if (!source.dataset.columns.some((c) => c.name === column)) {
|
|
1703
|
+
throw new DatasetSqlError(unknownColumnError(source.dataset, column));
|
|
1704
|
+
}
|
|
1661
1705
|
return source.path ? `${source.path}.${column}` : column;
|
|
1662
1706
|
}
|
|
1663
1707
|
|
|
@@ -1747,10 +1791,8 @@ class Sources {
|
|
|
1747
1791
|
`"${column}" is a column of ${owners.map((o) => o.dataset.name).join(" and ")}; qualify it as dataset.column.`,
|
|
1748
1792
|
);
|
|
1749
1793
|
}
|
|
1750
|
-
const joined = this.paths.length ? "
|
|
1751
|
-
throw new DatasetSqlError(
|
|
1752
|
-
`"${column}" is not a column of ${this.root.dataset.name}${joined}.`,
|
|
1753
|
-
);
|
|
1794
|
+
const joined = this.paths.length ? " Nor does any joined dataset." : "";
|
|
1795
|
+
throw new DatasetSqlError(unknownColumnError(this.root.dataset, column) + joined);
|
|
1754
1796
|
}
|
|
1755
1797
|
|
|
1756
1798
|
/** Every readable dataset once, whatever it is named as. */
|
|
@@ -2073,7 +2115,8 @@ function resolveGroupKeys(
|
|
|
2073
2115
|
key = twin ?? { expr, as: lastSegment(column ?? "") };
|
|
2074
2116
|
}
|
|
2075
2117
|
const taken = keys.get(groupKeyName(key));
|
|
2076
|
-
|
|
2118
|
+
// A name grouped again is the very same key; only a different one is written out to compare.
|
|
2119
|
+
if (taken && taken !== key && !same(operandOf(taken), operandOf(key))) {
|
|
2077
2120
|
throw new DatasetSqlError(
|
|
2078
2121
|
`Two group keys would answer as "${groupKeyName(key)}"; name each value: ${describeTerm(group)} AS name.`,
|
|
2079
2122
|
);
|
|
@@ -2466,10 +2509,18 @@ function calendarDate(now: Date, timeZone: string | undefined) {
|
|
|
2466
2509
|
}
|
|
2467
2510
|
}
|
|
2468
2511
|
|
|
2512
|
+
/** The longest statement a query may be: room for any query a model writes, and a bound on the work one costs. */
|
|
2513
|
+
export const DATASET_SQL_MAX_LENGTH = 8_000;
|
|
2514
|
+
|
|
2469
2515
|
/** Parses one SELECT against the catalog into the wire query; throws {@link DatasetSqlError} with what to write instead. */
|
|
2470
2516
|
export function parseDatasetSql(sql: string, catalog: DatasetCatalog, options: ParseOptions) {
|
|
2471
2517
|
const trimmed = sql.trim();
|
|
2472
2518
|
if (!trimmed) throw new DatasetSqlError("The query is empty.");
|
|
2519
|
+
if (trimmed.length > DATASET_SQL_MAX_LENGTH) {
|
|
2520
|
+
throw new DatasetSqlError(
|
|
2521
|
+
`The query is longer than ${DATASET_SQL_MAX_LENGTH} characters; ask for less at once.`,
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2473
2524
|
const today = calendarDate(options.now, options.timeZone);
|
|
2474
2525
|
return compile(catalog, new Parser(tokenize(trimmed), today).statement());
|
|
2475
2526
|
}
|