Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,16 @@ src/
│ ├── auth/ # Auth commands (configure, login, logout, status)
│ ├── board/ # Board (dashboard) commands (create, update, get, list, delete, scaffold, set-default, clear-default)
│ ├── folder/ # Folder commands (create, get, update, delete)
│ ├── opal/ # OPAL commands (check, verbs, functions, validate-ingest)
│ ├── fleet/ # Fleet commands (status, host, versions, auth) — observe-agent status via REST export query
│ ├── schema/ # Schema commands (introspect) — requires GraphQL introspection enabled
│ ├── worksheet/ # Worksheet commands (list, get, create, delete)
│ ├── cli/ # CLI management (install, uninstall, upgrade)
│ ├── content/ # Content pack management (experimental: gated + hidden)
│ │ ├── host/ # Host Explorer (install, view)
│ │ ├── kubernetes/ # Kubernetes Explorer (install, view)
│ │ └── tracing/ # Trace Explorer (install, view)
│ ├── dataset/ # Dataset commands (list, view)
│ ├── dataset/ # Dataset commands (list, view, dry-run, impact; dry-run/impact unverified — schema drifted)
│ ├── datastream/ # Datastream commands (create, list, view, update)
│ ├── ingest-token/ # Ingest token commands (experimental: gated + hidden)
│ ├── metric/ # Metric commands (list, view)
Expand All @@ -56,8 +60,11 @@ src/
│ ├── authtoken/ # Auth token mutations
│ ├── board/ # Board (dashboard) queries/mutations (hand-authored; codegen unavailable)
│ ├── folder/ # Folder queries/mutations (hand-authored; codegen unavailable)
│ ├── opal/ # OPAL queries (check-queries, verbs-and-functions, validate-ingest-filter) — uses generated snapshot ops
│ ├── schema/ # GraphQL introspection query (hand-authored)
│ ├── worksheet/ # Worksheet queries/mutations (hand-authored; codegen unavailable)
│ ├── content/ # Content pack queries/mutations
│ ├── dataset/ # Dataset queries
│ ├── dataset/ # Dataset queries (incl. dataset-analysis: dry-run/impact, hand-authored, unverified)
│ ├── datastream/ # Datastream queries/mutations
│ ├── ingest-token/ # Ingest token queries/mutations
│ ├── metric/ # Metric queries
Expand All @@ -67,6 +74,7 @@ src/
├── rest/ # REST API layer
│ ├── generated/ # Auto-generated client (DO NOT EDIT)
│ ├── client.ts # REST client factory
│ ├── export/ # OPAL export query helper (runOpalQueryCsv) used by fleet
│ └── config.yaml # OpenAPI generator config
└── lib/ # Shared utilities
├── auth/ # Auth flows (browser login, device code, server discovery)
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ To update installed skills after edits in this repo, run `npx skills update`.
> target tenant has GraphQL introspection disabled. They are commented out in
> `src/app.ts` (search for "TEMP: trimmed pending full Observe schema access")
> and will be restored once `bun codegen:gql` can run. The currently active
> commands are: `help`, `dataset`, `alert`, `board`, `folder`, `cli`.
> commands are: `help`, `dataset`, `alert`, `board`, `folder`, `opal`, `fleet`,
> `schema`, `worksheet`, `cli`.
>
> `schema introspect` additionally requires the tenant to have GraphQL
> introspection enabled. `dataset dry-run` and `dataset impact` are wired but
> unverified on this tenant: their underlying operations drifted
> (`saveDatasetDryRun` removed; `getDatasetsAffectedByDatasetUpdate` result
> fields renamed) and the new shapes cannot be re-derived without introspection.

