flipstream 0.5.0 → 0.6.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 +168 -12
- package/dist/commands/auth/login.js +7 -1
- package/dist/commands/auth/status.js +29 -3
- package/dist/commands/catalog.d.ts +15 -0
- package/dist/commands/catalog.js +110 -0
- package/dist/commands/connections/list.d.ts +1 -0
- package/dist/commands/connections/list.js +27 -1
- package/dist/commands/contract.d.ts +11 -0
- package/dist/commands/contract.js +35 -0
- package/dist/commands/health.d.ts +10 -0
- package/dist/commands/health.js +31 -0
- package/dist/commands/log/add.js +6 -2
- package/dist/commands/query.d.ts +15 -2
- package/dist/commands/query.js +255 -42
- package/dist/commands/skills/install.d.ts +16 -0
- package/dist/commands/skills/install.js +55 -0
- package/dist/commands/workspaces/connections.js +3 -1
- package/dist/commands/workspaces/get.js +4 -2
- package/dist/commands/workspaces/list.js +3 -0
- package/dist/lib/api/errors.d.ts +1 -0
- package/dist/lib/api/errors.js +13 -2
- package/dist/lib/api/http.d.ts +2 -0
- package/dist/lib/api/http.js +40 -4
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/flow.js +8 -1
- package/dist/lib/auth/headless.js +14 -10
- package/dist/lib/auth/refresh.js +21 -1
- package/dist/lib/command/base.d.ts +4 -0
- package/dist/lib/command/base.js +97 -3
- package/dist/lib/command/flags.d.ts +4 -0
- package/dist/lib/command/flags.js +11 -0
- package/dist/lib/command/planner.d.ts +9 -0
- package/dist/lib/command/planner.js +14 -0
- package/dist/lib/config/constants.d.ts +3 -1
- package/dist/lib/config/constants.js +14 -1
- package/dist/lib/config/xdg.d.ts +4 -0
- package/dist/lib/config/xdg.js +56 -1
- package/dist/lib/errors.d.ts +20 -1
- package/dist/lib/errors.js +125 -17
- package/dist/lib/output/dialogs.d.ts +27 -0
- package/dist/lib/output/dialogs.js +94 -0
- package/dist/lib/output/interactivity.d.ts +11 -0
- package/dist/lib/output/interactivity.js +48 -0
- package/dist/lib/output/redact.d.ts +1 -0
- package/dist/lib/output/redact.js +12 -0
- package/dist/lib/output/runlog.d.ts +3 -0
- package/dist/lib/output/runlog.js +72 -0
- package/dist/lib/output/sanitize.d.ts +2 -0
- package/dist/lib/output/sanitize.js +57 -0
- package/dist/lib/output/sidecar.d.ts +30 -0
- package/dist/lib/output/sidecar.js +58 -0
- package/dist/lib/output/table.js +5 -1
- package/dist/lib/output/trace.d.ts +11 -0
- package/dist/lib/output/trace.js +89 -0
- package/dist/lib/planner/catalog.d.ts +26 -0
- package/dist/lib/planner/catalog.js +60 -0
- package/dist/lib/planner/client.d.ts +14 -0
- package/dist/lib/planner/client.js +47 -0
- package/dist/lib/planner/connection.d.ts +14 -0
- package/dist/lib/planner/connection.js +139 -0
- package/dist/lib/planner/diagnose.d.ts +8 -0
- package/dist/lib/planner/diagnose.js +50 -0
- package/dist/lib/planner/errors.d.ts +14 -0
- package/dist/lib/planner/errors.js +129 -0
- package/dist/lib/planner/filters.d.ts +8 -0
- package/dist/lib/planner/filters.js +74 -0
- package/dist/lib/planner/request.d.ts +24 -0
- package/dist/lib/planner/request.js +51 -0
- package/dist/lib/planner/suggest.d.ts +2 -0
- package/dist/lib/planner/suggest.js +45 -0
- package/dist/lib/planner/vocabulary.d.ts +9 -0
- package/dist/lib/planner/vocabulary.js +95 -0
- package/dist/lib/skills/install.d.ts +24 -0
- package/dist/lib/skills/install.js +69 -0
- package/dist/lib/store/keyring.d.ts +3 -0
- package/dist/lib/store/keyring.js +45 -2
- package/dist/lib/store/memory-store.d.ts +1 -0
- package/dist/lib/store/memory-store.js +5 -0
- package/docs/AGENT-CONTRACT.md +238 -0
- package/oclif.manifest.json +392 -8
- package/package.json +7 -3
- package/skill/SKILL.md +55 -0
package/dist/lib/output/table.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeTable } from '@oclif/table';
|
|
2
|
+
import { sanitizeDeep } from './sanitize.js';
|
|
2
3
|
// Render a table to STDOUT — HUMAN output ONLY. Callers invoke this from a
|
|
3
4
|
// respond/respondList human formatter, which never runs under --json/--ndjson, so
|
|
4
5
|
// the agent contract (machine output is pure JSON/NDJSON) is preserved. Honors
|
|
@@ -8,10 +9,13 @@ export function renderTable(data, columns, emptyMessage = '(none)') {
|
|
|
8
9
|
process.stdout.write(`${emptyMessage}\n`);
|
|
9
10
|
return;
|
|
10
11
|
}
|
|
12
|
+
// Sanitize the DATA, not the rendered table: makeTable adds its own ANSI for
|
|
13
|
+
// borders and colour, so cleaning the finished string would strip that too.
|
|
14
|
+
// Every value here came from a service, so none of it may carry control bytes.
|
|
11
15
|
const table = makeTable({
|
|
12
16
|
borderStyle: 'vertical-with-outline',
|
|
13
17
|
columns,
|
|
14
|
-
data,
|
|
18
|
+
data: sanitizeDeep(data),
|
|
15
19
|
noStyle: Boolean(process.env.NO_COLOR),
|
|
16
20
|
overflow: 'truncate-end',
|
|
17
21
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function enableStderrTrace(): void;
|
|
2
|
+
export declare function resetTrace(): void;
|
|
3
|
+
export declare function traceRequest(method: string, url: string, body?: unknown): void;
|
|
4
|
+
export declare function traceFailure(method: string, url: string, reason: string, ms: number): void;
|
|
5
|
+
export declare function traceResponse(response: {
|
|
6
|
+
bodyText?: string;
|
|
7
|
+
method: string;
|
|
8
|
+
ms: number;
|
|
9
|
+
status: number;
|
|
10
|
+
url: string;
|
|
11
|
+
}): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { redact } from './redact.js';
|
|
2
|
+
import { appendRunLog, currentRunLogPath } from './runlog.js';
|
|
3
|
+
import { sanitizeTerminal } from './sanitize.js';
|
|
4
|
+
// Wire tracing (E11-6, #105): one line per request and response for every
|
|
5
|
+
// first-party API call. Two sinks with different rules:
|
|
6
|
+
//
|
|
7
|
+
// run log — always receives every line (context + order for post-mortems)
|
|
8
|
+
// stderr — only when enabled (FLIPSTREAM_LOG=debug or --verbose), which is
|
|
9
|
+
// also what fixes #92: trace lines are emitted AS THEY HAPPEN, so
|
|
10
|
+
// the failure path has diagnostics too, not just the happy path.
|
|
11
|
+
//
|
|
12
|
+
// Bodies and headers are OMITTED by default. FLIPSTREAM_LOG_SANITIZE=false
|
|
13
|
+
// includes bodies — still through redact(), which is the floor that never
|
|
14
|
+
// comes off (tokens never reach any sink).
|
|
15
|
+
let stderrEnabled = false;
|
|
16
|
+
// Called once per command from BaseCommand.init (flag) — the env var works
|
|
17
|
+
// even before that, for code paths that run pre-parse.
|
|
18
|
+
export function enableStderrTrace() {
|
|
19
|
+
stderrEnabled = true;
|
|
20
|
+
}
|
|
21
|
+
// Test seam.
|
|
22
|
+
export function resetTrace() {
|
|
23
|
+
stderrEnabled = false;
|
|
24
|
+
bodyNoticeShown = false;
|
|
25
|
+
}
|
|
26
|
+
function debugEnv() {
|
|
27
|
+
return process.env.FLIPSTREAM_LOG === 'debug';
|
|
28
|
+
}
|
|
29
|
+
function includeBodies() {
|
|
30
|
+
return process.env.FLIPSTREAM_LOG_SANITIZE === 'false';
|
|
31
|
+
}
|
|
32
|
+
let bodyNoticeShown = false;
|
|
33
|
+
function emit(line) {
|
|
34
|
+
// The run log is a file — it keeps the byte-faithful line for post-mortem.
|
|
35
|
+
// stderr is a terminal, so a body line under FLIPSTREAM_LOG_SANITIZE=false
|
|
36
|
+
// could carry cursor/erase escapes that rewrite the display; sanitize it
|
|
37
|
+
// there (every other human-facing writer already does).
|
|
38
|
+
appendRunLog(line);
|
|
39
|
+
if (stderrEnabled || debugEnv())
|
|
40
|
+
process.stderr.write(`${sanitizeTerminal(line)}\n`);
|
|
41
|
+
}
|
|
42
|
+
// FLIPSTREAM_LOG_SANITIZE=false writes full bodies to the run log even with no
|
|
43
|
+
// on-screen tracing — so the operator learns, once, that turning off
|
|
44
|
+
// sanitization also turned on persistence (E11-6 review).
|
|
45
|
+
function noticeBodiesPersist() {
|
|
46
|
+
if (bodyNoticeShown || stderrEnabled || debugEnv())
|
|
47
|
+
return;
|
|
48
|
+
bodyNoticeShown = true;
|
|
49
|
+
const path = currentRunLogPath();
|
|
50
|
+
process.stderr.write(`FLIPSTREAM_LOG_SANITIZE=false: unsanitized request/response bodies are being written to ${path ?? 'the run log'}\n`);
|
|
51
|
+
}
|
|
52
|
+
export function traceRequest(method, url, body) {
|
|
53
|
+
emit(`-- REQUEST ${method} ${redact(url)}`);
|
|
54
|
+
if (body === undefined)
|
|
55
|
+
return;
|
|
56
|
+
if (includeBodies()) {
|
|
57
|
+
noticeBodiesPersist();
|
|
58
|
+
emit(` body: ${JSON.stringify(redact(body))}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
emit(' body: omitted; set FLIPSTREAM_LOG_SANITIZE=false to include redacted data');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function traceFailure(method, url, reason, ms) {
|
|
65
|
+
emit(`-- FAILED ${method} ${redact(url)} (${ms} ms): ${redact(reason)}`);
|
|
66
|
+
}
|
|
67
|
+
// A body STRING gets structural redaction: parsed as JSON when possible so
|
|
68
|
+
// secret-NAMED keys are masked (string-level redaction only catches
|
|
69
|
+
// param-style and JWT-shaped values, not `{"access_token": "..."}`).
|
|
70
|
+
function redactBodyText(bodyText) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(redact(JSON.parse(bodyText)));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return redact(bodyText);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function traceResponse(response) {
|
|
79
|
+
emit(`-- RESPONSE ${response.status} ${response.method} ${redact(response.url)} (${response.ms} ms)`);
|
|
80
|
+
if (response.bodyText === undefined || response.bodyText.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
if (includeBodies()) {
|
|
83
|
+
noticeBodiesPersist();
|
|
84
|
+
emit(` body: ${redactBodyText(response.bodyText)}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
emit(' body: omitted; set FLIPSTREAM_LOG_SANITIZE=false to include redacted data');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface CatalogItem {
|
|
2
|
+
description: string;
|
|
3
|
+
label: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CatalogSource {
|
|
8
|
+
customDimensionsMax?: number;
|
|
9
|
+
description: string;
|
|
10
|
+
dimensions: CatalogItem[];
|
|
11
|
+
label: string;
|
|
12
|
+
metrics: CatalogItem[];
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
export interface CatalogSourceSummary {
|
|
16
|
+
dimensions: number;
|
|
17
|
+
label: string;
|
|
18
|
+
metrics: number;
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CatalogIndex {
|
|
22
|
+
defaultSource?: string;
|
|
23
|
+
sources: CatalogSourceSummary[];
|
|
24
|
+
}
|
|
25
|
+
export declare function parseCatalogIndex(payload: unknown): CatalogIndex;
|
|
26
|
+
export declare function parseCatalogSource(payload: unknown): CatalogSource;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Narrowing for the catalog documents the planner serves. The wire payload is
|
|
2
|
+
// untrusted, so it arrives as `unknown` and every field is proven here rather
|
|
3
|
+
// than asserted — the repo rule for API boundaries (see api/http.ts).
|
|
4
|
+
//
|
|
5
|
+
// What the planner publishes is an ALLOWLIST projection built by data-model:
|
|
6
|
+
// name, label, description, format and nothing else. `field`, `agg_func`,
|
|
7
|
+
// `table`, `grain`, `tier` and `routing` are absent by construction, not by
|
|
8
|
+
// filtering. We deliberately do not re-filter here — inventing a second
|
|
9
|
+
// allowlist would hide the day the first one breaks.
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
12
|
+
? value
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
function str(value, fallback = '') {
|
|
16
|
+
return typeof value === 'string' ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
function arr(value) {
|
|
19
|
+
return Array.isArray(value) ? value : [];
|
|
20
|
+
}
|
|
21
|
+
function parseItem(value) {
|
|
22
|
+
const record = asRecord(value) ?? {};
|
|
23
|
+
// `format` may be absent or null; its `type` is what the human table shows.
|
|
24
|
+
const format = asRecord(record.format) ?? {};
|
|
25
|
+
return {
|
|
26
|
+
description: str(record.description),
|
|
27
|
+
label: str(record.label),
|
|
28
|
+
name: str(record.name),
|
|
29
|
+
type: str(format.type),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// GET /catalog — the index of sources.
|
|
33
|
+
export function parseCatalogIndex(payload) {
|
|
34
|
+
const record = asRecord(payload) ?? {};
|
|
35
|
+
const sources = arr(record.sources).map((entry) => {
|
|
36
|
+
const source = asRecord(entry) ?? {};
|
|
37
|
+
return {
|
|
38
|
+
dimensions: arr(source.dimensions).length,
|
|
39
|
+
label: str(source.label),
|
|
40
|
+
metrics: arr(source.metrics).length,
|
|
41
|
+
name: str(source.name),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
const defaultSource = typeof record.defaultSource === 'string' ? record.defaultSource : undefined;
|
|
45
|
+
return { defaultSource, sources };
|
|
46
|
+
}
|
|
47
|
+
// GET /catalog/<source> — one source's full vocabulary.
|
|
48
|
+
export function parseCatalogSource(payload) {
|
|
49
|
+
const record = asRecord(payload) ?? {};
|
|
50
|
+
const custom = asRecord(record.customDimensions);
|
|
51
|
+
const max = custom !== undefined && typeof custom.max === 'number' ? custom.max : undefined;
|
|
52
|
+
return {
|
|
53
|
+
customDimensionsMax: max,
|
|
54
|
+
description: str(record.description),
|
|
55
|
+
dimensions: arr(record.dimensions).map((item) => parseItem(item)),
|
|
56
|
+
label: str(record.label),
|
|
57
|
+
metrics: arr(record.metrics).map((item) => parseItem(item)),
|
|
58
|
+
name: str(record.name),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RefreshDeps } from '../api/retry.js';
|
|
2
|
+
export interface PlannerCallOptions {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface PlannerClient {
|
|
6
|
+
getCatalog(source?: string, options?: PlannerCallOptions): Promise<unknown>;
|
|
7
|
+
postQuery(body: unknown, options?: PlannerCallOptions): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface AuthedPlannerClientDeps extends RefreshDeps {
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
url?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function createAuthedPlannerClient(deps: AuthedPlannerClientDeps): PlannerClient;
|
|
14
|
+
export declare function getPlannerHealth(url: string, options?: PlannerCallOptions): Promise<unknown>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { requestJson } from '../api/http.js';
|
|
2
|
+
import { withFreshToken } from '../api/retry.js';
|
|
3
|
+
import { PLANNER_HOST } from '../config/constants.js';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
function rstrip(host) {
|
|
6
|
+
return host.replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
// The authed QUERY-PLANNER client. Transport only: it knows the two paths and
|
|
9
|
+
// nothing about what a catalog document or a LogicalRequest contains.
|
|
10
|
+
//
|
|
11
|
+
// Wrapped in the SAME withFreshToken 401->refresh->retry-once used by the data and
|
|
12
|
+
// admin clients, and refresh always targets the AUTH issuer (deps.authHost) rather
|
|
13
|
+
// than the planner — the planner issues nothing, it only verifies and forwards.
|
|
14
|
+
export function createAuthedPlannerClient(deps) {
|
|
15
|
+
const base = rstrip(deps.url ?? PLANNER_HOST);
|
|
16
|
+
const defaultTimeout = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17
|
+
return {
|
|
18
|
+
getCatalog(source, options = {}) {
|
|
19
|
+
// encodeURIComponent so a source name can never escape the path segment.
|
|
20
|
+
const path = source === undefined ? '/catalog' : `/catalog/${encodeURIComponent(source)}`;
|
|
21
|
+
return withFreshToken(deps, (token) => requestJson({
|
|
22
|
+
method: 'GET',
|
|
23
|
+
timeoutMs: options.timeoutMs ?? defaultTimeout,
|
|
24
|
+
token,
|
|
25
|
+
url: `${base}${path}`,
|
|
26
|
+
}));
|
|
27
|
+
},
|
|
28
|
+
postQuery(body, options = {}) {
|
|
29
|
+
return withFreshToken(deps, (token) => requestJson({
|
|
30
|
+
body,
|
|
31
|
+
method: 'POST',
|
|
32
|
+
timeoutMs: options.timeoutMs ?? defaultTimeout,
|
|
33
|
+
token,
|
|
34
|
+
url: `${base}/query`,
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// GET /health — the one endpoint that takes NO token (it is open on the planner),
|
|
40
|
+
// so it answers "is it me or is it them" before any auth debugging starts.
|
|
41
|
+
export async function getPlannerHealth(url, options = {}) {
|
|
42
|
+
return requestJson({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
45
|
+
url: `${rstrip(url)}/health`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AdminClient } from '../api/admin-client.js';
|
|
2
|
+
export interface ResolveOptions {
|
|
3
|
+
note?: (message: string) => void;
|
|
4
|
+
}
|
|
5
|
+
export interface ResolvedConnection {
|
|
6
|
+
active: boolean;
|
|
7
|
+
clientId: string;
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function connectionsUrl(clientId: string): string;
|
|
12
|
+
export declare function assertConnectionActive(connection: ResolvedConnection): void;
|
|
13
|
+
export declare function resolveConnection(client: AdminClient, value: string, options?: ResolveOptions): Promise<ResolvedConnection>;
|
|
14
|
+
export declare function connectionIsVisible(client: AdminClient, connectionId: string): Promise<boolean | undefined>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { fetchConnections } from '../api/connections.js';
|
|
2
|
+
import { isUuid } from '../api/ids.js';
|
|
3
|
+
import { projectConnection } from '../api/projections.js';
|
|
4
|
+
import { uuidToShort } from '../api/short-uuid.js';
|
|
5
|
+
import { PULSE_FE_HOST } from '../config/constants.js';
|
|
6
|
+
import { CliError, UsageError } from '../errors.js';
|
|
7
|
+
import { ExitCode } from '../exit-codes.js';
|
|
8
|
+
const MAX_LISTED = 10;
|
|
9
|
+
// Read a string field, falling back into the projected `raw` passthrough.
|
|
10
|
+
// projectConnection() promotes only its core fields (name, type, active, …), so
|
|
11
|
+
// `slug` — which the admin API does carry — survives ONLY inside `raw`. Reading
|
|
12
|
+
// just the top level would make slug matching silently never fire.
|
|
13
|
+
// Candidate connections as " <id> <name>" lines, capped, so a refusal names what
|
|
14
|
+
// the search found and the retry is a copy-paste rather than a second guess.
|
|
15
|
+
function listed(candidates) {
|
|
16
|
+
return candidates
|
|
17
|
+
.slice(0, MAX_LISTED)
|
|
18
|
+
.map((record) => ` ${label(record, 'id')} ${label(record, 'name')}`)
|
|
19
|
+
.join('\n');
|
|
20
|
+
}
|
|
21
|
+
function label(record, key) {
|
|
22
|
+
const direct = record[key];
|
|
23
|
+
if (typeof direct === 'string')
|
|
24
|
+
return direct;
|
|
25
|
+
const raw = record.raw;
|
|
26
|
+
const nested = raw?.[key];
|
|
27
|
+
return typeof nested === 'string' ? nested : '';
|
|
28
|
+
}
|
|
29
|
+
// A projected admin record → the few fields the query path cares about.
|
|
30
|
+
// `active` defaults to TRUE when the field is missing: this check is a
|
|
31
|
+
// convenience over the server's, and inventing "inactive" from an absent field
|
|
32
|
+
// would block a working connection on a schema change. The server (pulse-data#49)
|
|
33
|
+
// is what actually enforces this.
|
|
34
|
+
function toResolved(record) {
|
|
35
|
+
const active = record.active ?? record.raw?.active;
|
|
36
|
+
return {
|
|
37
|
+
active: active !== false,
|
|
38
|
+
clientId: label(record, 'client_id'),
|
|
39
|
+
id: label(record, 'id'),
|
|
40
|
+
name: label(record, 'name'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Where a human goes to switch a connection back on. The workspace segment is the
|
|
44
|
+
// client_id in pulse-fe's short-UUID encoding — derived from the record we already
|
|
45
|
+
// hold, so building this costs no extra request.
|
|
46
|
+
export function connectionsUrl(clientId) {
|
|
47
|
+
if (!isUuid(clientId))
|
|
48
|
+
return `${PULSE_FE_HOST}/client-space`;
|
|
49
|
+
return `${PULSE_FE_HOST}/client-space/${uuidToShort(clientId)}/connections`;
|
|
50
|
+
}
|
|
51
|
+
// Refuse a query against an inactive connection.
|
|
52
|
+
//
|
|
53
|
+
// This is a CONVENIENCE, not the control. It saves a round trip and turns an
|
|
54
|
+
// opaque engine failure into an instruction, but the authoritative refusal lives
|
|
55
|
+
// in pulse-data (#49) — a check on this side of the wire can be edited out by
|
|
56
|
+
// anyone running a modified client, so it must never be the only thing standing
|
|
57
|
+
// between an inactive connector and its data.
|
|
58
|
+
export function assertConnectionActive(connection) {
|
|
59
|
+
if (connection.active)
|
|
60
|
+
return;
|
|
61
|
+
const where = connection.name.length > 0 ? `'${connection.name}'` : connection.id;
|
|
62
|
+
throw new CliError(`Connection ${where} is inactive, so it cannot be queried. Reactivate it at ` +
|
|
63
|
+
`${connectionsUrl(connection.clientId)}`, 'connection_inactive', ExitCode.USAGE);
|
|
64
|
+
}
|
|
65
|
+
// A UUID passes through; anything else is looked up by name or slug.
|
|
66
|
+
//
|
|
67
|
+
// Ambiguity is REFUSED rather than resolved to the first hit. Querying the wrong
|
|
68
|
+
// connection returns numbers rather than an error, and numbers are the one
|
|
69
|
+
// failure nobody notices — so a name that matches two connections is a usage
|
|
70
|
+
// error listing both, not a coin flip.
|
|
71
|
+
//
|
|
72
|
+
// The lookup reads the same pulse-admin list that pulse-data resolves against,
|
|
73
|
+
// with the same token and the same connections:read scope, so what resolves here
|
|
74
|
+
// is exactly what the query can reach and nothing more.
|
|
75
|
+
export async function resolveConnection(client, value, options = {}) {
|
|
76
|
+
// A UUID still costs one GET. It used to pass straight through, but the caller
|
|
77
|
+
// needs `active`, and skipping the lookup for the id form would make "use the
|
|
78
|
+
// UUID instead of the name" a one-word bypass of the inactive check.
|
|
79
|
+
if (isUuid(value)) {
|
|
80
|
+
const raw = await client.get(`/connections/${value}`);
|
|
81
|
+
return toResolved(projectConnection(raw));
|
|
82
|
+
}
|
|
83
|
+
const { records } = await fetchConnections(client, { all: true, limit: 50, offset: 0, q: value });
|
|
84
|
+
// Only an EXACT name/slug hit is an identity. The server's free-text results are
|
|
85
|
+
// SUGGESTIONS: 'acme' matching the single connection 'acme-staging' is not the
|
|
86
|
+
// user naming it, and resolving to it would query a connection they never asked
|
|
87
|
+
// for — which returns numbers rather than an error, and numbers are the one
|
|
88
|
+
// failure nobody notices. Refusing costs one retry; guessing costs a decision
|
|
89
|
+
// made on the wrong client's data.
|
|
90
|
+
const folded = value.toLowerCase();
|
|
91
|
+
const exact = records.filter((record) => label(record, 'name').toLowerCase() === folded || label(record, 'slug').toLowerCase() === folded);
|
|
92
|
+
if (exact.length === 0) {
|
|
93
|
+
if (records.length === 0) {
|
|
94
|
+
throw new UsageError(`No connection matches '${value}' — run \`flipstream connections list\` to see yours.`);
|
|
95
|
+
}
|
|
96
|
+
// Name what the search DID find, so the retry is a copy-paste rather than a
|
|
97
|
+
// second guess.
|
|
98
|
+
throw new UsageError(`No connection is named exactly '${value}'. Did you mean:\n${listed(records)}`);
|
|
99
|
+
}
|
|
100
|
+
if (exact.length > 1) {
|
|
101
|
+
throw new UsageError(`'${value}' matches ${exact.length} connections:\n${listed(exact)}`);
|
|
102
|
+
}
|
|
103
|
+
const found = exact[0] ?? {};
|
|
104
|
+
const resolved = toResolved(found);
|
|
105
|
+
if (resolved.id.length === 0)
|
|
106
|
+
throw new UsageError(`Connection '${value}' resolved to a record with no id.`);
|
|
107
|
+
options.note?.(`connection '${value}' → ${resolved.id} (${resolved.name})`);
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
// Can this token resolve that connection? `undefined` when we could not find out.
|
|
111
|
+
//
|
|
112
|
+
// Only ever used to EXPLAIN a failure that already happened. pulse-data resolves
|
|
113
|
+
// the connection through pulse-admin as the caller, so a connection belonging to
|
|
114
|
+
// another account fails there — and the planner is right not to say which, since
|
|
115
|
+
// the engine's own words name tables. This asks the same question from the
|
|
116
|
+
// outside, where the answer is the caller's to know.
|
|
117
|
+
export async function connectionIsVisible(client, connectionId) {
|
|
118
|
+
// This id can come from an opaque --body, so it is caller-controlled and goes
|
|
119
|
+
// straight into a path. `../../oauth/token` normalises out of /connections/
|
|
120
|
+
// entirely, which would point an authenticated GET at another admin route. Same
|
|
121
|
+
// origin and the caller's own token, so the blast radius is small — but this
|
|
122
|
+
// CLI is driven by agents that may be working from untrusted content, and a
|
|
123
|
+
// non-UUID is not a connection id in any case. Undefined is also the honest
|
|
124
|
+
// answer: we cannot determine visibility for something that cannot be one.
|
|
125
|
+
if (!isUuid(connectionId))
|
|
126
|
+
return undefined;
|
|
127
|
+
try {
|
|
128
|
+
await client.get(`/connections/${connectionId}`);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const { status } = error;
|
|
133
|
+
if (status === 404)
|
|
134
|
+
return false;
|
|
135
|
+
// 403, 5xx, a timeout: we genuinely do not know, and guessing here would put
|
|
136
|
+
// a confident wrong hint in front of a real failure.
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CliError } from '../errors.js';
|
|
2
|
+
export interface DiagnoseOptions {
|
|
3
|
+
bin: string;
|
|
4
|
+
body: unknown;
|
|
5
|
+
fetchCatalog: (source?: string) => Promise<unknown>;
|
|
6
|
+
onSkip?: (reason: string) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function attachVocabularyDiagnosis(error: CliError, options: DiagnoseOptions): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { parseCatalogIndex, parseCatalogSource } from './catalog.js';
|
|
2
|
+
import { didYouMean } from './suggest.js';
|
|
3
|
+
import { describeProblems, diagnoseUnknownNames } from './vocabulary.js';
|
|
4
|
+
export async function attachVocabularyDiagnosis(error, options) {
|
|
5
|
+
const upstream = error.details.upstreamCode;
|
|
6
|
+
if (upstream === undefined || !upstream.startsWith('UNKNOWN_'))
|
|
7
|
+
return;
|
|
8
|
+
// Structural guard, not just the planner's vocabulary discipline: a
|
|
9
|
+
// retryable failure (429/5xx) must never trigger an immediate follow-up
|
|
10
|
+
// fetch against a service that just asked us to back off.
|
|
11
|
+
if (error.details.retryable === true)
|
|
12
|
+
return;
|
|
13
|
+
const source = options.body?.source;
|
|
14
|
+
const sourceName = typeof source === 'string' && /^[\w.-]{1,64}$/.test(source) ? source : undefined;
|
|
15
|
+
try {
|
|
16
|
+
if (upstream === 'UNKNOWN_SOURCE') {
|
|
17
|
+
if (sourceName === undefined)
|
|
18
|
+
return;
|
|
19
|
+
const index = parseCatalogIndex(await options.fetchCatalog());
|
|
20
|
+
const suggestion = didYouMean(sourceName, index.sources.map((entry) => entry.name).filter((name) => name.length > 0));
|
|
21
|
+
// The next command must be RUNNABLE: never the name the planner just
|
|
22
|
+
// rejected — the bare index when there is no better idea, the suggested
|
|
23
|
+
// source when there is.
|
|
24
|
+
error.withDetails({
|
|
25
|
+
hint: suggestion === undefined
|
|
26
|
+
? `Unknown source "${sourceName}" — list the real ones first.`
|
|
27
|
+
: `Unknown source "${sourceName}" — did you mean "${suggestion}"?`,
|
|
28
|
+
next: [`${options.bin} catalog${suggestion === undefined ? '' : ` ${suggestion}`}`],
|
|
29
|
+
retryable: false,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (sourceName === undefined)
|
|
34
|
+
return;
|
|
35
|
+
const catalog = parseCatalogSource(await options.fetchCatalog(sourceName));
|
|
36
|
+
// An empty vocabulary means we did NOT get a catalog: a 200 carrying an
|
|
37
|
+
// HTML interstitial or a drifted shape parses to empty arrays, and diffing
|
|
38
|
+
// against that would accuse every CORRECT name in the request.
|
|
39
|
+
if (catalog.dimensions.length === 0 && catalog.metrics.length === 0) {
|
|
40
|
+
options.onSkip?.('catalog response carried no vocabulary');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const problems = diagnoseUnknownNames(options.body, catalog);
|
|
44
|
+
if (problems.length > 0)
|
|
45
|
+
error.withDetails({ hint: describeProblems(problems) });
|
|
46
|
+
}
|
|
47
|
+
catch (error_) {
|
|
48
|
+
options.onSkip?.(error_ instanceof Error ? error_.message : String(error_));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CliError } from '../errors.js';
|
|
2
|
+
export interface PlannerFailure {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function parsePlannerFailure(bodyText: string): PlannerFailure | undefined;
|
|
7
|
+
export declare function plannerCode(bodyText: string): string;
|
|
8
|
+
export interface CatalogContext {
|
|
9
|
+
bin: string;
|
|
10
|
+
source?: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function mapCatalogError(error: unknown, context: CatalogContext): CliError;
|
|
14
|
+
export declare function mapPlannerError(error: unknown): CliError;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mapDataError, rateLimited } from '../api/errors.js';
|
|
2
|
+
import { AuthFailedError, CliError, DataHttpError, NetworkError, retryPolicy, UsageError } from '../errors.js';
|
|
3
|
+
import { ExitCode } from '../exit-codes.js';
|
|
4
|
+
import { redact } from '../output/redact.js';
|
|
5
|
+
const MESSAGE_MAX = 500;
|
|
6
|
+
// The planner's codes are SCREAMING_SNAKE enum members. Anything else is not a
|
|
7
|
+
// planner code and gets no trust: `code` flows to stdout as `upstream_code`,
|
|
8
|
+
// so this shape check is what keeps that field bounded and unable to smuggle
|
|
9
|
+
// tokens/ANSI — the one envelope field that would otherwise skip redaction.
|
|
10
|
+
const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
11
|
+
// The planner's own error envelope is {code, message} — NOT the {error,
|
|
12
|
+
// error_description} of OAuth nor the {detail} of FastAPI's own validation, so
|
|
13
|
+
// api/errors.ts cannot read it. `detail` (which names tables and columns) is
|
|
14
|
+
// logged server-side and never returned, so there is nothing else to look for.
|
|
15
|
+
export function parsePlannerFailure(bodyText) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(bodyText);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
24
|
+
return undefined;
|
|
25
|
+
const record = parsed;
|
|
26
|
+
if (typeof record.code !== 'string' || !CODE_SHAPE.test(record.code))
|
|
27
|
+
return undefined;
|
|
28
|
+
return {
|
|
29
|
+
code: record.code,
|
|
30
|
+
message: typeof record.message === 'string' ? record.message.slice(0, MESSAGE_MAX) : '',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// The service's own error code for a response body, or '' when this did not come
|
|
34
|
+
// from the planner. Telling those apart is the difference between "typo" and
|
|
35
|
+
// "wrong build" — see the two 404s the catalog command distinguishes.
|
|
36
|
+
export function plannerCode(bodyText) {
|
|
37
|
+
return parsePlannerFailure(bodyText)?.code ?? '';
|
|
38
|
+
}
|
|
39
|
+
// A catalog failure, with the ONE distinction the generic mapper cannot make.
|
|
40
|
+
//
|
|
41
|
+
// Two different 404s reach this endpoint and they send you to different places:
|
|
42
|
+
// the planner's own UNKNOWN_SOURCE names a source that is not in the catalog (a
|
|
43
|
+
// typo), while FastAPI's bare "Not Found" means the route does not exist at all
|
|
44
|
+
// (an older deployment). Collapsing them into "not found" is how you spend an
|
|
45
|
+
// afternoon checking spelling against a build that never had the endpoint.
|
|
46
|
+
export function mapCatalogError(error, context) {
|
|
47
|
+
if (!(error instanceof DataHttpError) || error.status !== 404)
|
|
48
|
+
return mapPlannerError(error);
|
|
49
|
+
if (plannerCode(error.bodyText) === 'UNKNOWN_SOURCE') {
|
|
50
|
+
return new CliError(`No catalog entry for '${context.source ?? ''}' — run \`${context.bin} catalog\` to list them.`, 'not_found', ExitCode.GENERIC).withDetails({
|
|
51
|
+
hint: 'The source name is a catalog key, not a table name.',
|
|
52
|
+
next: [`${context.bin} catalog`],
|
|
53
|
+
retryable: false,
|
|
54
|
+
upstreamCode: 'UNKNOWN_SOURCE',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const path = context.source === undefined ? '/catalog' : `/catalog/${context.source}`;
|
|
58
|
+
return new CliError(`${context.url} serves no ${path} — is it running a build that has one?`, 'not_found', ExitCode.GENERIC);
|
|
59
|
+
}
|
|
60
|
+
// Map a planner failure onto the E3 error model. Falls through to mapDataError for
|
|
61
|
+
// anything that is not a recognisable planner envelope, so transport failures,
|
|
62
|
+
// timeouts and already-typed CliErrors keep their existing behaviour.
|
|
63
|
+
//
|
|
64
|
+
// Every planner `message` is built from PUBLIC vocabulary only — the logical names
|
|
65
|
+
// the caller already sent us — which is why it is safe to surface verbatim
|
|
66
|
+
// (redacted defensively all the same).
|
|
67
|
+
export function mapPlannerError(error) {
|
|
68
|
+
if (!(error instanceof DataHttpError))
|
|
69
|
+
return mapDataError(error);
|
|
70
|
+
const failure = parsePlannerFailure(error.bodyText);
|
|
71
|
+
if (!failure)
|
|
72
|
+
return mapDataError(error);
|
|
73
|
+
// Every planner-mapped error carries the planner's own code verbatim (#96):
|
|
74
|
+
// ENGINE_FAILED and UNKNOWN_DIMENSION are different situations for an agent
|
|
75
|
+
// even when our own classification of them coincides.
|
|
76
|
+
return classifyPlannerFailure(error, failure).withDetails({ upstreamCode: failure.code });
|
|
77
|
+
}
|
|
78
|
+
function classifyPlannerFailure(error, failure) {
|
|
79
|
+
const message = redact(failure.message || `HTTP ${error.status}`);
|
|
80
|
+
switch (failure.code) {
|
|
81
|
+
// Unlike the generic 403 mapper (which returns a FIXED message so an upstream
|
|
82
|
+
// body cannot leak), the planner's INSUFFICIENT_SCOPE message is designed to be
|
|
83
|
+
// returned: it names the missing scope, or the roles that would do instead.
|
|
84
|
+
// That is the whole point of checking scopes at the front door rather than
|
|
85
|
+
// letting a 403 surface two services away as an opaque PULSE_SERVICE_ERROR.
|
|
86
|
+
// Deterministic auth failures: explicitly NOT retryable — re-presenting the
|
|
87
|
+
// same token gets the same answer, and `false` is the anti-retry-loop
|
|
88
|
+
// signal the envelope exists to carry.
|
|
89
|
+
case 'INSUFFICIENT_SCOPE': {
|
|
90
|
+
return new AuthFailedError(message, 'role_forbidden').withDetails({ retryable: false });
|
|
91
|
+
}
|
|
92
|
+
// The planner's own "I broke, try again" — NetworkError is retryable by
|
|
93
|
+
// class; thread the server-stated wait through when one was sent.
|
|
94
|
+
case 'INTERNAL_ERROR': {
|
|
95
|
+
return new NetworkError(message, 'data_upstream_error').withDetails(retryPolicy(error.retryAfterMs));
|
|
96
|
+
}
|
|
97
|
+
case 'INVALID_AUDIENCE': {
|
|
98
|
+
return new AuthFailedError(`invalid_target: re-bind required — ${message}`, 'invalid_target').withDetails({
|
|
99
|
+
retryable: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// TOKEN_EXPIRED reaching here means the 401->refresh->retry wrapper already
|
|
103
|
+
// refreshed successfully and the planner rejected the NEW token too — which is
|
|
104
|
+
// an auth failure, not an expiry. A refresh that cannot renew raises
|
|
105
|
+
// session_expired (exit 4) from the wrapper and never gets this far.
|
|
106
|
+
case 'INVALID_TOKEN':
|
|
107
|
+
case 'TOKEN_EXPIRED': {
|
|
108
|
+
return new AuthFailedError(message, 'data_auth_failed').withDetails({ retryable: false });
|
|
109
|
+
}
|
|
110
|
+
default: {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ENGINE_FAILED carries the engine's status: 502 for transport/5xx, 400 when the
|
|
115
|
+
// engine itself refused. Classify on the status rather than the code so a
|
|
116
|
+
// retryable upstream failure is exit 7 and a bad request stays exit 1.
|
|
117
|
+
if (error.status >= 500) {
|
|
118
|
+
return new NetworkError(message, 'data_upstream_error').withDetails(retryPolicy(error.retryAfterMs));
|
|
119
|
+
}
|
|
120
|
+
if (error.status === 429)
|
|
121
|
+
return rateLimited(message, error.retryAfterMs);
|
|
122
|
+
if (error.status === 404)
|
|
123
|
+
return new CliError(message, 'not_found', ExitCode.GENERIC);
|
|
124
|
+
// A 422 is FastAPI refusing the body shape (`extra="forbid"`), which means WE
|
|
125
|
+
// emitted something the contract does not allow — a usage error on our side.
|
|
126
|
+
if (error.status === 422)
|
|
127
|
+
return new UsageError(message);
|
|
128
|
+
return new CliError(message, 'data_request_failed', ExitCode.GENERIC);
|
|
129
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type FilterEntry = Record<string, unknown>;
|
|
2
|
+
export interface SortSpec {
|
|
3
|
+
by: string;
|
|
4
|
+
dir: 'asc' | 'desc';
|
|
5
|
+
}
|
|
6
|
+
export declare function parseFilter(raw: string): FilterEntry;
|
|
7
|
+
export declare function describeSelections(entries: FilterEntry[]): string[];
|
|
8
|
+
export declare function parseSort(spec: string): SortSpec;
|