feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for dashboard-managed git worktree lane provisioning
|
||||
* and the confirmed, preflight-guarded reset, remove, and purge lifecycle API.
|
||||
* It documents the asynchronous provisioning and destructive action contracts
|
||||
* for the built-in Swagger and ReDoc surfaces, plus the read-only
|
||||
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
|
||||
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
|
||||
* Workspace page opens a directory with.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Lanes",
|
||||
description: "Durable parallel-work lanes and dashboard-managed git worktrees",
|
||||
},
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
LaneWorktreeCreateRequest: {
|
||||
type: "object",
|
||||
required: ["sourceRepo"],
|
||||
properties: {
|
||||
sourceRepo: {
|
||||
type: "string",
|
||||
description: "Existing absolute path to the source git repository.",
|
||||
example: "/Users/me/src/project",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Human-readable lane title.",
|
||||
example: "Criteria form",
|
||||
},
|
||||
base: {
|
||||
type: "string",
|
||||
description:
|
||||
"Preferred base branch. Defaults to the LANE_BASE_BRANCH env var, or `main` when that is also unset.",
|
||||
example: "main",
|
||||
},
|
||||
slug: {
|
||||
type: "string",
|
||||
description: "Optional branch/directory slug override.",
|
||||
example: "criteria-form",
|
||||
},
|
||||
},
|
||||
},
|
||||
LaneEnsureRequest: {
|
||||
type: "object",
|
||||
required: ["cwd"],
|
||||
properties: {
|
||||
cwd: {
|
||||
type: "string",
|
||||
description:
|
||||
"Absolute working directory to find or adopt a lane for. A lane whose own cwd is this path, or the closest path-boundary parent of it, is returned as-is.",
|
||||
example: "/Users/me/src/project/packages/app",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description:
|
||||
"Title for the lane if one has to be created; ignored when one already exists.",
|
||||
example: "App package",
|
||||
},
|
||||
},
|
||||
},
|
||||
LaneDestructiveActionRequest: {
|
||||
type: "object",
|
||||
required: ["confirm", "expect"],
|
||||
properties: {
|
||||
confirm: { type: "boolean", enum: [true] },
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Required by reset/remove when unpushed commits exist.",
|
||||
},
|
||||
expect: {
|
||||
type: "object",
|
||||
description:
|
||||
"Required complete facts returned by the preceding preflight: head, dirty, untracked, unpushed for reset/remove; sessions, events, tokenRows for purge. Differences return 409 ESTALE without destructive work.",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
LaneStageDetectionFields: {
|
||||
type: "object",
|
||||
description:
|
||||
"Fields the server's stage-detection heuristic (server/lib/stage-detect.js) adds to every lane returned by GET /api/lanes and GET /api/lanes/:id. An inferred stage is never evidence and never renders as done — see docs/LANES.md#stage-detection.",
|
||||
properties: {
|
||||
detected_stage: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"Stage id inferred from ingested tool events, or null if no signal has been seen. Independent of the agent's own declared `stage`.",
|
||||
},
|
||||
detected_signal: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"The tool-event signal that produced detected_stage, capped at 120 characters; null when detected_stage is null.",
|
||||
},
|
||||
detected: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Present on each entry of pipeline_nodes. True for the inferred node and any node before it that carries no declared record; decorates that node's state without ever upgrading it to done.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/lanes/ensure": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Find or adopt the lane owning a working directory",
|
||||
description:
|
||||
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
|
||||
operationId: "ensureLane",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/LaneEnsureRequest" } },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "An existing lane already owns that cwd.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["lane", "created"],
|
||||
properties: {
|
||||
lane: { type: "object", additionalProperties: true },
|
||||
created: { type: "boolean", enum: [false] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
201: {
|
||||
description: "No lane owned that cwd, so an adopted one was created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["lane", "created"],
|
||||
properties: {
|
||||
lane: { type: "object", additionalProperties: true },
|
||||
created: { type: "boolean", enum: [true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "cwd is missing or not an absolute path (EBADCWD).",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "The browser request was not same-origin/loopback.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/worktree": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Provision a managed git worktree lane",
|
||||
description:
|
||||
"Validates the absolute source repository, creates a managed lane in `provisioning` state, and returns immediately. Worktree creation continues under the lane lock; the existing `lane_update` broadcast reports either `idle` or `failed` with git stderr in `notes`.",
|
||||
operationId: "createLaneWorktree",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/LaneWorktreeCreateRequest" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
202: {
|
||||
description: "Managed lane accepted for background provisioning.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["lane"],
|
||||
properties: { lane: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "sourceRepo is relative, missing, or not a git repository.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "The browser request was not same-origin/loopback.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"The computed worktree directory already belongs to a lane, or no unique directory was available after 50 attempts.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}": {
|
||||
patch: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Partially update a lane",
|
||||
description:
|
||||
"Updates lane fields, including run_id; browser requests must pass the loopback same-origin guard. The provisioning-time facts kind, source_repo, slug and base_branch are NOT patchable — kind is check 1 of the destroy guard — and are silently ignored here.",
|
||||
operationId: "updateLane",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Updated lane." },
|
||||
400: { description: "kind was not one of adopted|managed (EBADKIND)." },
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/preflight": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Count facts before a destructive lane action",
|
||||
operationId: "preflightLaneAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "action",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string", enum: ["reset", "remove", "purge"] },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Current counted facts for the selected action. reset/remove additionally return blocked[] (hard blockers: adopted, missing, unreadable, unpushed-commits — the only one force overrides) and warnings[] (informational only, e.g. no-remote).",
|
||||
},
|
||||
400: { description: "Unknown action." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's working-copy facts",
|
||||
description:
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
operationId: "getLaneGitFacts",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A candidate source repo's local branches",
|
||||
description:
|
||||
"Feeds the Add-lane picker: given a repo path, returns its local branches (not origin/* refs — those aren't checkout-able into a new worktree without a fetch first) plus which one is currently checked out. Same repo validation as POST /api/lanes/worktree; a repo neither endpoint can resolve can't be provisioned from either. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneBranches",
|
||||
parameters: [
|
||||
{
|
||||
name: "repo",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Absolute path to an existing git repository.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{branches: string[], current: string | null}." },
|
||||
400: { description: "repo is missing, relative, does not exist, or is not a git repo." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/{action}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Confirm a reset, managed-worktree removal, or session purge",
|
||||
description:
|
||||
"Actions run under the lane lock after waiting for the lane child's actual exit. A failed spawn is already exited because no child started. Reset requires a live managed worktree; remove tears down a managed worktree, prunes git's stale record when the directory was deleted by hand, or only forgets an adopted-lane row without touching its directory. Reset/remove require force when managed work has unpushed commits.",
|
||||
operationId: "runLaneDestructiveAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "action",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", enum: ["reset", "remove", "purge"] },
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/LaneDestructiveActionRequest" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Action completed; purge includes its deleted-row counts." },
|
||||
400: {
|
||||
description:
|
||||
"Confirmation or complete expect facts missing, or the managed-worktree guard refused the target.",
|
||||
},
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
409: { description: "Preflight facts changed (ESTALE) or force is required (EUNPUSHED)." },
|
||||
500: {
|
||||
description:
|
||||
"Git, run-exit timeout, or internal failure; git failures include error.stderr.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* @file Enriched OpenAPI OVERRIDE operations for the core read/ingest endpoints
|
||||
* that already exist in `server/openapi.js`:
|
||||
*
|
||||
* GET /api/events
|
||||
* GET /api/events/facets
|
||||
* GET /api/stats
|
||||
* GET /api/analytics
|
||||
* POST /api/hooks/event
|
||||
*
|
||||
* These operations are intentionally CONTRACT-IDENTICAL to the base spec. Every
|
||||
* `operationId`, `tags` value, parameter name/`in`/schema, request-body `$ref`,
|
||||
* and response `$ref` is copied verbatim from `server/openapi.js`. The ONLY
|
||||
* additions here are richer prose `description`s, realistic per-parameter
|
||||
* `example`s, and realistic media-type `example`s on request/response bodies —
|
||||
* none of which change the wire contract.
|
||||
*
|
||||
* This module exports the override-merge surface expected by the spec builder:
|
||||
* - `tags`: [] (no new tags — reuse the base Events/Stats/Analytics/Hooks tags)
|
||||
* - `schemas`: {} (no new schemas — reuse base `$ref`s only)
|
||||
* - `paths`: the enriched override operations keyed by path
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// No new tags. The base operations already belong to the Events / Stats /
|
||||
// Analytics / Hooks tags; overriding those tag arrays here would risk drift.
|
||||
const tags = [];
|
||||
|
||||
// No new schemas. Every response and request body below reuses an existing
|
||||
// `#/components/schemas/...` `$ref` defined in `server/openapi.js`.
|
||||
const schemas = {};
|
||||
|
||||
const paths = {
|
||||
"/api/events": {
|
||||
get: {
|
||||
tags: ["Events"],
|
||||
summary: "List events with multi-dimensional filtering",
|
||||
operationId: "listEvents",
|
||||
description:
|
||||
"Returns a paginated, reverse-chronological slice of the `events` table " +
|
||||
"(ordered by `created_at DESC, id DESC`) together with the total row count " +
|
||||
"matching the active filters, so the UI can drive a paginator without a " +
|
||||
"second request.\n\n" +
|
||||
"All four entity filters — `event_type`, `tool_name`, `agent_id`, and " +
|
||||
"`session_id` — accept a **comma-separated list (CSV)** of values and match " +
|
||||
"with `IN (...)` semantics: passing `event_type=Stop,PreToolUse` returns rows " +
|
||||
"whose `event_type` is either `Stop` OR `PreToolUse`. Values are trimmed and " +
|
||||
"blank entries are dropped. Filters are combined with one another using AND.\n\n" +
|
||||
"`q` performs a case-insensitive substring (`LIKE %q%`) search across the " +
|
||||
"`summary`, `tool_name`, and the JSON-encoded `data` columns. `from`/`to` are " +
|
||||
"inclusive ISO-8601 datetime bounds on `created_at`; unparseable values are " +
|
||||
"ignored rather than rejected. `limit` is clamped to 1–500 (default 50) and " +
|
||||
"`offset` is clamped to >= 0 (default 0).\n\n" +
|
||||
"Note: each returned event's `data` field is a **JSON-encoded string**, not a " +
|
||||
"nested object — callers must `JSON.parse` it to inspect the payload.",
|
||||
parameters: [
|
||||
{
|
||||
in: "query",
|
||||
name: "event_type",
|
||||
description:
|
||||
"Comma-separated (CSV) list of `event_type` values; matched with IN semantics " +
|
||||
"(OR within the list). Common values: PreToolUse, PostToolUse, Stop, " +
|
||||
"SubagentStop, Notification, SessionStart, SessionEnd.",
|
||||
schema: { type: "string" },
|
||||
example: "Stop,PreToolUse",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "tool_name",
|
||||
description:
|
||||
"Comma-separated (CSV) list of `tool_name` values; matched with IN semantics " +
|
||||
"(OR within the list). Common values: Bash, Edit, Read, Write, Grep, Glob, Task.",
|
||||
schema: { type: "string" },
|
||||
example: "Bash,Edit",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "agent_id",
|
||||
description:
|
||||
"Comma-separated (CSV) list of `agent_id` values; matched with IN semantics. " +
|
||||
"The main agent of a session uses the id `<session_id>-main`.",
|
||||
schema: { type: "string" },
|
||||
example: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "session_id",
|
||||
description:
|
||||
"Comma-separated (CSV) list of `session_id` values; matched with IN semantics " +
|
||||
"(OR within the list).",
|
||||
schema: { type: "string" },
|
||||
example: "8f3c2a10-1b2c-4d5e-9f80-112233445566,2a7d9e44-3c1f-4a6b-bc20-aabbccddeeff",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "q",
|
||||
description:
|
||||
"Case-insensitive substring search (`LIKE %q%`) applied across the `summary`, " +
|
||||
"`tool_name`, and JSON-encoded `data` columns.",
|
||||
schema: { type: "string" },
|
||||
example: "curl",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "from",
|
||||
description:
|
||||
"ISO-8601 datetime lower bound (inclusive) on `created_at`. Unparseable values " +
|
||||
"are ignored.",
|
||||
schema: { type: "string", format: "date-time" },
|
||||
example: "2026-06-25T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "to",
|
||||
description:
|
||||
"ISO-8601 datetime upper bound (inclusive) on `created_at`. Unparseable values " +
|
||||
"are ignored.",
|
||||
schema: { type: "string", format: "date-time" },
|
||||
example: "2026-06-26T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
},
|
||||
{
|
||||
in: "query",
|
||||
name: "limit",
|
||||
description: "Max rows to return; clamped to 1–500 (default 50).",
|
||||
schema: { type: "integer", minimum: 1, maximum: 500, default: 50 },
|
||||
example: 50,
|
||||
},
|
||||
{ $ref: "#/components/parameters/OffsetQuery" },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event list with total count for pagination",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/EventsListResponse" },
|
||||
example: {
|
||||
events: [
|
||||
{
|
||||
id: 48213,
|
||||
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
|
||||
agent_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
|
||||
event_type: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
summary: "Bash: curl -s https://api.example.com/health",
|
||||
data: '{"session_id":"8f3c2a10-1b2c-4d5e-9f80-112233445566","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"curl -s https://api.example.com/health","description":"Check upstream health"},"cwd":"/Users/dev/project"}',
|
||||
created_at: "2026-06-25T18:42:07.512Z",
|
||||
},
|
||||
{
|
||||
id: 48212,
|
||||
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
|
||||
agent_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
|
||||
event_type: "Stop",
|
||||
tool_name: null,
|
||||
summary: "Session finished responding",
|
||||
data: '{"session_id":"8f3c2a10-1b2c-4d5e-9f80-112233445566","hook_event_name":"Stop"}',
|
||||
created_at: "2026-06-25T18:41:55.004Z",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
total: 1342,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/events/facets": {
|
||||
get: {
|
||||
tags: ["Events"],
|
||||
summary: "Distinct event_type and tool_name values available in the DB",
|
||||
operationId: "listEventFacets",
|
||||
description:
|
||||
"Returns the distinct, non-null `event_type` and `tool_name` values currently " +
|
||||
"present in the `events` table, each sorted alphabetically. The UI uses this to " +
|
||||
"populate the filter dropdowns on the Events screen without hardcoding the set of " +
|
||||
"tools or hook types — so the lists automatically reflect whatever has actually " +
|
||||
"been ingested. Both arrays are independent and may be empty when the table holds " +
|
||||
"no matching rows.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Facet values for populating filter dropdowns",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/EventsFacetsResponse" },
|
||||
example: {
|
||||
event_types: [
|
||||
"Notification",
|
||||
"PostToolUse",
|
||||
"PreToolUse",
|
||||
"SessionEnd",
|
||||
"SessionStart",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
],
|
||||
tool_names: ["Bash", "Edit", "Glob", "Grep", "Read", "Task", "Write"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/stats": {
|
||||
get: {
|
||||
tags: ["Stats"],
|
||||
summary: "Get aggregate dashboard stats",
|
||||
operationId: "getStats",
|
||||
description:
|
||||
"Returns the headline counters shown across the top of the dashboard: total and " +
|
||||
"active session/agent counts, total event count, today's event count, and the " +
|
||||
"current number of live WebSocket connections.\n\n" +
|
||||
"The overview counters are spread at the top level of the response object. Two " +
|
||||
"additional maps, `agents_by_status` and `sessions_by_status`, break the counts " +
|
||||
"down by lifecycle status (e.g. agents: working/waiting/completed/error; sessions: " +
|
||||
"active/completed/error/abandoned). **Statuses with a zero count are omitted from " +
|
||||
"these maps**, so callers must not assume every status key is present.\n\n" +
|
||||
"`events_today` is computed in the caller's local day. Pass `tz_offset` as the " +
|
||||
"minutes value from JavaScript's `Date.prototype.getTimezoneOffset()` (for example " +
|
||||
"`420` for US Pacific Daylight Time, `300` for US Eastern Daylight Time, `0` for " +
|
||||
"UTC). When omitted or non-numeric, the server falls back to UTC (offset 0).",
|
||||
parameters: [
|
||||
{
|
||||
in: "query",
|
||||
name: "tz_offset",
|
||||
description:
|
||||
"Caller timezone offset in MINUTES, as returned by JS " +
|
||||
"`Date.prototype.getTimezoneOffset()` (e.g. 420 for PDT, 300 for EDT, 0 for " +
|
||||
"UTC). Used to bucket `events_today` into the caller's local day. Defaults to " +
|
||||
"0 (UTC) when omitted or non-numeric.",
|
||||
schema: { type: "integer" },
|
||||
example: 420,
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Statistics overview",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/StatsResponse" },
|
||||
example: {
|
||||
total_sessions: 184,
|
||||
active_sessions: 3,
|
||||
active_agents: 5,
|
||||
total_agents: 372,
|
||||
total_events: 28451,
|
||||
events_today: 612,
|
||||
ws_connections: 2,
|
||||
agents_by_status: {
|
||||
working: 4,
|
||||
waiting: 1,
|
||||
completed: 360,
|
||||
error: 7,
|
||||
},
|
||||
sessions_by_status: {
|
||||
active: 3,
|
||||
completed: 175,
|
||||
error: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/analytics": {
|
||||
get: {
|
||||
tags: ["Analytics"],
|
||||
summary: "Get analytics aggregates",
|
||||
operationId: "getAnalytics",
|
||||
description:
|
||||
"Returns the full analytics rollup powering the Analytics screen: aggregate token " +
|
||||
"usage (`tokens`), total estimated spend across all sessions (`total_cost`, in USD, " +
|
||||
"computed from the configured pricing rules), per-tool invocation counts " +
|
||||
"(`tool_usage`), per-day event and session time series (`daily_events`, " +
|
||||
"`daily_sessions`), the distribution of subagent types (`agent_types`), per-type " +
|
||||
"event counts (`event_types`), the mean number of events per session " +
|
||||
"(`avg_events_per_session`), the total subagent count (`total_subagents`), and a " +
|
||||
"nested `overview` object mirroring the headline session/agent/event counters.\n\n" +
|
||||
"As with `/api/stats`, the top-level `agents_by_status` and `sessions_by_status` " +
|
||||
"maps **omit statuses whose count is zero**. The `agent_types[].subagent_type` field " +
|
||||
"may be `null` for the main agent / untyped subagents.\n\n" +
|
||||
"The daily time series are bucketed by the caller's local day. Pass `tz_offset` as " +
|
||||
"the minutes value from JS `Date.prototype.getTimezoneOffset()` (e.g. `420` for " +
|
||||
"PDT). When omitted or non-numeric, the server buckets in UTC.",
|
||||
parameters: [
|
||||
{
|
||||
in: "query",
|
||||
name: "tz_offset",
|
||||
description:
|
||||
"Caller timezone offset in MINUTES, as returned by JS " +
|
||||
"`Date.prototype.getTimezoneOffset()` (e.g. 420 for PDT, 300 for EDT, 0 for " +
|
||||
"UTC). Used to bucket the `daily_events` / `daily_sessions` time series into " +
|
||||
"the caller's local day. Defaults to UTC when omitted or non-numeric.",
|
||||
schema: { type: "integer" },
|
||||
example: 420,
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Analytics response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AnalyticsResponse" },
|
||||
example: {
|
||||
tokens: {
|
||||
total_input: 4821002,
|
||||
total_output: 1933517,
|
||||
total_cache_read: 19288440,
|
||||
total_cache_write: 2044120,
|
||||
},
|
||||
total_cost: 42.7183,
|
||||
tool_usage: [
|
||||
{ tool_name: "Bash", count: 5821 },
|
||||
{ tool_name: "Read", count: 4310 },
|
||||
{ tool_name: "Edit", count: 2980 },
|
||||
{ tool_name: "Grep", count: 1744 },
|
||||
],
|
||||
daily_events: [
|
||||
{ date: "2026-06-23", count: 488 },
|
||||
{ date: "2026-06-24", count: 921 },
|
||||
{ date: "2026-06-25", count: 612 },
|
||||
],
|
||||
daily_sessions: [
|
||||
{ date: "2026-06-23", count: 4 },
|
||||
{ date: "2026-06-24", count: 9 },
|
||||
{ date: "2026-06-25", count: 6 },
|
||||
],
|
||||
agent_types: [
|
||||
{ subagent_type: null, count: 184 },
|
||||
{ subagent_type: "general-purpose", count: 96 },
|
||||
{ subagent_type: "Explore", count: 71 },
|
||||
{ subagent_type: "code-reviewer", count: 21 },
|
||||
],
|
||||
event_types: [
|
||||
{ event_type: "PreToolUse", count: 14210 },
|
||||
{ event_type: "PostToolUse", count: 13988 },
|
||||
{ event_type: "Stop", count: 168 },
|
||||
{ event_type: "SubagentStop", count: 85 },
|
||||
],
|
||||
avg_events_per_session: 154.6,
|
||||
total_subagents: 188,
|
||||
overview: {
|
||||
total_sessions: 184,
|
||||
active_sessions: 3,
|
||||
active_agents: 5,
|
||||
total_agents: 372,
|
||||
total_events: 28451,
|
||||
},
|
||||
agents_by_status: {
|
||||
working: 4,
|
||||
waiting: 1,
|
||||
completed: 360,
|
||||
error: 7,
|
||||
},
|
||||
sessions_by_status: {
|
||||
active: 3,
|
||||
completed: 175,
|
||||
error: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/hooks/event": {
|
||||
post: {
|
||||
tags: ["Hooks"],
|
||||
summary: "Ingest Claude Code hook event",
|
||||
operationId: "ingestHookEvent",
|
||||
description:
|
||||
"Primary ingestion endpoint for Claude Code lifecycle hooks. The hook handler posts " +
|
||||
"an envelope of the form `{ hook_type, data }`, where `hook_type` is the Claude " +
|
||||
"Code hook name (PreToolUse, PostToolUse, Stop, SubagentStop, Notification, " +
|
||||
"SessionStart, SessionEnd) and `data` carries the raw hook payload — at minimum a " +
|
||||
"`session_id`. The server upserts the session and its main agent on first sight, " +
|
||||
"applies the appropriate lifecycle state transition, extracts token usage and " +
|
||||
"compaction signals from the transcript when present, persists an `events` row " +
|
||||
"(storing `data` as a JSON-encoded string), and broadcasts a `new_event` message " +
|
||||
"over the WebSocket.\n\n" +
|
||||
"On success the response is `{ ok: true, event: { ... } }`, where `event` echoes " +
|
||||
"the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, " +
|
||||
"`tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and " +
|
||||
"non-blocking for the hook caller.\n\n" +
|
||||
"Validation failures return HTTP 400 with an `ErrorResponse` body " +
|
||||
"(`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is " +
|
||||
"missing, and `MISSING_SESSION` when `data.session_id` is absent.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/HookEventRequest" },
|
||||
example: {
|
||||
hook_type: "PreToolUse",
|
||||
data: {
|
||||
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "curl -s https://api.example.com/health",
|
||||
description: "Check upstream health",
|
||||
},
|
||||
cwd: "/Users/dev/project",
|
||||
transcript_path:
|
||||
"/Users/dev/.claude/projects/-Users-dev-project/8f3c2a10-1b2c-4d5e-9f80-112233445566.jsonl",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event processed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/HookEventResponse" },
|
||||
example: {
|
||||
ok: true,
|
||||
event: {
|
||||
session_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566",
|
||||
agent_id: "8f3c2a10-1b2c-4d5e-9f80-112233445566-main",
|
||||
event_type: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
summary: "Bash: curl -s https://api.example.com/health",
|
||||
created_at: "2026-06-25T18:42:07.512Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid hook payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: {
|
||||
code: "MISSING_SESSION",
|
||||
message: "session_id is required in data",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
/**
|
||||
* @file Enriched OpenAPI OVERRIDE operations for the Pricing and Alerts routes.
|
||||
*
|
||||
* These eight paths are ALREADY documented in `server/openapi.js`. This module
|
||||
* re-declares the SAME operations (identical operationId / tags / request &
|
||||
* response `$ref` schema names / parameters) but layers on richer prose
|
||||
* descriptions plus realistic request/response/parameter examples so the
|
||||
* generated Swagger UI is self-explanatory. The wire contract is unchanged —
|
||||
* no new schemas, no new tags. The base `$ref`s under
|
||||
* `#/components/{schemas,parameters}` are reused verbatim.
|
||||
*
|
||||
* Shape: `{ tags: [], schemas: {}, paths: { ... } }`. The `tags` and `schemas`
|
||||
* collections are intentionally empty; everything here is a path-level override.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reusable realistic examples (kept here, NOT as components — examples live
|
||||
// inline on the operations so the override carries no schema/component state).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A representative stored pricing rule (matches PricingRule schema fields). */
|
||||
const PRICING_RULE_EXAMPLE = {
|
||||
model_pattern: "claude-opus-4%",
|
||||
display_name: "Claude Opus 4 (family)",
|
||||
input_per_mtok: 15,
|
||||
output_per_mtok: 75,
|
||||
cache_read_per_mtok: 1.5,
|
||||
cache_write_per_mtok: 18.75,
|
||||
cache_write_1h_per_mtok: 30,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
updated_at: "2026-06-25T18:42:11.000Z",
|
||||
};
|
||||
|
||||
/** A second rule to make the list example look like a real catalog. */
|
||||
const PRICING_RULE_EXAMPLE_2 = {
|
||||
model_pattern: "claude-haiku%",
|
||||
display_name: "Claude Haiku (family)",
|
||||
input_per_mtok: 0.8,
|
||||
output_per_mtok: 4,
|
||||
cache_read_per_mtok: 0.08,
|
||||
cache_write_per_mtok: 1,
|
||||
cache_write_1h_per_mtok: 1.6,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
updated_at: "2026-06-20T09:15:00.000Z",
|
||||
};
|
||||
|
||||
/** A full CostResult-shaped example body returned by both cost endpoints. */
|
||||
const COST_RESULT_EXAMPLE = {
|
||||
total_cost: 12.8431,
|
||||
breakdown: [
|
||||
{
|
||||
model: "claude-opus-4-8",
|
||||
speed: "standard",
|
||||
inference_geo: "global",
|
||||
service_tier: "standard",
|
||||
input_tokens: 184320,
|
||||
output_tokens: 51200,
|
||||
cache_read_tokens: 920000,
|
||||
cache_write_tokens: 64000,
|
||||
cache_write_1h_tokens: 12000,
|
||||
web_search_requests: 8,
|
||||
web_fetch_requests: 3,
|
||||
code_execution_requests: 2,
|
||||
cost: 8.4127,
|
||||
matched_rule: "claude-opus-4%",
|
||||
},
|
||||
{
|
||||
model: "claude-haiku-4-5",
|
||||
speed: "fast",
|
||||
inference_geo: "us",
|
||||
service_tier: "standard",
|
||||
input_tokens: 512000,
|
||||
output_tokens: 128000,
|
||||
cache_read_tokens: 64000,
|
||||
cache_write_tokens: 8000,
|
||||
cache_write_1h_tokens: 0,
|
||||
web_search_requests: 0,
|
||||
web_fetch_requests: 0,
|
||||
code_execution_requests: 0,
|
||||
cost: 1.5904,
|
||||
matched_rule: "claude-haiku%",
|
||||
},
|
||||
],
|
||||
feature_costs: {
|
||||
web_search_cost: 0.08,
|
||||
web_fetch_cost: 0,
|
||||
code_execution_cost: 0,
|
||||
code_execution_hours_estimated: 0.1667,
|
||||
code_execution_free_hours: 50,
|
||||
},
|
||||
unpriced_models: [
|
||||
{
|
||||
model: "claude-experimental-preview",
|
||||
input_tokens: 4096,
|
||||
output_tokens: 2048,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
},
|
||||
],
|
||||
daily_costs: [
|
||||
{ date: "2026-06-23", cost: 3.1102 },
|
||||
{ date: "2026-06-24", cost: 5.7421 },
|
||||
{ date: "2026-06-25", cost: 3.9908 },
|
||||
],
|
||||
};
|
||||
|
||||
/** A single fired-alert event row. `details` is a JSON STRING, per the route. */
|
||||
const ALERT_EVENT_EXAMPLE = {
|
||||
id: 42,
|
||||
rule_id: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
|
||||
rule_name: "Idle session watchdog",
|
||||
rule_type: "inactivity",
|
||||
message: "Session sess_8f2a has been inactive for 35 minutes",
|
||||
details: '{"session_id":"sess_8f2a","minutes":35,"threshold":30}',
|
||||
acknowledged: 0,
|
||||
created_at: "2026-06-25T17:05:44.000Z",
|
||||
};
|
||||
|
||||
/** A serialized alert RULE (config parsed to an object, enabled coerced bool). */
|
||||
const ALERT_RULE_EXAMPLE = {
|
||||
id: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
|
||||
name: "Idle session watchdog",
|
||||
rule_type: "inactivity",
|
||||
config: { minutes: 30 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 300,
|
||||
created_at: "2026-06-10T12:00:00.000Z",
|
||||
updated_at: "2026-06-24T08:30:00.000Z",
|
||||
};
|
||||
|
||||
/** A second rule of a different type for the list example. */
|
||||
const ALERT_RULE_EXAMPLE_2 = {
|
||||
id: "1a2b3c4d-5e6f-7081-9201-aabbccddeeff",
|
||||
name: "Heavy token burn",
|
||||
rule_type: "token_threshold",
|
||||
config: { total_tokens: 5000000 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 600,
|
||||
created_at: "2026-06-12T14:20:00.000Z",
|
||||
updated_at: "2026-06-12T14:20:00.000Z",
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// No new tags — reuse the base "Pricing" and "Alerts" tags.
|
||||
tags: [],
|
||||
// No new schemas — every $ref below points at the base components.
|
||||
schemas: {},
|
||||
paths: {
|
||||
// -----------------------------------------------------------------------
|
||||
// PRICING
|
||||
// -----------------------------------------------------------------------
|
||||
"/api/pricing": {
|
||||
get: {
|
||||
tags: ["Pricing"],
|
||||
summary: "List pricing rules",
|
||||
operationId: "listPricingRules",
|
||||
description:
|
||||
"Returns every stored pricing rule, wrapped as `{ pricing: [ ... ] }`. " +
|
||||
"Each rule carries per-MTok (per-million-token) rates for input, output, " +
|
||||
"cache reads, and the two cache-write tiers (5-minute and 1-hour " +
|
||||
"ephemeral), plus optional fast-mode input/output rates (0 = not " +
|
||||
"configured). Rules are matched against model ids by treating the SQL " +
|
||||
"`%` wildcard in `model_pattern` as `.*`; when several rules match, the " +
|
||||
"longest (most specific) pattern wins. Rates here feed the cost " +
|
||||
"calculations under `/api/pricing/cost`.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Pricing rules",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PricingListResponse" },
|
||||
example: { pricing: [PRICING_RULE_EXAMPLE, PRICING_RULE_EXAMPLE_2] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Pricing"],
|
||||
summary: "Create/update pricing rule",
|
||||
operationId: "upsertPricingRule",
|
||||
description:
|
||||
"Creates a pricing rule or updates the existing one with the same " +
|
||||
"`model_pattern` (upsert keyed on `model_pattern`). `model_pattern` and " +
|
||||
"`display_name` are required; every `*_per_mtok` rate is optional and " +
|
||||
"defaults to 0 when omitted. Use the SQL `%` wildcard in `model_pattern` " +
|
||||
"to match a model family (e.g. `claude-opus-4%`). Set " +
|
||||
"`fast_input_per_mtok` / `fast_output_per_mtok` only if the model bills " +
|
||||
"fast-mode usage at a premium; leave them 0 otherwise. " +
|
||||
"Note the asymmetry with the list endpoint: the response wraps a SINGLE " +
|
||||
"stored rule as `{ pricing: <rule> }` (not an array). A missing " +
|
||||
"`model_pattern` or `display_name` returns 400 `INVALID_INPUT`, and so " +
|
||||
"does any `*_per_mtok` rate that is not a non-negative finite number " +
|
||||
"(numeric strings are coerced; NaN and negative rates are rejected " +
|
||||
"before anything is written).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PricingUpsertRequest" },
|
||||
example: {
|
||||
model_pattern: "claude-opus-4%",
|
||||
display_name: "Claude Opus 4 (family)",
|
||||
input_per_mtok: 15,
|
||||
output_per_mtok: 75,
|
||||
cache_read_per_mtok: 1.5,
|
||||
cache_write_per_mtok: 18.75,
|
||||
cache_write_1h_per_mtok: 30,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Pricing rule stored",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PricingUpsertResponse" },
|
||||
example: { pricing: PRICING_RULE_EXAMPLE },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request body",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
message: "model_pattern and display_name are required",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/pricing/{pattern}": {
|
||||
delete: {
|
||||
tags: ["Pricing"],
|
||||
summary: "Delete pricing rule",
|
||||
operationId: "deletePricingRule",
|
||||
description:
|
||||
"Deletes the pricing rule whose `model_pattern` exactly matches the " +
|
||||
"`pattern` path segment. The pattern is URL-ENCODED: the SQL `%` " +
|
||||
"wildcard must be sent as `%25` (so `claude-opus-4%` becomes " +
|
||||
"`claude-opus-4%25`). The server decodes it before lookup. Returns " +
|
||||
"`{ ok: true }` on success, or 404 `NOT_FOUND` if no rule matches.",
|
||||
parameters: [
|
||||
// Mirrors components.parameters.PatternPath (name/in/required/schema
|
||||
// identical), inlined so a realistic URL-encoded example can be
|
||||
// attached — a bare $ref cannot carry an `example`.
|
||||
{
|
||||
name: "pattern",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Model pattern (URL-encoded). The SQL `%` wildcard must be escaped " +
|
||||
"as `%25` (e.g. `claude-opus-4%25` for the rule `claude-opus-4%`).",
|
||||
example: "claude-opus-4%25",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Rule deleted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/DeleteOkResponse" },
|
||||
example: { ok: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Pricing rule not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "NOT_FOUND", message: "Pricing rule not found" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/pricing/cost": {
|
||||
get: {
|
||||
tags: ["Pricing"],
|
||||
summary: "Get total token cost across all sessions",
|
||||
operationId: "getTotalCost",
|
||||
description:
|
||||
"Computes the aggregate token cost across EVERY session by matching " +
|
||||
"each (model, speed, inference_geo, service_tier) usage bucket against " +
|
||||
"the most specific pricing rule. Returns `total_cost`, a per-bucket " +
|
||||
"`breakdown`, `feature_costs` (web-search surcharge, code-execution " +
|
||||
"container time with the org free-hours allowance applied), " +
|
||||
"`unpriced_models` (usage with no matching rule, contributing $0 so the " +
|
||||
"total stays honest), and `daily_costs` bucketed by local calendar day. " +
|
||||
"Pass `tz_offset` (minutes; the JS `Date.getTimezoneOffset()` value, " +
|
||||
"e.g. 300 for US Eastern, -120 for CEST) so day boundaries align with " +
|
||||
"the viewer's timezone; omitted/invalid offsets fall back to UTC. " +
|
||||
"Honors the `sources` data-scope filter, like the sessions / stats / " +
|
||||
"analytics endpoints, so the reported cost matches the active scope.",
|
||||
parameters: [
|
||||
{
|
||||
name: "tz_offset",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer" },
|
||||
description:
|
||||
"Viewer timezone offset in minutes, as returned by " +
|
||||
"`Date.getTimezoneOffset()` (positive for zones behind UTC, e.g. " +
|
||||
"300 = US Eastern, -120 = CEST). Shifts the `daily_costs` day " +
|
||||
"boundaries; invalid or omitted values default to UTC.",
|
||||
example: 300,
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Comma-separated data-source ids to include (local history is " +
|
||||
"`local`; remote SSH machines use their `remote_sources.id`). Omit " +
|
||||
"for all sources. Narrows the aggregate cost to the given origins.",
|
||||
example: "local",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Cost result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CostResult" },
|
||||
example: COST_RESULT_EXAMPLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/pricing/cost/{sessionId}": {
|
||||
get: {
|
||||
tags: ["Pricing"],
|
||||
summary: "Get token cost for one session",
|
||||
operationId: "getSessionCost",
|
||||
description:
|
||||
"Same cost computation as `/api/pricing/cost`, but scoped to a single " +
|
||||
"session's token usage. Returns the identical `CostResult` shape " +
|
||||
"(`total_cost`, `breakdown`, `feature_costs`, `unpriced_models`, " +
|
||||
"`daily_costs`); `daily_costs` holds at most one entry — the session's " +
|
||||
"start date in the viewer's local day, or an empty array if the session " +
|
||||
"id is unknown. Pass `tz_offset` (minutes, `Date.getTimezoneOffset()`) " +
|
||||
"to place that start date in the viewer's timezone; defaults to UTC.",
|
||||
parameters: [
|
||||
{
|
||||
name: "sessionId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Session ID to price.",
|
||||
example: "sess_8f2a3b1c",
|
||||
},
|
||||
{
|
||||
name: "tz_offset",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer" },
|
||||
description:
|
||||
"Viewer timezone offset in minutes (`Date.getTimezoneOffset()`; " +
|
||||
"300 = US Eastern, -120 = CEST). Places the session start date in " +
|
||||
"the viewer's local day; defaults to UTC when omitted or invalid.",
|
||||
example: 300,
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session cost result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CostResult" },
|
||||
example: {
|
||||
...COST_RESULT_EXAMPLE,
|
||||
total_cost: 8.4127,
|
||||
daily_costs: [{ date: "2026-06-25", cost: 8.4127 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// -----------------------------------------------------------------------
|
||||
// ALERTS
|
||||
// -----------------------------------------------------------------------
|
||||
"/api/alerts": {
|
||||
get: {
|
||||
tags: ["Alerts"],
|
||||
summary: "List fired alerts, newest first",
|
||||
operationId: "listAlerts",
|
||||
description:
|
||||
"Returns the fired-alert feed, newest first, as " +
|
||||
"`{ alerts, total, unacked, limit, offset }`. Each alert event carries " +
|
||||
"the originating rule's id/name/type, a human-readable `message`, an " +
|
||||
"`acknowledged` flag (0/1), `created_at`, and `details` — which is a " +
|
||||
"JSON STRING (not an object) that callers must `JSON.parse`. " +
|
||||
"`limit` is clamped to 1–200 (default 50) and negative `offset` is " +
|
||||
"clamped to 0. Set `unacked=true` to return only unacknowledged alerts; " +
|
||||
"`total` then counts only unacked rows, while `unacked` always reports " +
|
||||
"the global unacknowledged count.",
|
||||
parameters: [
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer", minimum: 1, maximum: 200, default: 50 },
|
||||
description:
|
||||
"Page size, clamped to the 1–200 range (default 50). Values " +
|
||||
"outside the range are clamped, not rejected.",
|
||||
example: 50,
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer", minimum: 0 },
|
||||
description: "Pagination offset; negative values are clamped to 0.",
|
||||
example: 0,
|
||||
},
|
||||
{
|
||||
name: "unacked",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "boolean" },
|
||||
description:
|
||||
'When the literal string "true", return only unacknowledged ' +
|
||||
"alerts (and scope `total` to that subset).",
|
||||
example: "true",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Paginated alert feed with total and unacked counts",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description: "Includes alerts[], total, unacked, limit, offset.",
|
||||
},
|
||||
example: {
|
||||
alerts: [
|
||||
ALERT_EVENT_EXAMPLE,
|
||||
{
|
||||
id: 41,
|
||||
rule_id: "1a2b3c4d-5e6f-7081-9201-aabbccddeeff",
|
||||
rule_name: "Heavy token burn",
|
||||
rule_type: "token_threshold",
|
||||
message: "Session sess_3c1d crossed 5,000,000 total tokens",
|
||||
details:
|
||||
'{"session_id":"sess_3c1d","total_tokens":5120000,"threshold":5000000}',
|
||||
acknowledged: 1,
|
||||
created_at: "2026-06-25T16:40:02.000Z",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
unacked: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/alerts/rules": {
|
||||
get: {
|
||||
tags: ["Alerts"],
|
||||
summary: "List alert rules",
|
||||
operationId: "listAlertRules",
|
||||
description:
|
||||
"Returns all alert rules as `{ rules: [ ... ] }`. Each rule's `config` " +
|
||||
"is returned as a PARSED object (the column is stored as JSON text), and " +
|
||||
"`enabled` is coerced to a boolean. The `config` shape depends on " +
|
||||
"`rule_type`: `event_pattern` uses event_type / tool_name / " +
|
||||
"summary_contains plus optional count + window_minutes; `inactivity` " +
|
||||
"uses `minutes`; `status_duration` uses `status` + `minutes`; " +
|
||||
"`token_threshold` uses `total_tokens`.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "All alert rules with parsed config objects",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: { rules: [ALERT_RULE_EXAMPLE, ALERT_RULE_EXAMPLE_2] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Alerts"],
|
||||
summary: "Create an alert rule",
|
||||
operationId: "createAlertRule",
|
||||
description:
|
||||
"Creates an alert rule and returns it serialized as `{ rule: { ... } }` " +
|
||||
"with HTTP 201. `name`, `rule_type`, and `config` are required; the " +
|
||||
"`config` shape is validated per `rule_type`:\n" +
|
||||
"- `event_pattern`: `{ event_type?, tool_name?, summary_contains?, " +
|
||||
"count?, window_minutes? }` — fires when matching events accumulate.\n" +
|
||||
"- `inactivity`: `{ minutes }` — fires when a session goes idle.\n" +
|
||||
"- `status_duration`: `{ status, minutes }` — fires when a session " +
|
||||
"holds a status too long.\n" +
|
||||
"- `token_threshold`: `{ total_tokens }` — fires when usage crosses a " +
|
||||
"ceiling.\n" +
|
||||
"`enabled` defaults to true and `cooldown_seconds` defaults to 300 " +
|
||||
"(must be a non-negative integer). A bad name, unknown `rule_type`, " +
|
||||
"invalid `config`, or negative cooldown returns 400 `INVALID_INPUT`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "rule_type", "config"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
rule_type: {
|
||||
type: "string",
|
||||
enum: ["event_pattern", "inactivity", "status_duration", "token_threshold"],
|
||||
},
|
||||
config: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Type-specific config. event_pattern: event_type/tool_name/summary_contains + optional count/window_minutes. inactivity: minutes. status_duration: status + minutes. token_threshold: total_tokens.",
|
||||
},
|
||||
enabled: { type: "boolean", default: true },
|
||||
cooldown_seconds: { type: "integer", default: 300 },
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
inactivity: {
|
||||
summary: "Inactivity rule",
|
||||
value: {
|
||||
name: "Idle session watchdog",
|
||||
rule_type: "inactivity",
|
||||
config: { minutes: 30 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 300,
|
||||
},
|
||||
},
|
||||
event_pattern: {
|
||||
summary: "Event-pattern rule (repeated tool errors)",
|
||||
value: {
|
||||
name: "Repeated Bash failures",
|
||||
rule_type: "event_pattern",
|
||||
config: {
|
||||
event_type: "PostToolUse",
|
||||
tool_name: "Bash",
|
||||
summary_contains: "error",
|
||||
count: 3,
|
||||
window_minutes: 10,
|
||||
},
|
||||
enabled: true,
|
||||
cooldown_seconds: 600,
|
||||
},
|
||||
},
|
||||
status_duration: {
|
||||
summary: "Status-duration rule",
|
||||
value: {
|
||||
name: "Stuck waiting too long",
|
||||
rule_type: "status_duration",
|
||||
config: { status: "waiting", minutes: 15 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 300,
|
||||
},
|
||||
},
|
||||
token_threshold: {
|
||||
summary: "Token-threshold rule",
|
||||
value: {
|
||||
name: "Heavy token burn",
|
||||
rule_type: "token_threshold",
|
||||
config: { total_tokens: 5000000 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Created rule",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: { rule: ALERT_RULE_EXAMPLE },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Validation error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "INVALID_INPUT", message: "name is required" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/alerts/rules/{id}": {
|
||||
patch: {
|
||||
tags: ["Alerts"],
|
||||
summary: "Update an alert rule (partial; rule_type is immutable)",
|
||||
operationId: "updateAlertRule",
|
||||
description:
|
||||
"Partially updates an alert rule and returns it serialized as " +
|
||||
"`{ rule: { ... } }`. Only the fields present in the body change; " +
|
||||
"`rule_type` CANNOT be changed and any supplied `config` is validated " +
|
||||
"against the rule's STORED type. `name` (if present) must be a " +
|
||||
"non-empty string and `cooldown_seconds` (if present) must be a " +
|
||||
"non-negative integer. Returns 404 `NOT_FOUND` for an unknown id, or " +
|
||||
"400 `INVALID_INPUT` for a bad name, invalid config, or negative " +
|
||||
"cooldown.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Alert rule ID (UUID).",
|
||||
example: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
config: { type: "object", additionalProperties: true },
|
||||
enabled: { type: "boolean" },
|
||||
cooldown_seconds: { type: "integer" },
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
disableRule: {
|
||||
summary: "Disable a rule without touching its config",
|
||||
value: { enabled: false },
|
||||
},
|
||||
retuneInactivity: {
|
||||
summary: "Re-tune an inactivity rule's threshold + cooldown",
|
||||
value: { config: { minutes: 45 }, cooldown_seconds: 900 },
|
||||
},
|
||||
rename: {
|
||||
summary: "Rename a rule",
|
||||
value: { name: "Idle session watchdog (prod)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated rule",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
rule: { ...ALERT_RULE_EXAMPLE, enabled: false, cooldown_seconds: 900 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Validation error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
message: "cooldown_seconds must be a non-negative integer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Rule not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "NOT_FOUND", message: "Alert rule not found" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Alerts"],
|
||||
summary: "Delete an alert rule and its fired-alert history",
|
||||
operationId: "deleteAlertRule",
|
||||
description:
|
||||
"Deletes the alert rule with the given id. Its fired-alert history " +
|
||||
"cascades away with it (the foreign key is ON DELETE CASCADE), so any " +
|
||||
"alerts previously raised by this rule are also removed from the feed. " +
|
||||
"Returns `{ ok: true }` on success or 404 `NOT_FOUND` for an unknown id.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Alert rule ID (UUID).",
|
||||
example: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Deletion confirmation",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: { ok: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Rule not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "NOT_FOUND", message: "Alert rule not found" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/alerts/{id}/ack": {
|
||||
post: {
|
||||
tags: ["Alerts"],
|
||||
summary: "Acknowledge one fired alert",
|
||||
operationId: "ackAlert",
|
||||
description:
|
||||
"Marks a single fired alert (by its integer event id) as acknowledged " +
|
||||
"and returns the updated row as `{ alert: { ... } }` (with " +
|
||||
"`acknowledged: 1`). Acknowledging also broadcasts an `alert_updated` " +
|
||||
"WebSocket message so connected dashboards refresh their unacked badge. " +
|
||||
"The id must be numeric; an unknown id returns 404 `NOT_FOUND`.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "integer" },
|
||||
description: "Alert event ID (numeric).",
|
||||
example: 42,
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Acknowledged alert row",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: { alert: { ...ALERT_EVENT_EXAMPLE, acknowledged: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Alert not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "NOT_FOUND", message: "Alert not found" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/alerts/ack-all": {
|
||||
post: {
|
||||
tags: ["Alerts"],
|
||||
summary: "Acknowledge all unacked alerts",
|
||||
operationId: "ackAllAlerts",
|
||||
description:
|
||||
"Acknowledges every currently unacknowledged alert in one call and " +
|
||||
"returns `{ ok: true, acknowledged: <count> }` where `acknowledged` is " +
|
||||
"the number of rows actually updated. When at least one alert is " +
|
||||
"acknowledged, an `alert_updated` WebSocket message (`{ acked_all: " +
|
||||
"true }`) is broadcast so dashboards clear their unacked badge. Calling " +
|
||||
"this when nothing is unacked returns `acknowledged: 0`.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Count of acknowledged alerts",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: { ok: true, acknowledged: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,757 @@
|
||||
/**
|
||||
* @file Enriched OVERRIDE fragments for the already-documented Sessions and
|
||||
* Agents endpoints. These paths exist in the base spec (server/openapi.js); the
|
||||
* loader (server/openapi-extra.js) merges `paths` with override-on-key
|
||||
* semantics, so the operations below REPLACE the terser base versions while
|
||||
* preserving their contract: same `operationId`, same `tags`, and the same
|
||||
* request/response `$ref` schema names. The only additions are richer
|
||||
* `description`s and realistic `example`s on every parameter, response media
|
||||
* type, and request body — purely documentation, no contract change.
|
||||
*
|
||||
* No new schemas are defined here (`schemas` is empty by design); everything
|
||||
* reuses the base `components.schemas` and `components.parameters`. Error
|
||||
* responses keep referencing the base `ErrorResponse` ({ error: { code,
|
||||
* message } }). The Sessions/Agents tags are already declared in the base
|
||||
* literal, so `tags` is intentionally empty.
|
||||
*
|
||||
* Covers:
|
||||
* - GET /api/sessions (listSessions)
|
||||
* - POST /api/sessions (createSession)
|
||||
* - GET /api/sessions/{id} (getSession)
|
||||
* - PATCH /api/sessions/{id} (updateSession)
|
||||
* - GET /api/sessions/{id}/stats (getSessionStats)
|
||||
* - GET /api/sessions/{id}/transcripts (listSessionTranscripts)
|
||||
* - GET /api/sessions/{id}/transcript (getSessionTranscript)
|
||||
* - GET /api/agents (listAgents)
|
||||
* - POST /api/agents (createAgent)
|
||||
* - GET /api/agents/{id} (getAgent)
|
||||
* - PATCH /api/agents/{id} (updateAgent)
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [];
|
||||
|
||||
const schemas = {};
|
||||
|
||||
// --- Reusable realistic example fixtures ----------------------------------
|
||||
// Keep these consistent with the route handlers in server/routes/sessions.js
|
||||
// and server/routes/agents.js. Timestamps are ISO-8601 UTC with millisecond
|
||||
// precision; metadata is a raw JSON-encoded string (the DB column is TEXT).
|
||||
|
||||
const exampleSession = {
|
||||
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Refactor pricing route + add cost endpoint",
|
||||
status: "active",
|
||||
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
model: "claude-opus-4-20250514",
|
||||
started_at: "2026-06-25T14:02:11.004Z",
|
||||
ended_at: null,
|
||||
metadata: '{"source":"hook","git_branch":"feat/spend-budgets"}',
|
||||
updated_at: "2026-06-25T14:31:50.119Z",
|
||||
agent_count: 4,
|
||||
last_activity: "2026-06-25T14:31:50.119Z",
|
||||
cost: 0.8421,
|
||||
awaiting_input_since: null,
|
||||
awaiting_reason: null,
|
||||
};
|
||||
|
||||
const exampleCompletedSession = {
|
||||
id: "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
|
||||
name: "Fix flaky transcript pagination test",
|
||||
status: "completed",
|
||||
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
started_at: "2026-06-24T09:12:00.000Z",
|
||||
ended_at: "2026-06-24T09:48:32.501Z",
|
||||
metadata: null,
|
||||
updated_at: "2026-06-24T09:48:32.501Z",
|
||||
agent_count: 1,
|
||||
last_activity: "2026-06-24T09:48:32.501Z",
|
||||
cost: 0.1532,
|
||||
awaiting_input_since: null,
|
||||
awaiting_reason: null,
|
||||
};
|
||||
|
||||
const exampleMainAgent = {
|
||||
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
|
||||
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
subagent_type: null,
|
||||
status: "working",
|
||||
task: null,
|
||||
current_tool: "Edit",
|
||||
started_at: "2026-06-25T14:02:11.004Z",
|
||||
ended_at: null,
|
||||
parent_agent_id: null,
|
||||
metadata: '{"model":"claude-opus-4-20250514"}',
|
||||
updated_at: "2026-06-25T14:31:50.119Z",
|
||||
awaiting_input_since: null,
|
||||
awaiting_reason: null,
|
||||
};
|
||||
|
||||
const exampleSubagent = {
|
||||
id: "ad18a79192af10ed1",
|
||||
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Explore pricing module",
|
||||
type: "subagent",
|
||||
subagent_type: "Explore",
|
||||
status: "completed",
|
||||
task: "Map every caller of calculateCost() across server/routes",
|
||||
current_tool: null,
|
||||
started_at: "2026-06-25T14:10:22.310Z",
|
||||
ended_at: "2026-06-25T14:14:09.882Z",
|
||||
parent_agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
|
||||
metadata: null,
|
||||
updated_at: "2026-06-25T14:14:09.882Z",
|
||||
awaiting_input_since: null,
|
||||
awaiting_reason: null,
|
||||
};
|
||||
|
||||
const exampleEvent = {
|
||||
id: 48213,
|
||||
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
|
||||
event_type: "PostToolUse",
|
||||
tool_name: "Edit",
|
||||
summary: "Edited server/routes/pricing.js",
|
||||
data: '{"tool_input":{"file_path":"server/routes/pricing.js"},"tool_response":{"success":true}}',
|
||||
created_at: "2026-06-25T14:31:50.119Z",
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/sessions": {
|
||||
get: {
|
||||
tags: ["Sessions"],
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Returns a paginated list of sessions, newest activity first, each enriched with a SQL `agent_count` (LEFT JOIN onto agents), a `last_activity` alias of `updated_at`, and a `cost` computed from the session's token usage against the current pricing rules. The `status` and `q` filters compose (AND) with each other and with pagination; `q` is a case-insensitive LIKE across `id`, `name`, and `cwd`. `total` reflects all rows matching the filters independent of `limit`/`offset` so paginators stay accurate, while `cost` is only calculated for the rows on the returned page (when `sort_by=price` it is computed across all matching rows so the price sort is correct). The endpoint is read-only with no side effects; `metadata` on each session is returned as a raw JSON-encoded string, not a parsed object.",
|
||||
operationId: "listSessions",
|
||||
parameters: [
|
||||
{ $ref: "#/components/parameters/SessionStatusQuery", example: "active" },
|
||||
{
|
||||
name: "q",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.",
|
||||
example: "pricing",
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
},
|
||||
{ $ref: "#/components/parameters/LimitQuery", example: 50 },
|
||||
{ $ref: "#/components/parameters/OffsetQuery", example: 0 },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionsListResponse" },
|
||||
example: {
|
||||
sessions: [exampleSession, exampleCompletedSession],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
total: 137,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Sessions"],
|
||||
summary: "Create session (idempotent)",
|
||||
description:
|
||||
'Creates a session keyed by `id`. The operation is idempotent: if a session with that `id` already exists it is returned untouched with `created: false` and HTTP 200; only a brand-new row yields `created: true` and HTTP 201. New sessions are inserted with `status: "active"` and any omitted optional fields stored as null. The `metadata` field is accepted as a JSON object in the request but persisted (and returned on the session) as a JSON-encoded string. A successful create broadcasts a `session_created` websocket frame. A missing `id` returns 400 with code `INVALID_INPUT`.',
|
||||
operationId: "createSession",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionCreateRequest" },
|
||||
example: {
|
||||
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Refactor pricing route + add cost endpoint",
|
||||
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
model: "claude-opus-4-20250514",
|
||||
metadata: { source: "hook", git_branch: "feat/spend-budgets" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Session created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionCreateResponse" },
|
||||
example: {
|
||||
session: {
|
||||
...exampleSession,
|
||||
agent_count: 0,
|
||||
cost: 0,
|
||||
last_activity: "2026-06-25T14:02:11.004Z",
|
||||
updated_at: "2026-06-25T14:02:11.004Z",
|
||||
},
|
||||
created: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
200: {
|
||||
description: "Session already exists",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionCreateResponse" },
|
||||
example: {
|
||||
session: exampleSession,
|
||||
created: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request body",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "INVALID_INPUT", message: "id is required" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sessions/{id}": {
|
||||
get: {
|
||||
tags: ["Sessions"],
|
||||
summary: "Get session details",
|
||||
description:
|
||||
"Returns a single session together with all of its agents (chronological) and persisted events. Read-only, no side effects. The session's `metadata` and each event's `data` are returned as raw JSON-encoded strings, not parsed objects. Returns 404 with code `NOT_FOUND` when no session matches the path `id`.",
|
||||
operationId: "getSession",
|
||||
parameters: [
|
||||
{
|
||||
$ref: "#/components/parameters/SessionIdPath",
|
||||
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session with associated agents/events",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionDetailResponse" },
|
||||
example: {
|
||||
session: exampleSession,
|
||||
agents: [exampleMainAgent, exampleSubagent],
|
||||
events: [exampleEvent],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Session not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Sessions"],
|
||||
summary: "Update session",
|
||||
description:
|
||||
"Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
operationId: "updateSession",
|
||||
parameters: [
|
||||
{
|
||||
$ref: "#/components/parameters/SessionIdPath",
|
||||
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionUpdateRequest" },
|
||||
example: {
|
||||
status: "completed",
|
||||
ended_at: "2026-06-25T15:07:44.220Z",
|
||||
metadata: { source: "hook", git_branch: "feat/spend-budgets", outcome: "merged" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session updated",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionUpdateResponse" },
|
||||
example: {
|
||||
session: {
|
||||
...exampleSession,
|
||||
status: "completed",
|
||||
ended_at: "2026-06-25T15:07:44.220Z",
|
||||
metadata:
|
||||
'{"source":"hook","git_branch":"feat/spend-budgets","outcome":"merged"}',
|
||||
updated_at: "2026-06-25T15:07:44.220Z",
|
||||
last_activity: "2026-06-25T15:07:44.220Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Session not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sessions/{id}/stats": {
|
||||
get: {
|
||||
tags: ["Sessions"],
|
||||
summary: "Get aggregated session stats",
|
||||
description:
|
||||
"Returns aggregated counts for the SessionOverview panel: total events, events-by-type, the top 15 tools by usage, an error count (events whose `event_type`/`summary` match /error/i or /failed/i), the event time range, agent type/status counts, the subagent-type breakdown (excluding the special `compaction` type, which is surfaced under `agents.compaction`), and token totals. All aggregation runs in SQL, so it stays cheap even for sessions with tens of thousands of events; the endpoint is read-only with no side effects. The frontend debounces calls on `new_event` / `agent_*` / `session_updated` websocket frames so the counters track a running session. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
operationId: "getSessionStats",
|
||||
parameters: [
|
||||
{
|
||||
$ref: "#/components/parameters/SessionIdPath",
|
||||
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Aggregated session stats",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionStatsResponse" },
|
||||
example: {
|
||||
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
total_events: 1284,
|
||||
events_by_type: [
|
||||
{ event_type: "PostToolUse", count: 612 },
|
||||
{ event_type: "PreToolUse", count: 612 },
|
||||
{ event_type: "Notification", count: 41 },
|
||||
{ event_type: "Stop", count: 19 },
|
||||
],
|
||||
tools_used: [
|
||||
{ tool_name: "Bash", count: 188 },
|
||||
{ tool_name: "Edit", count: 143 },
|
||||
{ tool_name: "Read", count: 121 },
|
||||
{ tool_name: "Grep", count: 77 },
|
||||
],
|
||||
error_count: 6,
|
||||
first_event_at: "2026-06-25T14:02:11.052Z",
|
||||
last_event_at: "2026-06-25T14:31:50.119Z",
|
||||
agents: {
|
||||
total: 4,
|
||||
main: 1,
|
||||
subagent: 3,
|
||||
compaction: 1,
|
||||
by_status: { working: 1, completed: 2, error: 1 },
|
||||
},
|
||||
subagent_types: [
|
||||
{ subagent_type: "Explore", count: 2 },
|
||||
{ subagent_type: "general-purpose", count: 1 },
|
||||
],
|
||||
tokens: {
|
||||
input_tokens: 18422,
|
||||
output_tokens: 9134,
|
||||
cache_read_tokens: 1204880,
|
||||
cache_write_tokens: 88210,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Session not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sessions/{id}/transcripts": {
|
||||
get: {
|
||||
tags: ["Sessions"],
|
||||
summary: "List available transcripts for a session",
|
||||
description:
|
||||
"Lists every JSONL transcript file associated with a session — the main agent's transcript plus any subagent and compaction transcripts — by scanning the on-disk Claude project directory (live files, falling back to import-time snapshots). Read-only, no side effects. Each entry carries a best-effort `db_agent_id` resolved by matching transcripts to tracked agents (exact id first, then positional-by-time within each type group); it may be null when a transcript has no matching agent row. Used by the Conversation tab to populate the transcript switcher. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
operationId: "listSessionTranscripts",
|
||||
parameters: [
|
||||
{
|
||||
$ref: "#/components/parameters/SessionIdPath",
|
||||
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of transcripts available for the session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/TranscriptListResponse" },
|
||||
example: {
|
||||
transcripts: [
|
||||
{
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
|
||||
},
|
||||
{
|
||||
id: "ad18a79192af10ed1",
|
||||
name: "Explore pricing module",
|
||||
type: "subagent",
|
||||
subagent_type: "Explore",
|
||||
has_transcript: true,
|
||||
db_agent_id: "ad18a79192af10ed1",
|
||||
},
|
||||
{
|
||||
id: "acompact-7c1e2f90",
|
||||
name: "Context Compaction",
|
||||
type: "compaction",
|
||||
subagent_type: null,
|
||||
has_transcript: true,
|
||||
db_agent_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Session not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sessions/{id}/transcript": {
|
||||
get: {
|
||||
tags: ["Sessions"],
|
||||
summary: "Stream messages from a specific transcript",
|
||||
description:
|
||||
"Returns parsed, renderable messages from a JSONL transcript with cursor-based pagination, reading the live file under ~/.claude/projects and falling back to the durable import-time snapshot. Pass `agent_id` to select a specific subagent or compaction transcript (default is the session's main transcript). Pagination cursors are mutually exclusive: `after` returns messages strictly newer than a JSONL line number (incremental live updates on `new_event`), `before` returns messages strictly older than a line (load-on-scroll-up), and `offset` is legacy start-offset paging. `last_line`/`first_line` are the JSONL line numbers of the newest/oldest returned message — feed them back as `after`/`before`. When the session, transcript file, or path cannot be found the endpoint degrades gracefully to an empty result (`messages: []`, `total: 0`, `has_more: false`) rather than erroring. Read-only, no side effects.",
|
||||
operationId: "getSessionTranscript",
|
||||
parameters: [
|
||||
{
|
||||
$ref: "#/components/parameters/SessionIdPath",
|
||||
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
},
|
||||
{
|
||||
name: "agent_id",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Transcript identifier — 'main' for the session's main transcript, or a subagent / compaction id from /transcripts.",
|
||||
example: "main",
|
||||
},
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
schema: { type: "integer", default: 50, minimum: 1, maximum: 500 },
|
||||
description: "Maximum number of messages to return.",
|
||||
example: 50,
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 0 },
|
||||
description:
|
||||
"Offset from the start of the transcript (mutually exclusive with after/before).",
|
||||
example: 0,
|
||||
},
|
||||
{
|
||||
name: "after",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 0 },
|
||||
description:
|
||||
"Only return messages whose JSONL line number is strictly greater than this value. Used for incremental live updates.",
|
||||
example: 842,
|
||||
},
|
||||
{
|
||||
name: "before",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 0 },
|
||||
description:
|
||||
"Only return messages whose JSONL line number is strictly less than this value. Used to load older messages on scroll-up.",
|
||||
example: 200,
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Parsed messages with cursor metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/TranscriptResponse" },
|
||||
example: {
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
timestamp: "2026-06-25T14:02:11.004Z",
|
||||
content: [
|
||||
{ type: "text", text: "Refactor the pricing route and add a cost endpoint." },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-06-25T14:02:18.771Z",
|
||||
model: "claude-opus-4-20250514",
|
||||
content: [
|
||||
{ type: "thinking", text: "I'll start by reading server/routes/pricing.js." },
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "Read",
|
||||
id: "toolu_01A7c2Df9",
|
||||
input: { file_path: "server/routes/pricing.js" },
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 412, output_tokens: 96 },
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
timestamp: "2026-06-25T14:02:19.330Z",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "toolu_01A7c2Df9",
|
||||
output: 'const { Router } = require("express");\n...',
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1284,
|
||||
has_more: true,
|
||||
last_line: 5310,
|
||||
first_line: 5301,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Session or transcript not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Session not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/agents": {
|
||||
get: {
|
||||
tags: ["Agents"],
|
||||
summary: "List agents",
|
||||
description:
|
||||
"Returns agents, most recent first. Filters are applied with precedence rather than composition: when `session_id` is supplied it wins and returns every agent for that session (ignoring `status` and pagination); otherwise a `status` filter returns paginated agents in that lifecycle state; otherwise all agents are returned paginated. `limit` defaults to 10000 when not a positive integer. Read-only, no side effects. Each agent's `metadata` is returned as a raw JSON-encoded string, not a parsed object.",
|
||||
operationId: "listAgents",
|
||||
parameters: [
|
||||
{ $ref: "#/components/parameters/AgentStatusQuery", example: "working" },
|
||||
{
|
||||
$ref: "#/components/parameters/SessionFilterQuery",
|
||||
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
},
|
||||
{ $ref: "#/components/parameters/LimitQuery", example: 50 },
|
||||
{ $ref: "#/components/parameters/OffsetQuery", example: 0 },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Agent list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentsListResponse" },
|
||||
example: {
|
||||
agents: [exampleMainAgent, exampleSubagent],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Agents"],
|
||||
summary: "Create agent (idempotent)",
|
||||
description:
|
||||
'Creates an agent keyed by `id`. The operation is idempotent: if an agent with that `id` already exists it is returned untouched with `created: false` and HTTP 200; only a brand-new row yields `created: true` and HTTP 201. Omitted optional fields default server-side — `type` to `"main"`, `status` to `"waiting"` — and other unspecified columns are stored as null. `metadata` is accepted as a JSON object but persisted (and returned) as a JSON-encoded string. A successful create broadcasts an `agent_created` websocket frame. Missing `id`, `session_id`, or `name` returns 400 with code `INVALID_INPUT`.',
|
||||
operationId: "createAgent",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentCreateRequest" },
|
||||
example: {
|
||||
id: "ad18a79192af10ed1",
|
||||
session_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Explore pricing module",
|
||||
type: "subagent",
|
||||
subagent_type: "Explore",
|
||||
status: "working",
|
||||
task: "Map every caller of calculateCost() across server/routes",
|
||||
parent_agent_id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d-main",
|
||||
metadata: { spawned_by: "Task" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Agent created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentCreateResponse" },
|
||||
example: {
|
||||
agent: {
|
||||
...exampleSubagent,
|
||||
status: "working",
|
||||
ended_at: null,
|
||||
metadata: '{"spawned_by":"Task"}',
|
||||
updated_at: "2026-06-25T14:10:22.310Z",
|
||||
},
|
||||
created: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
200: {
|
||||
description: "Agent already exists",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentCreateResponse" },
|
||||
example: {
|
||||
agent: exampleSubagent,
|
||||
created: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request body",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "INVALID_INPUT", message: "id, session_id, and name are required" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/agents/{id}": {
|
||||
get: {
|
||||
tags: ["Agents"],
|
||||
summary: "Get agent",
|
||||
description:
|
||||
"Returns a single agent by `id`. Read-only, no side effects. The agent's `metadata` is returned as a raw JSON-encoded string, not a parsed object. Returns 404 with code `NOT_FOUND` when no agent matches the path `id`.",
|
||||
operationId: "getAgent",
|
||||
parameters: [{ $ref: "#/components/parameters/AgentIdPath", example: "ad18a79192af10ed1" }],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Agent details",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentDetailResponse" },
|
||||
example: { agent: exampleSubagent },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Agent not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Agent not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Agents"],
|
||||
summary: "Update agent",
|
||||
description:
|
||||
"Partially updates an agent by `id`. Accepts `name`, `status`, `task`, `current_tool`, `ended_at`, and `metadata`. The UPDATE uses COALESCE, so any field omitted (passed as null) leaves the existing column value unchanged — with one deliberate exception: `current_tool` is written through verbatim when present in the body, so it can be explicitly cleared to null (e.g. when a tool call finishes). `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts an `agent_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the agent does not exist.",
|
||||
operationId: "updateAgent",
|
||||
parameters: [{ $ref: "#/components/parameters/AgentIdPath", example: "ad18a79192af10ed1" }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentUpdateRequest" },
|
||||
example: {
|
||||
status: "completed",
|
||||
current_tool: null,
|
||||
ended_at: "2026-06-25T14:14:09.882Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Agent updated",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/AgentUpdateResponse" },
|
||||
example: {
|
||||
agent: {
|
||||
...exampleSubagent,
|
||||
status: "completed",
|
||||
current_tool: null,
|
||||
ended_at: "2026-06-25T14:14:09.882Z",
|
||||
updated_at: "2026-06-25T14:14:09.882Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Agent not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code: "NOT_FOUND", message: "Agent not found" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @file Supplementary OpenAPI 3.0 fragments for the Web Push routes mounted at
|
||||
* `/api/push` (see server/routes/push.js + server/lib/push.js). Exports
|
||||
* `{ tags, schemas, paths }` for merging into the base spec by
|
||||
* `createOpenApiSpec()` via server/openapi-extra.js. Schemas are prefixed
|
||||
* `Push` to avoid collisions with the base component schemas. Error responses
|
||||
* reuse the base `MessageErrorResponse` schema (`{ error: { message } }`),
|
||||
* which is the short shape these routes actually emit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Push",
|
||||
description:
|
||||
"Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app",
|
||||
},
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
PushVapidKeyResponse: {
|
||||
type: "object",
|
||||
required: ["publicKey"],
|
||||
description:
|
||||
"The server's VAPID public key. The browser passes this base64url-encoded key to `PushManager.subscribe({ applicationServerKey })` so the push service will accept deliveries signed by this server's private key.",
|
||||
properties: {
|
||||
publicKey: {
|
||||
type: "string",
|
||||
description:
|
||||
"Base64url-encoded VAPID (P-256 ECDSA) public application server key. Generated once and persisted alongside the SQLite DB so the web app and native apps share one key pair.",
|
||||
example:
|
||||
"BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
PushSubscriptionKeys: {
|
||||
type: "object",
|
||||
required: ["p256dh", "auth"],
|
||||
description:
|
||||
"Client encryption keys produced by the browser's PushManager subscription. Both are required to encrypt Web Push payloads for the endpoint.",
|
||||
properties: {
|
||||
p256dh: {
|
||||
type: "string",
|
||||
description:
|
||||
"Base64url-encoded P-256 ECDH public key from the browser subscription (`subscription.getKey('p256dh')`). Stored verbatim in the `push_subscriptions` table.",
|
||||
example:
|
||||
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
|
||||
},
|
||||
auth: {
|
||||
type: "string",
|
||||
description:
|
||||
"Base64url-encoded auth secret from the browser subscription (`subscription.getKey('auth')`). Stored verbatim in the `push_subscriptions` table.",
|
||||
example: "tBHItJI5svbpez7KI4CCXg",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
PushSubscribeRequest: {
|
||||
type: "object",
|
||||
required: ["endpoint", "keys"],
|
||||
description:
|
||||
"A browser PushSubscription serialized for storage. Persisted via `INSERT OR REPLACE` keyed on `endpoint`, so re-subscribing the same endpoint is idempotent (it overwrites the stored keys rather than duplicating the row).",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
description:
|
||||
"The push service delivery URL from `subscription.endpoint`. Acts as the primary key in `push_subscriptions`; sending later POSTs encrypted payloads here. Subscriptions that return HTTP 410 (Gone) during `/send` are pruned automatically.",
|
||||
example: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
|
||||
},
|
||||
keys: { $ref: "#/components/schemas/PushSubscriptionKeys" },
|
||||
},
|
||||
},
|
||||
|
||||
PushSubscribeResponse: {
|
||||
type: "object",
|
||||
required: ["ok"],
|
||||
description: "Confirmation that the subscription was stored (or overwritten).",
|
||||
properties: {
|
||||
ok: {
|
||||
type: "boolean",
|
||||
enum: [true],
|
||||
description: "Always `true` on success.",
|
||||
example: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
PushUnsubscribeRequest: {
|
||||
type: "object",
|
||||
required: ["endpoint"],
|
||||
description:
|
||||
"Identifies the subscription to delete by its push-service endpoint. NOTE: the endpoint is supplied in the request BODY (DELETE with a JSON body), not as a query parameter.",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
description:
|
||||
"The `endpoint` of the subscription to remove from `push_subscriptions`. Deletion is idempotent — removing an endpoint that is not stored still returns `{ ok: true }`.",
|
||||
example: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
PushOkResponse: {
|
||||
type: "object",
|
||||
required: ["ok"],
|
||||
description: "Generic success acknowledgement returned by subscribe/unsubscribe.",
|
||||
properties: {
|
||||
ok: {
|
||||
type: "boolean",
|
||||
enum: [true],
|
||||
description: "Always `true` on success.",
|
||||
example: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
PushSendRequest: {
|
||||
type: "object",
|
||||
required: ["title", "body"],
|
||||
description:
|
||||
"Notification content to broadcast. Both fields are mandatory; a missing title or body yields a 400. The same title/body is delivered to every reachable surface (native Electron notification + all stored Web Push subscriptions).",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Notification title line.",
|
||||
example: "Session completed",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "Notification body text.",
|
||||
example: "Your Claude Code session finished with 3 subagents.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
PushSendResponse: {
|
||||
type: "object",
|
||||
required: ["ok", "native", "pushed", "failed"],
|
||||
description:
|
||||
"Reports which delivery surfaces actually fired. This lets the client distinguish a real delivery from a silent no-op (no subscribers AND no Electron host), which would otherwise look like success.",
|
||||
properties: {
|
||||
ok: {
|
||||
type: "boolean",
|
||||
enum: [true],
|
||||
description: "Always `true` when dispatch ran without throwing.",
|
||||
example: true,
|
||||
},
|
||||
native: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"`true` when a native OS notification was shown via Electron's main-process Notification API (i.e. the server is hosted inside the desktop app and notifications are supported). `false` under a plain `npm start` host.",
|
||||
example: false,
|
||||
},
|
||||
pushed: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
description:
|
||||
"Count of stored Web Push subscriptions that accepted the encrypted payload (fulfilled `web-push` sends).",
|
||||
example: 2,
|
||||
},
|
||||
failed: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
description:
|
||||
"Count of Web Push sends that were rejected. Subscriptions rejected with HTTP 410 (Gone) are deleted from `push_subscriptions` as part of this request.",
|
||||
example: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/push/vapid-public-key": {
|
||||
get: {
|
||||
tags: ["Push"],
|
||||
summary: "Get the VAPID public key",
|
||||
description:
|
||||
"Returns the server's VAPID public application server key so a browser can register a Web Push subscription via `PushManager.subscribe({ applicationServerKey })`. The key pair is generated once and persisted in the shared data directory alongside the SQLite DB, so the web app and native apps reuse a single key pair across restarts. No authentication — this is a local-first dashboard. Safe to call repeatedly; always returns the same key.",
|
||||
operationId: "pushVapidPublicKey",
|
||||
responses: {
|
||||
200: {
|
||||
description: "The VAPID public key",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushVapidKeyResponse" },
|
||||
example: {
|
||||
publicKey:
|
||||
"BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/push/subscribe": {
|
||||
post: {
|
||||
tags: ["Push"],
|
||||
summary: "Register a Web Push subscription",
|
||||
description:
|
||||
"Stores a browser PushSubscription so future `/api/push/send` broadcasts reach this endpoint. Persisted with `INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth)`, keyed on `endpoint` — so the operation is idempotent: re-subscribing the same endpoint overwrites its keys instead of creating a duplicate. No authentication (local-first). Requires `endpoint`, `keys.p256dh`, and `keys.auth`; any missing field returns 400.",
|
||||
operationId: "pushSubscribe",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushSubscribeRequest" },
|
||||
example: {
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
|
||||
keys: {
|
||||
p256dh:
|
||||
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
|
||||
auth: "tBHItJI5svbpez7KI4CCXg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Subscription stored (created or overwritten)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushSubscribeResponse" },
|
||||
example: { ok: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"Missing required fields (one of `endpoint`, `keys.p256dh`, `keys.auth` was absent)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
|
||||
example: { error: { message: "Missing required fields" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Push"],
|
||||
summary: "Remove a Web Push subscription",
|
||||
description:
|
||||
"Deletes a stored subscription so it stops receiving broadcasts. The endpoint identifier is supplied in the request BODY (a DELETE with a JSON body), NOT as a query parameter. Idempotent — deleting an endpoint that is not stored still returns `{ ok: true }`. No authentication (local-first). A missing `endpoint` returns 400.",
|
||||
operationId: "pushUnsubscribe",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushUnsubscribeRequest" },
|
||||
example: {
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLWZha2UtZW5kcG9pbnQ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Subscription removed (or no-op if it was not stored)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushOkResponse" },
|
||||
example: { ok: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Missing endpoint in the request body",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
|
||||
example: { error: { message: "Missing endpoint" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/push/send": {
|
||||
post: {
|
||||
tags: ["Push"],
|
||||
summary: "Broadcast a notification to all surfaces",
|
||||
description:
|
||||
"Dispatches a notification to every reachable surface at once: it fires a native OS notification via Electron's main-process Notification API when the server is hosted inside the desktop app, AND sends an encrypted Web Push delivery to every stored subscription. Both legs run unconditionally so whichever surface the user is on receives the alert — under `npm start` the native leg is a no-op, and under the desktop app the Web Push leg is typically a no-op (Electron has no FCM credentials, so `push_subscriptions` is empty). Subscriptions rejected with HTTP 410 (Gone) are pruned from `push_subscriptions` during the request. The response reports `{ native, pushed, failed }` so the caller can tell a real delivery from a silent no-op. No authentication (local-first). A missing `title` or `body` returns 400; an unexpected dispatch error returns 500.",
|
||||
operationId: "pushSend",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushSendRequest" },
|
||||
example: {
|
||||
title: "Session completed",
|
||||
body: "Your Claude Code session finished with 3 subagents.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Dispatch ran; the body reports which surfaces fired and how many Web Push deliveries succeeded/failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/PushSendResponse" },
|
||||
example: { ok: true, native: false, pushed: 2, failed: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Missing title or body",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
|
||||
example: { error: { message: "Missing title or body" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Dispatch error while broadcasting the notification",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MessageErrorResponse" },
|
||||
example: { error: { message: "Push service unavailable" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user