| Command | Description |
| --------------------------------------- | ------------------------------------------------------- |
Expand All @@ -50,6 +57,8 @@ To update installed skills after edits in this repo, run `npx skills update`.
| `observe tag-key list` | Search tag keys in the knowledge graph |
| `observe dataset list` | List datasets with optional filtering |
| `observe dataset view` | View dataset details and schema |
| `observe dataset dry-run` | Dry-run a dataset pipeline change (unverified on tenant) |
| `observe dataset impact` | Report datasets affected by a change (unverified) |
| `observe metric list` | Search and list metrics |
| `observe metric view` | View metric details and dimensions |
| `observe query` | Execute OPAL queries on datasets |
Expand All @@ -74,6 +83,19 @@ To update installed skills after edits in this repo, run `npx skills update`.
| `observe folder get` | Look up a folder by name and print its ID |
| `observe folder update` | Update a folder's name, description, or icon URL |
| `observe folder delete` | Delete a folder by ID |
| `observe opal check` | Validate an OPAL pipeline and print its result schema |
| `observe opal verbs` | List all OPAL verbs |
| `observe opal functions` | List all OPAL functions |
| `observe opal validate-ingest` | Validate an OPAL ingest filter against a dataset |
| `observe fleet status` | Current status of all observe-agent instances |
| `observe fleet host` | observe-agent details for a single host |
| `observe fleet versions` | observe-agent versions across the fleet |
| `observe fleet auth` | observe-agent auth-check status across the fleet |
| `observe schema introspect` | Dump the GraphQL schema as JSON (introspection req'd) |
| `observe worksheet list` | List worksheets in a workspace |
| `observe worksheet get` | Get a worksheet by ID as JSON |
| `observe worksheet create` | Create a worksheet from a JSON file |
| `observe worksheet delete` | Delete a worksheet by ID |
| `observe cli install` | Configure shell integration (PATH, completions) |
| `observe cli uninstall` | Remove shell integration |
| `observe cli upgrade` | Upgrade to the latest version |
Expand Down
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import { helpCommand } from "./commands/help.js";
// import { tagValueRoutes } from "./commands/tag-value/index.js";
import { boardRoutes } from "./commands/board/index.js";
import { folderRoutes } from "./commands/folder/index.js";
import { opalRoutes } from "./commands/opal/index.js";
import { fleetRoutes } from "./commands/fleet/index.js";
import { schemaRoutes } from "./commands/schema/index.js";
import { worksheetRoutes } from "./commands/worksheet/index.js";
import { CURRENT_CLI_VERSION } from "./lib/constants.js";
import { defineRoutes } from "./lib/stricli-wrappers.js";

Expand All @@ -53,6 +57,10 @@ export const routes = defineRoutes({
// "datastream-token": datastreamTokenRoutes,
board: boardRoutes,
folder: folderRoutes,
opal: opalRoutes,
fleet: fleetRoutes,
schema: schemaRoutes,
worksheet: worksheetRoutes,
cli: cliRoutes,
},
defaultCommand: "help",
Expand Down
95 changes: 95 additions & 0 deletions src/commands/dataset/dry-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { buildCommand } from "@stricli/core";
import * as fs from "node:fs";
import type { LocalContext } from "../../context";
import { datasetDryRun } from "../../gql/dataset/dataset-analysis";
import { GqlApiError } from "../../gql/gql-request";
import { loadConfig } from "../../lib/config";

interface DatasetCmdInput {
workspaceId: string;
dataset: Record<string, unknown>;
query: Record<string, unknown>;
}

function readDatasetInput(file: string): DatasetCmdInput {
try {
return JSON.parse(fs.readFileSync(file, "utf-8")) as DatasetCmdInput;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`dataset: could not read or parse "${file}": ${message}`, {
cause: error,
});
}
}

async function dryRun(
this: LocalContext,
_flags: Record<string, never>,
file: string,
): Promise<void> {
const { process, writer } = this;

try {
const input = readDatasetInput(file);
const config = loadConfig();
const result = await datasetDryRun(config, {
workspaceId: input.workspaceId,
dataset: input.dataset,
query: input.query,
});

if (result.dataset) {
writer.write(
`Dataset: ${result.dataset.name ?? ""} (${result.dataset.id ?? ""})`,
);
}
for (const ds of result.dematerializedDatasets ?? []) {
writer.write(`Would rematerialize: ${ds.name ?? ""} (${ds.id ?? ""})`);
}

let hasErrors = false;
for (const errDs of result.errorDatasets ?? []) {
writer.write(
`Error in ${errDs.dataset?.name ?? ""}: ${errDs.errorText ?? ""}`,
);
hasErrors = true;
}

if (hasErrors) {
process.exit(1);
}
} catch (error) {
if (error instanceof GqlApiError) {
writer.error(`API Error (${error.statusCode}): ${error.message}`);
} else {
const message = error instanceof Error ? error.message : String(error);
writer.error(`Error: ${message}`);
}
process.exit(1);
}
}

export const dryRunCommand = buildCommand({
loader: async () => dryRun,
parameters: {
positional: {
kind: "tuple",
parameters: [
{
brief: "Path to a JSON file with { workspaceId, dataset, query }",
parse: String,
},
],
},
flags: {},
},
docs: {
brief: "Dry-run a dataset pipeline change and report rematerialization",
fullDescription:
"Dry-run a dataset pipeline change.\n\n" +
"NOTE: not verified against the current tenant — the saveDatasetDryRun " +
"mutation has drifted (see src/gql/dataset/dataset-analysis.ts). Works on " +
"tenants exposing the legacy schema; re-derive once introspection is " +
"available.",
},
});
115 changes: 115 additions & 0 deletions src/commands/dataset/impact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { buildCommand } from "@stricli/core";
import chalk from "chalk";
import * as fs from "node:fs";
import type { LocalContext } from "../../context";
import { datasetImpact } from "../../gql/dataset/dataset-analysis";
import { GqlApiError } from "../../gql/gql-request";
import { loadConfig } from "../../lib/config";
import {
formatTable,
createColumnHelper,
type ColumnDef,
} from "../../lib/formatters/table";

interface DatasetCmdInput {
workspaceId: string;
dataset: Record<string, unknown>;
query: Record<string, unknown>;
}

interface AffectedRow {
name: string;
id: string;
dependencyType: string;
}

function readDatasetInput(file: string): DatasetCmdInput {
try {
return JSON.parse(fs.readFileSync(file, "utf-8")) as DatasetCmdInput;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`dataset: could not read or parse "${file}": ${message}`, {
cause: error,
});
}
}

