@m6d/cortex-cli 0.14.0 → 0.15.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 +8 -0
- package/package.json +1 -1
- package/src/commands/apply.ts +136 -26
- package/src/commands/management.ts +9 -1
- package/src/commands/setup.ts +1 -0
- package/src/contracts/datasets/dataset-sql.ts +22 -5
- package/src/contracts/datasets/datasets.ts +139 -16
- package/src/contracts/egress-policy/scrub.ts +11 -8
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/manifest/index.ts +165 -38
- package/src/contracts/server/index.ts +1 -3
- package/src/contracts/server/reserved-names.fixtures.json +1 -3
- package/src/contracts/wire/index.ts +35 -1
- package/src/scaffold/files.ts +2 -2
- package/src/ui/prompts.ts +9 -0
package/README.md
CHANGED
|
@@ -143,8 +143,16 @@ touched, and a name one of them holds fails the apply.
|
|
|
143
143
|
cortex apply # ./cortex.manifest.ts
|
|
144
144
|
cortex apply deploy/manifest.json --dry-run
|
|
145
145
|
cortex apply -p prod
|
|
146
|
+
cortex apply --prefer live # keep every live edit the manifest also changed
|
|
146
147
|
```
|
|
147
148
|
|
|
149
|
+
An entry's `overridable` lists the fields people may change in Cortex, such
|
|
150
|
+
as an agent's `prompt`. An apply keeps those edits. When the manifest changes
|
|
151
|
+
a field that was also edited live, `cortex apply` asks which to keep for
|
|
152
|
+
each one. `--prefer live` or `--prefer manifest` answers them all. Without a
|
|
153
|
+
terminal and without `--prefer`, the apply changes nothing and lists the
|
|
154
|
+
conflicts; `--dry-run` marks each with `!`.
|
|
155
|
+
|
|
148
156
|
The path defaults to exactly `./cortex.manifest.ts` in the directory you run
|
|
149
157
|
in. TypeScript and JSON load the same way: the file's default export is the
|
|
150
158
|
manifest. `defineManifest` from `@m6d/cortex` types one as you write it:
|
package/package.json
CHANGED
package/src/commands/apply.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { Option } from "@commander-js/extra-typings";
|
|
4
5
|
import type { Command } from "@commander-js/extra-typings";
|
|
5
|
-
import {
|
|
6
|
+
import { isRecord } from "@cortex/contracts/json";
|
|
7
|
+
import {
|
|
8
|
+
appliedManifestSchema,
|
|
9
|
+
manifestApplyInputSchema,
|
|
10
|
+
manifestPreviewSchema,
|
|
11
|
+
} from "@cortex/contracts/manifest";
|
|
6
12
|
import { loadManifestFile } from "@cortex/contracts/manifest-file";
|
|
7
13
|
import { z } from "zod";
|
|
8
14
|
|
|
9
|
-
import { callManagement } from "@/management/client";
|
|
10
|
-
import {
|
|
15
|
+
import { callManagement, ManagementError } from "@/management/client";
|
|
16
|
+
import { failCall, requireProfile } from "@/commands/management";
|
|
17
|
+
import { askChoice } from "@/ui/prompts";
|
|
11
18
|
import * as report from "@/ui/report";
|
|
12
19
|
|
|
13
20
|
// Discovery is this exact name in the cwd, with no upward walk, so the
|
|
14
21
|
// argument's default is the discovery rule.
|
|
15
22
|
export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
|
|
16
23
|
|
|
24
|
+
/** Which value a field both the manifest and a live write changed keeps. */
|
|
25
|
+
const SIDES = manifestApplyInputSchema.shape.prefer.unwrap().options;
|
|
26
|
+
type Side = (typeof SIDES)[number];
|
|
27
|
+
type Resolution = z.input<typeof manifestApplyInputSchema>["resolutions"];
|
|
28
|
+
type Conflict = z.infer<typeof manifestPreviewSchema>["conflicts"][number];
|
|
29
|
+
|
|
30
|
+
/** A sync refused for fields both sides changed that nothing answered. */
|
|
31
|
+
const unresolvedSchema = z.object({
|
|
32
|
+
error: z.literal("unresolved_conflicts"),
|
|
33
|
+
conflicts: manifestPreviewSchema.shape.conflicts,
|
|
34
|
+
});
|
|
35
|
+
|
|
17
36
|
const CHANGES = ["create", "update", "delete"] as const;
|
|
18
37
|
const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
19
38
|
(typeof CHANGES)[number],
|
|
@@ -23,7 +42,9 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
|
23
42
|
/**
|
|
24
43
|
* `cortex apply [path]`: makes Cortex match a manifest in one
|
|
25
44
|
* transaction, through the management API with the profile's key.
|
|
26
|
-
* `--dry-run` answers what would change and changes nothing.
|
|
45
|
+
* `--dry-run` answers what would change and changes nothing. A field both
|
|
46
|
+
* the manifest and a live edit in Cortex changed is asked about, unless
|
|
47
|
+
* `--prefer` answers every one.
|
|
27
48
|
*/
|
|
28
49
|
export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
29
50
|
const command = program
|
|
@@ -31,7 +52,13 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
31
52
|
.helpGroup("Cortex:")
|
|
32
53
|
.description("Make Cortex match a manifest")
|
|
33
54
|
.argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
|
|
34
|
-
.option("--dry-run", "Show what would change, and change nothing")
|
|
55
|
+
.option("--dry-run", "Show what would change, and change nothing")
|
|
56
|
+
.addOption(
|
|
57
|
+
new Option(
|
|
58
|
+
"--prefer <side>",
|
|
59
|
+
"Keep the live or the manifest value of every field both changed, without asking",
|
|
60
|
+
).choices(SIDES),
|
|
61
|
+
);
|
|
35
62
|
|
|
36
63
|
command.action(async function (path, options) {
|
|
37
64
|
const { verbose = false, profile: profileName } = program.opts();
|
|
@@ -43,15 +70,26 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
43
70
|
report.input("Manifest", label(file));
|
|
44
71
|
report.input("Cortex", profile.url);
|
|
45
72
|
|
|
46
|
-
const { changes, triggerSecrets } = await applyManifest({
|
|
73
|
+
const { changes, triggerSecrets, conflicts } = await applyManifest({
|
|
74
|
+
profile,
|
|
75
|
+
file,
|
|
76
|
+
dryRun,
|
|
77
|
+
verbose,
|
|
78
|
+
prefer: options.prefer,
|
|
79
|
+
interactive: process.stdin.isTTY === true,
|
|
80
|
+
});
|
|
47
81
|
const created = countNames(changes.create);
|
|
48
82
|
const updated = countNames(changes.update);
|
|
49
83
|
const deleted = countNames(changes.delete);
|
|
84
|
+
const conflicted =
|
|
85
|
+
conflicts.length > 0
|
|
86
|
+
? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
|
|
87
|
+
: "";
|
|
50
88
|
report.footer(
|
|
51
|
-
created + updated + deleted === 0
|
|
89
|
+
created + updated + deleted + conflicts.length === 0
|
|
52
90
|
? "no changes"
|
|
53
91
|
: dryRun
|
|
54
|
-
? `would create ${created}, update ${updated}, delete ${deleted}`
|
|
92
|
+
? `would create ${created}, update ${updated}, delete ${deleted}${conflicted}`
|
|
55
93
|
: `created ${created}, updated ${updated}, deleted ${deleted}`,
|
|
56
94
|
);
|
|
57
95
|
printTriggerSecrets(triggerSecrets);
|
|
@@ -60,33 +98,36 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
60
98
|
|
|
61
99
|
/**
|
|
62
100
|
* Loads the manifest at `file` and sends it, a preview under `dryRun`, then
|
|
63
|
-
* prints its changes one row per entry
|
|
64
|
-
* refusal, or a summary this CLI cannot read.
|
|
65
|
-
* setup` both apply through it; each prints the
|
|
101
|
+
* prints its changes one row per entry, and a preview's conflicts. Ends the
|
|
102
|
+
* run on an invalid file, a refusal, or a summary this CLI cannot read.
|
|
103
|
+
* `cortex apply` and `cortex setup` both apply through it; each prints the
|
|
104
|
+
* trigger secrets it returns.
|
|
66
105
|
*/
|
|
67
106
|
export async function applyManifest(options: {
|
|
68
107
|
profile: ReturnType<typeof requireProfile>;
|
|
69
108
|
file: string;
|
|
70
109
|
dryRun: boolean;
|
|
71
110
|
verbose: boolean;
|
|
111
|
+
prefer?: Side;
|
|
112
|
+
interactive: boolean;
|
|
72
113
|
}) {
|
|
73
114
|
const { profile, file, dryRun, verbose } = options;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"
|
|
79
|
-
dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
80
|
-
manifest,
|
|
115
|
+
const answer = await sendManifest({
|
|
116
|
+
...options,
|
|
117
|
+
// What to run again: `cortex apply`, with the path unless it is the default.
|
|
118
|
+
command: ["cortex apply", ...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)])].join(
|
|
119
|
+
" ",
|
|
81
120
|
),
|
|
82
|
-
|
|
121
|
+
manifest: await loadManifest(file, verbose),
|
|
122
|
+
path: dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
123
|
+
});
|
|
83
124
|
// The secrets are read apart from the changes: a sync has committed by
|
|
84
125
|
// now, and a summary this CLI is too old to read must not lose them.
|
|
85
126
|
const { triggerSecrets = {} } = appliedManifestSchema
|
|
86
127
|
.pick({ triggerSecrets: true })
|
|
87
128
|
.partial()
|
|
88
129
|
.parse(answer);
|
|
89
|
-
const applied =
|
|
130
|
+
const applied = manifestPreviewSchema.partial({ conflicts: true }).safeParse(answer);
|
|
90
131
|
if (!applied.success) {
|
|
91
132
|
printTriggerSecrets(triggerSecrets);
|
|
92
133
|
report.fail({
|
|
@@ -97,8 +138,68 @@ export async function applyManifest(options: {
|
|
|
97
138
|
],
|
|
98
139
|
});
|
|
99
140
|
}
|
|
100
|
-
|
|
101
|
-
|
|
141
|
+
const { conflicts = [], ...changes } = applied.data;
|
|
142
|
+
printChanges(changes, conflicts);
|
|
143
|
+
return { changes, triggerSecrets, conflicts };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sends the manifest until nothing in it is left unanswered. A sync refused
|
|
148
|
+
* for fields both the manifest and a live edit changed asks about each one
|
|
149
|
+
* when it can, then goes again with the answers; one that cannot ask ends
|
|
150
|
+
* the run naming them. Any other refusal ends the run as well.
|
|
151
|
+
*/
|
|
152
|
+
async function sendManifest(options: {
|
|
153
|
+
profile: ReturnType<typeof requireProfile>;
|
|
154
|
+
path: string;
|
|
155
|
+
command: string;
|
|
156
|
+
manifest: Record<string, unknown>;
|
|
157
|
+
prefer?: Side;
|
|
158
|
+
interactive: boolean;
|
|
159
|
+
}) {
|
|
160
|
+
const { profile, path, command, manifest, prefer, interactive } = options;
|
|
161
|
+
const resolutions: Resolution = [];
|
|
162
|
+
for (;;) {
|
|
163
|
+
try {
|
|
164
|
+
return await callManagement(profile, "post", path, {
|
|
165
|
+
...manifest,
|
|
166
|
+
prefer,
|
|
167
|
+
resolutions,
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const refused =
|
|
171
|
+
error instanceof ManagementError
|
|
172
|
+
? unresolvedSchema.safeParse(error.detail)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (!refused?.success) return failCall(profile, error);
|
|
175
|
+
if (!interactive) failUnresolved(refused.data.conflicts, command);
|
|
176
|
+
for (const conflict of refused.data.conflicts) {
|
|
177
|
+
const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
|
|
178
|
+
{ value: "live", label: "Keep live" },
|
|
179
|
+
{ value: "manifest", label: "Use manifest" },
|
|
180
|
+
]);
|
|
181
|
+
resolutions.push({ ...conflict, keep });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** How a conflict reads: its entry and the field both sides changed. */
|
|
188
|
+
function conflictLabel({ kind, name, field }: Conflict) {
|
|
189
|
+
return `${kind} ${name}: ${field} changed in both places`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Ends a run that cannot ask, naming every conflict and how to run `command` again to answer them. */
|
|
193
|
+
function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
|
|
194
|
+
const count = conflicts.length;
|
|
195
|
+
return report.fail({
|
|
196
|
+
what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
|
|
197
|
+
blocks: [
|
|
198
|
+
conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
|
|
199
|
+
"Keep one side for all of them, or run in a terminal to choose each:",
|
|
200
|
+
` ${command} --prefer live\n ${command} --prefer manifest`,
|
|
201
|
+
],
|
|
202
|
+
});
|
|
102
203
|
}
|
|
103
204
|
|
|
104
205
|
/** How a manifest path prints: relative to the cwd, `./`-prefixed. */
|
|
@@ -140,17 +241,23 @@ async function loadManifest(path: string, verbose: boolean) {
|
|
|
140
241
|
verbose,
|
|
141
242
|
}),
|
|
142
243
|
);
|
|
143
|
-
if (!parsed.success) {
|
|
244
|
+
if (!parsed.success || !isRecord(written)) {
|
|
144
245
|
report.fail({
|
|
145
246
|
what: `${label(path)} is not a valid manifest`,
|
|
146
|
-
blocks: [z.prettifyError(parsed.error)],
|
|
247
|
+
blocks: parsed.success ? [] : [z.prettifyError(parsed.error)],
|
|
147
248
|
});
|
|
148
249
|
}
|
|
149
250
|
return written;
|
|
150
251
|
}
|
|
151
252
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
253
|
+
/**
|
|
254
|
+
* One row per changed entry under its kind: `+` created, `~` updated, `-`
|
|
255
|
+
* deleted, and `!` a field both the manifest and a live edit changed.
|
|
256
|
+
*/
|
|
257
|
+
function printChanges(
|
|
258
|
+
changes: Record<(typeof CHANGES)[number], Record<string, string[]>>,
|
|
259
|
+
conflicts: ReadonlyArray<Conflict>,
|
|
260
|
+
) {
|
|
154
261
|
const rows = new Map<string, string[]>();
|
|
155
262
|
for (const change of CHANGES) {
|
|
156
263
|
for (const [kind, names] of Object.entries(changes[change])) {
|
|
@@ -158,6 +265,9 @@ function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, s
|
|
|
158
265
|
rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
|
|
159
266
|
}
|
|
160
267
|
}
|
|
268
|
+
for (const { kind, name, field } of conflicts) {
|
|
269
|
+
rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
|
|
270
|
+
}
|
|
161
271
|
for (const [kind, lines] of rows) {
|
|
162
272
|
for (const [index, line] of lines.entries()) {
|
|
163
273
|
report.row(index === 0 ? kind : "", line, 14);
|
|
@@ -356,6 +356,13 @@ function declareCommand(
|
|
|
356
356
|
return command;
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
/** What a publish went ahead despite, such as a prompt naming a tool without an @-mention. */
|
|
360
|
+
function warningsOf(published: unknown) {
|
|
361
|
+
return isRecord(published) && Array.isArray(published.warnings)
|
|
362
|
+
? published.warnings.filter((warning): warning is string => typeof warning === "string")
|
|
363
|
+
: [];
|
|
364
|
+
}
|
|
365
|
+
|
|
359
366
|
/**
|
|
360
367
|
* A write to a publishable router is a draft: publish it now, or say how to.
|
|
361
368
|
* Fields Cortex reads live are already in effect and are named as such,
|
|
@@ -378,10 +385,11 @@ async function publishAfterWrite(
|
|
|
378
385
|
);
|
|
379
386
|
}
|
|
380
387
|
if (publish) {
|
|
381
|
-
await callOrFail(profile, () =>
|
|
388
|
+
const published = await callOrFail(profile, () =>
|
|
382
389
|
callManagement(profile, publisher.method, publisher.path, { id }),
|
|
383
390
|
);
|
|
384
391
|
options.note(`Published ${id}.`);
|
|
392
|
+
for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
|
|
385
393
|
} else {
|
|
386
394
|
options.note(
|
|
387
395
|
`${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
|
package/src/commands/setup.ts
CHANGED
|
@@ -15,7 +15,13 @@ import type {
|
|
|
15
15
|
DatasetQueryInput,
|
|
16
16
|
DatasetRelation,
|
|
17
17
|
} from "./datasets";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DATASET_BUCKETS,
|
|
20
|
+
DATASET_DATE_PARTS,
|
|
21
|
+
describeOperand,
|
|
22
|
+
groupKeyName,
|
|
23
|
+
unknownColumnError,
|
|
24
|
+
} from "./datasets";
|
|
19
25
|
|
|
20
26
|
/*
|
|
21
27
|
* The SQL a model writes against a dataset, and how it becomes the wire
|
|
@@ -1658,6 +1664,11 @@ class Sources {
|
|
|
1658
1664
|
`"${qualifier}" is a link table; read its columns from the datasets it links.`,
|
|
1659
1665
|
);
|
|
1660
1666
|
}
|
|
1667
|
+
// Checked here, where the query's own dataset name is known: the wire
|
|
1668
|
+
// reference names the relation path instead.
|
|
1669
|
+
if (!source.dataset.columns.some((c) => c.name === column)) {
|
|
1670
|
+
throw new DatasetSqlError(unknownColumnError(source.dataset, column));
|
|
1671
|
+
}
|
|
1661
1672
|
return source.path ? `${source.path}.${column}` : column;
|
|
1662
1673
|
}
|
|
1663
1674
|
|
|
@@ -1747,10 +1758,8 @@ class Sources {
|
|
|
1747
1758
|
`"${column}" is a column of ${owners.map((o) => o.dataset.name).join(" and ")}; qualify it as dataset.column.`,
|
|
1748
1759
|
);
|
|
1749
1760
|
}
|
|
1750
|
-
const joined = this.paths.length ? "
|
|
1751
|
-
throw new DatasetSqlError(
|
|
1752
|
-
`"${column}" is not a column of ${this.root.dataset.name}${joined}.`,
|
|
1753
|
-
);
|
|
1761
|
+
const joined = this.paths.length ? " Nor does any joined dataset." : "";
|
|
1762
|
+
throw new DatasetSqlError(unknownColumnError(this.root.dataset, column) + joined);
|
|
1754
1763
|
}
|
|
1755
1764
|
|
|
1756
1765
|
/** Every readable dataset once, whatever it is named as. */
|
|
@@ -2466,10 +2475,18 @@ function calendarDate(now: Date, timeZone: string | undefined) {
|
|
|
2466
2475
|
}
|
|
2467
2476
|
}
|
|
2468
2477
|
|
|
2478
|
+
/** The longest statement a query may be: room for any query a model writes, and a bound on the work one costs. */
|
|
2479
|
+
export const DATASET_SQL_MAX_LENGTH = 8_000;
|
|
2480
|
+
|
|
2469
2481
|
/** Parses one SELECT against the catalog into the wire query; throws {@link DatasetSqlError} with what to write instead. */
|
|
2470
2482
|
export function parseDatasetSql(sql: string, catalog: DatasetCatalog, options: ParseOptions) {
|
|
2471
2483
|
const trimmed = sql.trim();
|
|
2472
2484
|
if (!trimmed) throw new DatasetSqlError("The query is empty.");
|
|
2485
|
+
if (trimmed.length > DATASET_SQL_MAX_LENGTH) {
|
|
2486
|
+
throw new DatasetSqlError(
|
|
2487
|
+
`The query is longer than ${DATASET_SQL_MAX_LENGTH} characters; ask for less at once.`,
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2473
2490
|
const today = calendarDate(options.now, options.timeZone);
|
|
2474
2491
|
return compile(catalog, new Parser(tokenize(trimmed), today).statement());
|
|
2475
2492
|
}
|
|
@@ -25,11 +25,22 @@ export const datasetColumnRefSchema = z.string().regex(DATASET_COLUMN_REF_PATTER
|
|
|
25
25
|
export const DATASET_COLUMN_TYPES = ["string", "number", "boolean", "date"] as const;
|
|
26
26
|
export type DatasetColumnType = (typeof DATASET_COLUMN_TYPES)[number];
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/**
|
|
29
|
+
* One column. `values`, on a string column, lists every value it can hold:
|
|
30
|
+
* the agent reads them, and a query comparing the column to anything else is
|
|
31
|
+
* refused before it runs.
|
|
32
|
+
*/
|
|
33
|
+
export const datasetColumnSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
name: datasetIdentifierSchema,
|
|
36
|
+
type: z.enum(DATASET_COLUMN_TYPES),
|
|
37
|
+
description: z.string().max(200).default(""),
|
|
38
|
+
values: z.array(z.string().min(1).max(200)).min(1).max(100).optional(),
|
|
39
|
+
})
|
|
40
|
+
.refine((column) => column.values === undefined || column.type === "string", {
|
|
41
|
+
message: "only a string column lists its values",
|
|
42
|
+
path: ["values"],
|
|
43
|
+
});
|
|
33
44
|
export type DatasetColumn = z.infer<typeof datasetColumnSchema>;
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -396,10 +407,59 @@ export function resolveDatasetColumn(catalog: DatasetCatalog, from: string, ref:
|
|
|
396
407
|
dataset = target;
|
|
397
408
|
}
|
|
398
409
|
const column = dataset.columns.find((candidate) => candidate.name === columnName);
|
|
399
|
-
if (!column) return missing(
|
|
410
|
+
if (!column) return missing(unknownColumnError(dataset, columnName));
|
|
400
411
|
return { ok: true as const, column, path: segments.join(".") };
|
|
401
412
|
}
|
|
402
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Why `dataset` has no column `name`, with the columns it has and up to
|
|
416
|
+
* three closest ones: names containing it or contained in it (ignoring
|
|
417
|
+
* case, and only for names longer than two characters), else names two
|
|
418
|
+
* edits or fewer away. A query that guessed `name` reads `departments has
|
|
419
|
+
* no column "name". Closest: nameAr, nameEn. Columns: id, nameAr, ...`
|
|
420
|
+
*/
|
|
421
|
+
export function unknownColumnError(dataset: DatasetDefinition, name: string) {
|
|
422
|
+
const columns = dataset.columns.map((column) => column.name);
|
|
423
|
+
const wanted = name.toLowerCase();
|
|
424
|
+
const containing = columns.filter((column) => {
|
|
425
|
+
const lower = column.toLowerCase();
|
|
426
|
+
return (
|
|
427
|
+
(wanted.length > 2 && lower.includes(wanted)) ||
|
|
428
|
+
(lower.length > 2 && wanted.includes(lower))
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
const closest = (
|
|
432
|
+
containing.length > 0
|
|
433
|
+
? containing
|
|
434
|
+
: // Lengths more than two apart are more than two edits apart, so a
|
|
435
|
+
// name of any length costs only its own columns' comparisons.
|
|
436
|
+
columns.filter(
|
|
437
|
+
(column) =>
|
|
438
|
+
Math.abs(column.length - wanted.length) <= 2 &&
|
|
439
|
+
editDistance(column.toLowerCase(), wanted) <= 2,
|
|
440
|
+
)
|
|
441
|
+
).slice(0, 3);
|
|
442
|
+
const hint = closest.length > 0 ? ` Closest: ${closest.join(", ")}.` : "";
|
|
443
|
+
return `${dataset.name} has no column "${name}".${hint} Columns: ${columns.join(", ")}.`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Levenshtein distance: the fewest single-character edits from `a` to `b`. */
|
|
447
|
+
function editDistance(a: string, b: string) {
|
|
448
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
449
|
+
for (let i = 1; i <= a.length; i++) {
|
|
450
|
+
const current = [i];
|
|
451
|
+
for (let j = 1; j <= b.length; j++) {
|
|
452
|
+
current[j] = Math.min(
|
|
453
|
+
previous[j]! + 1,
|
|
454
|
+
current[j - 1]! + 1,
|
|
455
|
+
previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
previous = current;
|
|
459
|
+
}
|
|
460
|
+
return previous[b.length]!;
|
|
461
|
+
}
|
|
462
|
+
|
|
403
463
|
function refError(kind: string, ref: string, reason: string) {
|
|
404
464
|
return `${kind} "${ref}": ${reason}`;
|
|
405
465
|
}
|
|
@@ -654,6 +714,19 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
|
|
|
654
714
|
} satisfies QueryCheck;
|
|
655
715
|
|
|
656
716
|
for (const filter of query.filter) checkFilter(check, filter);
|
|
717
|
+
const row = checkAnsweredRow(check, catalog, query);
|
|
718
|
+
checkFinishedRow(check, query, row);
|
|
719
|
+
checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
|
|
720
|
+
return issues;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The row a query answers, checked as it is built: on a grouped query its
|
|
725
|
+
* keys and aggregate aliases (never one name for both, and no select), on a
|
|
726
|
+
* plain one its select aliases; each with the names that are a column as it
|
|
727
|
+
* is, which keep what the column says.
|
|
728
|
+
*/
|
|
729
|
+
function checkAnsweredRow(check: QueryCheck, catalog: DatasetCatalog, query: DatasetQuery) {
|
|
657
730
|
const groupKeys = checkGroupBy(check, query.groupBy);
|
|
658
731
|
const aliases = checkAggregates(check, query.aggregate);
|
|
659
732
|
for (const alias of aliases.keys()) {
|
|
@@ -671,12 +744,17 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
|
|
|
671
744
|
);
|
|
672
745
|
}
|
|
673
746
|
const selectAliases = checkSelect(check, query.select ?? []);
|
|
674
|
-
|
|
675
|
-
? {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
747
|
+
return grouped
|
|
748
|
+
? {
|
|
749
|
+
names: new Map([...groupKeys, ...aliases]),
|
|
750
|
+
kind: "grouped" as const,
|
|
751
|
+
columns: passedColumns(catalog, query, query.groupBy),
|
|
752
|
+
}
|
|
753
|
+
: {
|
|
754
|
+
names: selectAliases,
|
|
755
|
+
kind: "plain" as const,
|
|
756
|
+
columns: passedColumns(catalog, query, query.select ?? []),
|
|
757
|
+
};
|
|
680
758
|
}
|
|
681
759
|
|
|
682
760
|
/** How large any one expression a host translates may grow once every alias it names is written out in place. */
|
|
@@ -856,7 +934,7 @@ function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>
|
|
|
856
934
|
function checkFinishedRow(
|
|
857
935
|
check: QueryCheck,
|
|
858
936
|
query: DatasetQuery,
|
|
859
|
-
row: { names: Map<string, DatasetColumnType
|
|
937
|
+
row: RowNames & { names: Map<string, DatasetColumnType> },
|
|
860
938
|
) {
|
|
861
939
|
const rowCheck = rowScope(check, row);
|
|
862
940
|
// On a plain row a selected column is a name too; a window or calculation may not take it.
|
|
@@ -881,7 +959,7 @@ function rowScope(check: QueryCheck, row: RowNames) {
|
|
|
881
959
|
issue: (message: string) => check.issue(message),
|
|
882
960
|
columnOf(kind, ref) {
|
|
883
961
|
const type = row.names.get(ref);
|
|
884
|
-
if (type) return { name: ref, type, description: "" };
|
|
962
|
+
if (type) return row.columns.get(ref) ?? { name: ref, type, description: "" };
|
|
885
963
|
if (row.kind === "plain") return check.columnOf(kind, ref);
|
|
886
964
|
check.issue(
|
|
887
965
|
refError(
|
|
@@ -1203,10 +1281,31 @@ function checkFilter(check: QueryCheck, filter: DatasetFilter, kind = "filter")
|
|
|
1203
1281
|
? columnValueIssue(check, kind, type, filter.op, filter.value.col)
|
|
1204
1282
|
: (filterValueIssue(filter) ??
|
|
1205
1283
|
filterValueTypeIssue(type, filter) ??
|
|
1206
|
-
truncatedDateIssue(filter)
|
|
1284
|
+
truncatedDateIssue(filter) ??
|
|
1285
|
+
listedValueIssue(check, kind, filter));
|
|
1207
1286
|
if (issue) check.issue(refError(kind, shown, issue));
|
|
1208
1287
|
}
|
|
1209
1288
|
|
|
1289
|
+
const LISTED_VALUE_OPS = new Set<DatasetFilterOp>(["=", "!=", "in", "notIn"]);
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* A column that lists its values is only compared with one of them: any
|
|
1293
|
+
* other literal would match no row, or every row, and the host would answer
|
|
1294
|
+
* as if the data said so.
|
|
1295
|
+
*/
|
|
1296
|
+
function listedValueIssue(check: QueryCheck, kind: string, filter: DatasetCondition) {
|
|
1297
|
+
if (typeof filter.col !== "string" || !LISTED_VALUE_OPS.has(filter.op)) return undefined;
|
|
1298
|
+
if (filter.value === undefined || isColumnValue(filter.value)) return undefined;
|
|
1299
|
+
const listed = check.columnOf(kind, filter.col)?.values;
|
|
1300
|
+
if (!listed) return undefined;
|
|
1301
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
1302
|
+
// A number or a boolean is compared as the text the host turns it into.
|
|
1303
|
+
const odd = values.find((value) => !listed.includes(String(value)));
|
|
1304
|
+
return odd === undefined
|
|
1305
|
+
? undefined
|
|
1306
|
+
: `${JSON.stringify(odd)} is not one of its values: ${listed.join(", ")}`;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1210
1309
|
/** A truncated date is a whole day, so a literal against it is one too ("2024-03-01"); a time or a bare month would never match. */
|
|
1211
1310
|
function truncatedDateIssue(filter: DatasetCondition) {
|
|
1212
1311
|
const truncated =
|
|
@@ -1314,7 +1413,31 @@ function aggregateType(
|
|
|
1314
1413
|
}
|
|
1315
1414
|
|
|
1316
1415
|
/** What sort and rank may name: on a grouped query a group key or an alias; on a plain one a select alias or any reachable column. */
|
|
1317
|
-
type RowNames = {
|
|
1416
|
+
type RowNames = {
|
|
1417
|
+
names: ReadonlyMap<string, DatasetColumnType>;
|
|
1418
|
+
kind: "grouped" | "plain";
|
|
1419
|
+
/** The names that are a column as it is (group keys, select entries), so what the column says (its values) still holds. */
|
|
1420
|
+
columns: ReadonlyMap<string, DatasetColumn>;
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* The row names that are a column as it is, each with the column: a bare
|
|
1425
|
+
* column (`status`), or one under an alias (`{ expr: "status", as: "state" }`).
|
|
1426
|
+
*/
|
|
1427
|
+
function passedColumns(
|
|
1428
|
+
catalog: DatasetCatalog,
|
|
1429
|
+
query: DatasetQuery,
|
|
1430
|
+
entries: ReadonlyArray<string | { expr: DatasetOperand; as: string }>,
|
|
1431
|
+
) {
|
|
1432
|
+
return new Map(
|
|
1433
|
+
entries.flatMap((entry) => {
|
|
1434
|
+
const [name, ref] = typeof entry === "string" ? [entry, entry] : [entry.as, entry.expr];
|
|
1435
|
+
if (typeof ref !== "string") return [];
|
|
1436
|
+
const resolved = resolveDatasetColumn(catalog, query.from, ref);
|
|
1437
|
+
return resolved.ok ? [[name, resolved.column] as const] : [];
|
|
1438
|
+
}),
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1318
1441
|
|
|
1319
1442
|
function checkSort(check: QueryCheck, sort: DatasetQuery["sort"], kind: string, row: RowNames) {
|
|
1320
1443
|
for (const { col } of sort) checkRowName(check, kind, col, row);
|
|
@@ -7,19 +7,22 @@ const REDACTION_MARKER = "[REDACTED]";
|
|
|
7
7
|
* itself and, after a leading scheme word (`Bearer xyz`, `Basic abc`), the
|
|
8
8
|
* bare secret the scheme merely labels; each of those raw as in a header,
|
|
9
9
|
* percent-encoded as in a path slot (`encodeURIComponent`) or a query value
|
|
10
|
-
* (`URLSearchParams`, which spells a space as `+`),
|
|
11
|
-
* a request body
|
|
10
|
+
* (`URLSearchParams`, which spells a space as `+`), JSON-escaped as inside
|
|
11
|
+
* a request body, and JSON-escaped then percent-encoded as a form field sent
|
|
12
|
+
* as JSON text is. The scrubber replaces longer forms first.
|
|
12
13
|
*/
|
|
13
14
|
export function credentialForms(value: string) {
|
|
14
15
|
const bare = /^[A-Za-z][\w.-]* +(\S.*)$/.exec(value)?.[1];
|
|
16
|
+
const percentEncoded = (form: string) => [
|
|
17
|
+
encodeURIComponent(form),
|
|
18
|
+
new URLSearchParams({ v: form }).toString().slice(2),
|
|
19
|
+
];
|
|
15
20
|
return [
|
|
16
21
|
...new Set(
|
|
17
|
-
(bare ? [value, bare] : [value]).flatMap((form) =>
|
|
18
|
-
form,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
JSON.stringify(form).slice(1, -1),
|
|
22
|
-
]),
|
|
22
|
+
(bare ? [value, bare] : [value]).flatMap((form) => {
|
|
23
|
+
const json = JSON.stringify(form).slice(1, -1);
|
|
24
|
+
return [form, ...percentEncoded(form), json, ...percentEncoded(json)];
|
|
25
|
+
}),
|
|
23
26
|
),
|
|
24
27
|
];
|
|
25
28
|
}
|
|
@@ -24,3 +24,24 @@ export function escapePointerToken(token: string) {
|
|
|
24
24
|
export function unescapePointerToken(token: string) {
|
|
25
25
|
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/** An RFC 6901 array index: digits with no leading zero. */
|
|
29
|
+
export const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
|
|
30
|
+
|
|
31
|
+
/** RFC 6901 lookup; `undefined` when the pointer names nothing (an inherited key, or an array index not written canonically, included). */
|
|
32
|
+
export function readJsonPointer(value: unknown, pointer: string) {
|
|
33
|
+
if (pointer === "") return value;
|
|
34
|
+
let current: unknown = value;
|
|
35
|
+
for (const raw of pointer.slice(1).split("/")) {
|
|
36
|
+
const key = unescapePointerToken(raw);
|
|
37
|
+
if (Array.isArray(current)) {
|
|
38
|
+
if (!ARRAY_INDEX.test(key)) return undefined;
|
|
39
|
+
current = current[Number(key)];
|
|
40
|
+
} else if (isRecord(current) && Object.hasOwn(current, key)) {
|
|
41
|
+
current = current[key];
|
|
42
|
+
} else {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
@@ -46,8 +46,16 @@ export const TOOL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
|
46
46
|
*/
|
|
47
47
|
export const RESPONSE_WRAPS = ["items", "value"] as const;
|
|
48
48
|
|
|
49
|
+
/** How OpenAPI serializes a query parameter: `form` (the default), `spaceDelimited`, `pipeDelimited` or `deepObject`. */
|
|
50
|
+
export const QUERY_STYLES = ["form", "spaceDelimited", "pipeDelimited", "deepObject"] as const;
|
|
51
|
+
/** How OpenAPI serializes a path parameter: `simple` (the default), `label` or `matrix`. */
|
|
52
|
+
export const PATH_STYLES = ["simple", "label", "matrix"] as const;
|
|
53
|
+
|
|
49
54
|
export const SOAP_VERSIONS = ["1.1", "1.2"] as const;
|
|
50
55
|
|
|
56
|
+
/** An RFC 6901 pointer into a JSON document, naming a member: `/extra`, `/data/items`. */
|
|
57
|
+
const jsonPointerSchema = z.string().regex(/^\//).max(256);
|
|
58
|
+
|
|
51
59
|
type Bounds = { min: number; max: number; default: number };
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -91,6 +99,25 @@ function setting({ min, max, default: fallback }: Bounds) {
|
|
|
91
99
|
return z.number().int().min(min).max(max).default(fallback);
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The fields of an entry users may change in Cortex after an apply, each
|
|
104
|
+
* once, from the ones `fields` allows; none when left out. Every other field
|
|
105
|
+
* is the manifest's alone. An apply keeps a live change to a listed field
|
|
106
|
+
* unless the manifest changed that field too (a conflict, see
|
|
107
|
+
* `manifestApplyInputSchema`). Identity, what is fixed at creation, the
|
|
108
|
+
* audience, credentials, and whatever an apply checks against the
|
|
109
|
+
* manifest's own value are never offered.
|
|
110
|
+
*/
|
|
111
|
+
function overridable<const TField extends string>(fields: readonly [TField, ...TField[]]) {
|
|
112
|
+
return z
|
|
113
|
+
.array(z.enum(fields))
|
|
114
|
+
.refine(
|
|
115
|
+
uniqueBy((field: TField) => field),
|
|
116
|
+
"a field repeats",
|
|
117
|
+
)
|
|
118
|
+
.default([]);
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
/** Consumer-facing slugs: the manifest id, service keys, skill names and trigger names share it. */
|
|
95
122
|
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
96
123
|
const slugSchema = z.string().regex(SLUG_PATTERN).max(64);
|
|
@@ -175,6 +202,16 @@ export const soapActionSchema = z.string().regex(/^[^"\\\u0000-\u001f\u007f]*$/)
|
|
|
175
202
|
/** An envelope template: XML, so never only whitespace. */
|
|
176
203
|
const envelopeTemplateSchema = z.string().refine((template) => template.trim().length > 0);
|
|
177
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The media types a request body may be sent in besides JSON, the default.
|
|
207
|
+
* Each body field is written as its target says.
|
|
208
|
+
*/
|
|
209
|
+
export const FORM_MEDIA_TYPES = [
|
|
210
|
+
"application/x-www-form-urlencoded",
|
|
211
|
+
"multipart/form-data",
|
|
212
|
+
] as const;
|
|
213
|
+
export type FormMediaType = (typeof FORM_MEDIA_TYPES)[number];
|
|
214
|
+
|
|
178
215
|
/**
|
|
179
216
|
* The per-type payload a tool stores and publishes. Soap: the envelope
|
|
180
217
|
* template is the full request XML with `{{field}}` placeholders bound to
|
|
@@ -182,7 +219,12 @@ const envelopeTemplateSchema = z.string().refine((template) => template.trim().l
|
|
|
182
219
|
* acquires once per request.
|
|
183
220
|
*/
|
|
184
221
|
export const toolConfigByType = {
|
|
185
|
-
http: z.object({
|
|
222
|
+
http: z.object({
|
|
223
|
+
responseWrap: z.enum(RESPONSE_WRAPS).optional(),
|
|
224
|
+
requestMediaType: z.enum(FORM_MEDIA_TYPES).optional(),
|
|
225
|
+
/** Where a successful response keeps the result, for an API that wraps every answer (`{ success, extra }`); read before `responseWrap`. */
|
|
226
|
+
resultPath: jsonPointerSchema.optional(),
|
|
227
|
+
}),
|
|
186
228
|
embedded: z.object({ embedOrigin: embedOriginSchema, surface: z.enum(TOOL_SURFACES) }),
|
|
187
229
|
soap: z.object({
|
|
188
230
|
soapVersion: z.enum(SOAP_VERSIONS),
|
|
@@ -343,17 +385,44 @@ export function sourceFits(
|
|
|
343
385
|
}
|
|
344
386
|
|
|
345
387
|
/**
|
|
346
|
-
* Where a top-level
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
388
|
+
* Where a top-level input field travels in the upstream request; absent,
|
|
389
|
+
* path placeholders by name, then the query string on GET and the body
|
|
390
|
+
* otherwise. `name` renames a query parameter, header or cookie on the wire,
|
|
391
|
+
* and is the name `matrix` style writes into a path.
|
|
392
|
+
* `style` and `explode` write an array or object value as an OpenAPI
|
|
393
|
+
* parameter declares; left out, OpenAPI's defaults hold (`form` exploded for
|
|
394
|
+
* a query, `simple` unexploded for a path and a header). A cookie joins items
|
|
395
|
+
* with commas. In a form body (the tool's `requestMediaType`), a JSON
|
|
396
|
+
* `contentType` sends the value as JSON text, and `style` and `explode`
|
|
397
|
+
* write it as in a query; a multipart body repeats an array's items as
|
|
398
|
+
* parts, typing an object JSON unless `contentType` says text. A JSON body
|
|
399
|
+
* reads none of them.
|
|
350
400
|
*/
|
|
351
401
|
export const fieldTargetSchema = z.discriminatedUnion("kind", [
|
|
352
|
-
z.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
402
|
+
z.strictObject({
|
|
403
|
+
kind: z.literal("body"),
|
|
404
|
+
contentType: z.enum(["application/json", "text/plain"]).optional(),
|
|
405
|
+
style: z.enum(QUERY_STYLES).optional(),
|
|
406
|
+
explode: z.boolean().optional(),
|
|
407
|
+
}),
|
|
408
|
+
z.strictObject({
|
|
409
|
+
kind: z.literal("query"),
|
|
410
|
+
name: z.string().trim().min(1).max(120).optional(),
|
|
411
|
+
style: z.enum(QUERY_STYLES).optional(),
|
|
412
|
+
explode: z.boolean().optional(),
|
|
413
|
+
}),
|
|
414
|
+
z.strictObject({
|
|
415
|
+
kind: z.literal("path"),
|
|
416
|
+
name: z.string().trim().min(1).max(120).optional(),
|
|
417
|
+
style: z.enum(PATH_STYLES).optional(),
|
|
418
|
+
explode: z.boolean().optional(),
|
|
419
|
+
}),
|
|
420
|
+
z.strictObject({
|
|
421
|
+
kind: z.literal("header"),
|
|
422
|
+
name: headerNameSchema.optional(),
|
|
423
|
+
explode: z.boolean().optional(),
|
|
424
|
+
}),
|
|
425
|
+
z.strictObject({ kind: z.literal("cookie"), name: headerNameSchema.optional() }),
|
|
357
426
|
]);
|
|
358
427
|
export type FieldTarget = z.infer<typeof fieldTargetSchema>;
|
|
359
428
|
|
|
@@ -381,8 +450,8 @@ const itemsSchema = z.strictObject({
|
|
|
381
450
|
* a type (with `"null"` beside it when the value may be null), an `enum` of
|
|
382
451
|
* strings for a string, an object's `properties` and `required`, an
|
|
383
452
|
* array's `items`, a `description`, and the extensions `x-sensitive`
|
|
384
|
-
* (redacted from logs), `x-source`
|
|
385
|
-
* of a tool's input only).
|
|
453
|
+
* (redacted from logs), `x-source` (top-level scalar fields of a tool's
|
|
454
|
+
* input only) and `x-target` (top-level fields of a tool's input only).
|
|
386
455
|
*/
|
|
387
456
|
const schemaPropertySchema = z.strictObject({
|
|
388
457
|
type: z.union([schemaTypeSchema, z.tuple([schemaTypeSchema, z.literal("null")])]),
|
|
@@ -453,9 +522,9 @@ function enumIssueOf(property: SchemaProperty, type: string | undefined) {
|
|
|
453
522
|
/**
|
|
454
523
|
* What one property breaks of itself: an `enum` off a string, with no value,
|
|
455
524
|
* or listing `null` when the type does not allow it or the other way round;
|
|
456
|
-
* `items` off an array or an array without them;
|
|
457
|
-
*
|
|
458
|
-
*
|
|
525
|
+
* `items` off an array or an array without them; `x-source` off a scalar
|
|
526
|
+
* of a `bindable` node, or a source that does not fit the field; and
|
|
527
|
+
* `x-target` off a `bindable` node.
|
|
459
528
|
*/
|
|
460
529
|
function propertyIssues(property: SchemaProperty, bindable: boolean) {
|
|
461
530
|
const issues = new Array<RowIssue>();
|
|
@@ -469,13 +538,17 @@ function propertyIssues(property: SchemaProperty, bindable: boolean) {
|
|
|
469
538
|
});
|
|
470
539
|
}
|
|
471
540
|
const binds = bindable && SCALAR_TYPES.some((scalar) => scalar === type);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
541
|
+
if (property["x-source"] !== undefined && !binds) {
|
|
542
|
+
issues.push({
|
|
543
|
+
at: ["x-source"],
|
|
544
|
+
message: "only a top-level scalar field of a tool's input has it",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (property["x-target"] !== undefined && !bindable) {
|
|
548
|
+
issues.push({
|
|
549
|
+
at: ["x-target"],
|
|
550
|
+
message: "only a top-level field of a tool's input has it",
|
|
551
|
+
});
|
|
479
552
|
}
|
|
480
553
|
const source = property["x-source"];
|
|
481
554
|
if (binds && source && !sourceFits(source, { type, enum: property.enum })) {
|
|
@@ -487,8 +560,8 @@ function propertyIssues(property: SchemaProperty, bindable: boolean) {
|
|
|
487
560
|
/**
|
|
488
561
|
* Refuses, at every depth below `path`, what the console's field rows cannot
|
|
489
562
|
* hold, since it stores a schema as rows and would drop or change it in
|
|
490
|
-
* silence (`nodeIssues`, `propertyIssues`); `x-source`
|
|
491
|
-
* on a
|
|
563
|
+
* silence (`nodeIssues`, `propertyIssues`); `x-source` only on a scalar,
|
|
564
|
+
* and `x-target` only on a field, directly under a node where `bindable`.
|
|
492
565
|
*/
|
|
493
566
|
function heldByRows(
|
|
494
567
|
node: {
|
|
@@ -516,8 +589,8 @@ function heldByRows(
|
|
|
516
589
|
|
|
517
590
|
/**
|
|
518
591
|
* An object's JSON Schema, the shape of a tool's or trigger's input and
|
|
519
|
-
* output. `bindable` admits `x-source`
|
|
520
|
-
*
|
|
592
|
+
* output. `bindable` admits `x-source` on its top-level scalar fields and
|
|
593
|
+
* `x-target` on its top-level fields, as a tool's input has them.
|
|
521
594
|
*/
|
|
522
595
|
function objectSchemaOf(bindable: boolean) {
|
|
523
596
|
return z
|
|
@@ -572,6 +645,15 @@ const toolBaseSchema = z.strictObject({
|
|
|
572
645
|
pinned: z.boolean().default(false),
|
|
573
646
|
tags: toolTagsSchema.default([]),
|
|
574
647
|
audience: audienceSchema,
|
|
648
|
+
overridable: overridable([
|
|
649
|
+
"description",
|
|
650
|
+
"timeoutMs",
|
|
651
|
+
"maxResponseBytes",
|
|
652
|
+
"rateLimitRequests",
|
|
653
|
+
"rateLimitWindowSeconds",
|
|
654
|
+
"pinned",
|
|
655
|
+
"tags",
|
|
656
|
+
]),
|
|
575
657
|
});
|
|
576
658
|
|
|
577
659
|
/** A tool that calls an endpoint; with `requiresApproval`, every call waits for the end user to approve it. */
|
|
@@ -629,6 +711,8 @@ const manifestOpenApiSchema = z.strictObject({
|
|
|
629
711
|
/** Where the document is read, with no credentials: OpenAPI 3 or Swagger 2, JSON or YAML. */
|
|
630
712
|
url: upstreamUrlSchema,
|
|
631
713
|
credentials: credentialsSchema,
|
|
714
|
+
/** Where every operation's successful response keeps its result, when the API wraps each answer. */
|
|
715
|
+
resultPath: jsonPointerSchema.optional(),
|
|
632
716
|
/** Tools listed in every turn of every agent they reach, whatever the question. */
|
|
633
717
|
pinned: toolNamesSchema,
|
|
634
718
|
approval: z
|
|
@@ -650,6 +734,7 @@ const manifestServiceSchema = z.strictObject({
|
|
|
650
734
|
/** Rich text: it may mention tools and skills. */
|
|
651
735
|
description: localizedTextSchema,
|
|
652
736
|
audience: audienceSchema,
|
|
737
|
+
overridable: overridable(["description"]),
|
|
653
738
|
});
|
|
654
739
|
export type ManifestService = z.input<typeof manifestServiceSchema>;
|
|
655
740
|
|
|
@@ -661,6 +746,7 @@ const manifestSkillSchema = z.strictObject({
|
|
|
661
746
|
/** The key of the service the skill renders inside; fixed once created. */
|
|
662
747
|
service: slugSchema.optional(),
|
|
663
748
|
audience: audienceSchema,
|
|
749
|
+
overridable: overridable(["description", "body"]),
|
|
664
750
|
});
|
|
665
751
|
export type ManifestSkill = z.input<typeof manifestSkillSchema>;
|
|
666
752
|
|
|
@@ -677,7 +763,7 @@ export const triggerThreadPolicySchema = z.discriminatedUnion("policy", [
|
|
|
677
763
|
z.object({ policy: z.literal("single") }),
|
|
678
764
|
z.object({ policy: z.literal("per-firing") }),
|
|
679
765
|
z.object({ policy: z.literal("ephemeral") }),
|
|
680
|
-
z.object({ policy: z.literal("keyed"), key:
|
|
766
|
+
z.object({ policy: z.literal("keyed"), key: jsonPointerSchema }),
|
|
681
767
|
]);
|
|
682
768
|
export type TriggerThreadPolicy = z.infer<typeof triggerThreadPolicySchema>;
|
|
683
769
|
|
|
@@ -708,6 +794,14 @@ const manifestTriggerSchema = z.strictObject({
|
|
|
708
794
|
/** The model its runs talk to, over each agent's. */
|
|
709
795
|
model: modelSchema.optional(),
|
|
710
796
|
audience: audienceSchema,
|
|
797
|
+
overridable: overridable([
|
|
798
|
+
"description",
|
|
799
|
+
"instructions",
|
|
800
|
+
"threadPolicy",
|
|
801
|
+
"budgetPerDay",
|
|
802
|
+
"disableAfterFailures",
|
|
803
|
+
"model",
|
|
804
|
+
]),
|
|
711
805
|
});
|
|
712
806
|
export type ManifestTrigger = z.input<typeof manifestTriggerSchema>;
|
|
713
807
|
|
|
@@ -731,6 +825,7 @@ const manifestDatasetHostSchema = z
|
|
|
731
825
|
/** Among those, the ones listed in every turn, whatever the question. */
|
|
732
826
|
pinned: z.array(datasetIdentifierSchema).max(50).default([]),
|
|
733
827
|
audience: audienceSchema,
|
|
828
|
+
overridable: overridable(["timeoutMs"]),
|
|
734
829
|
})
|
|
735
830
|
.superRefine((host, context) => {
|
|
736
831
|
const unexposed = unexposedPins(host);
|
|
@@ -771,6 +866,7 @@ const manifestMcpServerSchema = z.strictObject({
|
|
|
771
866
|
include: mcpToolNamesSchema.default([]),
|
|
772
867
|
exclude: mcpToolNamesSchema.default([]),
|
|
773
868
|
audience: audienceSchema,
|
|
869
|
+
overridable: overridable(["include", "exclude"]),
|
|
774
870
|
});
|
|
775
871
|
export type ManifestMcpServer = z.input<typeof manifestMcpServerSchema>;
|
|
776
872
|
|
|
@@ -793,6 +889,7 @@ const manifestWebhookSchema = z.strictObject({
|
|
|
793
889
|
),
|
|
794
890
|
timeoutMs: setting(SETTING_BOUNDS.webhook.timeoutMs),
|
|
795
891
|
audience: audienceSchema,
|
|
892
|
+
overridable: overridable(["description", "timeoutMs"]),
|
|
796
893
|
});
|
|
797
894
|
export type ManifestWebhook = z.input<typeof manifestWebhookSchema>;
|
|
798
895
|
|
|
@@ -802,7 +899,10 @@ const manifestCollectionSchema = z.strictObject({
|
|
|
802
899
|
description: z.string().trim().max(2_000).default(""),
|
|
803
900
|
/** The key of the service every document in the collection belongs to. */
|
|
804
901
|
service: slugSchema.optional(),
|
|
902
|
+
/** Whether answers may name its documents; false keeps their names, sections and pages from the model. */
|
|
903
|
+
citable: z.boolean().default(true),
|
|
805
904
|
audience: audienceSchema,
|
|
905
|
+
overridable: overridable(["description"]),
|
|
806
906
|
});
|
|
807
907
|
export type ManifestCollection = z.input<typeof manifestCollectionSchema>;
|
|
808
908
|
|
|
@@ -840,10 +940,11 @@ const { agent: agentBounds, agentContext, agentLimits } = SETTING_BOUNDS;
|
|
|
840
940
|
|
|
841
941
|
/** How the agent runs; every setting left out keeps its default. */
|
|
842
942
|
const agentSettingsSchema = z.strictObject({
|
|
843
|
-
/**
|
|
943
|
+
/** Lets `searchRegistries` search the knowledge in the collections the agent sees. */
|
|
844
944
|
ragEnabled: z.boolean().default(false),
|
|
845
|
-
/**
|
|
945
|
+
/** Lets `searchRegistries` search the tools the agent sees, beyond the pinned and mentioned ones. */
|
|
846
946
|
toolRegistryEnabled: z.boolean().default(false),
|
|
947
|
+
/** Lets `searchRegistries` search the services the agent sees. */
|
|
847
948
|
serviceRegistryEnabled: z.boolean().default(false),
|
|
848
949
|
attachmentsEnabled: z.boolean().default(true),
|
|
849
950
|
/** Hosts the built-in `fetchImage` tool may pull images from; none, no such tool. */
|
|
@@ -903,6 +1004,7 @@ const manifestAgentSchema = z.strictObject({
|
|
|
903
1004
|
settings: agentSettingsSchema.prefault({}),
|
|
904
1005
|
suggestions: suggestionsSchema.default({ en: [], ar: [] }),
|
|
905
1006
|
greeting: greetingSchema.default({ en: "", ar: "" }),
|
|
1007
|
+
overridable: overridable(["name", "prompt", "models", "settings", "suggestions", "greeting"]),
|
|
906
1008
|
});
|
|
907
1009
|
export type ManifestAgent = z.input<typeof manifestAgentSchema>;
|
|
908
1010
|
|
|
@@ -923,7 +1025,9 @@ export const CORTEX_MANIFEST_ID = "cortex";
|
|
|
923
1025
|
/**
|
|
924
1026
|
* A deployment's agents and components, owned by the manifest `id`: an apply
|
|
925
1027
|
* of it creates what is new, updates what changed, and deletes what it no
|
|
926
|
-
* longer lists, touching nothing it did not create.
|
|
1028
|
+
* longer lists, touching nothing it did not create. What an entry lists as
|
|
1029
|
+
* `overridable` may then change in Cortex too, and an apply keeps that
|
|
1030
|
+
* change unless the manifest changed the same field.
|
|
927
1031
|
*/
|
|
928
1032
|
export const manifestSchema = z.strictObject({
|
|
929
1033
|
/** Fixed for the deployment it describes: the rows an apply owns carry it. */
|
|
@@ -942,14 +1046,11 @@ export const manifestSchema = z.strictObject({
|
|
|
942
1046
|
/** A manifest as written: every field with a default may be left out. */
|
|
943
1047
|
export type Manifest = z.input<typeof manifestSchema>;
|
|
944
1048
|
|
|
945
|
-
/**
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
*/
|
|
949
|
-
const namesByKindSchema = z.record(
|
|
950
|
-
manifestSchema.keyof().exclude(["id", "openapi"]),
|
|
951
|
-
z.array(z.string()),
|
|
952
|
-
);
|
|
1049
|
+
/** The kinds of row a manifest owns; an OpenAPI source's operations are `tools`. */
|
|
1050
|
+
const manifestKindSchema = manifestSchema.keyof().exclude(["id", "openapi"]);
|
|
1051
|
+
|
|
1052
|
+
/** Entry names per kind: agents by slug, services by key, everything else by name. */
|
|
1053
|
+
const namesByKindSchema = z.record(manifestKindSchema, z.array(z.string()));
|
|
953
1054
|
|
|
954
1055
|
/** What an apply creates, updates and deletes, kind by kind; a preview answers the same without doing it. */
|
|
955
1056
|
export const manifestChangesSchema = z.object({
|
|
@@ -958,6 +1059,32 @@ export const manifestChangesSchema = z.object({
|
|
|
958
1059
|
delete: namesByKindSchema,
|
|
959
1060
|
});
|
|
960
1061
|
|
|
1062
|
+
/** A field of one entry that both the manifest and a live write changed since the last apply. */
|
|
1063
|
+
const manifestConflictSchema = z.strictObject({
|
|
1064
|
+
kind: manifestKindSchema,
|
|
1065
|
+
name: z.string(),
|
|
1066
|
+
field: z.string(),
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
/** Which value a conflict keeps: the one changed live in Cortex, or the manifest's. */
|
|
1070
|
+
const conflictSideSchema = z.enum(["live", "manifest"]);
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* What preview and sync take: the manifest, and how its conflicts resolve.
|
|
1074
|
+
* A conflict takes its own entry in `resolutions`, else `prefer`; a sync
|
|
1075
|
+
* left with one neither answers is refused as `unresolved_conflicts`,
|
|
1076
|
+
* naming them. A resolution for a field that no longer conflicts is ignored.
|
|
1077
|
+
*/
|
|
1078
|
+
export const manifestApplyInputSchema = manifestSchema.extend({
|
|
1079
|
+
prefer: conflictSideSchema.optional(),
|
|
1080
|
+
resolutions: z.array(manifestConflictSchema.extend({ keep: conflictSideSchema })).default([]),
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
/** What a preview answers: the changes, and the conflicts nothing resolved. */
|
|
1084
|
+
export const manifestPreviewSchema = manifestChangesSchema.extend({
|
|
1085
|
+
conflicts: z.array(manifestConflictSchema),
|
|
1086
|
+
});
|
|
1087
|
+
|
|
961
1088
|
/**
|
|
962
1089
|
* An apply's changes, and a fire secret, shown this once, for each trigger
|
|
963
1090
|
* it left with none anyone holds: one it created, or one whose
|
|
@@ -97,10 +97,8 @@ export const BUILT_IN_TOOL_NAMES = [
|
|
|
97
97
|
"queryDataset",
|
|
98
98
|
"readAttachment",
|
|
99
99
|
"readReport",
|
|
100
|
-
"
|
|
101
|
-
"searchServices",
|
|
100
|
+
"searchRegistries",
|
|
102
101
|
"searchThreadHistory",
|
|
103
|
-
"searchTools",
|
|
104
102
|
REQUEST_LOCATION_TOOL,
|
|
105
103
|
TRIGGER_ANSWER_TOOL_NAME,
|
|
106
104
|
] as const;
|
|
@@ -131,12 +131,23 @@ export type ClientToolBinding = {
|
|
|
131
131
|
*/
|
|
132
132
|
export type AgentCapabilities = {
|
|
133
133
|
attachments: boolean;
|
|
134
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* False when this caller may not send to the chat: the agent's `message`
|
|
136
|
+
* trigger is off, or the caller only reads (`readOnly`). The widget hides
|
|
137
|
+
* its composer and answers no tool call.
|
|
138
|
+
*/
|
|
135
139
|
messages: boolean;
|
|
140
|
+
/** True when the caller only reads, as an operator inspecting a user's thread does. */
|
|
141
|
+
readOnly: boolean;
|
|
136
142
|
/** Prompt chips on the blank-chat landing per locale; clicking one sends its `prompt` as the first message. */
|
|
137
143
|
suggestions: Suggestions;
|
|
138
144
|
/** Headline of the blank-chat landing per locale; "" falls back to the widget's built-in copy. */
|
|
139
145
|
greeting: Greeting;
|
|
146
|
+
/**
|
|
147
|
+
* Whether this caller gets the debug view: tool inputs and outputs, reasoning,
|
|
148
|
+
* model calls. Cortex sends anyone else only what the widget's normal UI draws.
|
|
149
|
+
*/
|
|
150
|
+
debug: boolean;
|
|
140
151
|
};
|
|
141
152
|
|
|
142
153
|
export type MessageMetadata = {
|
|
@@ -178,6 +189,13 @@ export const STATEMENT_TOOLS = ["executeCode", "queryDataset", "describeDataset"
|
|
|
178
189
|
*/
|
|
179
190
|
export const APPROVAL_TITLE_FIELD = "approvalTitle";
|
|
180
191
|
|
|
192
|
+
/** The title the model wrote into an approval call's input, or undefined while it has none. */
|
|
193
|
+
export function approvalTitleOf(input: unknown) {
|
|
194
|
+
if (!input || typeof input !== "object" || !(APPROVAL_TITLE_FIELD in input)) return undefined;
|
|
195
|
+
const title: unknown = input[APPROVAL_TITLE_FIELD];
|
|
196
|
+
return typeof title === "string" && title.trim() ? title.trim() : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
/**
|
|
182
200
|
* What Cortex stamps on a tool call's `metadata.cortex` from its first chunk,
|
|
183
201
|
* so the live part and the stored one agree.
|
|
@@ -331,3 +349,19 @@ export type WsEvent =
|
|
|
331
349
|
|
|
332
350
|
/** The header the widget sends the browser's IANA time zone in; the turn reasons in that clock. */
|
|
333
351
|
export const TIMEZONE_HEADER = "X-Cortex-Timezone";
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The console operator's short-lived token, sent by the playground and the
|
|
355
|
+
* thread inspector on every request to an agent and by the fire and answer
|
|
356
|
+
* calls Cortex makes to a trigger's route. A verified one makes the operator the caller unless the
|
|
357
|
+
* request also carries an end-user token to test as, and marks the threads it
|
|
358
|
+
* opens as test threads. A WebSocket upgrade carries it as `?operator=`.
|
|
359
|
+
*/
|
|
360
|
+
export const OPERATOR_HEADER = "X-Cortex-Operator";
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Beside the operator token, the id of a thread the operator inspects: the
|
|
364
|
+
* request then reads as that thread's owner, read-only, with the debug view.
|
|
365
|
+
* A WebSocket upgrade carries it as `?inspect=`.
|
|
366
|
+
*/
|
|
367
|
+
export const INSPECT_HEADER = "X-Cortex-Inspect";
|
package/src/scaffold/files.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { applyPorts, type Ports } from "@/scaffold/ports";
|
|
|
27
27
|
* `@m6d/cortex` range below are stamped by scripts/sync-package-versions.ts
|
|
28
28
|
* before the CLI publishes, so a scaffold pins what was current then.
|
|
29
29
|
*/
|
|
30
|
-
const CORTEX_VERSION = "0.
|
|
30
|
+
const CORTEX_VERSION = "0.15.0";
|
|
31
31
|
|
|
32
32
|
/** This machine as Cortex's containers name it: the one private host the stack's egress policy admits. */
|
|
33
33
|
export const DOCKER_HOST = "host.docker.internal";
|
|
@@ -150,7 +150,7 @@ function packageJson({ name, datasets, setup }: Project) {
|
|
|
150
150
|
verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
|
|
151
151
|
},
|
|
152
152
|
dependencies: {
|
|
153
|
-
"@m6d/cortex": "~0.
|
|
153
|
+
"@m6d/cortex": "~0.3.0",
|
|
154
154
|
// drizzle 1.0 is in beta; this is the build `@m6d/cortex/drizzle` is tested against.
|
|
155
155
|
...(datasets ? { "drizzle-orm": "1.0.0-beta.15-859cf75" } : {}),
|
|
156
156
|
hono: "^4.12.3",
|
package/src/ui/prompts.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
log,
|
|
15
15
|
outro,
|
|
16
16
|
password,
|
|
17
|
+
select,
|
|
17
18
|
spinner,
|
|
18
19
|
text,
|
|
19
20
|
} from "@clack/prompts";
|
|
@@ -50,6 +51,14 @@ export function askName(check: (name: string) => string | undefined) {
|
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** One of `options`, by its value; the first is the default. */
|
|
55
|
+
export function askChoice<const TValue extends string>(
|
|
56
|
+
message: string,
|
|
57
|
+
options: Parameters<typeof select<TValue>>[0]["options"],
|
|
58
|
+
) {
|
|
59
|
+
return orExit(select({ message, options }));
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
/** A yes-or-no question a flag left open, answered no unless the caller says otherwise. */
|
|
54
63
|
export function askConfirm(message: string, initialValue = false) {
|
|
55
64
|
return orExit(confirm({ message, initialValue }));
|