diff --git a/src/lib/model/index.ts b/src/lib/model/index.ts index 1a028cc..5ae349a 100644 --- a/src/lib/model/index.ts +++ b/src/lib/model/index.ts @@ -1,6 +1,11 @@ export { DASHBOARD_SCHEMA_VERSION, DashboardDocumentSchema, + DatasourceReferenceSchema, + ServiceEntrySchema, + ServiceGroupSchema, + TelemetryCardSchema, + ThresholdSchema, dashboardDocumentJsonSchema, type DashboardDocument, type DashboardModule, diff --git a/src/lib/model/schema.ts b/src/lib/model/schema.ts index e0215a4..86e3ab7 100644 --- a/src/lib/model/schema.ts +++ b/src/lib/model/schema.ts @@ -27,7 +27,7 @@ const LinkSchema = Type.Object( { additionalProperties: false }, ); -const StaticDatasourceSchema = Type.Object( +export const StaticDatasourceSchema = Type.Object( { type: Type.Literal("static"), label: Type.Optional(Type.String({ minLength: 1 })), @@ -36,7 +36,7 @@ const StaticDatasourceSchema = Type.Object( { additionalProperties: false }, ); -const PlaceholderDatasourceSchema = Type.Object( +export const PlaceholderDatasourceSchema = Type.Object( { type: Type.Literal("placeholder"), reason: Type.String({ minLength: 1 }), @@ -44,7 +44,7 @@ const PlaceholderDatasourceSchema = Type.Object( { additionalProperties: false }, ); -const ExternalDatasourceSchema = Type.Object( +export const ExternalDatasourceSchema = Type.Object( { type: Type.Literal("external"), adapter: Type.Union([ @@ -58,7 +58,7 @@ const ExternalDatasourceSchema = Type.Object( { additionalProperties: false }, ); -const DatasourceReferenceSchema = Type.Union([ +export const DatasourceReferenceSchema = Type.Union([ StaticDatasourceSchema, PlaceholderDatasourceSchema, ExternalDatasourceSchema, @@ -98,13 +98,13 @@ const TextMetricValueSchema = Type.Object( { additionalProperties: false }, ); -const MetricValueSchema = Type.Union([ +export const MetricValueSchema = Type.Union([ PercentMetricValueSchema, NonNegativeMetricValueSchema, TextMetricValueSchema, ]); -const ThresholdSchema = Type.Object( +export const ThresholdSchema = Type.Object( { warning: Type.Optional(Type.Number({ minimum: 0 })), danger: Type.Optional(Type.Number({ minimum: 0 })), diff --git a/src/lib/server/agent-config/agent-config.test.ts b/src/lib/server/agent-config/agent-config.test.ts new file mode 100644 index 0000000..87449e8 --- /dev/null +++ b/src/lib/server/agent-config/agent-config.test.ts @@ -0,0 +1,595 @@ +import { existsSync, rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import type { DashboardDocument } from "$lib/model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store"; +import { + AgentConfigAuthorizationError, + authorizeAgentConfigRequest, + handleAgentDashboardRequest, + previewDashboardChanges, + publishDashboardChanges, + rollbackDashboardRevision, + type AgentDashboardOperation, + type JsonPatchOperation, +} from "."; + +const stores: DashboardStore[] = []; +const tempRoots: string[] = []; + +afterEach(() => { + stores.splice(0).forEach((store) => store.close()); + tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true })); +}); + +describe("agent dashboard configuration API", () => { + test("previews typed dashboard operations with an RFC 6902-compatible patch", () => { + const operations = exampleOperations(); + + const result = previewDashboardChanges(genericDashboardFixture, operations); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected preview success"); + expect(result.document.serviceGroups[0]?.id).toBe("edge"); + expect(result.document.serviceGroups[0]?.services[0]).toMatchObject({ + id: "edge-router", + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://edge.example.test/api/status", + }, + }); + expect(result.document.layout.telemetry).toEqual([ + "service-uptime", + "edge-latency", + "queue-depth", + ]); + expect(genericDashboardFixture.serviceGroups.map((group) => group.id)).toEqual([ + "core-services", + ]); + expect(result.patch.length).toBeGreaterThan(0); + expect(result.patch.every((operation) => operation.path.startsWith("/"))).toBe(true); + expect(result.patch.map((operation) => operation.op)).toContain("add"); + }); + + test("returns structured repairable errors for invalid operations", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "add_service", + groupId: "missing-group", + service: exampleService(), + }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected preview failure"); + expect(result.errors).toEqual([ + expect.objectContaining({ + code: "group_not_found", + operationIndex: 0, + path: "/serviceGroups", + }), + ]); + }); + + test("rejects unsupported target-specific mutations", () => { + const datasourceResult = previewDashboardChanges(genericDashboardFixture, [ + { + type: "connect_datasource", + target: { kind: "statusItem", stripId: "runtime", id: "status" }, + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://status.example.test/api", + }, + }, + ]); + expect(datasourceResult.ok).toBe(false); + if (datasourceResult.ok) throw new Error("expected datasource failure"); + expect(datasourceResult.errors[0]).toMatchObject({ + code: "unsupported_target", + operationIndex: 0, + path: "/statusStrips", + }); + + const thresholdResult = previewDashboardChanges(genericDashboardFixture, [ + { + type: "set_status_rule", + target: { kind: "service", id: "identity" }, + thresholds: { warning: 1 }, + }, + ]); + expect(thresholdResult.ok).toBe(false); + if (thresholdResult.ok) throw new Error("expected threshold failure"); + expect(thresholdResult.errors[0]).toMatchObject({ + code: "unsupported_target", + operationIndex: 0, + path: "/serviceGroups", + }); + }); + + test("removes status strip items through the shared remove operation", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "remove_item", + target: { kind: "statusItem", stripId: "runtime", id: "sync" }, + }, + ]); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected remove success"); + expect(result.document.statusStrips[0]?.items.map((item) => item.id)).toEqual([ + "status", + ]); + expect(result.patch.map((operation) => operation.op)).toContain("remove"); + }); + + test("returns RFC 6902-applicable patches for multiple array removals", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "remove_item", + target: { kind: "statusItem", stripId: "runtime", id: "status" }, + }, + { + type: "remove_item", + target: { kind: "statusItem", stripId: "runtime", id: "sync" }, + }, + ]); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected remove success"); + expect( + result.patch + .filter((operation) => operation.op === "remove") + .map((operation) => operation.path), + ).toEqual(["/statusStrips/0/items/1", "/statusStrips/0/items/0"]); + expect(applyJsonPatch(genericDashboardFixture, result.patch)).toMatchObject({ + statusStrips: [{ items: [] }], + }); + }); + + test("rejects ambiguous service and status targets", () => { + const duplicateDocument = documentWithDuplicateNestedIds(); + + const connect = previewDashboardChanges(duplicateDocument, [ + { + type: "connect_datasource", + target: { kind: "service", id: "identity" }, + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://identity.example.test/health", + }, + }, + ]); + expect(connect.ok).toBe(false); + if (connect.ok) throw new Error("expected ambiguous service failure"); + expect(connect.errors[0]).toMatchObject({ + code: "ambiguous_target", + operationIndex: 0, + path: "/serviceGroups", + }); + + const remove = previewDashboardChanges(duplicateDocument, [ + { + type: "remove_item", + target: { kind: "service", id: "identity" }, + }, + ]); + expect(remove.ok).toBe(false); + if (remove.ok) throw new Error("expected ambiguous removal failure"); + expect(remove.errors[0]).toMatchObject({ + code: "ambiguous_target", + operationIndex: 0, + path: "/serviceGroups", + }); + + const status = previewDashboardChanges(duplicateDocument, [ + { + type: "set_status_rule", + target: { kind: "statusItem", id: "status" }, + value: "Healthy", + }, + ]); + expect(status.ok).toBe(false); + if (status.ok) throw new Error("expected ambiguous status failure"); + expect(status.errors[0]).toMatchObject({ + code: "ambiguous_target", + operationIndex: 0, + path: "/statusStrips", + }); + }); + + test("rejects create_dashboard when it is not the first operation", () => { + const result = previewDashboardChanges(genericDashboardFixture, [ + { + type: "remove_item", + target: { kind: "telemetry", id: "queue-depth" }, + }, + { + type: "create_dashboard", + document: genericDashboardFixture, + }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected sequence failure"); + expect(result.errors[0]).toMatchObject({ + code: "invalid_operation_sequence", + operationIndex: 1, + path: "/1", + }); + }); + + test("publishes valid operations as a persisted dashboard revision", async () => { + const { dbPath, store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + message: "initial dashboard", + }); + + const result = publishDashboardChanges(store, exampleOperations(), { + actor: "agent", + message: "add edge router", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected publish success"); + expect(existsSync(dbPath)).toBe(true); + expect(result.revision.operation).toBe("commit"); + expect(result.revision.actor).toBe("agent"); + expect(result.revision.message).toBe("add edge router"); + expect(result.previousRevisionId).toBe(seed.id); + expect(result.patch.length).toBeGreaterThan(0); + expect(store.getActiveDashboard()?.document.serviceGroups[0]?.id).toBe("edge"); + expect(store.listRevisions()).toHaveLength(2); + }); + + test("does not publish invalid operations", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + + const result = publishDashboardChanges(store, [ + { + type: "remove_item", + target: { kind: "telemetry", id: "missing-metric" }, + }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected publish failure"); + expect(result.errors[0]).toMatchObject({ + code: "item_not_found", + operationIndex: 0, + }); + expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id); + expect(store.listRevisions()).toHaveLength(1); + }); + + test("rolls back through the agent-safe revision path", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + const publish = publishDashboardChanges(store, exampleOperations(), { + actor: "agent", + }); + expect(publish.ok).toBe(true); + + const rollback = rollbackDashboardRevision(store, seed.id, { + actor: "agent", + message: "restore previous dashboard", + }); + + expect(rollback.operation).toBe("rollback"); + expect(rollback.actor).toBe("agent"); + expect(rollback.sourceRevisionId).toBe(seed.id); + expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console"); + expect(store.listRevisions().map((revision) => revision.operation)).toEqual([ + "rollback", + "commit", + "seed", + ]); + }); + + test("authenticates agent configuration requests with a shared token", () => { + const authorized = new Request("https://dimensionlab.test/api/agent/dashboard", { + headers: { authorization: "Bearer shared-secret" }, + }); + const rejected = new Request("https://dimensionlab.test/api/agent/dashboard"); + + expect(authorizeAgentConfigRequest(authorized, "shared-secret")).toEqual({ + ok: true, + }); + expect(() => authorizeAgentConfigRequest(rejected, "shared-secret")).toThrow( + AgentConfigAuthorizationError, + ); + expect(() => authorizeAgentConfigRequest(authorized, "")).toThrow( + AgentConfigAuthorizationError, + ); + }); + + test("handles preview, publish, and rollback requests through the HTTP adapter", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + + const preview = await handleAgentDashboardRequest( + jsonRequest({ + action: "preview_changes", + operations: exampleOperations(), + }), + { store, token: "shared-secret" }, + ); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ + ok: true, + action: "preview_changes", + patch: expect.any(Array), + }); + expect(store.listRevisions()).toHaveLength(1); + + const publish = await handleAgentDashboardRequest( + jsonRequest({ + action: "publish_changes", + actor: "agent", + message: "publish edge router", + operations: exampleOperations(), + }), + { store, token: "shared-secret" }, + ); + expect(publish.status).toBe(200); + expect(await publish.json()).toMatchObject({ + ok: true, + action: "publish_changes", + revision: { operation: "commit", actor: "agent" }, + }); + expect(store.listRevisions()).toHaveLength(2); + + const rollback = await handleAgentDashboardRequest( + jsonRequest({ + action: "rollback_revision", + actor: "agent", + revisionId: seed.id, + }), + { store, token: "shared-secret" }, + ); + expect(rollback.status).toBe(200); + expect(await rollback.json()).toMatchObject({ + ok: true, + action: "rollback_revision", + revision: { operation: "rollback", sourceRevisionId: seed.id }, + }); + }); + + test("returns a structured error for unknown rollback revisions", async () => { + const { store } = await createTestStore(); + store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "seed", + }); + + const rollback = await handleAgentDashboardRequest( + jsonRequest({ + action: "rollback_revision", + actor: "agent", + revisionId: "missing-revision", + }), + { store, token: "shared-secret" }, + ); + + expect(rollback.status).toBe(404); + expect(await rollback.json()).toMatchObject({ + ok: false, + errors: [ + { + code: "revision_not_found", + path: "/revisionId", + }, + ], + }); + }); + + test("previews create_dashboard requests against an empty store", async () => { + const { store } = await createTestStore(); + + const preview = await handleAgentDashboardRequest( + jsonRequest({ + action: "preview_changes", + operations: [ + { + type: "create_dashboard", + document: genericDashboardFixture, + }, + ], + }), + { store, token: "shared-secret" }, + ); + + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ + ok: true, + action: "preview_changes", + document: { + metadata: { + title: "Operations Console", + }, + }, + }); + expect(store.listRevisions()).toHaveLength(0); + }); +}); + +async function createTestStore() { + const root = await mkdtemp(join(tmpdir(), "dimensionlab-agent-config-")); + tempRoots.push(root); + const dbPath = join(root, "dashboard.sqlite"); + const store = createDashboardStore({ + databaseUrl: `file:${dbPath}`, + }); + stores.push(store); + + return { dbPath, store }; +} + +function exampleOperations(): AgentDashboardOperation[] { + return [ + { + type: "add_section", + section: { + id: "edge", + title: "Edge", + layout: "list", + }, + }, + { + type: "add_service", + groupId: "edge", + service: exampleService(), + }, + { + type: "add_metric_card", + card: { + id: "edge-latency", + label: "Edge Latency", + value: { kind: "latency", value: 12, precision: 0 }, + severity: "ok", + detail: "p95", + datasource: { + type: "external", + adapter: "prometheus", + reference: "histogram_quantile(0.95, edge_request_duration_seconds_bucket)", + }, + }, + position: { afterId: "service-uptime" }, + }, + { + type: "connect_datasource", + target: { + kind: "service", + groupId: "edge", + id: "edge-router", + }, + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://edge.example.test/api/status", + }, + }, + { + type: "set_status_rule", + target: { kind: "telemetry", id: "edge-latency" }, + thresholds: { warning: 50, danger: 100 }, + }, + { + type: "arrange_item", + area: "serviceGroups", + id: "edge", + index: 0, + }, + ]; +} + +function exampleService() { + return { + id: "edge-router", + label: "Edge Router", + description: "Ingress and routing", + icon: "mdi:router-network", + severity: "ok" as const, + detail: "pending datasource", + datasource: { type: "placeholder" as const, reason: "health adapter pending" }, + link: { + href: "https://edge.example.test", + label: "Open Edge Router", + external: true, + }, + }; +} + +function documentWithDuplicateNestedIds(): DashboardDocument { + const document = structuredClone(genericDashboardFixture); + document.layout.serviceGroups.push("secondary-services"); + document.serviceGroups.push({ + id: "secondary-services", + title: "Secondary Services", + layout: "list", + services: [ + { + ...document.serviceGroups[0].services[0], + label: "Shadow Identity", + }, + ], + }); + document.layout.statusStrips.push("secondary-runtime"); + document.statusStrips.push({ + id: "secondary-runtime", + items: [ + { + ...document.statusStrips[0].items[0], + value: "Healthy", + }, + ], + }); + return document; +} + +function applyJsonPatch(value: T, patch: JsonPatchOperation[]): T { + const next = structuredClone(value); + for (const operation of patch) { + const { parent, key } = jsonPointerTarget(next, operation.path); + if (operation.op === "remove") { + if (Array.isArray(parent)) { + parent.splice(Number(key), 1); + } else { + delete parent[key]; + } + } else if (operation.op === "add") { + if (Array.isArray(parent)) { + parent.splice(Number(key), 0, operation.value); + } else { + parent[key] = operation.value; + } + } else if (operation.op === "replace") { + if (Array.isArray(parent)) { + parent[Number(key)] = operation.value; + } else { + parent[key] = operation.value; + } + } + } + return next; +} + +function jsonPointerTarget(value: unknown, path: string) { + const segments = path + .split("/") + .slice(1) + .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")); + const key = segments.pop(); + if (key === undefined) throw new Error(`Invalid JSON pointer: ${path}`); + + let parent = value as Record | unknown[]; + for (const segment of segments) { + parent = Array.isArray(parent) + ? (parent[Number(segment)] as Record | unknown[]) + : (parent[segment] as Record | unknown[]); + } + return { parent, key }; +} + +function jsonRequest(body: unknown) { + return new Request("https://dimensionlab.test/api/agent/dashboard", { + body: JSON.stringify(body), + headers: { + authorization: "Bearer shared-secret", + "content-type": "application/json", + }, + method: "POST", + }); +} diff --git a/src/lib/server/agent-config/index.ts b/src/lib/server/agent-config/index.ts new file mode 100644 index 0000000..ffa7dd8 --- /dev/null +++ b/src/lib/server/agent-config/index.ts @@ -0,0 +1,1292 @@ +import Ajv, { type ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; +import { Type, type Static } from "@sinclair/typebox"; +import { + DashboardDocumentSchema, + DatasourceReferenceSchema, + ServiceEntrySchema, + TelemetryCardSchema, + ThresholdSchema, + validateDashboardDocument, + type DashboardDocument, + type DashboardModule, + type DashboardValidationFailure, + type DatasourceReference, + type ServiceEntry, + type ServiceGroup, + type StatusItem, + type TelemetryCard, +} from "$lib/model"; +import { + createDashboardStore, + DashboardRevisionNotFoundError, + type DashboardRevision, + type DashboardStore, + type DashboardWriteMetadata, +} from "$lib/server/db/dashboard-store"; + +const IdentifierSchema = Type.String({ + minLength: 1, + pattern: "^[a-z0-9][a-z0-9-_.:]*$", +}); + +const PositionSchema = Type.Object( + { + index: Type.Optional(Type.Integer({ minimum: 0 })), + beforeId: Type.Optional(IdentifierSchema), + afterId: Type.Optional(IdentifierSchema), + }, + { additionalProperties: false, minProperties: 1 }, +); + +const AgentTargetSchema = Type.Union([ + Type.Object( + { + kind: Type.Literal("telemetry"), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("service"), + groupId: Type.Optional(IdentifierSchema), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("serviceGroup"), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("statusItem"), + stripId: Type.Optional(IdentifierSchema), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + kind: Type.Literal("module"), + id: IdentifierSchema, + }, + { additionalProperties: false }, + ), +]); + +const AddSectionOperationSchema = Type.Object( + { + type: Type.Literal("add_section"), + section: Type.Object( + { + id: IdentifierSchema, + title: Type.String({ minLength: 1 }), + layout: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("grid")])), + }, + { additionalProperties: false }, + ), + position: Type.Optional(PositionSchema), + }, + { additionalProperties: false }, +); + +const AddServiceOperationSchema = Type.Object( + { + type: Type.Literal("add_service"), + groupId: IdentifierSchema, + service: ServiceEntrySchema, + position: Type.Optional(PositionSchema), + }, + { additionalProperties: false }, +); + +const AddMetricCardOperationSchema = Type.Object( + { + type: Type.Literal("add_metric_card"), + card: TelemetryCardSchema, + position: Type.Optional(PositionSchema), + }, + { additionalProperties: false }, +); + +const ConnectDatasourceOperationSchema = Type.Object( + { + type: Type.Literal("connect_datasource"), + target: AgentTargetSchema, + datasource: DatasourceReferenceSchema, + }, + { additionalProperties: false }, +); + +const SetStatusRuleOperationSchema = Type.Object( + { + type: Type.Literal("set_status_rule"), + target: AgentTargetSchema, + thresholds: Type.Optional(ThresholdSchema), + severity: Type.Optional( + Type.Union([ + Type.Literal("neutral"), + Type.Literal("ok"), + Type.Literal("warning"), + Type.Literal("danger"), + Type.Literal("stale"), + Type.Literal("unavailable"), + ]), + ), + detail: Type.Optional(Type.String()), + value: Type.Optional(Type.String()), + }, + { additionalProperties: false, minProperties: 3 }, +); + +const ArrangeItemOperationSchema = Type.Object( + { + type: Type.Literal("arrange_item"), + area: Type.Union([ + Type.Literal("telemetry"), + Type.Literal("serviceGroups"), + Type.Literal("statusStrips"), + Type.Literal("modules"), + ]), + id: IdentifierSchema, + index: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, +); + +const RemoveItemOperationSchema = Type.Object( + { + type: Type.Literal("remove_item"), + target: AgentTargetSchema, + }, + { additionalProperties: false }, +); + +const NestedDashboardDocumentSchema = Type.Unsafe( + schemaWithoutRootId(DashboardDocumentSchema), +); + +const CreateDashboardOperationSchema = Type.Object( + { + type: Type.Literal("create_dashboard"), + document: NestedDashboardDocumentSchema, + }, + { additionalProperties: false }, +); + +export const AgentDashboardOperationSchema = Type.Union([ + CreateDashboardOperationSchema, + AddSectionOperationSchema, + AddServiceOperationSchema, + AddMetricCardOperationSchema, + ConnectDatasourceOperationSchema, + SetStatusRuleOperationSchema, + ArrangeItemOperationSchema, + RemoveItemOperationSchema, +]); + +export const AgentDashboardOperationsSchema = Type.Array(AgentDashboardOperationSchema, { + minItems: 1, +}); + +const PreviewRequestSchema = Type.Object( + { + action: Type.Literal("preview_changes"), + operations: AgentDashboardOperationsSchema, + }, + { additionalProperties: false }, +); + +const PublishRequestSchema = Type.Object( + { + action: Type.Literal("publish_changes"), + actor: Type.Optional(Type.String({ minLength: 1 })), + message: Type.Optional(Type.String({ minLength: 1 })), + operations: AgentDashboardOperationsSchema, + }, + { additionalProperties: false }, +); + +const RollbackRequestSchema = Type.Object( + { + action: Type.Literal("rollback_revision"), + actor: Type.Optional(Type.String({ minLength: 1 })), + message: Type.Optional(Type.String({ minLength: 1 })), + revisionId: Type.String({ minLength: 1 }), + }, + { additionalProperties: false }, +); + +export const AgentDashboardRequestSchema = Type.Union([ + PreviewRequestSchema, + PublishRequestSchema, + RollbackRequestSchema, +]); + +export type AgentDashboardOperation = Static; +export type AgentDashboardRequest = Static; + +export interface JsonPatchAddOperation { + op: "add"; + path: string; + value: unknown; +} + +export interface JsonPatchRemoveOperation { + op: "remove"; + path: string; +} + +export interface JsonPatchReplaceOperation { + op: "replace"; + path: string; + value: unknown; +} + +export type JsonPatchOperation = + | JsonPatchAddOperation + | JsonPatchRemoveOperation + | JsonPatchReplaceOperation; + +export interface AgentConfigError { + code: string; + message: string; + operationIndex?: number; + path: string; + details?: unknown; +} + +export type AgentPreviewResult = + | { + ok: true; + document: DashboardDocument; + patch: JsonPatchOperation[]; + } + | { + ok: false; + errors: AgentConfigError[]; + }; + +export type AgentPublishResult = + | { + ok: true; + document: DashboardDocument; + patch: JsonPatchOperation[]; + previousRevisionId: string | null; + revision: DashboardRevision; + } + | { + ok: false; + errors: AgentConfigError[]; + }; + +export interface AgentDashboardHandlerOptions { + store?: DashboardStore; + token?: string; +} + +export class AgentConfigAuthorizationError extends Error { + constructor() { + super("Unauthorized dashboard configuration request"); + this.name = "AgentConfigAuthorizationError"; + } +} + +const validateOperations = createAjv().compile( + AgentDashboardOperationsSchema, +); +const validateRequest = createAjv().compile( + AgentDashboardRequestSchema, +); + +function schemaWithoutRootId(schema: T): T { + const clone = JSON.parse(JSON.stringify(schema)) as T & { $id?: string }; + delete clone.$id; + return clone; +} + +function createAjv() { + return addFormats( + new Ajv({ + allErrors: true, + strict: false, + strictNumbers: true, + }), + ); +} + +export function previewDashboardChanges( + document: DashboardDocument, + operations: AgentDashboardOperation[], +): AgentPreviewResult { + const operationValidation = validateAgentOperations(operations); + if (operationValidation.length) return { ok: false, errors: operationValidation }; + + const before = structuredClone(document); + let next = structuredClone(document); + const errors: AgentConfigError[] = []; + + operations.forEach((operation, operationIndex) => { + if (errors.length) return; + const result = applyOperation(next, operation, operationIndex); + if (result.ok) { + next = result.document; + } else { + errors.push(...result.errors); + } + }); + + if (errors.length) return { ok: false, errors }; + + const validation = validateDashboardDocument(next); + if (!validation.valid) { + return { + ok: false, + errors: dashboardValidationErrors(validation), + }; + } + + return { + ok: true, + document: validation.data, + patch: createJsonPatch(before, validation.data), + }; +} + +export function publishDashboardChanges( + store: DashboardStore, + operations: AgentDashboardOperation[], + metadata: DashboardWriteMetadata = {}, +): AgentPublishResult { + const active = store.getActiveDashboard(); + if (!active) { + const create = operations[0]; + if (!create || create.type !== "create_dashboard") { + return { + ok: false, + errors: [ + { + code: "no_active_dashboard", + message: "No active dashboard exists; start with create_dashboard", + path: "/", + }, + ], + }; + } + } + + const base = active?.document || emptyDashboardDocument(); + const preview = previewDashboardChanges(base, operations); + if (!preview.ok) return preview; + + const revision = store.commitDashboard(preview.document, metadata); + return { + ok: true, + document: revision.document, + patch: preview.patch, + previousRevisionId: active?.currentRevisionId || null, + revision, + }; +} + +export function rollbackDashboardRevision( + store: DashboardStore, + revisionId: string, + metadata: DashboardWriteMetadata = {}, +) { + return store.rollbackToRevision(revisionId, metadata); +} + +export function authorizeAgentConfigRequest( + request: Request, + token = process.env.AGENT_CONFIG_TOKEN, +): { ok: true } { + if (!token) throw new AgentConfigAuthorizationError(); + + const authorization = request.headers.get("authorization") || ""; + const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]; + const headerToken = request.headers.get("x-agent-config-token"); + if (bearer === token || headerToken === token) return { ok: true }; + + throw new AgentConfigAuthorizationError(); +} + +export async function handleAgentDashboardRequest( + request: Request, + options: AgentDashboardHandlerOptions = {}, +): Promise { + const ownsStore = !options.store; + const store = options.store || createDashboardStore(); + + try { + authorizeAgentConfigRequest(request, options.token); + const body = await parseRequestBody(request); + const requestValidationErrors = validateAgentRequest(body); + if (requestValidationErrors.length) { + return Response.json( + { ok: false, errors: requestValidationErrors }, + { status: 400 }, + ); + } + + const agentRequest = body as AgentDashboardRequest; + if (agentRequest.action === "rollback_revision") { + const revision = rollbackDashboardRevision(store, agentRequest.revisionId, { + actor: agentRequest.actor || "agent", + message: agentRequest.message, + }); + return Response.json({ + ok: true, + action: agentRequest.action, + revision: serializeRevision(revision), + }); + } + + if (agentRequest.action === "preview_changes") { + const active = store.getActiveDashboard(); + const base = active?.document || previewBaseDocument(agentRequest.operations); + if (!base) { + return Response.json( + { + ok: false, + errors: [ + { + code: "no_active_dashboard", + message: "No active dashboard exists to preview against", + path: "/", + }, + ], + }, + { status: 409 }, + ); + } + const preview = previewDashboardChanges(base, agentRequest.operations); + return Response.json( + { + ...preview, + action: agentRequest.action, + }, + { status: preview.ok ? 200 : 422 }, + ); + } + + const publish = publishDashboardChanges(store, agentRequest.operations, { + actor: agentRequest.actor || "agent", + message: agentRequest.message, + }); + return Response.json( + { + ...publish, + action: agentRequest.action, + revision: publish.ok ? serializeRevision(publish.revision) : undefined, + }, + { status: publish.ok ? 200 : 422 }, + ); + } catch (error) { + if (error instanceof AgentConfigAuthorizationError) { + return Response.json( + { + ok: false, + errors: [ + { + code: "unauthorized", + message: error.message, + path: "/", + }, + ], + }, + { status: 401 }, + ); + } + + if (error instanceof SyntaxError) { + return Response.json( + { + ok: false, + errors: [ + { + code: "invalid_json", + message: error.message, + path: "/", + }, + ], + }, + { status: 400 }, + ); + } + + if (error instanceof DashboardRevisionNotFoundError) { + return Response.json( + { + ok: false, + errors: [ + { + code: "revision_not_found", + message: error.message, + path: "/revisionId", + }, + ], + }, + { status: 404 }, + ); + } + + throw error; + } finally { + if (ownsStore) store.close(); + } +} + +function validateAgentOperations(operations: unknown): AgentConfigError[] { + if (!validateOperations(operations)) { + return schemaErrors(validateOperations.errors || [], "invalid_operation_schema"); + } + + const createDashboardIndices = operations.flatMap((operation, index) => + operation.type === "create_dashboard" ? [index] : [], + ); + if (createDashboardIndices.length > 1) { + const operationIndex = createDashboardIndices[1]; + return [ + { + code: "invalid_operation_sequence", + message: "create_dashboard can only appear once", + operationIndex, + path: `/${operationIndex}`, + }, + ]; + } + if (createDashboardIndices[0] !== undefined && createDashboardIndices[0] !== 0) { + const operationIndex = createDashboardIndices[0]; + return [ + { + code: "invalid_operation_sequence", + message: "create_dashboard must be the first operation", + operationIndex, + path: `/${operationIndex}`, + }, + ]; + } + + return []; +} + +function validateAgentRequest(value: unknown): AgentConfigError[] { + if (validateRequest(value)) return []; + return schemaErrors(validateRequest.errors || [], "invalid_request_schema"); +} + +function schemaErrors(errors: ErrorObject[], code: string): AgentConfigError[] { + return errors.map((error) => ({ + code, + message: error.message || "is invalid", + operationIndex: operationIndexFromPath(error.instancePath), + path: error.instancePath || "/", + details: error.params, + })); +} + +function dashboardValidationErrors( + failure: DashboardValidationFailure, +): AgentConfigError[] { + return failure.errors.map((message, index) => ({ + code: "dashboard_validation_failed", + message, + path: failure.details[index]?.instancePath || "/", + details: failure.details[index]?.params, + })); +} + +function operationIndexFromPath(path: string): number | undefined { + const segments = path.split("/").filter(Boolean); + const operationsIndex = segments.indexOf("operations"); + const candidate = + operationsIndex >= 0 ? segments[operationsIndex + 1] : segments[0]; + const index = Number(candidate); + return Number.isInteger(index) ? index : undefined; +} + +type ApplyResult = + | { ok: true; document: DashboardDocument } + | { ok: false; errors: AgentConfigError[] }; + +function applyOperation( + document: DashboardDocument, + operation: AgentDashboardOperation, + operationIndex: number, +): ApplyResult { + const next = structuredClone(document); + + switch (operation.type) { + case "create_dashboard": + return { ok: true, document: structuredClone(operation.document) }; + case "add_section": + return addSection(next, operation, operationIndex); + case "add_service": + return addService(next, operation, operationIndex); + case "add_metric_card": + return addMetricCard(next, operation, operationIndex); + case "connect_datasource": + return connectDatasource(next, operation, operationIndex); + case "set_status_rule": + return setStatusRule(next, operation, operationIndex); + case "arrange_item": + return arrangeItem(next, operation, operationIndex); + case "remove_item": + return removeItem(next, operation, operationIndex); + } +} + +type AddSectionOperation = Extract; +type AddServiceOperation = Extract; +type AddMetricCardOperation = Extract; +type ConnectDatasourceOperation = Extract; +type SetStatusRuleOperation = Extract; +type ArrangeItemOperation = Extract; +type RemoveItemOperation = Extract; + +function addSection( + document: DashboardDocument, + operation: AddSectionOperation, + operationIndex: number, +): ApplyResult { + if (document.serviceGroups.some((group) => group.id === operation.section.id)) { + return failure( + "duplicate_id", + `Service group already exists: ${operation.section.id}`, + operationIndex, + "/serviceGroups", + ); + } + + const group: ServiceGroup = { + ...operation.section, + services: [], + }; + document.serviceGroups.push(group); + document.layout.serviceGroups = insertId( + document.layout.serviceGroups, + group.id, + operation.position, + ); + return { ok: true, document }; +} + +function addService( + document: DashboardDocument, + operation: AddServiceOperation, + operationIndex: number, +): ApplyResult { + const group = document.serviceGroups.find((item) => item.id === operation.groupId); + if (!group) { + return failure( + "group_not_found", + `Service group not found: ${operation.groupId}`, + operationIndex, + "/serviceGroups", + ); + } + + if ( + document.serviceGroups.some((item) => + item.services.some((service) => service.id === operation.service.id), + ) + ) { + return failure( + "duplicate_id", + `Service already exists: ${operation.service.id}`, + operationIndex, + "/serviceGroups", + ); + } + + group.services = insertItem(group.services, operation.service, operation.position); + return { ok: true, document }; +} + +function addMetricCard( + document: DashboardDocument, + operation: AddMetricCardOperation, + operationIndex: number, +): ApplyResult { + if (document.telemetry.some((card) => card.id === operation.card.id)) { + return failure( + "duplicate_id", + `Telemetry card already exists: ${operation.card.id}`, + operationIndex, + "/telemetry", + ); + } + + document.telemetry.push(operation.card); + document.layout.telemetry = insertId( + document.layout.telemetry, + operation.card.id, + operation.position, + ); + return { ok: true, document }; +} + +function connectDatasource( + document: DashboardDocument, + operation: ConnectDatasourceOperation, + operationIndex: number, +): ApplyResult { + if (!supportsDatasource(operation.target.kind)) { + return failure( + "unsupported_target", + `Target does not support datasources: ${operation.target.kind}`, + operationIndex, + targetPath(operation.target), + ); + } + + const targetResolution = resolveTarget(document, operation.target, operationIndex); + if (!targetResolution.ok) { + return { ok: false, errors: targetResolution.errors }; + } + + const target = targetResolution.target; + (target as DatasourceAgentTarget).datasource = operation.datasource; + return { ok: true, document }; +} + +function setStatusRule( + document: DashboardDocument, + operation: SetStatusRuleOperation, + operationIndex: number, +): ApplyResult { + if (operation.target.kind === "serviceGroup") { + return failure( + "unsupported_target", + `Target does not support status rules: ${operation.target.kind}`, + operationIndex, + targetPath(operation.target), + ); + } + if (operation.thresholds && operation.target.kind !== "telemetry") { + return failure( + "unsupported_target", + "Thresholds can only be set on telemetry targets", + operationIndex, + targetPath(operation.target), + ); + } + if (operation.detail !== undefined && !supportsDetail(operation.target.kind)) { + return failure( + "unsupported_target", + "Detail can only be set on telemetry, service, or module targets", + operationIndex, + targetPath(operation.target), + ); + } + if (operation.value !== undefined && !supportsStringValue(operation.target.kind)) { + return failure( + "unsupported_target", + "String values can only be set on status item or module targets", + operationIndex, + targetPath(operation.target), + ); + } + + const targetResolution = resolveTarget(document, operation.target, operationIndex); + if (!targetResolution.ok) { + return { ok: false, errors: targetResolution.errors }; + } + + const target = targetResolution.target; + if (operation.thresholds && operation.target.kind === "telemetry") { + (target as TelemetryCard).thresholds = operation.thresholds; + } + if (operation.severity) { + target.severity = operation.severity; + } + if (operation.detail !== undefined && supportsDetail(operation.target.kind)) { + (target as DetailAgentTarget).detail = operation.detail; + } + if (operation.value !== undefined && supportsStringValue(operation.target.kind)) { + (target as StringValueAgentTarget).value = operation.value; + } + + return { ok: true, document }; +} + +function arrangeItem( + document: DashboardDocument, + operation: ArrangeItemOperation, + operationIndex: number, +): ApplyResult { + const list = layoutList(document, operation.area); + if (!list.includes(operation.id)) { + return failure( + "item_not_found", + `Layout item not found: ${operation.id}`, + operationIndex, + `/layout/${operation.area}`, + ); + } + + const without = list.filter((id) => id !== operation.id); + const index = Math.min(operation.index, without.length); + without.splice(index, 0, operation.id); + setLayoutList(document, operation.area, without); + syncCollectionOrder(document, operation.area, without); + return { ok: true, document }; +} + +function removeItem( + document: DashboardDocument, + operation: RemoveItemOperation, + operationIndex: number, +): ApplyResult { + if (operation.target.kind === "telemetry") { + if (!document.telemetry.some((card) => card.id === operation.target.id)) { + return failure( + "item_not_found", + `Telemetry card not found: ${operation.target.id}`, + operationIndex, + "/telemetry", + ); + } + document.telemetry = document.telemetry.filter((card) => card.id !== operation.target.id); + document.layout.telemetry = document.layout.telemetry.filter((id) => id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "serviceGroup") { + if (!document.serviceGroups.some((group) => group.id === operation.target.id)) { + return failure( + "item_not_found", + `Service group not found: ${operation.target.id}`, + operationIndex, + "/serviceGroups", + ); + } + document.serviceGroups = document.serviceGroups.filter((group) => group.id !== operation.target.id); + document.layout.serviceGroups = document.layout.serviceGroups.filter((id) => id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "service") { + const matches = findServices(document, operation.target.id, operation.target.groupId); + if (!matches.length) { + return failure( + "item_not_found", + `Service not found: ${operation.target.id}`, + operationIndex, + "/serviceGroups", + ); + } + if (!operation.target.groupId && matches.length > 1) { + return failure( + "ambiguous_target", + `Service exists in multiple groups: ${operation.target.id}`, + operationIndex, + "/serviceGroups", + ); + } + + const match = matches[0]; + match.group.services = match.group.services.filter((service) => service.id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "module") { + if (!(document.modules || []).some((module) => module.id === operation.target.id)) { + return failure( + "item_not_found", + `Module not found: ${operation.target.id}`, + operationIndex, + "/modules", + ); + } + document.modules = (document.modules || []).filter((module) => module.id !== operation.target.id); + document.layout.modules = (document.layout.modules || []).filter((id) => id !== operation.target.id); + return { ok: true, document }; + } + + if (operation.target.kind === "statusItem") { + const matches = findStatusItems(document, operation.target.id, operation.target.stripId); + if (!matches.length) { + return failure( + "item_not_found", + `Status item not found: ${operation.target.id}`, + operationIndex, + "/statusStrips", + ); + } + if (!operation.target.stripId && matches.length > 1) { + return failure( + "ambiguous_target", + `Status item exists in multiple strips: ${operation.target.id}`, + operationIndex, + "/statusStrips", + ); + } + + const [match] = matches; + match.strip.items = match.strip.items.filter((item) => item.id !== operation.target.id); + return { ok: true, document }; + } + + return failure("unsupported_target", "Cannot remove target kind", operationIndex, "/"); +} + +function resolveTarget( + document: DashboardDocument, + target: ConnectDatasourceOperation["target"], + operationIndex: number, +): ResolveTargetResult { + if (target.kind === "telemetry") { + const card = document.telemetry.find((item) => item.id === target.id); + if (!card) { + return resolutionFailure( + "item_not_found", + `Telemetry card not found: ${target.id}`, + operationIndex, + "/telemetry", + ); + } + return { ok: true, target: card }; + } + if (target.kind === "service") { + const matches = findServices(document, target.id, target.groupId); + if (!matches.length) { + return resolutionFailure( + "item_not_found", + `Service not found: ${target.id}`, + operationIndex, + "/serviceGroups", + ); + } + if (!target.groupId && matches.length > 1) { + return resolutionFailure( + "ambiguous_target", + `Service exists in multiple groups: ${target.id}`, + operationIndex, + "/serviceGroups", + ); + } + return { ok: true, target: matches[0].service }; + } + if (target.kind === "statusItem") { + const matches = findStatusItems(document, target.id, target.stripId); + if (!matches.length) { + return resolutionFailure( + "item_not_found", + `Status item not found: ${target.id}`, + operationIndex, + "/statusStrips", + ); + } + if (!target.stripId && matches.length > 1) { + return resolutionFailure( + "ambiguous_target", + `Status item exists in multiple strips: ${target.id}`, + operationIndex, + "/statusStrips", + ); + } + return { ok: true, target: matches[0].item }; + } + if (target.kind === "module") { + const module = (document.modules || []).find((item) => item.id === target.id); + if (!module) { + return resolutionFailure( + "item_not_found", + `Module not found: ${target.id}`, + operationIndex, + "/modules", + ); + } + return { ok: true, target: module }; + } + return resolutionFailure( + "item_not_found", + `Target not found: ${target.id}`, + operationIndex, + targetPath(target), + ); +} + +type ResolveTargetResult = + | { ok: true; target: ResolvedAgentTarget } + | { ok: false; errors: AgentConfigError[] }; +type ResolvedAgentTarget = TelemetryCard | ServiceEntry | StatusItem | DashboardModule; +type DatasourceAgentTarget = TelemetryCard | ServiceEntry | DashboardModule; +type DetailAgentTarget = TelemetryCard | ServiceEntry | DashboardModule; +type StringValueAgentTarget = StatusItem | DashboardModule; + +type DatasourceTargetKind = Extract< + ConnectDatasourceOperation["target"]["kind"], + "telemetry" | "service" | "module" +>; + +function supportsDatasource( + kind: ConnectDatasourceOperation["target"]["kind"], +): kind is DatasourceTargetKind { + return kind === "telemetry" || kind === "service" || kind === "module"; +} + +type DetailTargetKind = Extract< + SetStatusRuleOperation["target"]["kind"], + "telemetry" | "service" | "module" +>; + +function supportsDetail( + kind: SetStatusRuleOperation["target"]["kind"], +): kind is DetailTargetKind { + return kind === "telemetry" || kind === "service" || kind === "module"; +} + +type StringValueTargetKind = Extract< + SetStatusRuleOperation["target"]["kind"], + "statusItem" | "module" +>; + +function supportsStringValue( + kind: SetStatusRuleOperation["target"]["kind"], +): kind is StringValueTargetKind { + return kind === "statusItem" || kind === "module"; +} + +function findServices(document: DashboardDocument, id: string, groupId?: string) { + const groups = groupId + ? document.serviceGroups.filter((group) => group.id === groupId) + : document.serviceGroups; + return groups.flatMap((group) => + group.services.flatMap((service) => + service.id === id ? [{ group, service }] : [], + ), + ); +} + +function findStatusItems(document: DashboardDocument, id: string, stripId?: string) { + const strips = stripId + ? document.statusStrips.filter((strip) => strip.id === stripId) + : document.statusStrips; + return strips.flatMap((strip) => + strip.items.flatMap((item) => (item.id === id ? [{ strip, item }] : [])), + ); +} + +function resolutionFailure( + code: string, + message: string, + operationIndex: number, + path: string, +): ResolveTargetResult { + return { + ok: false, + errors: [ + { + code, + message, + operationIndex, + path, + }, + ], + }; +} + +function targetPath(target: ConnectDatasourceOperation["target"]): string { + if (target.kind === "telemetry") return "/telemetry"; + if (target.kind === "service") return "/serviceGroups"; + if (target.kind === "serviceGroup") return "/serviceGroups"; + if (target.kind === "statusItem") return "/statusStrips"; + return "/modules"; +} + +function layoutList(document: DashboardDocument, area: ArrangeItemOperation["area"]) { + if (area === "modules") return document.layout.modules || []; + return document.layout[area]; +} + +function setLayoutList( + document: DashboardDocument, + area: ArrangeItemOperation["area"], + value: string[], +) { + if (area === "modules") { + document.layout.modules = value; + return; + } + document.layout[area] = value; +} + +function syncCollectionOrder( + document: DashboardDocument, + area: ArrangeItemOperation["area"], + orderedIds: string[], +) { + if (area === "telemetry") { + document.telemetry = orderByIds(document.telemetry, orderedIds); + return; + } + if (area === "serviceGroups") { + document.serviceGroups = orderByIds(document.serviceGroups, orderedIds); + return; + } + if (area === "statusStrips") { + document.statusStrips = orderByIds(document.statusStrips, orderedIds); + return; + } + document.modules = orderByIds(document.modules || [], orderedIds); +} + +function orderByIds(items: T[], orderedIds: string[]): T[] { + const order = new Map(orderedIds.map((id, index) => [id, index])); + return [...items].sort( + (left, right) => + (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - + (order.get(right.id) ?? Number.MAX_SAFE_INTEGER), + ); +} + +function insertItem( + items: T[], + item: T, + position?: Static, +): T[] { + const next = [...items]; + next.splice(resolveInsertionIndex(next.map((entry) => entry.id), position), 0, item); + return next; +} + +function insertId( + ids: string[], + id: string, + position?: Static, +): string[] { + const next = [...ids]; + next.splice(resolveInsertionIndex(next, position), 0, id); + return next; +} + +function resolveInsertionIndex(ids: string[], position?: Static) { + if (!position) return ids.length; + if (typeof position.index === "number") return Math.min(position.index, ids.length); + if (position.beforeId) { + const beforeIndex = ids.indexOf(position.beforeId); + return beforeIndex >= 0 ? beforeIndex : ids.length; + } + if (position.afterId) { + const afterIndex = ids.indexOf(position.afterId); + return afterIndex >= 0 ? afterIndex + 1 : ids.length; + } + return ids.length; +} + +function failure( + code: string, + message: string, + operationIndex: number, + path: string, +): ApplyResult { + return { + ok: false, + errors: [ + { + code, + message, + operationIndex, + path, + }, + ], + }; +} + +function createJsonPatch( + before: unknown, + after: unknown, + path = "", +): JsonPatchOperation[] { + if (before === undefined) return [{ op: "add", path: path || "/", value: after }]; + if (after === undefined) return [{ op: "remove", path: path || "/" }]; + if (Object.is(before, after)) return []; + + if (!isObjectLike(before) || !isObjectLike(after)) { + return [{ op: "replace", path: path || "/", value: after }]; + } + + if (Array.isArray(before) || Array.isArray(after)) { + if (!Array.isArray(before) || !Array.isArray(after)) { + return [{ op: "replace", path: path || "/", value: after }]; + } + const operations: JsonPatchOperation[] = []; + const sharedLength = Math.min(before.length, after.length); + for (let index = 0; index < sharedLength; index += 1) { + operations.push( + ...createJsonPatch(before[index], after[index], `${path}/${index}`), + ); + } + for (let index = before.length - 1; index >= after.length; index -= 1) { + operations.push({ op: "remove", path: `${path}/${index}` }); + } + for (let index = before.length; index < after.length; index += 1) { + operations.push({ op: "add", path: `${path}/${index}`, value: after[index] }); + } + return operations; + } + + const beforeObject = before as Record; + const afterObject = after as Record; + const keys = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)]); + return [...keys].flatMap((key) => + createJsonPatch(beforeObject[key], afterObject[key], `${path}/${escapeJsonPointer(key)}`), + ); +} + +function isObjectLike(value: unknown): value is Record | unknown[] { + return typeof value === "object" && value !== null; +} + +function escapeJsonPointer(value: string): string { + return value.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +async function parseRequestBody(request: Request): Promise { + const text = await request.text(); + return text ? JSON.parse(text) : {}; +} + +function previewBaseDocument(operations: AgentDashboardOperation[]): DashboardDocument | null { + return operations[0]?.type === "create_dashboard" ? emptyDashboardDocument() : null; +} + +function serializeRevision(revision: DashboardRevision) { + return { + id: revision.id, + dashboardId: revision.dashboardId, + schemaVersion: revision.schemaVersion, + actor: revision.actor, + message: revision.message, + operation: revision.operation, + sourceRevisionId: revision.sourceRevisionId, + createdAt: revision.createdAt.toISOString(), + }; +} + +function emptyDashboardDocument(): DashboardDocument { + return { + schemaVersion: "dashboard.v1", + metadata: { + title: "Untitled Dashboard", + }, + layout: { + telemetry: [], + serviceGroups: [], + statusStrips: [], + modules: [], + }, + telemetry: [], + serviceGroups: [], + statusStrips: [], + modules: [], + }; +} diff --git a/src/routes/api/agent/dashboard/+server.ts b/src/routes/api/agent/dashboard/+server.ts new file mode 100644 index 0000000..ddfbef4 --- /dev/null +++ b/src/routes/api/agent/dashboard/+server.ts @@ -0,0 +1,4 @@ +import { handleAgentDashboardRequest } from "$lib/server/agent-config"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = ({ request }) => handleAgentDashboardRequest(request);