const col = createColumnHelper<AffectedRow>();

const columns: ColumnDef<AffectedRow>[] = [
col.accessor((row) => row.name, { header: "NAME" }),
col.accessor((row) => row.id, {
header: "ID",
format: (value) => chalk.cyan(value),
}),
col.accessor((row) => row.dependencyType, { header: "DEPENDENCY" }),
];

async function impact(
this: LocalContext,
_flags: Record<string, never>,
file: string,
): Promise<void> {
const { process, writer } = this;

try {
const input = readDatasetInput(file);
const config = loadConfig();
const result = await datasetImpact(config, {
workspaceId: input.workspaceId,
dataset: input.dataset,
query: input.query,
});

const rows: AffectedRow[] = (result.affectedDatasets ?? []).map((a) => ({
name: a.dataset?.name ?? "",
id: a.dataset?.id ?? "",
dependencyType: a.dependencyType ?? "",
}));

if (rows.length > 0) {
writer.write(formatTable(rows, columns));
} else {
writer.warn("No affected datasets.");
}

for (const errDs of result.errorDatasets ?? []) {
writer.error(
`Error in ${errDs.dataset?.name ?? ""}: ${errDs.errorText ?? ""}`,
);
}
} catch (error) {
if (error instanceof GqlApiError) {
writer.error(`API Error (${error.statusCode}): ${error.message}`);
} else {
const message = error instanceof Error ? error.message : String(error);
writer.error(`Error: ${message}`);
}
process.exit(1);
}
}

export const impactCommand = buildCommand({
loader: async () => impact,
parameters: {
positional: {
kind: "tuple",
parameters: [
{
brief: "Path to a JSON file with { workspaceId, dataset, query }",
parse: String,
},
],
},
flags: {},
},
docs: {
brief: "Report datasets affected by a dataset pipeline change",
fullDescription:
"Report downstream datasets affected by a dataset change.\n\n" +
"NOTE: not verified against the current tenant — the " +
"getDatasetsAffectedByDatasetUpdate result schema has drifted (see " +
"src/gql/dataset/dataset-analysis.ts). Works on tenants exposing the " +
"legacy schema; re-derive once introspection is available.",
},
});
14 changes: 13 additions & 1 deletion src/commands/dataset/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { defineRoutes } from "../../lib/stricli-wrappers";
import { listCommand } from "./list";
import { viewCommand } from "./view";
import { dryRunCommand } from "./dry-run";
import { impactCommand } from "./impact";

export const datasetRoutes = defineRoutes({
routes: {
list: listCommand,
view: viewCommand,
"dry-run": dryRunCommand,
impact: impactCommand,
},
docs: {
brief: "View observe datasets",
fullDescription: "View and manage datasets in Observe.",
fullDescription: [
"View and manage datasets in Observe",
"",
"Commands:",
" list List datasets in Observe",
" view View details of a dataset",
" dry-run Dry-run a dataset pipeline change (unverified on this tenant)",
" impact Report datasets affected by a change (unverified on this tenant)",
].join("\n"),
},
});
28 changes: 28 additions & 0 deletions src/commands/fleet/fleet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { parseWindow } from "./fleet";

// parseWindow is re-exported from lib/parsers (parseDurationMs); these tests
// guard the fleet --window contract specifically.
describe("fleet parseWindow", () => {
test("parses minutes", () => {
expect(parseWindow("20m")).toBe(20 * 60 * 1000);
});

test("parses hours", () => {
expect(parseWindow("168h")).toBe(168 * 60 * 60 * 1000);
});

test("parses seconds and milliseconds", () => {
expect(parseWindow("90s")).toBe(90 * 1000);
expect(parseWindow("500ms")).toBe(500);
});

test("tolerates surrounding whitespace", () => {
expect(parseWindow(" 24h ")).toBe(24 * 60 * 60 * 1000);
});

test("throws on an invalid duration", () => {
expect(() => parseWindow("20")).toThrow(/Invalid duration/);
expect(() => parseWindow("abc")).toThrow(/Invalid duration/);
});
});
Loading
Loading