// Generated from the backend's route table (src/api/surface/table.rs) by // `just api`. Do not edit: change the table (or the templates next to // it), run `just api`, and commit what it writes. // Types for `api.js`. The method signatures and the endpoint map below // are generated from the backend's route table (`src/api/surface/table.rs`); // the payload shapes mirror the Rust types they are serialized from // (`src/http/views.rs`, `src/http/info.rs`, `src/hub/`, and // `threepm-link` for what the agent sends). // ── identifiers ──────────────────────────────────────────────────── /** `3pmc_…` — a bucket. */ export type ClusterId = string; /** `3pmn_…` — a node. */ export type NodeId = string; /** Milliseconds since the Unix epoch. */ export type EpochMs = number; /** Enrollment state in the database, presence aside. */ export type NodeState = "active" | "pending" | "revoked" | "unknown"; /** `NodeView.state`: the enrollment state, or the presence of an active node. */ export type DisplayState = NodeState | "online" | "stale" | "offline"; /** What the host owner lets the backend ask of a node, as its `hello` declared it. */ export type Capability = "metrics:read" | "proc:restart" | "proc:stop" | "unknown"; /** * What a node may ask of its bucket with its own bearer, granted by the * owner (`setNodeAccess`): `fleet:list` the fleet as a list, `fleet:read` * its metrics, hosts, trees and action catalogue, `fleet:invoke` placing * calls. Nothing by default. */ export type NodeScope = "fleet:list" | "fleet:read" | "fleet:invoke"; /** `TokenView.state` at listing time. */ export type TokenState = "usable" | "expired" | "exhausted" | "revoked"; // ── payloads ─────────────────────────────────────────────────────── export interface Info { server_version: string; link_min: number; link_max: number; /** Whether the telemetry is persisted (`--metrics-url`, `--events-url`), and how that is going. */ store: StoreStatus; /** Application events left off the buckets' live feeds (50 a second per bucket at most), since the backend started. */ feed_app_events_dropped: number; /** What the link got wrong, since the backend started. */ link: LinkInfo; } /** `Info.link`: agent frames this build accepted and could not act on, since the backend started. Both 0 when the fleet and this backend agree on the wire. */ export interface LinkInfo { /** Frames whose tag this build knows and whose body it could not read: skipped, the session kept. A tag it does not know is not counted — that is a newer agent, not a disagreement. */ frames_invalid: number; /** Sessions in which a full state section failed its checksum: accepted, and the checks turned off for the rest of that session. */ crc_full_mismatch: number; } /** Why the store's last request to an engine failed. */ export type StoreErrorKind = "connect" | "timeout" | "throttled" | "rejected" | "server"; /** `Info.store`: the metrics/events store, counted since the backend started. */ export interface StoreStatus { /** A metrics engine is configured. */ metrics: boolean; /** An events engine is configured. */ events: boolean; /** Stored resolution: one sample per series per interval. */ interval_ms: number; /** Samples and events delivered. */ sent: number; /** Samples and events lost: queue full, body given up on. */ dropped: number; /** Bodies given up on after their retries. */ errors: number; /** Attempts that failed and were retried. */ retries: number; last_error_kind?: StoreErrorKind; last_flush_at?: EpochMs; /** The query cache, with `--metrics-url` and `--store-query-cache-mb` above 0. */ cache?: CacheStatus; /** The logs nodes ship (`pushLogs`), with `--events-url`. */ logs?: LogsStatus; } /** `StoreStatus.logs`: the log batches nodes pushed, counted since the backend started. */ export interface LogsStatus { /** Batches stored. */ batches: number; /** Batches that were retries of one already stored. */ duplicates: number; records_accepted: number; records_skipped: number; /** Shipped lines written to the logs store (a line already stored is not). */ lines_stored: number; /** Bytes inflated, every batch read included. */ bytes_inflated: number; /** Batches refused: over budget, too large, not a batch. */ refused: number; /** Batches the stores did not take: answered 503, retried by the node. */ failed: number; } /** `StoreStatus.cache`: the engine's answers kept for a step, counted since the backend started. */ export interface CacheStatus { entries: number; /** Estimated. */ bytes: number; budget_bytes: number; /** Answered from an entry. */ hits: number; /** Went to the engine. */ misses: number; /** Waited for the identical request already in flight. */ coalesced: number; } // ── accounts ─────────────────────────────────────────────────────── export interface Register { /** 1–64 characters; not unique. */ name: string; } export interface Login { /** `3pmu_…`, as `register` or `createUserToken` returned it. */ token: string; } export interface CreateUserToken { /** 1–64 characters; `token` by default. */ label?: string; } export interface UserView { name: string; created_at: EpochMs; } /** A token as listed: never the secret. */ export interface UserTokenView { token_id: string; label: string; created_at: EpochMs; /** Refreshed at most once a minute. */ last_used_at: EpochMs | null; /** The token behind this request, or behind its session. */ current: boolean; } export interface Registered { user: UserView; /** The secret, `3pmu_…`: returned once, never stored. */ token: string; token_id: string; /** Buckets without an owner this first account took over. */ adopted_buckets: number; } export interface LoggedIn { user: UserView; } export interface Me { user: UserView; tokens: UserTokenView[]; /** How many buckets the account owns. */ buckets: number; } export interface UserTokenCreated { /** The secret, `3pmu_…`: returned once, never stored. */ token: string; token_id: string; label: string; created_at: EpochMs; } // ── buckets ──────────────────────────────────────────────────────── export interface BucketView { cluster_id: ClusterId; name: string; created_at: EpochMs; /** Does a host that links with a reusable token wait for `approveNode`? `false`: a valid token of the bucket is enough (`setBucketSettings`). For the owner only — absent from what a node's bearer reads. */ approval?: boolean; /** Does a node ask of the bucket only what it was granted (`setNodeAccess`)? `false`: every active node may place a call on every other, with no scope granted node by node (`setBucketSettings`). For the owner only, like `approval`. */ require_access_rights?: boolean; /** Is the screen of each `3pm fleet ssh` / `fleet attach` session kept as an asciicast artifact of its call (`setBucketSettings`)? On by default. For the owner only, like `approval`. */ record_shells?: boolean; } /** `bucketToken`: the bucket's permanent enrollment token — the only one whose secret can be read again. */ export interface BucketToken { token_id: string; /** `3pmk_.`: the same at every read. Whoever holds it enrolls hosts into the bucket, for as long as the bucket lives. */ token: string; /** `3pm link `, ready to paste or to hand to a provisioning script. */ paste_line: string; /** `curl -fsSL …/install.sh | sh -s -- link `: installs 3pm and links, for a host without 3pm. Re-run, it updates 3pm and keeps the link. */ install_line: string; created_at: EpochMs; } /** `setBucketSettings`'s body: a setting left out is unchanged; a body naming none is a 400. */ export interface SetBucketSettings { /** `false`: every host that links with a valid token of the bucket is active at once, and the hosts already waiting are approved. */ approval?: boolean; /** `false`: every active node may place a call on every other node of the bucket — each is treated as holding `fleet:invoke`, `fleet:list` and `fleet:read` for as long as this is off, whatever its own `scopes`. A target's `callers` rules still narrow who may call it. `true` (the default): a node does only what `setNodeAccess` granted it. */ require_access_rights?: boolean; /** `false`: shell, command and attach sessions are no longer recorded. `true` (the default): what each session's screen showed — never what was typed — is kept as an asciicast (`.cast`) artifact of its call. */ record_shells?: boolean; } export interface CreateBucket { /** 1–64 characters. */ name: string; } /** `GET /api/buckets/{id}`, and the feed's `init`. */ export interface BucketDetail { bucket: BucketView; /** `host:port` the pasted lines point at. */ advertise: string; nodes: NodeView[]; /** The last telemetry frame of each node the hub has heard from. */ snapshots: Telemetry[]; /** Per node, per process: the recent samples. */ history: NodeHistory[]; } /** The last time a node was turned away at `/api/v1/link`. */ export interface AuthError { /** `ERR_NODE_PENDING`, `ERR_NODE_REVOKED`, … */ code: string; at: EpochMs; } export interface NodeView { node_id: NodeId; /** What to display: enrollment first, presence for an active node. */ state: DisplayState; /** The database state alone. */ enrollment: NodeState; hostname: string; os: string; arch: string; agent_version: string; /** Which token admitted the host. */ token_id?: string; /** * What the host owner lets be asked of this node, exactly as its last * `hello` declared it (`link.toml` `caps`). Empty means the node * accepts nothing — or, for a node that has not linked since it * enrolled, that it has not said yet. */ caps: Capability[]; /** What this node may ask of the bucket with its own bearer; empty by default. */ scopes: NodeScope[]; /** Who may place a call on this node: empty, every node holding `fleet:invoke`; with rules, what a rule admits. */ callers: CallerRule[]; /** What the node lets go agent to agent with a LAN neighbour: nothing, a call's files (the default), or its live legs too. */ direct: DirectMode; enrolled_at: EpochMs; approved_at?: EpochMs; revoked_at?: EpochMs; last_seen_at?: EpochMs; last_auth_error?: AuthError; /** Bytes received over the node's link per second, averaged over the last 5s. */ rx_bps: number; /** Bytes received since the backend started. */ rx_bytes: number; /** Where the node's last link came from, as this backend saw it (behind `--trust-proxy`, the forwarded client); absent until the node linked since the backend started. */ peer_ip?: string; /** The private addresses of the node's interfaces (RFC 1918, CGNAT, IPv6 ULA; container bridges left out), from its last complete host snapshot. */ private_ips?: string[]; /** The account the node's daemon runs as, from its last complete host snapshot; absent until it sent one. */ user?: string; /** The accounts with an open login session on the node, from its last complete host snapshot. Sent to the bucket's owner and to a node holding `fleet:read`; absent otherwise, which is not the same as nobody being logged in. */ sessions?: string[]; } /** * One line of a node's `callers`: who may place a call on it once the * list holds a rule (an empty list accepts every node holding * `fleet:invoke`). `"*"` is the wildcard, and a missing `from_app` / * `app` / `action` means `"*"` — except an action of the `3pm:` prefix, * which a wildcard never reaches. * * `from_app` is an operational guard rail, not a boundary: the calling * application's name is declared by the calling daemon and the backend * cannot check it. The trust boundary is the node. */ export interface CallerRule { /** The calling node: a `3pmn_…` id, or `"*"`. */ from: string; from_app?: string; app?: string; action?: string; } /** `setNodeAccess`'s body: a list left out is unchanged, an empty one clears it (no scope; `callers` open again); each replaces, never merges. */ export interface SetNodeAccess { scopes?: NodeScope[]; callers?: CallerRule[]; /** What the node lets go agent to agent with a LAN neighbour; left out = unchanged. `all` sends a call's live legs direct too, and the relay then has nothing to show a viewer. */ direct?: DirectMode; } /** `off`, `files` (a call's files go direct between LAN neighbours) or `all` (the default: its live legs too). */ export type DirectMode = "off" | "files" | "all" | (string & {}); /** * The other end of a call whose two nodes are LAN neighbours: its node id, its Ed25519 public key * (base64url, the only key its TLS handshake may present) and the private addresses of its listener. */ export interface Peer { node_id: NodeId; pubkey: string; addrs: string[]; } /** A token as listed: never the secret. */ export interface TokenView { token_id: string; created_at: EpochMs; expires_at: EpochMs; uses_total: number; uses_left: number; /** The host it admits is active at once, with no approval step. */ pre_approved: boolean; revoked_at?: EpochMs; state: TokenState; /** The bucket's permanent token (`bucketToken`): always `usable`, its `expires_at` and uses mean nothing, and `revokeToken` refuses it (409). */ permanent?: boolean; } export interface CreateToken { /** Seconds; one hour by default. */ ttl_s?: number; /** One by default. */ uses?: number; } export interface TokenCreated { /** The secret: returned once, never stored. */ token: string; token_id: string; expires_at: EpochMs; uses: number; pre_approved: boolean; /** `3pm link ws://` — what a human pastes on a host. */ paste_line: string; /** The same link through the installer, for a host without 3pm (see `BucketToken.install_line`). */ install_line: string; } // ── telemetry ────────────────────────────────────────────────────── export type ProcStatus = | "launching" | "online" | "stopping" | "stopped" | "waiting_restart" | "errored" | "unknown"; /** One managed process, as the agent redacts it. */ export interface RedactedRecord { id: number; name: string; instance: number; namespace?: string | null; status: ProcStatus; pid?: number | null; /** Absent during the warm-up window after a spawn. */ cpu_percent?: number | null; mem_bytes: number; uptime_ms?: number | null; restarts: number; unstable_restarts: number; listen_ports: number[]; exit_code?: number | null; exit_signal?: string | null; created_at_epoch_ms: number; next_cron_epoch_ms?: number | null; waiting_for: string[]; module?: string | null; interpreter?: string | null; watch: boolean; /** Environment variable **names** — never values. */ env_keys: string[]; /** * Live processes of the instance's tree, the spawned pid included: * 1 for an app running alone, absent from a daemon predating process * trees. The tree itself is `nodeProcessTrees`. */ proc_count?: number; /** * The tree's network traffic, short keys: absent on a node with no * source, before the daemon's first 5 s window, and from an older * agent. Its history: `process.net.received` / `process.net.sent`. */ net?: RedactedNet; } /** Network traffic as the link carries it. */ export interface RedactedNet { /** Bytes per second received / sent over the daemon's last window, rounded to whole bytes. */ r: number; t: number; /** Bytes received / sent since the daemon started measuring; reset by a restart or a daemon update. */ rb: number; tb: number; /** The window lost some notifications: a lower bound. Absent when false. */ pa?: boolean; } export interface DaemonInfo { version: string; protocol: number; pid: number; } /** * Host gauges and counters. Wide and platform-dependent — see * `HostMetrics` in `threepm-protocol`; the fields below are the ones * every platform fills. */ export interface HostMetrics { hostname: string; os_name: string; os_version: string; kernel_version: string; arch: string; uptime_s: number; cpu_brand: string; logical_cpus: number; global_cpu_percent: number; per_core_percent: number[]; load_avg: [number, number, number]; mem_total: number; mem_used: number; mem_available: number; swap_total: number; swap_used: number; processes_count: number; /** 3pm's own footprint, measured by the host agent; absent from an older daemon. */ "3pmDaemon"?: ThreepmDaemon; /** The heaviest processes of the host, managed by 3pm or not: the ten largest by CPU, by memory and by network rate, merged and sorted by pid — sort by the column you want and take your own top. Live only (no series is kept). Absent from a daemon with the option off, or predating the table. */ top_processes?: HostTopProcess[]; [field: string]: unknown; } /** One row of `HostMetrics.top_processes`. */ export interface HostTopProcess { pid: number; /** The executable's name: never its path, never its arguments. */ name: string; /** The account it runs as; absent when the platform does not say. */ user?: string; /** Share of one core over the last window: 100 is one core busy. */ cpu_percent: number; /** Resident set; shared pages are counted in every process holding them. */ mem_bytes: number; /** Physical footprint (macOS): what Activity Monitor calls Memory, with the compressed pages and graphics memory the RSS misses. Never on Linux; absent on macOS for a process the source did not list. When a table has it, its memory top was ranked on it, not on `mem_bytes`. */ footprint_bytes?: number; /** Bytes per second over the last window. Absent, not zero, when the node has no per-process source or the source cannot see this process. */ net_rx_bytes_per_s?: number; net_tx_bytes_per_s?: number; } /** * `GET /api/buckets/{id}/nodes/{node}/host`: the host now — rebuilt from * the node's state, whole and current (the feed's `telemetry` blanks * the identity on a tick). */ export interface NodeHostView { node_id: NodeId; /** The node's last frame, as the feed numbers them (`Telemetry.seq`). */ seq: number; /** The agent's clock on the frame that last moved the host. */ ts: EpochMs; /** Always `patched`: the host is rebuilt from the node's state, whole and current. The field stays for the pages that read it; `full`, `merged` and `tick` went with the streams that produced them. */ identity: "patched"; host: HostMetrics; } /** What 3pm itself costs the node (`HostMetrics.3pmDaemon`). */ export interface ThreepmDaemon { /** When the agent assembled this part, Unix ms. */ at_epoch_ms?: number; /** One row per 3pm process: the daemon, then the host agent. */ processes: ThreepmProcess[]; /** The daemon's counters; absent for a tick the daemon did not answer. */ runtime?: ThreepmRuntime; } /** One 3pm process as its host agent sees it from the OS. */ export interface ThreepmProcess { /** `daemon` or `host-agent` (the set may grow). */ role: string; pid: number; cpu_percent: number; mem_bytes: number; uptime_ms?: number; /** Linux only for the daemon row on the OS side; filled from the daemon elsewhere. */ open_fds?: number; /** Linux only. */ threads?: number; } /** The daemon's runtime counters, the health subset of `3pm internals`. */ export interface ThreepmRuntime { version: string; boot_id: number; uptime_ms: number; apps_total: number; online: number; launching: number; stopping: number; stopped: number; waiting_restart: number; errored: number; restarts_total: number; unstable_restarts_total: number; timers_armed: number; bus_subscribers: number; log_sinks: number; log_rotations_total: number; log_bytes_written_total: number; daemon_log_bytes: number; nofile_soft: number; nofile_hard: number; handoffs: number; host_agent_restarts: number; } export interface TelemetrySnapshot { /** Volatile host gauges, identity blanked. */ host_tick?: HostMetrics; /** The whole host snapshot: first frame, identity change, and periodically. */ host_full?: HostMetrics; procs: RedactedRecord[]; /** Absent while the daemon is down. */ daemon?: DaemonInfo; } /** One `telemetry` event. */ export interface Telemetry { node_id: NodeId; seq: number; ts: EpochMs; /** State patches of the node the backend refused so far: a gap (answered by a resync) or a section over a bound. */ dropped: number; snap: TelemetrySnapshot; } /** One sample of one instance: cpu and RSS of its whole process tree, as 3pm reports them. */ export interface Point { /** Epoch seconds. */ t_s: number; cpu: number; rss_kb: number; } export interface ProcHistory { id: number; name: string; points: Point[]; } export interface NodeHistory { node_id: NodeId; procs: ProcHistory[]; } // ── metrics ──────────────────────────────────────────────────────── // // `queryMetrics(clusterId, body)`: a chart query, validated against the // catalog (`metricsCatalog`) and run against the metrics engine, scoped // to the bucket. Unknown fields are refused (400 `ERR_BAD_REQUEST` // naming the field), as are a metric the catalog lacks, a label the // metric does not carry, `rate` on a gauge, a regex that does not // compile, a range past 31 days or more points than `max_points`. // // The numeric bounds are served as data — `limits.queryMetrics` and // `limits.eventsQuery` of `GET /api/v1/routes` — except the step floor, // which is the store's resolution: `store.interval_ms` of // `GET /api/v1/info`. They bound the grid, not the window, and what a // step is measured against is `options.max_points` — 600 by default, // 2 000 at most: 30 days at `5m` is 8 640 points, refused whatever it // is set to, while the same 30 days draws at `2h` on the default and // at `30m` (1 442 points) with `max_points: 2000`. /** The labels a query may filter or group on; the bucket itself is never one of them. */ /** A built-in label, or an application's own label name (`^[a-z][a-z0-9_]{0,31}$`, not reserved) on a metric that carries it. */ export type QueryLabel = "node" | "app" | "instance" | "ns" | "mount" | "window" | "src" | "core" | "iface" | "blockdev" | "sensor" | "gpu" | "array" | "proc" | (string & {}); export type MetricKind = "gauge" | "counter" | "histogram"; /** Where a catalog line comes from: the store's own telemetry, or an application's metric seen in this bucket. */ export type MetricSource = "static" | "app"; export type FilterOp = "eq" | "neq" | "in" | "nin" | "regex" | "nregex"; /** `rate`, `increase`, `delta` for counters; the `*_over_time` family for gauges; `raw` for either; `quantile` (with `q`), `rate` or `increase` for a histogram. */ export type TimeFnName = | "raw" | "rate" | "increase" | "delta" | "avg_over_time" | "min_over_time" | "max_over_time" | "last_over_time" | "quantile_over_time" | "quantile"; export type AggregateFnName = "sum" | "avg" | "min" | "max" | "count" | "quantile"; /** Epoch milliseconds, or `now` / `now-` (`now-1h`, `now-7d`). */ export type TimeValue = EpochMs | string; /** Milliseconds, or `30s` / `5m` / `1h` / `7d` / `2w`. */ export type DurationValue = number | string; export interface TimeRange { from: TimeValue; /** Default `now`. */ to?: TimeValue; /** `30s`, `5m`, `1h` or milliseconds (what `step_ms` answers); omitted ⇒ derived from `options.max_points`. Both ends are aligned to it. */ step?: DurationValue; } export interface Filter { label: QueryLabel; op: FilterOp; /** `eq`, `neq`, `regex`, `nregex`. ≤ 256 characters. */ value?: string; /** `in`, `nin`: 1–64 values. */ values?: string[]; } export interface TimeFn { fn: TimeFnName; /** `1m` or milliseconds, like `range.step`; default twice the step. Not for `raw`. */ window?: DurationValue; /** 0..1, `quantile_over_time` and `quantile` (a histogram's) only. */ q?: number; } export interface Aggregate { fn: AggregateFnName; /** Labels the metric carries. */ by?: QueryLabel[]; /** 0..1, `quantile` only. */ q?: number; } export interface QuerySpec { /** `^[A-Z][A-Z0-9]{0,7}$`, unique across queries and formulas. */ ref: string; /** A catalog id, `process.cpu.utilization`. */ metric: string; /** AND-ed; at most 16. */ filters?: Filter[]; /** Default: the catalog's `default_time` for the metric. */ time?: TimeFn; aggregate?: Aggregate; /** Computed for a formula, not returned. */ hidden?: boolean; } export interface Formula { ref: string; /** Refs, numbers, `+ - * /` and parentheses: `A / B * 100`. */ expr: string; unit?: string; } export interface QueryOptions { /** 2–2000, default 600: the step is raised until the range fits. Every bound here is also `limits.queryMetrics` of `GET /api/v1/routes`, which is generated from the validator. */ max_points?: number; /** 1–2000, default 200: the series kept are the top ones by `sort` over the whole range (the engine ranks, the backend re-ranks the same way). */ max_series?: number; /** What ranks series for `max_series`, descending; default `avg`. */ sort?: "avg" | "last"; /** Default `null`. */ fill?: "null" | "zero" | "previous"; /** `series` (default) or `summary` (no values). */ mode?: "series" | "summary"; /** Add `results[].expr`: the expression as compiled for the engine. */ explain?: boolean; } /** `POST /api/buckets/{id}/metrics/query`. */ export interface MetricsQuery { range: TimeRange; /** 1–8. */ queries: QuerySpec[]; /** ≤ 8. */ formulas?: Formula[]; options?: QueryOptions; } /** Over the points the engine returned on the grid — before `fill`, so `sort` ranks what the engine ranked. */ export interface Summary { min: number | null; max: number | null; avg: number | null; last: number | null; /** Points the engine returned (not the filled ones). */ count: number; } export interface Series { /** The `by` labels, or every public label without an aggregation — never the bucket. */ labels: Record; /** Aligned on `MetricsResult.times`; absent in `summary` mode. */ values?: (number | null)[]; summary: Summary; } export interface ResultSet { ref: string; /** The catalog id; absent on a formula. */ metric?: string; kind: MetricKind; /** `ratio`, `bytes`, `seconds`, `count`, `bool`, or the formula's; absent on a formula that gave none. */ unit?: string; series: Series[]; /** The series the engine answered, at most `max_series + 1`: `truncated` is exact, this is a floor, not the bucket's count. */ series_total: number; truncated: boolean; /** With `options.explain`: the expression as compiled for the engine. */ expr?: string; } export interface ResultRange { /** Aligned to `step_ms`: `from` rounded down, `to` rounded up. */ from: EpochMs; to: EpochMs; step_ms: number; } export interface MetricsResult { range: ResultRange; /** One timestamp per point, shared by every series. */ times: EpochMs[]; results: ResultSet[]; took_ms: number; } /** One line of `metricsCatalog`. */ export interface MetricDef { id: string; kind: MetricKind; unit: string; /** What `filters` and `by` may name for this metric. */ labels: QueryLabel[]; /** What a chart wants when `time` is omitted. */ default_time: TimeFn; description: string; source: MetricSource; } // ── events query (`eventsQuery`) ─────────────────────────────────── /** `POST /api/buckets/{id}/events/query`: the persisted events, newest first. */ export interface EventsQuery { /** `step` is ignored. */ range: TimeRange; filters?: EventFilters; /** ≤ 1000, default 200. */ limit?: number; } /** Exact matches on the stored fields. */ export interface EventFilters { /** `process`, `daemon_shutdown`, `app`, `action`, `shell`. */ kind?: string; node?: NodeId; app?: string; instance?: number; src?: string; level?: string; event_name?: string; fingerprint?: string; /** A call id: every line of one action or shell session. */ call?: string; } export interface EventsResult { /** The stored fields as they are (`_time`, `_msg`, `kind`, `app`, `level`, `a.*`, `exception.*`, `fingerprint`, `repeat`…). */ events: Record[]; /** More than `limit` matched. */ truncated: boolean; took_ms: number; } // ── logs query (`logsQuery`) ────────────────────────────────────── /** `POST /api/buckets/{id}/logs/query`: the logs the bucket's nodes shipped. */ export interface LogsQuery { /** `step` is ignored; 30 days at most. */ range: TimeRange; filters?: LogFilters; /** A substring of the shipped lines (case sensitive). */ text?: string; limits?: { templates?: number; lines?: number }; } /** Exact matches; `sev` is one word, a list, or `warning+` (that level or worse). */ export interface LogFilters { node?: NodeId; src?: string; owner?: string; sev?: string | string[]; /** A template id: unique per source of a node only, so `node` and `src` are required with it. */ tpl?: number; } export interface LogsResult { range: { from: EpochMs; to: EpochMs }; sources: LogSourceView[]; /** Worst severity first, then by lines. */ templates: LogTemplateView[]; /** Newest first. */ lines: LogLineView[]; /** How many sources, templates and lines the limits cut; `counts`: nodes whose counts the engine would not give (their templates and source `lines` missing), 0 when whole. */ truncated: { sources: number; templates: number; lines: number; counts: number }; } export interface LogSourceView { node: NodeId; hostname: string; src: string; /** `app`, `journal`, `file`. */ kind: string; path?: string; app?: string; owner?: string; format?: string; /** Lines counted over the range. */ lines: number; /** Lines shipped and stored over the range. */ shipped: number; /** Lines the node read and did not ship. */ dropped: number; /** Templates with lines over the range. */ templates: number; } export interface LogTemplateView { node: NodeId; src: string; /** `null`: the templates without a label of their own, together (`"(other templates)"`). */ tpl: number | null; sev: string; text: string; lines: number; first_seen: EpochMs | null; last_seen: EpochMs | null; params: { var: string; distinct: number; top: string[] }[]; } export interface LogLineView { ts: EpochMs; node: NodeId; hostname: string; src: string; tpl?: number; sev: string; /** Why it was shipped: `new`, `error`, `back`, `spike`, `return`, `context`. */ why: string; line: string; ident?: string; unit?: string; } // ── application metrics (`nodeAppMetrics`) ──────────────────────── /** `GET /api/buckets/{id}/nodes/{node}/app-metrics`: what the hub last applied for every instance of the node. */ export interface AppMetricsView { node_id: NodeId; instances: AppInstanceView[]; /** States refused since the backend first held the node: over the caps (1 000 series, 128 buckets, 100 instances) or an identity off the grammar. */ refused: number; } export interface AppInstanceView { /** The daemon's process id. */ id: number; /** Incarnation: a restart starts a new one, with new series ids. */ gen: number; name: string; instance: number; ns?: string; /** The state's revision, as the daemon counts it. */ rev: number; /** The agent's clock on the frame that last moved the instance's metrics, epoch ms. */ ts: number; sources: AppSourceView[]; series: AppSeriesView[]; } export interface AppSourceView { source: string; pid?: number; /** The actions this source exposes to the backend (`nodeAction`); absent when none. */ actions?: AppActionView[]; } export interface AppActionView { name: string; description?: string; /** The declared timeout, ms (30 s when absent). */ timeout_ms?: number; /** The declared `output.max_bytes`: what a call asking for the file reserves of its caller's blob quota (256 MiB when absent). */ output_max_bytes?: number; } /** `GET /api/buckets/{id}/nodes/{node}/actions`: the actions the node's apps declare, nothing of their metrics. */ export interface NodeActionsView { node_id: NodeId; /** Every instance with at least one action, in the hub's order. */ instances: ActionInstanceView[]; } /** One instance of `NodeActionsView`: its identity and the sources that declare an action (a source without one is left out). */ export interface ActionInstanceView { /** The daemon's process id. */ id: number; /** Incarnation: a restart starts a new one. */ gen: number; name: string; instance: number; ns?: string; sources: AppSourceView[]; } /** `nodeAction`'s body. */ export interface NodeActionBody { /** The app, by name or by process id. */ app: string; /** One instance of the app (default: every instance that declares the action, 16 at most). */ instance?: number; /** The source to call, when several of an instance declare it. */ source?: string; action: string; /** JSON for the handler (≤ 64 KiB serialised). */ params?: unknown; /** Bounded by the declaration's (30 s default, 10 min ceiling). */ timeout_ms?: number; /** The bytes this call carries; the answer names the blob to `PUT` them to (`putBlob`). */ input?: CallInputBody; /** Ask for an answer in bytes: the answer names the blob to `GET` (`getBlob`). */ output?: boolean; /** * A session rather than a call: two `live` blobs, one each way, carrying framed messages * relayed and never kept. It names its `instance` and carries neither `input` nor `output` — * the session's two legs are both. The answer's `input.blob` is the leg you write and * `output.blob` the one you read, with `input.live` true and `input.bytes` absent; `live` * on the call counts what it has relayed. */ stream?: boolean; /** The calling application, as the calling daemon named it; read only from a node's bearer. */ from_app?: string; } /** `nodeShell`'s body: the terminal the shell is opened on, or the command run instead. */ export interface NodeShellBody { /** `$TERM`: 1 to 64 characters of `[A-Za-z0-9._+-]`. */ term: string; /** Columns and rows, clamped to what a pty takes (2 to 1000). */ cols: number; rows: number; /** One command instead of a shell, its words (`3pm fleet ssh -- `): at most 256 words, 16 KiB in all, no NUL, the first not empty. */ command?: string[]; /** With `command`: run it on a pty of `term`, `cols` × `rows`; without, on pipes (stdout as `output`, stderr as `stderr`, stdin closed by `eof`). */ pty?: boolean; /** The terminal of a `tty = true` app instead of a shell (`3pm fleet attach `); never with `command`. */ attach?: NodeShellAttach; /** The unix account the shell or the command runs as (`--user`): `[a-z_][a-z0-9_-]{0,31}`; an agent that is not root takes only its own name. Never with `attach`. */ user?: string; /** The caller takes its legs through its own agent's `data.sock`: when both nodes are LAN neighbours set to `direct: all` and the bucket does not record shells, the answer names the target (`peer`) and the legs may go agent to agent. */ direct?: boolean; /** End-to-end encrypted (`3pm fleet ssh`, the default): the legs carry records sealed between the caller and the node, relayed and never read nor recorded here. 409 `ERR_NODE_NO_E2E` when the node's agent does not announce it. */ e2e?: boolean; /** `true` only with `--record` (a current 3pm always says it): `false` leaves the session unrecorded even when the bucket records shells; absent (an older 3pm) is `true`. Only ever lowers `record_shells`. */ record?: boolean; } /** `nodeForward`'s body: where the node connects (`3pm fleet ssh -L`), or the connection one of the caller's listens accepted (`-R`). */ export interface NodeForwardBody { /** 1 to 255 bytes, no space nor control character: a name the node resolves, or an address. Absent with `accepted`. */ host?: string; /** 1 to 65535. Absent with `accepted`. */ port?: number; /** `-R`: take over connection `conn` of the listen call `listen` (a live `nodeListen` of this caller on this node) instead of dialing; requires `e2e`. */ accepted?: ForwardAccepted; /** As `NodeShellBody.direct`; a forward is never recorded. */ direct?: boolean; /** As `NodeShellBody.e2e`. */ e2e?: boolean; } /** A connection a listen accepted, as its `accepted` message numbered it. */ export interface ForwardAccepted { /** The listen's call id. */ listen: string; conn: number; } /** `nodeListen`'s body: where the node listens (`3pm fleet ssh -R`). */ export interface NodeListenBody { /** The node's address to bind: empty or absent is its loopback; any other is refused by the node unless its own `gateway_ports` allows it. 255 bytes at most, no space. */ bind?: string; /** 0 or absent: a port the node picks (its `bound` message says which). */ port?: number; /** As `NodeShellBody.direct`; a listen is never recorded. */ direct?: boolean; /** Required: a listen is always sealed end to end (422 otherwise). */ e2e: boolean; } /** The app `nodeShell` attaches to, and how: `3pm attach`'s flags. */ export interface NodeShellAttach { /** The app's name: 1 to 256 bytes, no control character. */ app: string; /** Which instance of the app (0 when absent). */ instance?: number; /** `rendered` (the daemon's screen, the default) or `raw` (the app's bytes). */ mode?: 'rendered' | 'raw'; /** Neither input nor resize. */ read_only?: boolean; /** One `text` message of the screen, then `detached`. */ peek?: boolean; } /** The input of a call, as `nodeAction` takes it. */ export interface CallInputBody { /** How many bytes will be `PUT`. Required: an input with no size is a stream. */ bytes: number; /** Hex SHA-256 of those bytes, checked by the target (the relay never hashes). */ sha256?: string; /** The extension the target's daemon names the file with. */ ext?: string; } /** `nodeCall`'s body: one of the link's generic `AgentRequest`s (an action goes through `nodeAction`). */ export type NodeCallBody = | { op: "ping" } | { op: "restart"; target: NodeTarget } | { op: "stop"; target: NodeTarget }; /** The `AgentRequest` a call sent: a generic command, or the action `nodeAction` built. */ export type CallRequest = | NodeCallBody | { op: "action"; target: NodeTarget; instance?: number; source?: string; action: string; params?: unknown; timeout_ms?: number; /** The input blob and what it holds, as the target's agent reads it. */ input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean }; /** The output blob the target writes its answer to. */ output?: string; /** A session: `input` and `output` are its two `live` legs. */ stream?: boolean; } | { /** `nodeShell`: `input` is the keyboard leg, `output` the screen leg (both `pipe`). */ op: "shell"; term: string; cols: number; rows: number; input: string; output: string; /** Who placed it (`placed_by`), and that node's hostname. */ from: string; from_hostname?: string; /** The legs are sealed end to end between the caller and the node: the backend relays bytes it cannot read. */ e2e?: boolean; /** The node records the session's screen itself (an `e2e` session; `record_shells` on). */ record?: boolean; } | { /** `nodeForward`: `host:port`, or (`accepted`) a connection a listen accepted, `host` empty and `port` 0. */ op: "forward"; host: string; port: number; input: string; output: string; from: string; from_hostname?: string; e2e?: boolean; accepted?: ForwardAccepted; } | { /** `nodeListen`: `bind` empty is the node's loopback, `port` 0 one it picks. */ op: "listen"; bind: string; port: number; input: string; output: string; from: string; from_hostname?: string; e2e?: boolean; }; export type NodeTarget = { id: number } | { name: string }; export type CallState = "sent" | "acked" | "replied" | "unacked" | "refused" | "lost" | (string & {}); /** One remote command, from `sent` to its end. */ export interface CallView { call_id: string; node_id: NodeId; /** The `AgentRequest` sent. */ request: CallRequest; state: CallState; /** Who placed it: `user:`, or `node:<3pmn_…>/` (`-` when the application was not named). */ placed_by: string; /** * Once `replied`: for an action, `{ ok: true, res: "triggered", results: ActionOutcomeView[] }`; * for the rest, the agent's `AgentResponse` as is (`{ res: "pong", … }`, `{ res: "ok", affected }`, `{ res: "err", code, message }`). * `ok` says the action ran (`res === "triggered"`), never whether every instance succeeded — each outcome carries its own `ok`; * for a shell (`res: "exited"`, `code?`, `signal?`, `recording?` — the artifact the node uploaded its recording as, for an `e2e` session), that it exited 0. * A refusal (`res: "err"`) keeps the agent's own `code`, finer than `err.code`: `e2e_required` (the node serves no session in clear; `forbidden` in `err`), `key_not_authorized` (the caller's `cli.key` is not in the node's own `authorized_keys`, the message names the fingerprint; `forbidden` in `err`) and `e2e_broken` (the encrypted channel failed; `failed` in `err`). * `err` is set when the agent refused the call as a whole — `busy` (it already runs its most commands: retry), `timeout`, `forbidden`, `failed` — the same error the placing node's `call_result` carries. */ result?: | { ok: boolean; res: "triggered"; results: ActionOutcomeView[] } | { ok: boolean; res: string; err?: { code: ActionErrCode; message: string }; [k: string]: unknown }; sent_at: EpochMs; deadline_at?: EpochMs; acked_at?: EpochMs; replied_at?: EpochMs; /** * The blob to `PUT` the call's bytes to, when it carries any — the same thing `request.input` holds. * `bytes` is absent on a stream (`live: true`): a session has no length to declare. */ input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean }; /** * The blob the target writes its answer to, when one was asked for; `GET` it once the call is replied. * `bytes`, `sha256` and `ext` are absent until an outcome has claimed it: check the bytes you pull against `sha256`. */ output?: { blob: string; bytes?: number; sha256?: string; ext?: string }; /** * A stream's counters while it is relayed, and frozen at their last value once it has settled; * absent for a call that carries no stream, and gone once the relay has forgotten its blobs. */ live?: LiveView; /** The files the call's actions left, once replied. */ artifacts: ArtifactView[]; /** The other end, when the two nodes are LAN neighbours: in the answer that placed the call only, for the caller's agent to move the bytes itself. */ peer?: Peer; /** Which way the call's blob went, once its writer said: `direct` (agent to agent) or `relay`; absent, the relay. */ path?: "direct" | "relay" | (string & {}); } /** What a `live` session has relayed, counted in memory — never stored. */ export interface LiveView { /** Bytes the caller has sent into the session (the input blob). */ bytes_in: number; /** Bytes the target has sent back (the output blob). */ bytes_out: number; /** Whole messages the window threw away with a reader still behind them, both ways together. */ dropped_out: number; /** When the session's blobs were opened. */ since: EpochMs; /** The answer leg goes agent to agent between LAN neighbours (both nodes `direct: "all"`): there is no preview to watch. A leg gone direct counts what its writer reports every 5 s. */ direct?: boolean; } /** `viewBlob`'s body: how the tag will read the blob. */ export interface ViewBlobBody { /** `"mjpeg"` for an `` over a stream of JPEG messages; absent for the raw bytes. */ as?: "mjpeg"; } /** The URL a browser tag can carry, and when it stops working. */ export interface ViewBlobUrl { /** A `getBlob` URL on this origin, with `?k=` (and `&as=` when one was asked for). */ url: string; /** Epoch ms; sixty seconds after it was minted, and never renewed. */ expires_at: EpochMs; } /** What a `getBlob` may ask for besides the bytes. */ export interface GetBlobQuery { /** `"1"` from an agent relaying a stream: it says what the caller meant, never what the blob is. */ live?: string; /** `"mjpeg"`: the stream as `multipart/x-mixed-replace`, one `image/jpeg` part per message. */ as?: "mjpeg"; } export type ActionErrCode = "failed" | "timeout" | "busy" | "gone" | "refused" | "too_large" | (string & {}); /** What one instance answered to an action. */ export interface ActionOutcomeView { proc_id: number; name: string; instance: number; source: string; action: string; duration_ms: number; ok: boolean; err?: { code: ActionErrCode; message: string }; /** The handler's JSON (≤ 256 KiB); bigger, `data_artifact_id` names an artifact holding it. */ data?: unknown; data_artifact_id?: string; /** The file the handler left, as an artifact. */ artifact_id?: string; /** The output the handler pushed to the call's blob: where it is, how long, and its digest (the target's claim, never checked here: check it when you pull the blob). */ output?: { blob: string; bytes: number; ext: string; sha256: string }; } /** A file an action left: what `listArtifacts` lists and a call carries. */ export interface ArtifactView { artifact_id: string; node_id: NodeId; call_id: string; app: string; instance: number; source: string; action: string; /** `cpuprofile`, `heapprofile`, `json`, …: what the file is; `artifactFile` serves gzip of it. */ ext: string; bytes: number; /** The size of the stored file; equal to `bytes` when `encoding` is `identity`. */ gz_bytes: number; sha256: string; /** `gzip` (what the agent sent) or `identity` (a call's output kept raw, by a backend that still copied them). */ encoding: "gzip" | "identity" | (string & {}); /** Who placed the call it came out of; empty once that call's row is past retention. */ placed_by: string; /** A profile's hot frames, when it parsed. */ summary?: ProfileSummary; created_at: EpochMs; } /** * What the feed (`events`, `follow`) leaves out; each field absent = the * feed whole on that account. The generated clients send `history: 0` * unless told otherwise. */ export interface FeedQuery { /** `0` (the clients' default): `init.history` empty. `1`: sent. */ history?: 0 | 1 | "0" | "1" | boolean; /** * Only the nodes whose hostname contains this text: a plain substring, * case-sensitive — `"envision"` already reads as `*envision*`. No * wildcard and no regex: `*` and `?` are matched literally, so * `"envision-*"` keeps no node (an empty feed, not an error). Absent or * `""`: every node. */ match?: string; /** `"live"`: each telemetry frame cut to the host's gauges and a short row per process. */ view?: "live" | "full"; } /** `listArtifacts`'s URL parameters; absent = no filter. */ export interface ArtifactsQuery { /** One node's artifacts. */ node?: NodeId; /** One app's artifacts, by name. */ app?: string; } /** * A call on the feed (`call` events): its state and who answered how, * never what was asked or answered — the feed needs no token, so the * params, the handlers' data and the error messages stay behind * `getCall`. */ export interface CallEvent { call_id: string; node_id: NodeId; /** The request's `op`: `action`, `ping`, `restart`, `stop`. */ op: string; /** The action's name, for an `action`. */ action?: string; /** The target: an app name, or a process id as text. */ app?: string; instance?: number; state: CallState; /** Who placed it (`user:`, `node:<3pmn_…>/`): what a node's feed is filtered on. */ placed_by: string; /** The size the caller declared for the call's input, when it carries one; absent for a stream. */ input_bytes?: number; /** For a stream: what it has relayed so far, counted in memory. */ live?: LiveView; sent_at: EpochMs; deadline_at?: EpochMs; acked_at?: EpochMs; replied_at?: EpochMs; /** Once replied: the answer's tag (`triggered`, `pong`, `ok`, `err`, `unknown`). */ res?: string; /** For `res: "err"`: its code (`forbidden`, `not_found`, `unknown`…). */ err_code?: string; /** For an action: one line per instance reached. */ results?: CallOutcomeEvent[]; /** Which way the call's blob went (`direct`, `relay`), once its writer said. */ path?: string; } /** One instance's answer on the feed: no data, no message. */ export interface CallOutcomeEvent { proc_id: number; name: string; instance: number; source: string; action: string; duration_ms: number; ok: boolean; err_code?: ActionErrCode; artifact_id?: string; data_artifact_id?: string; } /** A file an action left, on the feed (`artifact` events): no summary — `listArtifacts` has it. */ export interface ArtifactEvent { artifact_id: string; node_id: NodeId; call_id: string; app: string; instance: number; source: string; action: string; ext: string; bytes: number; gz_bytes: number; /** `gzip` or `identity`, as `artifactFile` serves it. */ encoding: "gzip" | "identity" | (string & {}); /** Who placed the call it came out of: what a node's feed is filtered on. */ placed_by: string; created_at: EpochMs; } /** The hot frames of a sampling profile (`threepm_core::profile_summary`). */ export interface ProfileSummary { kind: "cpu" | "heap"; /** Microseconds (cpu) or bytes (heap) over every frame. */ total: number; samples: number; duration_ms?: number; top: { function: string; location: string; self_value: number; percent: number }[]; } export type AppMetricKind = "gauge" | "counter" | "histogram" | "unknown"; export interface AppSeriesView { /** The daemon's series id, unique per incarnation. */ sid: number; /** The app's name for it (`http.latency`). */ name: string; kind: AppMetricKind; /** UCUM (`ms`, `By`, `1`). */ unit?: string; description?: string; /** The `source` that owns it. */ source?: string; labels?: Record; /** A gauge's or counter's value. */ value?: number; /** A histogram's. */ hist?: AppHistView; } /** An exponential histogram: bucket `i` covers `(base^i, base^(i+1)]` with `base = 2^(2^-scale)`. */ export interface AppHistView { scale: number; count: number; sum: number; /** Observations equal to zero. */ zero: number; /** `[index, count]`, index ascending, non-empty buckets only. */ buckets: [number, number][]; } /** `metricLabelValues(clusterId, label)`. */ export interface LabelValues { label: QueryLabel; values: string[]; } /** What an answer costs to put in a prompt: the first field of every answer that carries it (`metricsInventory`, `GET /api/v1/routes`). */ export interface TokenCount { /** Claude's tokenizer, estimated from the bytes with a ratio measured per shape through the `count_tokens` API — within a few percent. */ claude: number; /** OpenAI's `o200k_base`, exact. Claude counts 1.5–1.7× more on this backend's answers. */ o200k: number; } /** `metricsInventory(clusterId)`: what a dashboard or an LLM starts from. The route answers TOON-style text by default; this client asks for this JSON (`Accept: application/json`), as does `?format=json`. `?detail=index` leaves the descriptions out. */ export interface MetricsInventory { /** What this whole answer costs an LLM; first, so a reader knows before the rest. */ tokens: TokenCount; /** The catalog lines with a series stored over the last two UTC days, grouped by the labels they carry; groups and lines in catalog order. */ metric_groups: MetricGroup[]; /** What every other label of those lines holds over the same window — not `node`, `app`, `instance`, `ns` (`hosts` has them) nor `pid` —, in the order the lines name them; a label that holds nothing is left out. */ label_values: InventoryLabel[]; /** Every enrolled node of the bucket, whatever its state, with its processes; a node only the store knows comes last, `state: "unknown"`. Every series carries `node` = `node_id`: a query names a host by it, never by hostname, and this list names a legend's `node`. */ hosts: InventoryHost[]; } export interface InventoryLabel { label: QueryLabel; /** Sorted, at most 50: values a filter takes as written. */ values: string[]; /** How many more there were past the 50; absent when none. */ more?: number; } export interface MetricGroup { /** What `filters` and `aggregate.by` may name for every metric of the group. */ labels: QueryLabel[]; metrics: InventoryMetric[]; } /** A catalog line, minus what is empty or derivable: it is an application's exactly when `id` starts with `app.`. */ export interface InventoryMetric { id: string; kind: MetricKind; /** Absent when the metric has none. */ unit?: string; /** Absent when it is the kind's: `avg_over_time` for a gauge, `rate` for a counter, `quantile` for a histogram. */ default_time?: TimeFnName; /** Absent when the application gave none, and with `?detail=index`. */ description?: string; /** An application line only: the apps whose series carry it, sorted — absent when none did. With several, put `app` in `aggregate.by` or a filter. */ apps?: string[]; } export interface InventoryHost { node_id: NodeId; hostname: string; os: string; arch: string; /** As `NodeView.state`; `"unknown"` for a node only the store knows. */ state: DisplayState | "unknown"; /** The instances the store holds a series for, sorted: label values, ready for `filters` next to `node` = `node_id`. */ processes: InventoryProcess[]; } export interface InventoryProcess { app: string; instance: string; ns?: string; } /** `metricsByServer(clusterId)`: which metric is stored on which server, for which app. The route answers TOON-style text by default; this client asks for this JSON (`Accept: application/json`), as does `?format=json`. `?detail=index` leaves the descriptions out. */ export interface MetricsByServer { /** What this whole answer costs an LLM; first, so a reader knows before the rest. */ tokens: TokenCount; /** The catalog lines with a series on some server over the last 24 hours, grouped by the labels they carry as `metricsInventory` gives them (without `apps`): what each id of `servers` is. */ metric_groups: MetricGroup[]; /** Keyed by node_id — the `node` label every series carries —: the enrolled nodes in the bucket's order, then a node only the store knows, `state: "unknown"`. */ servers: Record; } /** One server of `MetricsByServer`: ids only, in catalog order. Present means at least one series over the last 24 hours. */ export interface ServerMetrics { hostname: string; os: string; arch: string; /** As `NodeView.state`; `"unknown"` for a node only the store knows. */ state: DisplayState | "unknown"; /** The host's own lines (series without `app`); absent when none. */ metrics?: string[]; /** Keyed by app name, sorted: the lines whose series carry `app` = that name on this server — `process.*`, `subprocess.*`, the app's own `app.*`; absent when none. */ apps?: Record; } /** `metricsByApp(clusterId)`: which metric each app has on which server — `MetricsByServer` turned around, a host's own lines left out. The route answers TOON-style text by default; this client asks for this JSON (`Accept: application/json`), as does `?format=json`. `?detail=index` leaves the descriptions out. */ export interface MetricsByApp { /** What this whole answer costs an LLM; first, so a reader knows before the rest. */ tokens: TokenCount; /** The catalog lines an app's series carry on some server over the last 24 hours, grouped by the labels they carry as `metricsInventory` gives them (without `apps`): what each id of `apps` is. */ metric_groups: MetricGroup[]; /** Every enrolled node in the bucket's order, then a node only the store knows (`state: "unknown"`): what turns a node_id of `apps` into a hostname. */ hosts: ServerHost[]; /** Keyed by app name, sorted; each keyed by node_id in the order of `hosts`: the ids whose series carry `app` = that name on that server, in catalog order. Present means at least one series over the last 24 hours. */ apps: Record>; } export interface ServerHost { node_id: NodeId; hostname: string; os: string; arch: string; /** As `NodeView.state`; `"unknown"` for a node only the store knows. */ state: DisplayState | "unknown"; } // ── SDKs (`sdk`) ───────── /** The URL parameters of `sdk(query)`; an unknown one is a 400. */ export interface SdkQuery { /** `json` or `llm`; the client asks for JSON. */ format?: "json" | "llm"; } /** `sdk()`: how to put an application on the fleet. The route answers TOON-style text by default; this client asks for this JSON (`Accept: application/json`), as does `?format=json`. */ export interface SdkGuide { /** What this whole answer costs an LLM; first. */ tokens: TokenCount; about: string; /** The download site the SDKs are published on. */ site: string; /** `sdk.json` on that site: every SDK version, file and sha256. */ catalog: string; /** `llms.txt` on that site: the SDK page of each language, in full. */ docs: string; /** Install 3pm, link the host to a bucket, start the app under 3pm. */ steps: string[]; sdks: SdkEntry[]; /** The routes that give back what an app sent, by route name. */ read: SdkReadRoute[]; /** What no type says: series naming, what is stored, what is dropped. */ rules: string[]; } export interface SdkEntry { id: "node" | "python" | "rust" | "cpp"; /** The package name in its language's tooling. */ package: string; /** The version released with this backend. */ version: string; /** One line (C++: the CMake calls), from the download site. */ install: string; supports: SdkSupports; /** The same small app in every language: metrics, an event, an action where the SDK has them. */ example: string; } export interface SdkSupports { metrics: boolean; events: boolean; /** `io.action`: a function `nodeAction` can run. */ actions: boolean; /** `io.call`: one call to another node's action, its answer awaited. */ call: boolean; /** `io.open` / `io.stream`: a session of framed messages. */ streams: boolean; } export interface SdkReadRoute { /** A route name of this API. */ route: string; what: string; } // ── the whole documentation (`docs`) ───────── /** The URL parameters of `docs(query)`; an unknown one is a 400. */ export interface DocsQuery { /** `json` or `llm`; the client asks for JSON. */ format?: "json" | "llm"; } /** `docs()`: the whole documentation, by section. The route answers one text by default — the five sections under `# 1.` … `# 5.` headings, after a contents line that prices each — to paste into an AI; this client asks for this JSON (`Accept: application/json`), as does `?format=json`. */ export interface DocsBundle { /** What the whole answer costs an LLM; first. */ tokens: TokenCount; about: string; /** `api` (index and recipes), `routes` (every route in full), `types` (each once), `client` (the JavaScript client and its methods), `sdk` (the SDKs), in that order. */ sections: DocsSection[]; } export interface DocsSection { id: "api" | "routes" | "types" | "client" | "sdk"; title: string; /** What this section alone costs an LLM. */ tokens: TokenCount; /** Markdown with TOON-style lists and TypeScript blocks. */ text: string; } // ── insights (`insights`, `insight`) ───────── /** The URL parameters of `insights(clusterId, query)`; an unknown one is a 400. */ export interface InsightsQuery { /** `problem`, `context`, `inventory`, comma-separated; all three when absent. */ tier?: string; /** One node's incidents, and the fleet-wide ones it takes part in. */ node?: NodeId; /** Only the incidents at this score or above, as served (one decimal). */ min_score?: number; /** 1 to 200; 20 when absent. */ limit?: number; /** Also the incidents labelled `known` (hidden otherwise). */ known?: boolean; /** `json` or `llm`; the client asks for JSON. */ format?: "json" | "llm"; } /** The pass of the insights apps an answer comes from. */ export interface InsightsPass { /** Epoch ms of the last ranking pass (one every five minutes). */ pass_at_ms?: number; next_pass_ms?: number; /** The last pass is older than two intervals: the ranking stopped moving. */ stale: boolean; /** Why the last pass failed, when it did: the incidents are then the pass before. */ error?: string; /** Each detector read at that pass: `ok`, `fallback` (read from its state file), `stale`, `pending` (no pass yet), `error`, `absent`. */ sources: Record; /** Per detector, the rules it evaluated only in part at that pass (a failed or truncated store query); only counts above 0, absent when every detector was whole. */ partial?: Record; /** The language of every `summary`: `fr`. */ lang: string; } /** `insights(clusterId)`: the bucket's incidents, ranked. The route answers TOON-style text by default; this client asks for this JSON (`Accept: application/json`), as does `?format=json`. */ export interface InsightsList extends InsightsPass { /** What this whole answer costs an LLM; first. */ tokens: TokenCount; /** Incidents that pass the filters, before `limit`. */ total: number; /** Best first. */ incidents: Incident[]; } /** `insight(clusterId, incident)`: one incident and the findings it groups. */ export interface InsightDetail extends InsightsPass { tokens: TokenCount; incident: Incident; /** The findings the incident groups, best first, at most 30. */ members: IncidentMember[]; /** How many there are in all. */ n_members: number; /** The 3pm process events of the last 6 hours on its node, per app: counts by event (`exit≠0`, `errored`, `restart`…) and `last_ms`, the latest one; absent when none. */ events?: Record>; /** Its place among the incidents of the same machine that started together; absent when none. */ story?: IncidentStory; } export interface IncidentStory { /** The metric family of the incident heading the story (not an incident id). */ head_family: string; /** On the head: every family of the story, its own first. */ families?: string[]; /** On the others: their place in it (2 = the second). */ rank?: number; } /** What several detectors found on one machine and metric family, or on the fleet. Absent fields are empty. */ export interface Incident { /** Stable while the incident lasts: `:[:]` for a machine, `flotte@:` or `@:[:départ]` fleet-wide. URL-encode it in `insight`'s path. */ id: string; /** 0 to 10: 1 notable, 3 worth a look. */ score: number; /** `problem`: something bad in itself opens it. `context`: only unusual, score damped. `inventory`: a standing fact. */ tier: "problem" | "context" | "inventory"; node_id?: NodeId; hostname?: string; /** Fleet-wide: no `node_id`; its machines in `hosts`. */ fleet?: true; hosts?: NodeId[]; /** `host.swap`, `process.events`, `app.automonit`… */ family?: string; /** The catalog id of the headline series. */ metric?: string; /** The store name, only when no catalog line has it (a node's state). */ metric_name?: string; labels?: Record; /** The detectors that agree: `rules`, `exhaustion`, `drift`, `peers`, `changepoints`, `profiles`, `events`. */ sources: string[]; /** What was found, in French, with the figures. */ summary: string; /** Epoch ms: first listed. */ since_ms?: number; /** Epoch ms: when the series moved, as the detectors date it. */ onset_ms?: number; /** Ranking passes it has been listed in. */ passes: number; /** A person's verdict, when one was given. */ label?: { verdict: "true" | "false" | "known" | "unknown"; note?: string }; /** Labelled `known`: listed only with `known=true`. */ hidden?: true; /** A `queryMetrics` body that draws the headline series over the incident's span. Absent when `metric` is. */ chart?: MetricsQuery; } /** One finding of an incident. */ export interface IncidentMember { app: string; node_id?: NodeId; hostname?: string; metric?: string; metric_name?: string; labels?: Record; score?: number; /** Its weight in the incident's score. */ weight?: number; summary: string; } // ── process trees (`nodeProcessTrees`, `nodeProcessTree`) ───────── /** `GET /api/buckets/{id}/nodes/{node}/trees`: the process tree of every instance of the node, as the hub last received it. */ export interface ProcessTreesView { node_id: NodeId; /** One per instance the hub holds a tree for; an instance running alone has none. */ trees: ProcessTreeView[]; /** Trees refused since the backend first held the node (the count survives its reconnects): an id the process table does not list, or over the caps (100 trees a node, 256 lines a tree, 64 bytes a name). */ refused: number; } /** `GET /api/buckets/{id}/nodes/{node}/procs/{proc}/tree`: one instance's tree — every process, and the folded picture `3pm ls --tree` draws. 404 while the backend holds none: an instance running alone, before its first tree, or of a past incarnation. */ export interface ProcessTreeView { node_id: NodeId; /** The daemon's process id. */ id: number; /** Incarnation: a restart starts a new one. */ gen: number; /** The tree's revision: moves when the membership changes, never on cpu or memory. */ rev: number; name: string; instance: number; ns?: string; /** The agent's clock on the frame, epoch ms. */ ts: number; /** The totals of the same frame as the lines, omitted members included: percent of one core, RSS summed (an upper bound when members share pages). */ cpu_percent: number; mem_bytes: number; /** Members the daemon cut by its caps (256 lines a tree); their figures are in the totals. */ omitted: number; /** Every line: `procs[0]` the spawned pid, a parent before its children, siblings by pid. */ procs: TreeProc[]; /** The folded picture: chains of single children on one row, twins grouped. */ compact: CompactRow[]; } /** One live process of an instance's tree. */ export interface TreeProc { pid: number; /** As the kernel reports it — not necessarily a member. */ ppid: number; /** The executable's name as the OS shows it (at most 64 bytes, text the app controls), never the arguments. */ name: string; /** Percent of one core; 0 on a member's first tick. */ cpu_percent: number; mem_bytes: number; /** Process start, epoch ms (second resolution). */ started_at_epoch_ms: number; /** Left the app's process group (`setsid`): `3pm stop` will not reach it. Absent when false. */ detached?: boolean; /** Always absent on this API: ports do not travel on the link (the record's `listen_ports` is their union). */ listen_ports?: number[]; /** The process's network traffic; absent where the node has no source. */ net?: NetUsage; } /** Network traffic of a process, long keys. */ export interface NetUsage { rx_bytes_per_s: number; tx_bytes_per_s: number; /** Since the daemon started measuring this process. */ rx_bytes: number; tx_bytes: number; /** A lower bound: the window lost some notifications. Absent when false. */ partial?: boolean; } /** One display row of the folded tree, in drawing order. */ export interface CompactRow { /** 0 for the spawned pid's row, +1 per level below it. */ depth: number; /** Last among its siblings. */ last: boolean; /** A process name, or a folded chain (`npm › bash › node`). */ label: string; /** The process (the last link of a chain); absent on a `×N` group. */ pid?: number; /** Twins folded into this row; 1 for a single process or chain. */ count: number; /** Processes this row stands for. Sums to `procs.length` over the rows. */ procs: number; cpu_percent: number; mem_bytes: number; /** The last link's start (a chain), the oldest twin's (a group). */ started_at_epoch_ms: number; /** The row is one detached process. */ detached: boolean; /** Always empty on this API: ports do not travel on the link. */ listen_ports: number[]; } // ── lifecycle events ─────────────────────────────────────────────── export type ProcEventKind = | "start" | "online" | "exit" | "restart" | "stop" | "delete" | "errored" | "unknown"; export type ShutdownReason = "kill" | "signal" | "home_removed" | "unknown"; export type AgentEvent = | { ev: "process"; id: number; name: string; kind: ProcEventKind; exit_code?: number; exit_signal?: string; } | { ev: "daemon_shutdown"; reason: ShutdownReason } | { ev: "unknown" }; /** One `emit` event: which node, and what happened on it. */ export interface EmitData { node_id: NodeId; ev: AgentEvent; } // ── the feed ─────────────────────────────────────────────────────── export type EventKind = "init" | "node" | "telemetry" | "emit" | "call" | "artifact" | "reset"; export const EVENT_KINDS: readonly EventKind[]; /** One item of `Client.follow()`: the event's kind and its payload. */ export type FeedItem = | ["init", BucketDetail] | ["node", NodeView] | ["telemetry", Telemetry] | ["emit", EmitData] | ["call", CallEvent] | ["artifact", ArtifactEvent] | ["reset", null]; export interface IterateOptions { /** Aborting it closes the feed and ends the iteration. */ signal?: AbortSignal; /** Transient errors the reconnecting subscription swallows. */ onError?: (error: Error, sub: Subscription) => void; /** What the feed leaves out, on the URL (`history: 0` unless set). */ query?: FeedQuery; } export interface FeedHandlers { /** The bucket as a whole; sent first, and again after a `reset`. */ init?: (detail: BucketDetail, sub: Subscription) => void; /** One node changed: approved, revoked, presence, traffic. */ node?: (node: NodeView, sub: Subscription) => void; telemetry?: (frame: Telemetry, sub: Subscription) => void; emit?: (event: EmitData, sub: Subscription) => void; /** A remote command changed state (placed, acknowledged, replied, settled); `getCall` has the rest. */ call?: (call: CallEvent, sub: Subscription) => void; /** An action left a file; `listArtifacts` has its summary. */ artifact?: (artifact: ArtifactEvent, sub: Subscription) => void; /** * What the subscriber holds is no longer the bucket: the server's * log no longer reaches back far enough, or a node was deleted * (`deleteNode`) and must not linger on a page. The * subscription drops its position and reconnects from a fresh * `init` on its own — a handler only has to note it. */ reset?: (sub: Subscription) => void; open?: (sub: Subscription) => void; error?: (error: Error, sub: Subscription) => void; /** Every event, after its own handler. */ event?: (kind: EventKind, payload: unknown, sub: Subscription) => void; } /** A live feed, reconnecting on its own until `close()`. */ export class Subscription { constructor(client: Client, url: string, handlers?: FeedHandlers); readonly url: string; /** The id of the last event received; `null` after a `reset`. */ readonly lastEventId: string | null; readonly connected: boolean; close(): void; } // ── the agent's protocol ─────────────────────────────────────────── /** * What `enroll` and `link` speak: the agent's wire protocol (`3pm * link`), not a feed a page can read. `enroll()` and `link()` throw an * `AgentOnlyError` pointing at `events()`; `wsUrl()` is for an agent. */ export interface RealtimeAgent { /** The `agent-ws` routes, by name — `enroll` and `link`. */ routes: EndpointName[]; transport: "websocket"; /** The WebSocket sub-protocol both sides negotiate — `3pm.link`. */ subprotocol: string; /** The link versions this backend accepts. */ min: number; max: number; /** Where the protocol is written down: a path from the root of the `3pm-rd` workspace, not a URL. */ specPath: string; } /** The one feed a page reads: `Client.events()` / the `iter` method. */ export interface RealtimeFeed { /** The `sse` route, by name — `events`. */ route: EndpointName; transport: "sse"; eventKinds: EventKind[]; /** The client's async-iterator method for it — `follow`. */ iter: string; } /** * The real-time surface by role, pointing into `ENDPOINTS` / `routes` * by name: how live data is had, without filtering the table on `kind`. */ export interface Realtime { feed: RealtimeFeed; agent: RealtimeAgent; } /** Generated from the route table and `threepm-link`; the same block `GET /api/v1/routes` carries. */ export const REALTIME: Realtime; /** `REALTIME.agent`: the agent's wire protocol. */ export const LINK: RealtimeAgent; /** * The backend version this client was generated from. `discover()` * compares it with the answering backend's `version` and reports a * difference through `ClientOptions.onVersionMismatch`. */ export const VERSION: string; // ── errors and the client ────────────────────────────────────────── export class ApiError extends Error { constructor(status: number, code: string, message: string); readonly status: number; /** `ERR_NOT_FOUND`, `ERR_UNAUTHORIZED`, `ERR_ADVERTISE_LOCAL`, … */ readonly code: string; readonly detail: string; readonly unauthorized: boolean; static of(res: Response): Promise; } export interface ClientOptions { /** Where the backend is; `""` for the page's own origin. */ baseUrl?: string; /** * An account's token (`3pmu_…`), sent as `Authorization: Bearer`; * without one, the page's session cookie (same origin). */ token?: string | null; /** Called on a 401; return a token to retry the call once. */ onUnauthorized?: (client: Client) => string | null | Promise; /** * Called by `discover()` when the backend's version is not `VERSION`. * A `console.warn` by default; `null` to keep quiet. The client * follows the backend's table either way. */ onVersionMismatch?: ((backend: string, client: string, api: Client) => void) | null; /** Extra headers on every request. */ headers?: Record; fetch?: typeof fetch; EventSource?: typeof EventSource; } export interface ConnectOptions extends ClientOptions { /** * The `localStorage` key `connect()` remembers the token under, as * `tokenKey(namespace)`. The page's own `location.pathname` by * default, so two pages on one origin do not share a token. */ namespace?: string; } /** What every call takes last. */ export interface CallOptions { /** * Abandons the request: the promise rejects with the `AbortError` * `fetch` raises (not an `ApiError`). */ signal?: AbortSignal; } export interface RequestOptions extends CallOptions { /** `false` skips the `onUnauthorized` retry on a 401. */ retry?: boolean; } /** One entry of the generated endpoint map. */ export interface EndpointMeta { method: "GET" | "POST" | "DELETE"; /** The path with its arguments named, e.g. `/api/buckets/{clusterId}/nodes`. */ path: string; args: readonly string[]; kind: "json" | "sse" | "agent-ws" | "static"; /** Behind the cross-site guard: another site's page is refused, unless it carries a bearer token on a `user` route. */ guarded: boolean; /** `user`: needs a login (and, under a bucket, a bucket the account owns); `public`: anyone. */ auth: "public" | "user"; /** * Also open to a node's own bearer (`3pma_…`), when the node's row * carries this scope (`setNodeAccess`); `null` for a route only an * account may call. A node is a second kind of credential the account * layer admits, not a third kind of route. */ node: NodeScope | null; body: string | null; returns: string; /** For an `sse` route, the name of its async-iterator twin (`follow` for `events`). */ iter: string | null; doc: string; } export type EndpointName = | "info" | "routes" | "client" | "sdk" | "docs" | "register" | "login" | "logout" | "me" | "createUserToken" | "revokeUserToken" | "listBuckets" | "createBucket" | "getBucket" | "setBucketSettings" | "bucketToken" | "events" | "listNodes" | "approveNode" | "revokeNode" | "deleteNode" | "setNodeAccess" | "listTokens" | "createToken" | "revokeToken" | "queryMetrics" | "metricsCatalog" | "metricLabelValues" | "metricsInventory" | "metricsByServer" | "metricsByApp" | "eventsQuery" | "logsQuery" | "insights" | "insight" | "nodeAppMetrics" | "nodeActions" | "nodeHost" | "nodeProcessTrees" | "nodeProcessTree" | "nodeAction" | "nodeShell" | "nodeForward" | "nodeListen" | "nodeCall" | "putCallArtifact" | "getCall" | "listArtifacts" | "artifactFile" | "putBlob" | "getBlob" | "viewBlob" | "enroll" | "link" | "pushLogs" | "indexPage" | "bucketPage" | "bucketSettingsPage" | "asset"; /** Every route the backend serves, generated from its own route table. */ export const ENDPOINTS: Readonly>; /** One route of `GET /api/v1/routes`: an `EndpointMeta` that names itself. */ export interface RouteDescriptor extends EndpointMeta { name: string; /** * The long form, where the one-line `doc` is not enough: a route * whose body is a small language of its own carries what each field * means, what comes back, and an example — everything needed to build * a request from this document alone. `null` on the plain routes. */ details: string | null; } /** What `queryMetrics` refuses past — `options`, the range and the shape of a spec. */ export interface MetricsQueryLimits { /** 31 days. */ max_range_ms: number; max_points: number; /** Applied when `options.max_points` is absent. */ default_points: number; max_series: number; /** Applied when `options.max_series` is absent. */ default_series: number; max_queries: number; max_formulas: number; /** Per query spec. */ max_filters: number; /** Per `in` / `nin` filter. */ max_values: number; /** Characters, per filter value. */ max_value_len: number; /** The body is refused before it is parsed past this. */ max_body_bytes: number; /** * The one bound not carried here: `range.step` may not go below the * store's resolution, which depends on how the backend was started. * The value of this field is where to read it — `store.interval_ms` * of `GET /api/v1/info`. */ step_floor_from: string; } /** What `eventsQuery` refuses past. */ export interface EventsQueryLimits { /** 31 days. */ max_range_ms: number; max_limit: number; /** Applied when `limit` is absent. */ default_limit: number; /** Characters, per filter value. */ max_value_len: number; max_body_bytes: number; } /** What `logsQuery` refuses past. */ export interface LogsQueryLimits { /** 30 days, the engines' retention. */ max_range_ms: number; max_templates: number; default_templates: number; max_lines: number; default_lines: number; /** Bytes, per filter value and `text`. */ max_value_len: number; max_body_bytes: number; } /** * What a query body may ask for, by route name: the very constants the * backend validates against, so a caller reads its bounds instead of * discovering them one 400 at a time. */ export interface ApiLimits { queryMetrics: MetricsQueryLimits; eventsQuery: EventsQueryLimits; logsQuery: LogsQueryLimits; } /** * What `GET /api/v1/routes` answers this client, which asks for JSON * (without that, the route answers text for an LLM): the table of the * backend that answered. Compare `version` with the build this client was generated * from, or read `routes` to drive a client with no generated methods * at all (`Client.call` / `Client.url` take a name and arguments). */ export interface ApiDescriptor { /** * What this document costs to put in a prompt, before what it says — * over the compact JSON the route sends, this field included (not the * pretty form `assets/api.json` is written in). */ tokens: TokenCount; /** The backend's own version — never `"latest"`. */ version: string; /** How live data is had: the SSE feed and the agent's WebSockets, by route name. */ realtime: Realtime; /** What the query routes refuse past, by route name. */ limits: ApiLimits; /** * Where this client lives and how to use it. The URLs are relative: * they resolve against whatever origin served the descriptor. */ client: { /** The ES module — `/api/v1/client.js`. */ url: string; /** These types — `/assets/api.d.ts`. */ types: string; /** This document — `/api/v1/routes`. */ descriptor: string; /** A runnable snippet: construct, call, subscribe. */ example: string; }; routes: RouteDescriptor[]; } /** `name` with its arguments substituted in. */ export function buildPath(name: EndpointName, ...args: (string | number)[]): string; /** * A ready client, asking the viewer for an account token (`3pmu_…`) * only when it has none stored for this page. Browser only — it draws * a prompt, and throws in a runtime with no `document`. The token is * checked against the backend before it is kept, and a 401 later on * clears it, asks again, and retries the call that met it. * * Rejects with a `TokenPromptCancelled` error if the prompt is * dismissed (Escape, Cancel, a click outside). */ export function connect(options?: ConnectOptions): Promise; /** * Ask for a token and resolve with one the backend accepted, drawing * the card in a shadow root of its own. Rejects with a * `TokenPromptCancelled` error when dismissed. Browser only. */ export function promptForToken(api: Client, options?: { message?: string | null }): Promise; /** The `localStorage` key `connect()` uses: `location.pathname` unless `namespace` says otherwise. */ export function tokenKey(namespace?: string): string; export class Client { constructor(options?: ClientOptions); baseUrl: string; token: string | null; headers: Record; onUnauthorized: ClientOptions["onUnauthorized"] | null; onVersionMismatch: ClientOptions["onVersionMismatch"] | null; /** What `discover()` read, if it was called. */ descriptor: ApiDescriptor | null; /** `discover()` has run and found a backend of another version than `VERSION`. */ readonly versionMismatch: boolean; /** * Read the table of the backend at the other end (`latest` by * default) and use it from then on, so a page built against one * version keeps working against another. A version other than * `VERSION` is reported to `onVersionMismatch`. */ discover(version?: string, options?: CallOptions): Promise; /** What is known about an endpoint: the backend's own after `discover()`, else the generated table. */ meta(name: EndpointName): EndpointMeta | RouteDescriptor | undefined; /** The absolute URL of an endpoint, arguments substituted in. */ url(name: EndpointName, ...args: (string | number)[]): string; /** * Same, as `ws://` / `wss://` — for an agent opening `enroll` or * `link` (sub-protocol `LINK.subprotocol`). A page's live data is `events()`. */ wsUrl(name: EndpointName, ...args: (string | number)[]): string; /** One JSON call by name; `null` on 204. `options.signal` abandons it. */ call(name: EndpointName, args?: (string | number)[], body?: unknown, options?: CallOptions): Promise; /** One request, the token and the 401 retry applied. */ request(method: string, url: string, body?: unknown, options?: RequestOptions): Promise; /** Open a feed by name; the caller closes it. `query` goes on the URL, over `{ history: 0 }`. */ subscribe(name: EndpointName, args?: (string | number)[], handlers?: FeedHandlers, query?: FeedQuery): Subscription; /** * A feed by name as an async iterable of `[kind, payload]`. Leaving * the loop or aborting `options.signal` closes it; transient errors * are not items (the subscription reconnects), a `reset` is * `["reset", null]` followed by a fresh `init`. */ iterate( name: EndpointName, args?: (string | number)[], options?: IterateOptions, ): AsyncGenerator<[EventKind, unknown], void, undefined>; /** Forget the token: off this client, and out of the store `connect()` put it in. */ forgetToken(): void; /** * The agent's upload of the file an action returned, before the reply that names it: raw bytes, `x-3pm-ext` and `x-3pm-instance`, answered `201 {artifact_id, bytes, sha256}` — agent only; a page reads the call's files off `getCall` or `events`. * * `PUT /api/buckets/{clusterId}/calls/{callId}/artifacts` — a node's bearer (`3pma_…`) only. Throws: see `events()`, and `wsUrl()` for an agent. */ putCallArtifact(clusterId: ClusterId, callId: string): never; /** * The agent's enrollment request (`3pm link`): an enrollment token and a node key for a node id — agent only, a page reads `events`. * * `POST /api/v1/enroll`. Throws: see `events()`, and `wsUrl()` for an agent. */ enroll(): never; /** * The agent's session WebSocket (`Authorization: 3pm-node`, sub-protocol `3pm.link`): telemetry up, commands down — a page reads `events`. * * `GET /api/v1/link`. Throws: see `events()`, and `wsUrl()` for an agent. */ link(): never; /** * The agent's log batches (`3pm link`): gzip NDJSON records (`batch`, `src`, `tpl`, `cnt`, `line`, `dropped`), answered `{accepted, skipped, duplicate?, refused?}` (`refused`: new sources/templates past the node's catalog bound) — agent only, a page reads `events`. * * `POST /api/buckets/{clusterId}/logs` — a node's bearer (`3pma_…`) only. Throws: see `events()`, and `wsUrl()` for an agent. */ pushLogs(clusterId: ClusterId): never; /** * Versions, the accepted link range, how the store is doing and what the link got wrong; no credentials needed. * * `GET /api/v1/info`. */ info(options?: CallOptions): Promise; /** * Every route this backend serves: an index for an LLM by default, with `metricsInventory` and `queryMetrics` given whole; one route in full with `?route=` (details, bounds, body and answer types), the JSON table with `?format=json` or `Accept: application/json` (what this client sends); `?version=` defaults to `latest`. * * `GET /api/v1/routes`. */ routes(options?: CallOptions): Promise; /** * How to put an application on the fleet: the SDK of each language (Node, Python, Rust, C++) with its version, install line, what it supports and a short example, the steps that link the host, the routes that read back what the app sent and the rules no type says — text for an LLM by default, JSON with `?format=json` or `Accept: application/json`; no credentials needed. * * `GET /api/v1/sdk`. */ sdk(query?: SdkQuery, options?: CallOptions): Promise; /** * The whole documentation in one text, to paste into an AI: the route index and its recipes, every route in full, every type once, the JavaScript client and its methods, the SDKs — each section priced in its contents line; text by default, JSON (its sections) with `?format=json` or `Accept: application/json`; no credentials needed. * * `GET /api/v1/docs`. */ docs(query?: DocsQuery, options?: CallOptions): Promise; /** * Create an account: its token comes back once (there is no other way in, keep it) with a session cookie. Only the first account unless the backend runs with `--registration open`; that first account adopts every bucket without an owner. * * `POST /api/auth/register`. */ register(body: Register, options?: CallOptions): Promise; /** * Trade an account's token for a session cookie — the dashboard's way in; a script sends the token as `Authorization: Bearer` on each request instead. * * `POST /api/auth/login`. */ login(body: Login, options?: CallOptions): Promise; /** * Close this session and drop its cookie; the token stays valid. * * `POST /api/auth/logout` — needs a login. */ logout(options?: CallOptions): Promise; /** * The account behind this request: its name, its tokens (never their secrets) and how many buckets it owns. * * `GET /api/auth/me` — needs a login. */ me(options?: CallOptions): Promise; /** * Another token for the account (a laptop, a CI job); the secret comes back once. * * `POST /api/auth/tokens` — needs a login. */ createUserToken(body: CreateUserToken, options?: CallOptions): Promise; /** * Revoke one of the account's tokens and close every session it opened; never the last one (409 `ERR_LAST_TOKEN`). * * `DELETE /api/auth/tokens/{tokenId}` — needs a login. */ revokeUserToken(tokenId: string, options?: CallOptions): Promise; /** * The caller's buckets, oldest first. * * `GET /api/buckets` — needs a login. */ listBuckets(options?: CallOptions): Promise; /** * Create a bucket: a cluster id to paste, and nodes to enroll into it. * * `POST /api/buckets` — needs a login. */ createBucket(body: CreateBucket, options?: CallOptions): Promise; /** * One bucket with its nodes, last telemetry and history — the shape the feed opens with; `history`, `match` and `view` cut it as they cut the feed's `init`. * * `GET /api/buckets/{clusterId}` — needs a login. * * The optional URL parameters of `events`, applied to this answer as to the feed's `init`: `history=0` sends `history` empty, `match=` keeps the nodes whose hostname contains `` (and their snapshots and history), `view=live` cuts each snapshot to the host's gauges and a short row per process. Without them the answer is whole, as it always was. A value it does not know (`history=2`, `view=lite`) is 400 `ERR_BAD_REQUEST`. The generated method takes them in `options.query`: `api.getBucket(clusterId, { query: { history: 0, view: "live" } })`. Example: `GET /api/buckets/3pmc_…?history=0&view=live`. */ getBucket(clusterId: ClusterId, options?: CallOptions): Promise; /** * Set the bucket's settings — `approval`: whether a host that links with a reusable token waits for `approveNode` (turning it off approves the hosts already waiting); `require_access_rights`: off, every active node may place a call on every other, as if each held `fleet:invoke`, `fleet:list` and `fleet:read`; `record_shells` (on by default): the screen of each shell session is kept as an asciicast artifact of its call. Answers the bucket as it now stands. * * `POST /api/buckets/{clusterId}/settings` — needs a login. */ setBucketSettings(clusterId: ClusterId, body: SetBucketSettings, options?: CallOptions): Promise; /** * The bucket's permanent enrollment token and its paste line: the same at every read, never expiring, any number of hosts, not revocable. Created on the first read; its hosts wait for `approveNode` unless the bucket's `approval` is off. * * `GET /api/buckets/{clusterId}/enroll-token` — needs a login. */ bucketToken(clusterId: ClusterId, options?: CallOptions): Promise; /** * The bucket's live feed: `init`, then `node` / `telemetry` / `emit` / `call` / `artifact` — a call's state and an artifact's id and sizes only, never params, results or summaries (`getCall`, `listArtifacts`); `history`, `match` and `view` cut it to what the page draws. * * `GET /api/buckets/{clusterId}/events` — needs a login. * * Three optional URL parameters, applied to `init`, to the replay and to the live stream alike, cut the feed to what the page draws; without them it is sent whole. `history=0` sends `init.history` empty (the generated clients ask for it by default: pass `{ history: 1 }` to get it). `match=` keeps the nodes whose hostname contains ``: a plain substring, case-sensitive, at most 253 bytes — `envision` already reads as `*envision*`, and there is no wildcard or regex (`*` and `?` are matched literally: `match=envision-*` keeps no node, an empty feed rather than an error); absent or empty (`match=`), every node. `init` lists only the kept ones, a `node` event moves a node in or out of the set as its hostname changes, and the `telemetry`, `emit`, `call` and `artifact` of any other node are not sent. `view=live` cuts each `telemetry` frame, in `init.snapshots` too, to the host's gauges (CPU, per-core, load, memory, swap, uptime, sensors, batteries, `on_ac`, and the identity a `host_full` carries) and a short row per process (`id`, `name`, `instance`, `namespace`, `status`, `pid`, `cpu_percent`, `mem_bytes`, `uptime_ms`, `restarts`, `unstable_restarts`, `module`); disks, block devices, interfaces, GPUs, pressure, the kernel counters and the rest of the process record stay on `nodeHost` and `queryMetrics`. A value it does not know (`history=2`, `view=lite`) is 400 `ERR_BAD_REQUEST`. Example: `GET /api/buckets/3pmc_…/events?history=0&match=envision&view=live`. */ events(clusterId: ClusterId, handlers?: FeedHandlers, query?: FeedQuery): Subscription; /** * The same feed as an async iterable of `[kind, payload]`: `for await (const [kind, payload] of api.follow(…))`. * * `GET /api/buckets/{clusterId}/events` — needs a login. See `iterate()`. */ follow(clusterId: ClusterId, options?: IterateOptions): AsyncGenerator; /** * Every enrolled node of the bucket, presence layered on. * * `GET /api/buckets/{clusterId}/nodes` — needs a login. */ listNodes(clusterId: ClusterId, options?: CallOptions): Promise; /** * Admit a pending node. Idempotent. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/approve` — needs a login. */ approveNode(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * Revoke a node, cut its live session and drop the bearer it was using. Idempotent. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/revoke` — needs a login. */ revokeNode(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * Delete a node: its row, its calls and its artifacts go, its live session is cut and the feed sends `reset`. Unlike `revokeNode` nothing is held against its key — the host may enroll again, as a new node. * * `DELETE /api/buckets/{clusterId}/nodes/{nodeId}` — needs a login. */ deleteNode(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * What this node may ask of the bucket with its own bearer (`scopes`), who may place a call on it (`callers`), and what it lets go agent to agent with a LAN neighbour (`direct`); no scope by default, and an empty `callers` accepts every node holding `fleet:invoke`. Replaces each list it names, and takes effect on the node's next request. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/access` — needs a login. * * The body is `{ scopes?, callers?, direct? }`; a list left out is unchanged, an empty one clears it (no scope; `callers` open again). `direct` (`off`, `files`, `all` — the default) is what the node lets go agent to agent when it and the other end of a call sit behind the same public address on one LAN: a call's files, or its live legs too (`all`, on both ends — the relay then never sees them, and a viewer has nothing to watch: set `files` on a node whose live legs must stay watchable); left out, unchanged. `--direct off` on this backend turns it off for everyone. `scopes` is what the node's own bearer (`3pma_…`, minted in every `welcome`) may do here: `fleet:list` (the bucket, its nodes, the feed, the per-server and per-app metric inventories), `fleet:read` (metrics, host, process trees, the catalog of actions — the fleet's attack map, granted separately), `fleet:invoke` (place calls, and read back what its own calls produced). An unknown name is a 400. `callers` narrows who may place a call on **this** node — empty, any node of the bucket holding `fleet:invoke` may, every action included; with rules, only what a rule admits: `[{ from, from_app?, app?, action? }]`, `from` a node id (`3pmn_…`) or `"*"`, the other three a name or `"*"` (absent means `"*"`); a rule whose `from` is neither is a 400, and an action of the `3pm:` prefix (`3pm:profile`, built into every Node application) must be named in full in a list with rules — a wildcard never reaches it. To close a node to every other node, write a rule no call can match, such as `{ "from": "*", "app": "-" }` (`-` is no application's name). `from_app` is an operational guard rail, not a boundary: the calling application's name is declared by the calling daemon and this backend cannot check it, so the trust boundary is the node. The answer is the node's `NodeView`, which also goes out on the feed as a `node` event. Every grant is read again on every request, so both directions are felt on the node's very next request: what you take away stops working at once, what you grant works at once, with nothing to reconnect. The node's bearer is untouched (only a revoke drops it, together with its live session). */ setNodeAccess(clusterId: ClusterId, nodeId: NodeId, body: SetNodeAccess, options?: CallOptions): Promise; /** * Every enrollment token of the bucket; never the secrets. * * `GET /api/buckets/{clusterId}/tokens` — needs a login. */ listTokens(clusterId: ClusterId, options?: CallOptions): Promise; /** * Mint an enrollment token. The secret and its paste line are returned once. * * `POST /api/buckets/{clusterId}/tokens` — needs a login. */ createToken(clusterId: ClusterId, body?: CreateToken, options?: CallOptions): Promise; /** * Revoke an enrollment token; the bucket's permanent one (`bucketToken`) is refused, 409. * * `DELETE /api/buckets/{clusterId}/tokens/{tokenId}` — needs a login. */ revokeToken(clusterId: ClusterId, tokenId: string, options?: CallOptions): Promise; /** * Time series for charts: metrics, filters, aggregation and step, scoped to the bucket. * * `POST /api/buckets/{clusterId}/metrics/query` — needs a login. * * Discovery first: `metricsCatalog` gives every metric id, the labels each one carries and its `default_time`; `metricLabelValues` gives what a label holds in this bucket. A metric the catalog lacks, a label the metric does not carry, `rate` on a gauge, a regex that does not compile or any unknown field is a 400 `ERR_BAD_REQUEST` naming what was wrong. * * Body `{ range, queries, formulas?, options? }`: * * - `range` = `{ from, to?, step? }`. `from` and `to` are epoch milliseconds or `now` / `now-` (`now-90m`, `now-7d`); `to` defaults to `now`. `step` is `30s` / `5m` / `1h` or milliseconds, and is optional: left out, it is derived from `options.max_points`. Both ends are aligned to it, and the answer says which step was used (`range.step_ms`). * - `queries` = 1 to 8 `{ ref, metric, filters?, time?, aggregate?, hidden? }`. `ref` matches `^[A-Z][A-Z0-9]{0,7}$` and is unique across queries and formulas. `metric` is a catalog id (`process.cpu.utilization`, or `app.` for an application's own). `filters` are AND-ed `{ label, op, value | values }` over `eq | neq | in | nin | regex | nregex`. `time` is the time function and its window — `rate` / `increase` / `delta` on a counter, the `*_over_time` family on a gauge, `quantile` (with `q`) on a histogram, `raw` on either — the window defaulting to twice the step. `aggregate` = `{ fn: sum | avg | min | max | count | quantile, by: [labels] }` collapses the series that share `by`; without it, one series comes back per label combination the metric carries. * - `formulas` = up to 8 `{ ref, expr }` over the other refs: `A / B * 100`, with `+ - * /` and parentheses. A query marked `hidden: true` is computed for a formula and not returned. * - `options`: `max_points` (the step is raised until the range fits), `max_series` with `sort` (`avg` or `last`) deciding which are kept, `fill` (`null` | `zero` | `previous`), `mode` (`series`, or `summary` to drop the values and keep min/max/avg/last/count per series), `explain` to get the compiled expression back. * * Answer `{ range, times, results, took_ms }`: one `times[]` shared by every series, then one entry per visible ref with `series[].labels`, `series[].values` aligned on `times`, `series[].summary`, and `series_total` / `truncated` when the ranking cut some off. * * The bounds are `limits.queryMetrics` of `GET /api/v1/routes`, and the step may not go below the store's resolution (`store.interval_ms` of `GET /api/v1/info`). They bound the grid, not the window, and what a step is measured against is `options.max_points` — 600 by default, 2000 at most: 30 days at `5m` is 8 640 points and is refused whatever it is set to. The same 30 days draws at `2h` on the default and at `30m` (1 442 points) with `max_points: 2000`; a `5m` step fits two days on the default and just under seven at 2000. Leave `step` out and the finest step of the ladder that fits is picked for you. * * Example — 24 hours per app: the peak resident memory, and the average CPU turned into a percentage by a formula (the ratio itself computed and not returned). Every metric it names is a static catalog line, so it runs against any bucket: * * {"range":{"from":"now-24h"},"queries":[{"ref":"CPU","metric":"process.cpu.utilization","filters":[{"label":"app","op":"in","values":["api","worker"]}],"aggregate":{"fn":"avg","by":["app"]},"hidden":true},{"ref":"RSS","metric":"process.memory.rss","aggregate":{"fn":"max","by":["app"]}}],"formulas":[{"ref":"PCT","expr":"CPU * 100","unit":"percent"}],"options":{"max_points":300}} */ queryMetrics(clusterId: ClusterId, body: MetricsQuery, options?: CallOptions): Promise; /** * Every metric a query may name: id, kind, unit, labels, default time function. * * `GET /api/buckets/{clusterId}/metrics/catalog` — needs a login. * * Every metric this bucket can be queried on, static and application alike: `{ id, kind, unit, labels, default_time, source, doc }`. `id` is what a query's `metric` names, `labels` what its `filters` and `aggregate.by` may use, `default_time` the time function applied when the query gives none, and `source` tells a built-in line (`static`) from one an application sent here (`app`, id prefixed `app.`). Read it before building a query: nothing else says which metrics exist on this bucket, and a query naming one it lacks is refused. */ metricsCatalog(clusterId: ClusterId, options?: CallOptions): Promise; /** * The values one label takes in this bucket over the last two UTC days, at most 5000. * * `GET /api/buckets/{clusterId}/metrics/labels/{label}/values` — needs a login. * * What one label of `QueryLabel` actually holds in this bucket, to fill a filter with values that exist rather than a regex that might: `{ label, values }`, sorted, at most 5000, over the last two UTC days. `label` is a path argument (`app`, `node`, `instance`, `ns`, `src`, an application's own label name…), not a body field. */ metricLabelValues(clusterId: ClusterId, label: QueryLabel, options?: CallOptions): Promise; /** * What a dashboard or an LLM starts from, in one call: the metrics the bucket holds series for, with their descriptions, its hosts and their processes — text for an LLM by default, JSON with `?format=json` or `Accept: application/json` (what this client sends). * * `GET /api/buckets/{clusterId}/metrics/inventory` — needs a login. * * Text for an LLM by default; the generated client asks for the JSON (`Accept: application/json`), and so does `?format=json`. The JSON is `{ tokens, metric_groups, label_values, hosts }`, shaped for few tokens: nothing empty, nothing derivable, no join to make. `tokens` comes first: what the whole answer costs an LLM, estimated from its bytes with the ratio measured for its shape on Claude's tokenizer (about 2.4 bytes a token; OpenAI's counts 1.5–1.7× fewer). `metric_groups` are the catalog lines that have at least one series stored over the last two UTC days — the catalog says what a query may name, this says what would draw something — grouped by the labels they carry: `{ labels, metrics: [{ id, kind, unit?, default_time?, description?, apps? }] }`. `default_time` is left out when it is the kind's (`avg_over_time` for a gauge, `rate` for a counter, `quantile` for a histogram), `unit` and `description` when empty; a line is an application's exactly when its id starts with `app.`, and `apps` lists the apps whose series carry it (one read of every application series but a histogram's buckets, at most 5000: past that, the lists may be short). `label_values` are `{ label, values, more? }`: what every other label of those lines holds over the same window (not `node`, `app`, `instance`, `ns` — `hosts` has them — nor `pid`), sorted, at most 50 values, `more` counting the rest. `hosts` are every enrolled node, `{ node_id, hostname, os, arch, state, processes: [{ app, instance, ns? }] }`: the instances the store holds a series for over the same window, label values that go into a query's `filters` as they are, next to `node` = `node_id`. Every series carries `node` = a `node_id`: a query names a host by it, never by hostname, and `hosts` turns a legend's `node` into a name. `?detail=index` leaves the descriptions out, about 40 % fewer tokens. The text (`?format=llm`, the default) is the same answer as TOON-style `text/plain` after an `about:` line saying how the lists join, about a fifth fewer tokens than the JSON (more than half fewer with `detail=index`): a header per list with its count and fields (`metrics[140]{id,kind,unit,default_time,description}:`), one row per entry, every `default_time` spelled out. An unknown parameter or value is a 400. The store is read for at most 5000 series names and 5000 processes. Without `--metrics-url` the route answers 409 `ERR_STORE_DISABLED`. */ metricsInventory(clusterId: ClusterId, options?: CallOptions): Promise; /** * Which metric is stored on which server, for which app: each node with its host's own metrics, then each of its apps with theirs — text for an LLM by default, JSON with `?format=json` or `Accept: application/json` (what this client sends). * * `GET /api/buckets/{clusterId}/metrics/servers` — needs a login. * * The inventory by server, for a question that starts from a host or an app: what can be queried with `node` = this node_id (and `app` = this app) without drawing nothing. The JSON is `{ tokens, metric_groups, servers }`. `tokens` comes first, priced as `metricsInventory`'s. `metric_groups` are the catalog lines with a series on some server over the last 24 hours, grouped by the labels they carry, as `metricsInventory` gives them (without `apps`): what each id below is — kind, unit, default time function, labels, description. `servers` is an object keyed by node_id — the value of the `node` label every series carries —: every enrolled node in the bucket's order, then a node only the store knows (`state: "unknown"`), each `{ hostname, os, arch, state, metrics?, apps? }`. `metrics` are the ids of the host's own lines stored on that server (series without `app`: `system.*`, `daemon.*`, `node.*`…), in catalog order; `apps` is an object keyed by app name, sorted, each the ids whose series carry `app` = that name on that server — `process.*`, `subprocess.*`, the app's own `app.*` — in catalog order. A list that would be empty is left out. Present means at least one series over the last 24 hours, not that it is reported right now. `?detail=index` leaves the descriptions out. The text (`?format=llm`, the default) is the same answer as TOON-style `text/plain`: an `about:` line, `metric_groups` as `metricsInventory` writes them, then `servers:` with a key per node_id and, under `apps:`, one `[]: ` line per app. One instant query on the store answers for the whole bucket, cached a minute. An unknown parameter or value is a 400; without `--metrics-url` the route answers 409 `ERR_STORE_DISABLED`. */ metricsByServer(clusterId: ClusterId, options?: CallOptions): Promise; /** * Which metric each app has on which server: each app with the servers it has series on and, on each, their metrics — text for an LLM by default, JSON with `?format=json` or `Accept: application/json` (what this client sends). * * `GET /api/buckets/{clusterId}/metrics/apps` — needs a login. * * The inventory by app, for a question that starts from an app: on which servers it runs and what can be queried there with `app` = this name and `node` = that node_id. `metricsByServer` turned around; a host's own lines (`system.*`, `daemon.*`…) are left to that route. The JSON is `{ tokens, metric_groups, hosts, apps }`. `tokens` comes first, priced as `metricsInventory`'s. `metric_groups` are the catalog lines an app's series carry on some server over the last 24 hours, grouped by the labels they carry, as `metricsInventory` gives them (without `apps`): what each id below is. `hosts` are every enrolled node in the bucket's order, then a node only the store knows (`state: "unknown"`), each `{ node_id, hostname, os, arch, state }`: what turns a node_id of `apps` into a name. `apps` is an object keyed by app name, sorted; each is an object keyed by node_id, in the order of `hosts`, holding the ids whose series carry `app` = that name on that server — `process.*`, `subprocess.*`, the app's own `app.*` — in catalog order. Present means at least one series over the last 24 hours, not that it is reported right now. `?detail=index` leaves the descriptions out. The text (`?format=llm`, the default) is the same answer as TOON-style `text/plain`: an `about:` line, `metric_groups` as `metricsInventory` writes them, `hosts[n]{node_id,hostname,os,arch,state}:` with a row per node, then `apps:` with a key per app and, under it, one `[]: ` line per server. One instant query on the store answers for the whole bucket, cached a minute and shared with `metricsByServer`. An unknown parameter or value is a 400; without `--metrics-url` the route answers 409 `ERR_STORE_DISABLED`. */ metricsByApp(clusterId: ClusterId, options?: CallOptions): Promise; /** * The persisted events of the bucket, newest first: a range, exact filters (kind, app, level, event_name…), a limit. * * `POST /api/buckets/{clusterId}/events/query` — needs a login. * * Body `{ range, filters?, limit? }`. `range` is the metrics query's, minus `step` (there is no grid): `from` / `to` as epoch milliseconds or `now-`. `filters` are exact matches, AND-ed, on the fields the store writes — `kind` (`process`, `daemon_shutdown`, `app`, `action`, `shell`), `node`, `app`, `instance`, `src`, `level`, `event_name`, `fingerprint`, `call` (every line of one action or shell session) — no operators, no regex. `limit` is 1 to 1000 (default 200). * * Answer `{ events, truncated, took_ms }`, newest first, each event the record as stored (`_time`, `_msg`, `kind`, and the fields above); `truncated` says more matched than `limit`. Bounds: `limits.eventsQuery` of `GET /api/v1/routes`. * * Example — the last 50 error-level application events of one app over a day: * * {"range":{"from":"now-24h"},"filters":{"kind":"app","app":"api","level":"error"},"limit":50} */ eventsQuery(clusterId: ClusterId, body: EventsQuery, options?: CallOptions): Promise; /** * The logs the bucket's nodes shipped over a range: their sources, the templates with their counts (worst first) and the shipped lines (newest first), filtered by node, source, owner, severity, template or text. * * `POST /api/buckets/{clusterId}/logs/query` — needs a login. * * Body `{ range, filters?, text?, limits? }`. `range` is the events query's (`from` / `to` as epoch milliseconds or `now-`, no grid). `filters` are exact matches, AND-ed: `node` (a node id), `src` (the node's source id), `owner` (the writer's name, as the source declares it), `tpl` (a template id, unique per source of a node only: it needs `node` and `src`, else 400), `sev` — one word of `debug`, `info`, `notice`, `warning`, `err`, `crit`, a list of them, or `+` for that level or worse (`warning+`). `text` keeps the shipped lines that contain it (case sensitive). `limits` = `{ templates, lines }`. * * Answer `{ range, sources, templates, lines, truncated }`. `sources`: every source the nodes declared over the range, `{ node, hostname, src, kind, path?, app?, owner?, format?, lines, shipped, dropped, templates }` — the lines counted, shipped and stored, read and not shipped, and the templates with lines, over the range. `templates`: worst severity first, then by lines, `{ node, src, tpl, sev, text, lines, first_seen, last_seen, params: [{ var, distinct, top }] }`; the templates a node does not label on their own (beyond its 2 000 busiest) come back together per source and severity as `tpl: null`, `text: "(other templates)"`. `lines`: the lines shipped, newest first, `{ ts, node, hostname, src, tpl?, sev, why, line, ident?, unit? }`. `truncated` counts what the limits cut (`sources` past the 5 000 the catalog reads), and `counts` the nodes whose counts the engine would not give (asked a few nodes at a time; their templates and source `lines` are missing, the rest of the answer stands). Counts need `--metrics-url`; without it they are 0. The counts follow `node`, `src`, `owner`, `sev` and `tpl`; `text` filters the lines (and `shipped`) only — a count has no text. A template's `sev` is the worst severity counted for it over the range, the one it is ranked and filtered on. Counts come in windows of about 5 minutes stamped at their end: a window ending just after `from` counts whole, and the window still open at `to` is not counted yet, so the last 5 to 10 minutes lag and a short range may show lines with no counts. `params` are read for the templates returned only, and come back empty rather than failing the answer: `distinct` is the largest over the whole range, `top` the values most often in the tops of the latest 2 000 windows. Bounds: `limits.logsQuery` of `GET /api/v1/routes`. * * Example — what went wrong on the bucket over the last six hours, errors and worse only: * * {"range":{"from":"now-6h"},"filters":{"sev":"err+"},"limits":{"templates":50,"lines":100}} */ logsQuery(clusterId: ClusterId, body: LogsQuery, options?: CallOptions): Promise; /** * What deserves a look on this fleet: incidents ranked by the insights detectors, best first — text for an LLM by default, JSON with `?format=json` or `Accept: application/json` (what this client sends). * * `GET /api/buckets/{clusterId}/insights` — needs a login. * * The bucket's incidents as the insights apps rank them. Eight detectors read this bucket's metrics and events every few minutes (absolute rules, resources running out, a machine unlike its peers, a series unlike its own past, dated level shifts, series profiles, crash loops); one of them, triage, groups what they find on one machine and metric family — or on the fleet — into an incident and scores it 0 to 10 (1 notable, 3 worth a look). `tier` says how to read it: `problem` when something bad in itself opens it (a rule, a saturation, a crash loop, a move in a metric's bad direction), `context` when it is only unusual (score damped), `inventory` for a standing fact (an old agent, a worn battery). The JSON is `{ tokens, pass_at_ms?, next_pass_ms?, stale, error?, sources, lang, total, incidents }`: `stale` is true when the last ranking pass is older than two intervals, `error` the reason when that pass failed (the incidents are then the pass before), `sources` the state of each detector at that pass (`ok`, `fallback`, `stale`, `pending`, `error`, `absent`), `total` the incidents that pass the filters before `limit`. Each incident is `{ id, score, tier, node_id?, hostname?, fleet?, hosts?, family?, metric?, metric_name?, labels?, sources, summary, since_ms?, onset_ms?, passes, label?, hidden?, chart? }`: a fleet-wide one has no `node_id` and names its machines in `hosts`; `metric` is a catalog id (`metric_name` the store's name when no catalog line has it, and then no `chart`), `chart` a `queryMetrics` body that draws the headline series, `summary` the finding in French with its figures, `label` a person's verdict (`true`, `false`, `known`, `unknown`). Parameters: `tier` (comma-separated), `node` (a node_id: its incidents and the fleet-wide ones that name it), `min_score` (on the score as served), `limit` (1 to 200, 20 by default), `known=true` (also the incidents labelled known, hidden otherwise), `format`. The text (`?format=llm`, the default) is one `about:` line, the pass, then `incidents[n]{id,score,tier,hostname,metric,labels,sources,since,passes,label,summary}:` and a row per incident. `insight` gives one incident with its findings; its id goes URL-encoded in the path. The ranking is relayed and cached 30 s, never computed here: the store is not read. An unknown parameter or value is a 400; without `--insights-url` the route answers 409 `ERR_INSIGHTS_DISABLED`, and 503 `ERR_INSIGHTS_UNAVAILABLE` when the insights apps do not answer, answer after 5 s, are not ready since a restart, or speak another contract version. */ insights(clusterId: ClusterId, query?: InsightsQuery, options?: CallOptions): Promise; /** * One incident of `insights` and the findings it groups, each with its detector, series, score and summary — text for an LLM by default, JSON with `?format=json` or `Accept: application/json`. * * `GET /api/buckets/{clusterId}/insights/{incident}` — needs a login. * * One incident by its `id` (as `insights` lists it, URL-encoded; it stays the same while the incident lasts), with what makes it: `{ tokens, pass_at_ms?, next_pass_ms?, stale, error?, sources, lang, incident, members, n_members, events?, story? }`. `incident` is shaped as in `insights`; `members` are the findings it groups, best first (at most 30; `n_members` counts them all), each `{ app, node_id?, hostname?, metric?, metric_name?, labels?, score?, weight?, summary }` — `app` is the detector, `weight` its share of the score. `events` are the 3pm process events of the last 6 hours on the incident's node, per app: counts by event, and `last_ms`. `story` places it among the incidents of the same machine that started together: `{ head_family, families?, rank? }` — `head_family` is the metric family of the incident heading them, `families` (on the head) all of theirs, `rank` (on the others) its place. An id `insights` does not list (an incident that ended, one of another bucket, a guess) is a 404 `ERR_NOT_FOUND`, answered without asking the insights apps; `?format=json` or `Accept: application/json` for the JSON, the text otherwise. */ insight(clusterId: ClusterId, incident: string, options?: CallOptions): Promise; /** * The application metrics of every instance of the node, as last received: sources, series, values and histogram buckets. * * `GET /api/buckets/{clusterId}/nodes/{nodeId}/app-metrics` — needs a login. */ nodeAppMetrics(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * The custom actions the apps of the node declare (`io.action`), per instance and source, with descriptions and timeouts — what `nodeAction` may run; none of the metrics, so a page may poll it all the time. * * `GET /api/buckets/{clusterId}/nodes/{nodeId}/actions` — needs a login. */ nodeActions(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * The node's host as it is now, whole: the gauges and counters with the identity (hostname, OS, totals, interface and mount names) the feed's ticks blank; 404 before its first frame, and for a node that sends no host. * * `GET /api/buckets/{clusterId}/nodes/{nodeId}/host` — needs a login. */ nodeHost(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * The process trees of every instance of the node, as last received — every process and the folded picture of `3pm ls --tree` — with the count of trees refused; what the dashboard's panel polls. * * `GET /api/buckets/{clusterId}/nodes/{nodeId}/trees` — needs a login. */ nodeProcessTrees(clusterId: ClusterId, nodeId: NodeId, options?: CallOptions): Promise; /** * The process tree of one instance of the node, as last received: every process, and the same folded picture as `3pm ls --tree`; 404 while the backend holds none — an instance running alone, or before its first tree. * * `GET /api/buckets/{clusterId}/nodes/{nodeId}/procs/{procId}/tree` — needs a login. */ nodeProcessTree(clusterId: ClusterId, nodeId: NodeId, procId: number, options?: CallOptions): Promise; /** * Run a custom action an app of the node declared (`io.action`), on every instance of the app or on one, optionally carrying bytes in and out; the call is answered as soon as it is placed — follow it with `getCall` or the feed's `call` events. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/actions` — needs a login. * * The body: `app` (a process name, or an id), `action` (the declared name), and optionally `instance` (one row of the app; default every instance that declares the action, at most 16), `source` (the process of the app to call, when several of one instance declare it), `params` (JSON for the handler, at most 64 KiB serialised; `null` by default) and `timeout_ms` (bounded by the declaration's; left out or 0, the action's declared timeout as the node last published it, else 30 s; 10 min at most). The answer is the `CallView` in `sent` state (`refused`, with 409 `ERR_NODE_OFFLINE` when the node has no live session, 503 `ERR_NODE_BUSY` when its session has too many frames queued); the reply lands on `getCall` as `result.results[]` — one per instance reached, `ok` with the handler's `data` (JSON), or an `err` (`failed`, `timeout`, `busy`, `gone`, `refused` — the action is declared `remote: false` —, `too_large`); a file the action leaves (a profile, a dump) is an **artifact**, a copy this backend keeps (`artifact_id` on the outcome, `artifacts[]` on the call, `artifactFile` to download, `listArtifacts` to find it again), and so is `data` past the 256 KiB the whole call keeps inline (`data_artifact_id` on the outcome). The node's agent needs the `proc:action` capability (granted by default), and the action must be declared by the app: what `nodeAppMetrics` lists under each source's `actions`. Every call is audited on the node's bus (`proc_action`) and on this backend's events (`kind: action`). A node's own bearer reaches this route with `fleet:invoke`, unless the target's `callers` (`setNodeAccess`) holds rules and none admits it, and may name the calling application in `from_app` (recorded in `placed_by` as `node:<3pmn_…>/`, `-` when it names none); an account's `from_app` is ignored. Bytes: `input` (`{bytes, sha256?, ext?}`) opens a blob the caller `PUT`s and the target `GET`s, and `output: true` opens one the other way — the answer carries `input.blob` and `output.blob`, membership of the call is the only authorisation either needs, both blobs are charged to whoever placed the call, and `deadline_at` allows for the transfer as well as the timeout. A call that carries bytes names its `instance` (422 otherwise); an `input.bytes` over `--blob-max-mb` is 413 `ERR_TOO_LARGE` before anything is reserved; a node calling itself is 422. What the target writes to the output blob is not an artifact: it is relayed and never copied — no `artifact_id`, nothing in `listArtifacts` —, and `getBlob` on `output.blob` reads it until the relay forgets it, 15 min after the target finished writing once somebody has read it, 2 min if nobody has; `output.{bytes,sha256,ext}` on the call says how long it is and what digest to check it against (the target's claim: this backend never reads the bytes). `stream: true` opens a **session** instead: two `live` blobs, one each way, carrying framed messages (`KEY`/`END`/`INIT`, 1 MiB each at most) that are relayed and never kept. It names its `instance` and carries neither `input` nor `output` — its two legs are both; the answer's `input.blob` is the leg the caller writes and `output.blob` the one it reads, `input.live` is `true` and `input.bytes` absent. While it runs, `live` on the call (and on the feed's `call` events) counts `bytes_in`, `bytes_out` and `dropped_out` from memory; a session goes quiet for sixty seconds on either leg, or runs for a day, and it is over. `getBlob?as=mjpeg` is how a browser watches one. When the caller is a node and it and the target sit behind the same public address, each announced a LAN listener and neither's `direct` setting (`setNodeAccess`) refuses it, the answer carries `peer` — the target's node id, public key and private addresses — and the target's ticket names the caller the same way: the two agents may then move the blob between themselves, the blobs above staying open as the fallback, and `path` on the call says which way it went once the writer reported it. */ nodeAction(clusterId: ClusterId, nodeId: NodeId, body: NodeActionBody, options?: CallOptions): Promise; /** * Open an interactive login shell on the node (`3pm fleet ssh`), or run one command there (`command`, `3pm fleet ssh -- `): the answer is the call, whose `input.blob` you `PUT` keystrokes (or stdin) to and `output.blob` you `GET` the screen (or stdout and stderr) from; it replies `exited` when the shell or the command ends. Never from a page on another site. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/shell` — needs a login. * * The body: `term` (the caller's `$TERM`, 1 to 64 characters of `[A-Za-z0-9._+-]`, else 422 `ERR_UNPROCESSABLE`), `cols` and `rows` (clamped to 2..1000), and optionally `command` and `pty`. The shell is the login shell of the unix user the node's agent runs as, on a real pty of that size. Who may: the bucket's account always; a node's own bearer with `fleet:invoke` (else 403 `ERR_FORBIDDEN`) when the target's `callers` (`setNodeAccess`) is empty or holds a rule naming the action `3pm:shell` with `app` `-` for it — a wildcard action never reaches it — else 403 `ERR_SHELL_NOT_ALLOWED`. A `Bearer` from a page on another site is refused (403), unlike `nodeAction`. The answer is the `CallView` in `sent` state (409 `ERR_NODE_OFFLINE` when the node has no live session or this backend is stopping, 503 `ERR_NODE_BUSY` when its session is saturated, 429 `ERR_QUOTA` past the blob quotas): `input.blob` (`live: true`, no `bytes`) is the keyboard leg the caller `PUT`s as one streamed body, `output.blob` the screen leg it `GET`s; both are `pipe` blobs — lossless, one reader each, framed `live` messages carrying `attach` payloads (input, resize, ping one way; output, pong, exited the other) — that only the two ends of the call may touch, never through a `?k=`. While it runs, `live` on the call counts the bytes each way. The call stays `sent`/`acked` as long as the shell runs (a day at most) and survives the node's link reconnecting; it settles `replied` with `result.res` `exited` (`code` or `signal`; `ok` when it exited 0, else `err.code` `failed`), or with the agent's refusal (`err`: `forbidden` — no `node:shell` capability on the node, the caller's `cli.key` not in the node's own `authorized_keys` (the agent's code `key_not_authorized`, its message naming the refused fingerprint and the `3pm link authorize` to type on the node), or an agent too old to know the command —, `busy`). A session quiet for sixty seconds on either leg (the command line pings every 20 s), one whose reader went away, or a backend that stops, ends both legs: the shell is hung up. Audit, in the events store (`eventsQuery`, `kind: shell`, `call` = the call id): this backend writes `open_requested` when the call is placed (`refused`, with why and no `call`, for an attempt it turns away) and `ended` when it settles, however it settles; the node's agent adds `open`, `line` (each command line as the screen showed it, `[hidden]` when not echoed), `screen` (a full-screen program came or went) and `close`. Behind a reverse proxy the two legs are long streamed requests: buffering off and timeouts over a minute, or a quiet shell is cut. With `command` (its words: 1 to 256 of them, 16 KiB in all, no NUL, the first not empty, else 422 `ERR_UNPROCESSABLE`) the node runs that one command instead of a shell, through the same login shell (`sh -c`, the words quoted, one word taken as the whole line) — `exec` on the link, which an agent too old to know it refuses (`forbidden`). With `pty: true` it gets a pty of `term`, `cols` × `rows` and the legs carry what a shell's do; without, three pipes: its stdout comes back as `output` messages, its stderr as `stderr` (`0x1d`), and an `eof` (`0x05`) on the keyboard leg closes its stdin. The same permission, legs, quotas and end; `open_requested` (and `refused`) carry `command`, and the agent writes `exec` (the line the shell was given) after `open` — never the command's stdin. */ nodeShell(clusterId: ClusterId, nodeId: NodeId, body: NodeShellBody, options?: CallOptions): Promise; /** * Open one TCP connection from the node to `host:port` (`3pm fleet ssh -L`, one call per local connection): the answer is the call, whose `input.blob` you `PUT` the bytes to send and `output.blob` you `GET` the bytes that come back; it replies `exited` once both ways are closed. Never from a page on another site. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/forward` — needs a login. * * The body: `host` (1 to 255 bytes, no space nor control character) and `port` (1 to 65535), else 422 `ERR_UNPROCESSABLE` — or, for `3pm fleet ssh -R`, `accepted` (`{listen, conn}`: a connection one of the caller's `nodeListen` calls accepted, which the node takes over instead of dialing) with neither and `e2e: true`, refused 404 `ERR_NOT_FOUND` unless `listen` is a running listen this caller placed on this node (one answer for every case, so a guessed id says nothing). Any destination the node can reach, itself included. Who may: the bucket's account always; a node's own bearer with `fleet:invoke` (else 403 `ERR_FORBIDDEN`) when the target's `callers` (`setNodeAccess`) is empty or holds a rule naming the action `3pm:forward` with `app` `-` for it — a wildcard action never reaches it — else 403 `ERR_FORWARD_NOT_ALLOWED`. A `Bearer` from a page on another site is refused (403). The answer and the legs are `nodeShell`'s (the same states, quotas, quiet minute and day), except what the legs carry: `live` frames whose payload is raw bytes (64 KiB at most each), an empty frame flagged `END` closing that way (a half-close: the node shuts its socket's write side, the caller its own). The node connects within 5 s or the call settles with the agent's `err` (`unavailable`, `connect :: `); the agent refuses it `forbidden` without the `node:forward` capability (on by default) or when too old to know `forward`. Audit (`eventsQuery`, `kind: shell`): `open_requested` (or `refused`, with why) carrying `forward` = `host:port` (`listen` and `conn` for a connection taken over), `ended` when it settles, and the agent's `close` with `bytes_in` (to the destination) and `bytes_out` (back). */ nodeForward(clusterId: ClusterId, nodeId: NodeId, body: NodeForwardBody, options?: CallOptions): Promise; /** * Make the node listen on a port for you (`3pm fleet ssh -R`): the answer is the call, whose sealed legs carry the port it bound and one `accepted` message per connection, each of which you take over with a `nodeForward` naming it (`accepted`); it replies `exited` when you close it. Never from a page on another site. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/listen` — needs a login. * * The body: `bind` (the node's address; empty or absent: its loopback; 255 bytes at most, no space nor control character), `port` (0 or absent: one the node picks) and `e2e`, which must be `true` — a listen is always sealed end to end — else 422 `ERR_UNPROCESSABLE`; 409 `ERR_NODE_NO_E2E` when the node's agent cannot encrypt. A non-loopback `bind` is the node's decision alone (its local `gateway_ports` setting): it refuses one it does not allow. Who may: exactly as `nodeForward` — the bucket's account always, a node's own bearer with `fleet:invoke` when the target's `callers` names `3pm:forward` (or is empty), else 403 `ERR_FORWARD_NOT_ALLOWED`; never a `Bearer` from a page on another site. The answer and the legs are `nodeForward`'s; inside the sealed records each `live` frame is one listen message (`threepm_link::listen`: `bound {port}`, then `accepted {conn, peer}` per connection, from the node; `keepalive` both ways). Between connections the legs are quiet: the caller's keepalive every 20 s is what keeps them under the relay's idle minute, and the node stops listening after a minute without one; a listen lasts a day at most (the session's bound). Each accepted connection is taken over by `nodeForward` with `{"accepted": {"listen": , "conn": }, "e2e": true}` and neither `host` nor `port`, within 10 s or the node closes it; that forward is refused 404 `ERR_NOT_FOUND` unless the listen is a running `listen` call the same caller placed on that node. The agent refuses `forbidden` without the `node:forward` capability or when too old to know `listen`. Never recorded. Audit (`eventsQuery`, `kind: shell`): `open_requested` (or `refused`) with `bind` and `port`, `ended` when it settles; each connection taken over is a forward's own `open_requested` naming `listen` and `conn`. */ nodeListen(clusterId: ClusterId, nodeId: NodeId, body: NodeListenBody, options?: CallOptions): Promise; /** * Send one generic command to the node's agent — `{"op": "restart", "target": {"name": "api"}}`, `stop` or `ping` (an action goes through `nodeAction`); answered as soon as it is placed — follow it with `getCall`. * * `POST /api/buckets/{clusterId}/nodes/{nodeId}/calls` — needs a login. * * The body is the `AgentRequest` as the link carries it: `{"op": "ping"}`, `{"op": "restart", "target": {"name": "api"}}` or `{"target": {"id": 3}}`, `{"op": "stop", "target": …}`. Any other op, `action` included, is a 400: an action goes through `nodeAction`, which checks its name, its params and its target. Never `start`, never `all`. The host must allow the capability the op needs (`proc:restart`, `proc:stop`; `ping` needs none), else the reply is `err {forbidden}`. At-most-once: a call in flight when the link drops is never replayed — it ends `lost`, and the caller places a new one. */ nodeCall(clusterId: ClusterId, nodeId: NodeId, body: NodeCallBody, options?: CallOptions): Promise; /** * One remote command: its state (`sent`, `acked`, `replied`, `unacked`, `refused`, `lost`), who placed it, the blobs it carries, its result once replied (`result.ok` says the action ran) and the artifacts it left. A node's bearer sees the calls it placed itself and nothing else. * * `GET /api/buckets/{clusterId}/calls/{callId}` — needs a login. */ getCall(clusterId: ClusterId, callId: string, options?: CallOptions): Promise; /** * The files actions left (profiles, dumps), newest first, at most 200; `node` (a node id) and `app` (a name) narrow. Kept 30 days and 1 GB per bucket. A node's bearer sees what its own calls left and nothing else. * * `GET /api/buckets/{clusterId}/artifacts` — needs a login. */ listArtifacts(clusterId: ClusterId, query?: ArtifactsQuery, options?: CallOptions): Promise; /** * Mint the one credential a browser tag can carry: a `getBlob` URL with `?k=` on it, good for sixty seconds, for this blob, for reading — `{as: "mjpeg"}` adds the rendering an `` needs. * * `POST /api/buckets/{clusterId}/blobs/{blobId}/view` — needs a login. */ viewBlob(clusterId: ClusterId, blobId: string, body: ViewBlobBody, options?: CallOptions): Promise; } export default Client;