@legioncodeinc/hive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +262 -0
- package/dist/cli-commands.d.ts +41 -0
- package/dist/cli-commands.js +76 -0
- package/dist/cli-commands.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +46 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/dashboard/app.js +118 -0
- package/dist/daemon/dashboard/host.d.ts +90 -0
- package/dist/daemon/dashboard/host.js +172 -0
- package/dist/daemon/dashboard/host.js.map +1 -0
- package/dist/daemon/dashboard/web-assets.d.ts +88 -0
- package/dist/daemon/dashboard/web-assets.js +195 -0
- package/dist/daemon/dashboard/web-assets.js.map +1 -0
- package/dist/daemon/fleet-status.d.ts +15 -0
- package/dist/daemon/fleet-status.js +56 -0
- package/dist/daemon/fleet-status.js.map +1 -0
- package/dist/daemon/gate.d.ts +55 -0
- package/dist/daemon/gate.js +128 -0
- package/dist/daemon/gate.js.map +1 -0
- package/dist/daemon/proxy.d.ts +38 -0
- package/dist/daemon/proxy.js +126 -0
- package/dist/daemon/proxy.js.map +1 -0
- package/dist/daemon/registry.d.ts +31 -0
- package/dist/daemon/registry.js +120 -0
- package/dist/daemon/registry.js.map +1 -0
- package/dist/daemon/server.d.ts +41 -0
- package/dist/daemon/server.js +126 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/setup-auth.d.ts +37 -0
- package/dist/daemon/setup-auth.js +51 -0
- package/dist/daemon/setup-auth.js.map +1 -0
- package/dist/daemon/telemetry-proxy.d.ts +47 -0
- package/dist/daemon/telemetry-proxy.js +63 -0
- package/dist/daemon/telemetry-proxy.js.map +1 -0
- package/dist/dashboard/contracts.d.ts +235 -0
- package/dist/dashboard/contracts.js +43 -0
- package/dist/dashboard/contracts.js.map +1 -0
- package/dist/dashboard/web/app.d.ts +44 -0
- package/dist/dashboard/web/app.js +179 -0
- package/dist/dashboard/web/app.js.map +1 -0
- package/dist/dashboard/web/boot-route.d.ts +23 -0
- package/dist/dashboard/web/boot-route.js +29 -0
- package/dist/dashboard/web/boot-route.js.map +1 -0
- package/dist/dashboard/web/build-graph-button.d.ts +46 -0
- package/dist/dashboard/web/build-graph-button.js +81 -0
- package/dist/dashboard/web/build-graph-button.js.map +1 -0
- package/dist/dashboard/web/buzzing-screen.d.ts +30 -0
- package/dist/dashboard/web/buzzing-screen.js +135 -0
- package/dist/dashboard/web/buzzing-screen.js.map +1 -0
- package/dist/dashboard/web/folder-picker.d.ts +54 -0
- package/dist/dashboard/web/folder-picker.js +125 -0
- package/dist/dashboard/web/folder-picker.js.map +1 -0
- package/dist/dashboard/web/graph-layout.d.ts +101 -0
- package/dist/dashboard/web/graph-layout.js +0 -0
- package/dist/dashboard/web/graph-layout.js.map +1 -0
- package/dist/dashboard/web/harness-strip.d.ts +38 -0
- package/dist/dashboard/web/harness-strip.js +65 -0
- package/dist/dashboard/web/harness-strip.js.map +1 -0
- package/dist/dashboard/web/health-rail.d.ts +26 -0
- package/dist/dashboard/web/health-rail.js +61 -0
- package/dist/dashboard/web/health-rail.js.map +1 -0
- package/dist/dashboard/web/main.d.ts +22 -0
- package/dist/dashboard/web/main.js +51 -0
- package/dist/dashboard/web/main.js.map +1 -0
- package/dist/dashboard/web/needs-project.d.ts +51 -0
- package/dist/dashboard/web/needs-project.js +69 -0
- package/dist/dashboard/web/needs-project.js.map +1 -0
- package/dist/dashboard/web/page-frame.d.ts +95 -0
- package/dist/dashboard/web/page-frame.js +98 -0
- package/dist/dashboard/web/page-frame.js.map +1 -0
- package/dist/dashboard/web/pages/coming-soon.d.ts +23 -0
- package/dist/dashboard/web/pages/coming-soon.js +19 -0
- package/dist/dashboard/web/pages/coming-soon.js.map +1 -0
- package/dist/dashboard/web/pages/dashboard.d.ts +32 -0
- package/dist/dashboard/web/pages/dashboard.js +205 -0
- package/dist/dashboard/web/pages/dashboard.js.map +1 -0
- package/dist/dashboard/web/pages/graph.d.ts +72 -0
- package/dist/dashboard/web/pages/graph.js +362 -0
- package/dist/dashboard/web/pages/graph.js.map +1 -0
- package/dist/dashboard/web/pages/harnesses.d.ts +65 -0
- package/dist/dashboard/web/pages/harnesses.js +210 -0
- package/dist/dashboard/web/pages/harnesses.js.map +1 -0
- package/dist/dashboard/web/pages/health.d.ts +22 -0
- package/dist/dashboard/web/pages/health.js +91 -0
- package/dist/dashboard/web/pages/health.js.map +1 -0
- package/dist/dashboard/web/pages/lifecycle-panel.d.ts +60 -0
- package/dist/dashboard/web/pages/lifecycle-panel.js +187 -0
- package/dist/dashboard/web/pages/lifecycle-panel.js.map +1 -0
- package/dist/dashboard/web/pages/logs.d.ts +55 -0
- package/dist/dashboard/web/pages/logs.js +311 -0
- package/dist/dashboard/web/pages/logs.js.map +1 -0
- package/dist/dashboard/web/pages/memories.d.ts +35 -0
- package/dist/dashboard/web/pages/memories.js +439 -0
- package/dist/dashboard/web/pages/memories.js.map +1 -0
- package/dist/dashboard/web/pages/projects.d.ts +38 -0
- package/dist/dashboard/web/pages/projects.js +325 -0
- package/dist/dashboard/web/pages/projects.js.map +1 -0
- package/dist/dashboard/web/pages/roi-chart.d.ts +52 -0
- package/dist/dashboard/web/pages/roi-chart.js +133 -0
- package/dist/dashboard/web/pages/roi-chart.js.map +1 -0
- package/dist/dashboard/web/pages/roi.d.ts +93 -0
- package/dist/dashboard/web/pages/roi.js +375 -0
- package/dist/dashboard/web/pages/roi.js.map +1 -0
- package/dist/dashboard/web/pages/settings.d.ts +103 -0
- package/dist/dashboard/web/pages/settings.js +420 -0
- package/dist/dashboard/web/pages/settings.js.map +1 -0
- package/dist/dashboard/web/pages/sync.d.ts +59 -0
- package/dist/dashboard/web/pages/sync.js +280 -0
- package/dist/dashboard/web/pages/sync.js.map +1 -0
- package/dist/dashboard/web/panels.d.ts +178 -0
- package/dist/dashboard/web/panels.js +471 -0
- package/dist/dashboard/web/panels.js.map +1 -0
- package/dist/dashboard/web/primitives.d.ts +80 -0
- package/dist/dashboard/web/primitives.js +208 -0
- package/dist/dashboard/web/primitives.js.map +1 -0
- package/dist/dashboard/web/readiness-splash.d.ts +39 -0
- package/dist/dashboard/web/readiness-splash.js +201 -0
- package/dist/dashboard/web/readiness-splash.js.map +1 -0
- package/dist/dashboard/web/registry.d.ts +96 -0
- package/dist/dashboard/web/registry.js +99 -0
- package/dist/dashboard/web/registry.js.map +1 -0
- package/dist/dashboard/web/router.d.ts +48 -0
- package/dist/dashboard/web/router.js +76 -0
- package/dist/dashboard/web/router.js.map +1 -0
- package/dist/dashboard/web/scope-context.d.ts +155 -0
- package/dist/dashboard/web/scope-context.js +266 -0
- package/dist/dashboard/web/scope-context.js.map +1 -0
- package/dist/dashboard/web/service-icons.d.ts +33 -0
- package/dist/dashboard/web/service-icons.js +80 -0
- package/dist/dashboard/web/service-icons.js.map +1 -0
- package/dist/dashboard/web/setup-gate.d.ts +116 -0
- package/dist/dashboard/web/setup-gate.js +272 -0
- package/dist/dashboard/web/setup-gate.js.map +1 -0
- package/dist/dashboard/web/sidebar.d.ts +63 -0
- package/dist/dashboard/web/sidebar.js +91 -0
- package/dist/dashboard/web/sidebar.js.map +1 -0
- package/dist/dashboard/web/use-fleet-telemetry.d.ts +111 -0
- package/dist/dashboard/web/use-fleet-telemetry.js +278 -0
- package/dist/dashboard/web/use-fleet-telemetry.js.map +1 -0
- package/dist/dashboard/web/wire.d.ts +1937 -0
- package/dist/dashboard/web/wire.js +1837 -0
- package/dist/dashboard/web/wire.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/install/registry.d.ts +35 -0
- package/dist/install/registry.js +110 -0
- package/dist/install/registry.js.map +1 -0
- package/dist/lock.d.ts +10 -0
- package/dist/lock.js +87 -0
- package/dist/lock.js.map +1 -0
- package/dist/service/commands.d.ts +15 -0
- package/dist/service/commands.js +49 -0
- package/dist/service/commands.js.map +1 -0
- package/dist/service/index.d.ts +38 -0
- package/dist/service/index.js +174 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/platform.d.ts +37 -0
- package/dist/service/platform.js +67 -0
- package/dist/service/platform.js.map +1 -0
- package/dist/service/templates.d.ts +10 -0
- package/dist/service/templates.js +120 -0
- package/dist/service/templates.js.map +1 -0
- package/dist/shared/constants.d.ts +15 -0
- package/dist/shared/constants.js +19 -0
- package/dist/shared/constants.js.map +1 -0
- package/dist/shared/daemon-routing.d.ts +11 -0
- package/dist/shared/daemon-routing.js +38 -0
- package/dist/shared/daemon-routing.js.map +1 -0
- package/dist/shared/fleet-readiness.d.ts +18 -0
- package/dist/shared/fleet-readiness.js +9 -0
- package/dist/shared/fleet-readiness.js.map +1 -0
- package/dist/shared/fleet-telemetry.d.ts +68 -0
- package/dist/shared/fleet-telemetry.js +61 -0
- package/dist/shared/fleet-telemetry.js.map +1 -0
- package/dist/shared/lifecycle-flags.d.ts +34 -0
- package/dist/shared/lifecycle-flags.js +39 -0
- package/dist/shared/lifecycle-flags.js.map +1 -0
- package/dist/shared/memory-types.d.ts +55 -0
- package/dist/shared/memory-types.js +75 -0
- package/dist/shared/memory-types.js.map +1 -0
- package/dist/shared/service-status.d.ts +82 -0
- package/dist/shared/service-status.js +97 -0
- package/dist/shared/service-status.js.map +1 -0
- package/dist/telemetry/emit.d.ts +197 -0
- package/dist/telemetry/emit.js +196 -0
- package/dist/telemetry/emit.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,1837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard web app's WIRE LAYER — PRD-024 Wave 2 (AC-2..AC-6).
|
|
3
|
+
*
|
|
4
|
+
* The browser app is a THIN CLIENT: it hydrates entirely from the daemon's already-served
|
|
5
|
+
* JSON endpoints (D-2, no canned `data.js`). This module is the single typed boundary
|
|
6
|
+
* between the untyped `fetch` Response and the kit's React props:
|
|
7
|
+
*
|
|
8
|
+
* - It declares a zod schema PER endpoint, mirroring the daemon-side view-model contracts
|
|
9
|
+
* in `src/dashboard/contracts.ts` (D-5 — reuse the shapes, do not re-derive them). The
|
|
10
|
+
* WIRE truth (what `src/daemon/runtime/dashboard/api.ts` + the recall/logs routes
|
|
11
|
+
* actually return) is what these schemas validate, NOT the canned `data.js` shapes.
|
|
12
|
+
* - It parses every payload through zod, so a malformed/partial response degrades to a
|
|
13
|
+
* safe empty/zero state rather than throwing into React (AC-2 empty states).
|
|
14
|
+
* - No `any` crosses the fetch boundary: each fetcher returns a typed, validated value.
|
|
15
|
+
*
|
|
16
|
+
* Wire endpoints (all loopback, all already served — this app NEVER rebuilds them):
|
|
17
|
+
* GET /api/diagnostics/kpis|sessions|settings|rules|skills → the dashboard view-models
|
|
18
|
+
* GET /api/graph → the codebase graph view
|
|
19
|
+
* POST /api/memories/recall → recall hits
|
|
20
|
+
* GET /api/logs → the request-log ring buffer
|
|
21
|
+
* GET /health → daemon liveness
|
|
22
|
+
* POST /api/diagnostics/pollinate → the Wave-1 Pollinate trigger
|
|
23
|
+
*/
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { EMPTY_ROI_TREND, EMPTY_ROI_VIEW } from "../contracts.js";
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Endpoint paths (single source — the host serves these under the daemon origin).
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
/** The diagnostics view-model endpoints (served under `/api/diagnostics/*` + `/api/graph`). */
|
|
30
|
+
export const ENDPOINTS = Object.freeze({
|
|
31
|
+
kpis: "/api/diagnostics/kpis",
|
|
32
|
+
sessions: "/api/diagnostics/sessions",
|
|
33
|
+
settings: "/api/diagnostics/settings",
|
|
34
|
+
rules: "/api/diagnostics/rules",
|
|
35
|
+
skills: "/api/diagnostics/skills",
|
|
36
|
+
graph: "/api/graph",
|
|
37
|
+
// PRD-041a — the codebase-graph BUILD trigger (`POST /api/graph/build`). Runs the real worker
|
|
38
|
+
// end-to-end and writes the LOCAL snapshot; a subsequent `GET /api/graph` returns it immediately
|
|
39
|
+
// (local file, no eventual-consistency wait). On the RBAC-protected `/api/graph` group.
|
|
40
|
+
graphBuild: "/api/graph/build",
|
|
41
|
+
// PRD-041b — the memory-graph view-model (the knowledge graph of memories/entities). Served off
|
|
42
|
+
// the diagnostics group (`/api/diagnostics/memory-graph`), mirroring `/api/graph`. Returns the SAME
|
|
43
|
+
// `GraphView` shape so the existing `GraphCanvas` renders it unchanged; `built:false` until PRD-008
|
|
44
|
+
// data is populated (the page shows its honest "no memory graph yet" empty state).
|
|
45
|
+
memoryGraph: "/api/diagnostics/memory-graph",
|
|
46
|
+
recall: "/api/memories/recall",
|
|
47
|
+
// PRD-040 — the memory-management surface. `memories` is BOTH the list (GET /api/memories)
|
|
48
|
+
// and the store (POST /api/memories) endpoint; `getMemory`/`modify`/`forget` are built by
|
|
49
|
+
// appending `/:id`[`/modify`|`/forget`] to it. `compact` is the version-history reaper trigger.
|
|
50
|
+
memories: "/api/memories",
|
|
51
|
+
compact: "/api/diagnostics/compact",
|
|
52
|
+
// PRD-058d — the lifecycle operator surface reads (all on the `/api/memories` session group). The
|
|
53
|
+
// conflict QUEUE + stale-ref list + lifecycle-filtered audit + the 058e calibration introspection.
|
|
54
|
+
// The resolve action POSTs the 058b `/api/memories/conflicts/:id/resolve` (built off `memories`).
|
|
55
|
+
lifecycleConflicts: "/api/memories/conflicts",
|
|
56
|
+
lifecycleStaleRefs: "/api/memories/stale-refs",
|
|
57
|
+
lifecycleHistory: "/api/memories/history",
|
|
58
|
+
calibration: "/api/memories/calibration",
|
|
59
|
+
logs: "/api/logs",
|
|
60
|
+
// PRD-042c / PRD-021d — the Server-Sent-Events follow stream off the SAME ring buffer as `logs`.
|
|
61
|
+
// `GET /api/logs/stream` backfills the recent records then emits each NEW record (`event: "log"`,
|
|
62
|
+
// `data: JSON.stringify(record)`). The Sync activity feed FOLLOWS this tail (c-AC-2) instead of polling.
|
|
63
|
+
logsStream: "/api/logs/stream",
|
|
64
|
+
// PRD-039a — the harness registry + last-seen telemetry endpoint (the data backbone the
|
|
65
|
+
// Harnesses page 039b/039c reads). Served under the diagnostics group (`/api/diagnostics/harnesses`).
|
|
66
|
+
harnesses: "/api/diagnostics/harnesses",
|
|
67
|
+
// PRD-042 — the Sync page `installed ∪ synced` union view-model (skills + agents, each with state
|
|
68
|
+
// + detail). Served under the diagnostics group (`/api/diagnostics/assets`). The five write actions
|
|
69
|
+
// POST to `/api/diagnostics/sync/{promote,pull,demote,enable,disable}` (built off this base).
|
|
70
|
+
assets: "/api/diagnostics/assets",
|
|
71
|
+
syncAction: "/api/diagnostics/sync",
|
|
72
|
+
health: "/health",
|
|
73
|
+
pollinate: "/api/diagnostics/pollinate",
|
|
74
|
+
// PRD-032c — the vault `setting`-class surface (Wave 1 `vault/api.ts`) + the names-only
|
|
75
|
+
// secrets surface (PRD-012a `secrets/api.ts`, used ONLY for presence, never a value).
|
|
76
|
+
vaultSettings: "/api/settings",
|
|
77
|
+
secrets: "/api/secrets",
|
|
78
|
+
// PRD-044a — the REDACTED DeepLake auth-status read-model (`auth/status-api.ts`). Metadata
|
|
79
|
+
// only (org/workspace/agent/source/savedAt/expiresAt) — NO token by construction.
|
|
80
|
+
authStatus: "/api/auth/status",
|
|
81
|
+
// PRD-050b — the pre-auth guided-setup STATE read (`dashboard/setup-state.ts`). Local-mode-only,
|
|
82
|
+
// loopback. Reports credential-dir presence + onboarding phase/prior-tool + the derived
|
|
83
|
+
// `authenticated` bit + the embeddings warmup signal — install metadata only, NO token/secret.
|
|
84
|
+
setupState: "/setup/state",
|
|
85
|
+
// PRD-050c — the "First time setup" on-page device-flow login (`dashboard/setup-login.ts`). The
|
|
86
|
+
// 050b button POSTs here; the response is the `user_code` + verification URIs (NO token).
|
|
87
|
+
setupLogin: "/setup/login",
|
|
88
|
+
// PRD-050d — the "Proceed with Honeycomb" migration handler (`dashboard/setup-migrate.ts`). The
|
|
89
|
+
// coexistence-warning wizard POSTs here; the response carries the terminal phase + a plain-language
|
|
90
|
+
// message + the backup path + `needsLogin`/`migrated` flags (NO token). `migrateRollback` is the
|
|
91
|
+
// crash-recovery "Roll back" affordance (d-AC-7).
|
|
92
|
+
setupMigrate: "/setup/migrate-from-hivemind",
|
|
93
|
+
setupMigrateRollback: "/setup/migrate-from-hivemind/rollback",
|
|
94
|
+
// PRD-049e — the dashboard scope-switcher enumeration reads (local-mode-only loopback). The
|
|
95
|
+
// switcher hydrates its Org→Workspace→Project dropdowns from these. `scopeOrgs`/`scopeWorkspaces`
|
|
96
|
+
// are privilege-scoped by the daemon's token (`GET /organizations` / `GET /workspaces`);
|
|
97
|
+
// `scopeProjects` is the workspace's synced 049a registry copy. Changing the Org re-mints the
|
|
98
|
+
// org-bound token (PRD-011) on the daemon side BEFORE enumerating the new org (49e-AC-3).
|
|
99
|
+
scopeOrgs: "/api/diagnostics/scope/orgs",
|
|
100
|
+
scopeWorkspaces: "/api/diagnostics/scope/workspaces",
|
|
101
|
+
scopeProjects: "/api/diagnostics/scope/projects",
|
|
102
|
+
// PRD-059b/059c/059d — the daemon-served folder-picker + bind surface (local-mode-only loopback,
|
|
103
|
+
// mirroring the scope-enumeration reads). `fsBrowse` is the dirs-only directory browser the picker
|
|
104
|
+
// renders (a browser cannot hand back an absolute path; the daemon serves the tree). `projectsBind`
|
|
105
|
+
// binds a chosen absolute folder to a NEW/named project (059b); `projectsBindExisting` binds it to an
|
|
106
|
+
// EXISTING registry project_id (059d import); `projectsUnbind` removes the LOCAL binding only (059c) —
|
|
107
|
+
// the registry row is never touched. Every body carries paths + ids + names only — NO token/secret.
|
|
108
|
+
fsBrowse: "/api/diagnostics/fs/browse",
|
|
109
|
+
projectsBind: "/api/diagnostics/projects/bind",
|
|
110
|
+
projectsBindExisting: "/api/diagnostics/projects/bind-existing",
|
|
111
|
+
projectsUnbind: "/api/diagnostics/projects/unbind",
|
|
112
|
+
// IRD-122 — the scope-switch PERSISTENCE routes (the honest counterpart to the viewer-only
|
|
113
|
+
// enumeration reads). `scopeOrgSwitch` re-mints an org-bound token + persists it to the shared
|
|
114
|
+
// credential (122-AC-2); `scopeWorkspaceSwitch` persists the workspace id (no re-mint). The token
|
|
115
|
+
// rides ONLY in the daemon's Authorization header + credential file — the ack bodies are ids/names
|
|
116
|
+
// only, NO token (D-4). These make the switcher persist a real scope change instead of a no-op.
|
|
117
|
+
scopeOrgSwitch: "/api/diagnostics/scope/org-switch",
|
|
118
|
+
scopeWorkspaceSwitch: "/api/diagnostics/scope/workspace-switch",
|
|
119
|
+
// PRD-060e — the composite ROI read-model + the trend series. Both served off the diagnostics
|
|
120
|
+
// group (`/api/diagnostics/roi` + `/api/diagnostics/roi/trend`), local-mode-only loopback like the
|
|
121
|
+
// other dashboard view-models. The page is a PURE function of the `RoiView` the daemon assembles
|
|
122
|
+
// (savings/infra/pollination/net + the org/team/agent/project rollups); the chart reads `roi/trend`.
|
|
123
|
+
roi: "/api/diagnostics/roi",
|
|
124
|
+
roiTrend: "/api/diagnostics/roi/trend",
|
|
125
|
+
// Dashboard imperative actions (`dashboard/actions-api.ts`, `/api/actions` group, local-mode +
|
|
126
|
+
// origin/CSRF gated). The named CLI lifecycle actions, now performable from the Settings page:
|
|
127
|
+
// `logout` removes the shared DeepLake credential; `embeddings` toggles + persists the on/off
|
|
128
|
+
// preference (live via the embed supervisor); `restart` respawns the daemon; `uninstall` returns
|
|
129
|
+
// the guided removal (detected harnesses + the exact CLI command). No token/secret crosses any.
|
|
130
|
+
actionsLogout: "/api/actions/logout",
|
|
131
|
+
actionsEmbeddings: "/api/actions/embeddings",
|
|
132
|
+
actionsRestart: "/api/actions/restart",
|
|
133
|
+
actionsUninstall: "/api/actions/uninstall",
|
|
134
|
+
});
|
|
135
|
+
/**
|
|
136
|
+
* PRD-049e (49e-AC-2) — the header carrying the dashboard's SELECTED project id. The switcher's
|
|
137
|
+
* selection is VIEWER-SIDE: stamping this header on a read narrows the daemon's project-segment
|
|
138
|
+
* predicate to exactly that project (the daemon honors it in `resolveRequestProject`), WITHOUT
|
|
139
|
+
* touching any per-folder CLI binding (49e-AC-4 — it is a request header, never a write). An empty
|
|
140
|
+
* selection omits the header (the read stays project-agnostic / cwd-resolved, back-compat).
|
|
141
|
+
*/
|
|
142
|
+
export const PROJECT_HEADER = "x-honeycomb-project";
|
|
143
|
+
/** Build the per-request project header, or an empty object when no project is selected. */
|
|
144
|
+
export function projectHeader(projectId) {
|
|
145
|
+
return projectId !== undefined && projectId !== "" ? { [PROJECT_HEADER]: projectId } : {};
|
|
146
|
+
}
|
|
147
|
+
/** PRD-040a — the default first-page size the Memories list requests (the daemon clamps to 500). */
|
|
148
|
+
export const DEFAULT_MEMORY_LIST_LIMIT = 50;
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
// Zod schemas — mirror `src/dashboard/contracts.ts` (D-5) over the WIRE truth.
|
|
151
|
+
// Every field defaults so a partial payload degrades to a safe empty/zero state.
|
|
152
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
/** `GET /api/diagnostics/kpis` → {@link import("../contracts.js").KpisView}. */
|
|
154
|
+
export const KpisSchema = z.object({
|
|
155
|
+
memoryCount: z.number().catch(0),
|
|
156
|
+
sessionCount: z.number().catch(0),
|
|
157
|
+
// PRD-035a: the honest "Turns" count (same value as `sessionCount`). `.catch(0)` tolerance so an
|
|
158
|
+
// OLD daemon payload that carries only `sessionCount` degrades safely (the app falls back to it).
|
|
159
|
+
turnCount: z.number().catch(0),
|
|
160
|
+
estimatedSavings: z.number().catch(0),
|
|
161
|
+
// PRD-036c: the team-shared skill count the "Team skills" KPI binds to. `.catch(0)` so an older
|
|
162
|
+
// payload without it reads 0 rather than throwing into React.
|
|
163
|
+
teamSkillCount: z.number().catch(0),
|
|
164
|
+
});
|
|
165
|
+
/** One row of `GET /api/diagnostics/sessions` → {@link import("../contracts.js").SessionRow}. */
|
|
166
|
+
export const SessionRowSchema = z.object({
|
|
167
|
+
sessionId: z.string().catch(""),
|
|
168
|
+
project: z.string().catch(""),
|
|
169
|
+
startedAt: z.string().catch(""),
|
|
170
|
+
eventCount: z.number().catch(0),
|
|
171
|
+
status: z.string().catch("captured"),
|
|
172
|
+
});
|
|
173
|
+
export const SessionsSchema = z.object({
|
|
174
|
+
sessions: z.array(SessionRowSchema).catch([]),
|
|
175
|
+
});
|
|
176
|
+
/** `GET /api/diagnostics/settings` → {@link import("../contracts.js").SettingsView}. */
|
|
177
|
+
export const SettingsSchema = z.object({
|
|
178
|
+
orgId: z.string().catch(""),
|
|
179
|
+
orgName: z.string().catch(""),
|
|
180
|
+
workspace: z.string().catch(""),
|
|
181
|
+
settings: z.record(z.string(), z.string()).catch({}),
|
|
182
|
+
});
|
|
183
|
+
/** A graph node/edge → {@link import("../contracts.js").GraphNode} / `GraphEdge`. */
|
|
184
|
+
export const GraphNodeSchema = z.object({
|
|
185
|
+
id: z.string().catch(""),
|
|
186
|
+
label: z.string().catch(""),
|
|
187
|
+
kind: z.string().catch(""),
|
|
188
|
+
});
|
|
189
|
+
export const GraphEdgeSchema = z.object({
|
|
190
|
+
from: z.string().catch(""),
|
|
191
|
+
to: z.string().catch(""),
|
|
192
|
+
kind: z.string().catch(""),
|
|
193
|
+
});
|
|
194
|
+
/** Bounded-view metadata → {@link import("../contracts.js").GraphViewMeta} (the graph memory cap — the graph cap). */
|
|
195
|
+
export const GraphMetaSchema = z.object({
|
|
196
|
+
totalNodes: z.number().catch(0),
|
|
197
|
+
totalEdges: z.number().catch(0),
|
|
198
|
+
shownNodes: z.number().catch(0),
|
|
199
|
+
shownEdges: z.number().catch(0),
|
|
200
|
+
truncated: z.boolean().catch(false),
|
|
201
|
+
});
|
|
202
|
+
export const GraphSchema = z.object({
|
|
203
|
+
built: z.boolean().catch(false),
|
|
204
|
+
nodes: z.array(GraphNodeSchema).catch([]),
|
|
205
|
+
edges: z.array(GraphEdgeSchema).catch([]),
|
|
206
|
+
// Optional + fail-soft: a malformed/absent `meta` degrades to undefined, never nuking the graph.
|
|
207
|
+
meta: GraphMetaSchema.optional().catch(undefined),
|
|
208
|
+
});
|
|
209
|
+
/** One rule of `GET /api/diagnostics/rules` → {@link import("../contracts.js").RuleRow}. */
|
|
210
|
+
export const RuleRowSchema = z.object({
|
|
211
|
+
id: z.string().catch(""),
|
|
212
|
+
title: z.string().catch(""),
|
|
213
|
+
active: z.boolean().catch(false),
|
|
214
|
+
});
|
|
215
|
+
export const RulesSchema = z.object({
|
|
216
|
+
rules: z.array(RuleRowSchema).catch([]),
|
|
217
|
+
});
|
|
218
|
+
/** One skill of `GET /api/diagnostics/skills` → {@link import("../contracts.js").SkillSyncRow}. */
|
|
219
|
+
export const SkillRowSchema = z.object({
|
|
220
|
+
name: z.string().catch(""),
|
|
221
|
+
scope: z.string().catch(""),
|
|
222
|
+
syncState: z.string().catch("pending"),
|
|
223
|
+
});
|
|
224
|
+
export const SkillsSchema = z.object({
|
|
225
|
+
skills: z.array(SkillRowSchema).catch([]),
|
|
226
|
+
});
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
// PRD-060e — the composite ROI view-model + the trend series. Mirrors the daemon
|
|
229
|
+
// contracts in `src/dashboard/contracts.ts` (`RoiView` / `RoiTrendView`) over the
|
|
230
|
+
// WIRE truth `src/daemon/runtime/dashboard/api.ts` returns. Every money field is
|
|
231
|
+
// INTEGER cents (`z.number().int()` — a float-cents value is `.catch()`-rejected to a
|
|
232
|
+
// safe integer, so a test asserts NO float-cents survives the wire schema, e-AC-11);
|
|
233
|
+
// every per-section status discriminant `.catch()`-defaults to the SAFE degraded value
|
|
234
|
+
// (`'absent'` for the data sections, `'unreachable'` for billing-backed lines) so a
|
|
235
|
+
// partial/malformed payload degrades honestly rather than throwing into React (e-AC-2).
|
|
236
|
+
// NO secret rides these shapes by construction (numbers + labels + status enums only).
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
/** Integer-cents wire field (e-AC-11): a present-but-float/garbage value degrades to `0`, never a float. */
|
|
239
|
+
const roiCentsField = z.number().int().catch(0);
|
|
240
|
+
/** The per-section status discriminant → {@link import("../contracts.js").RoiSectionStatus}. */
|
|
241
|
+
export const RoiSectionStatusSchema = z
|
|
242
|
+
.enum(["ok", "partial", "absent", "unreachable", "unauthenticated"])
|
|
243
|
+
.catch("absent");
|
|
244
|
+
/** The status default for a BILLING-backed line — `unreachable` (couldn't read), not `absent` (no data). */
|
|
245
|
+
export const RoiBillingStatusSchema = z
|
|
246
|
+
.enum(["ok", "partial", "absent", "unreachable", "unauthenticated"])
|
|
247
|
+
.catch("unreachable");
|
|
248
|
+
/** The cost-basis tag → {@link import("../contracts.js").RoiCostBasisTag}. */
|
|
249
|
+
export const RoiCostBasisSchema = z.enum(["measured", "allocated", "none"]).catch("none");
|
|
250
|
+
/** The modeled assumption carried as data (e-AC-8) → {@link import("../contracts.js").RoiAssumption}. */
|
|
251
|
+
export const RoiAssumptionSchema = z.object({
|
|
252
|
+
kind: z.string().catch(""),
|
|
253
|
+
assumptionText: z.string().catch(""),
|
|
254
|
+
signedOff: z.boolean().catch(false),
|
|
255
|
+
});
|
|
256
|
+
/** The savings section (e-AC-3) → {@link import("../contracts.js").RoiSavingsSection}. */
|
|
257
|
+
export const RoiSavingsSectionSchema = z.object({
|
|
258
|
+
status: RoiSectionStatusSchema,
|
|
259
|
+
measuredCents: roiCentsField,
|
|
260
|
+
modeledCents: roiCentsField,
|
|
261
|
+
assumption: RoiAssumptionSchema.catch({ kind: "", assumptionText: "", signedOff: false }),
|
|
262
|
+
// `null` until token capture is live (e-AC-11) — a present-but-float value degrades to null, never a float.
|
|
263
|
+
blendedCentsPerMtok: z.number().int().nullable().catch(null),
|
|
264
|
+
});
|
|
265
|
+
/** The infra cost section (e-AC-6) → {@link import("../contracts.js").RoiInfraSection}. */
|
|
266
|
+
export const RoiInfraSectionSchema = z.object({
|
|
267
|
+
status: RoiBillingStatusSchema,
|
|
268
|
+
cents: roiCentsField,
|
|
269
|
+
costBasis: RoiCostBasisSchema,
|
|
270
|
+
});
|
|
271
|
+
/** One pollination split line → {@link import("../contracts.js").RoiPollinationLine}. */
|
|
272
|
+
export const RoiPollinationLineSchema = z.object({
|
|
273
|
+
label: z.string().catch(""),
|
|
274
|
+
cents: roiCentsField,
|
|
275
|
+
});
|
|
276
|
+
/** The pollination cost section (e-AC-6) → {@link import("../contracts.js").RoiPollinationSection}. */
|
|
277
|
+
export const RoiPollinationSectionSchema = z.object({
|
|
278
|
+
status: RoiBillingStatusSchema,
|
|
279
|
+
cents: roiCentsField,
|
|
280
|
+
lines: z.array(RoiPollinationLineSchema).catch([]),
|
|
281
|
+
});
|
|
282
|
+
/** The net-ROI section (e-AC-6) → {@link import("../contracts.js").RoiNetSection}. */
|
|
283
|
+
export const RoiNetSectionSchema = z.object({
|
|
284
|
+
status: RoiSectionStatusSchema,
|
|
285
|
+
// `computed:false` ⇒ the page renders a dash, NOT the number (e-AC-6 net-not-fabricated).
|
|
286
|
+
computed: z.boolean().catch(false),
|
|
287
|
+
netCents: roiCentsField,
|
|
288
|
+
// The net folds a modeled term → ALWAYS `est.` (e-AC-3). Defaults TRUE (safe: treat as estimate).
|
|
289
|
+
modeled: z.boolean().catch(true),
|
|
290
|
+
costBasis: RoiCostBasisSchema,
|
|
291
|
+
});
|
|
292
|
+
/** One rollup row → {@link import("../contracts.js").RoiRollupRow}. */
|
|
293
|
+
export const RoiRollupRowSchema = z.object({
|
|
294
|
+
key: z.string().catch(""),
|
|
295
|
+
label: z.string().catch(""),
|
|
296
|
+
measuredSavingsCents: roiCentsField,
|
|
297
|
+
netCents: roiCentsField,
|
|
298
|
+
infraCostCents: roiCentsField,
|
|
299
|
+
costBasis: RoiCostBasisSchema,
|
|
300
|
+
sessions: z.number().int().catch(0),
|
|
301
|
+
});
|
|
302
|
+
/** One rollup view (e-AC-13) → {@link import("../contracts.js").RoiRollup}. */
|
|
303
|
+
export const RoiRollupSchema = z.object({
|
|
304
|
+
dimension: z.enum(["org", "team", "agent", "project"]).catch("org"),
|
|
305
|
+
rows: z.array(RoiRollupRowSchema).catch([]),
|
|
306
|
+
mixedBasis: z.boolean().catch(false),
|
|
307
|
+
});
|
|
308
|
+
/** `GET /api/diagnostics/roi` → {@link import("../contracts.js").RoiView}. */
|
|
309
|
+
export const RoiViewSchema = z.object({
|
|
310
|
+
savings: RoiSavingsSectionSchema.catch({
|
|
311
|
+
status: "absent",
|
|
312
|
+
measuredCents: 0,
|
|
313
|
+
modeledCents: 0,
|
|
314
|
+
assumption: { kind: "", assumptionText: "", signedOff: false },
|
|
315
|
+
blendedCentsPerMtok: null,
|
|
316
|
+
}),
|
|
317
|
+
infra: RoiInfraSectionSchema.catch({ status: "unreachable", cents: 0, costBasis: "none" }),
|
|
318
|
+
pollination: RoiPollinationSectionSchema.catch({ status: "unreachable", cents: 0, lines: [] }),
|
|
319
|
+
net: RoiNetSectionSchema.catch({ status: "absent", computed: false, netCents: 0, modeled: true, costBasis: "none" }),
|
|
320
|
+
rollups: z.array(RoiRollupSchema).catch([]),
|
|
321
|
+
// Per-user is gated off until verified backend claims land (e-AC-14) — defaults FALSE (safe: empty state).
|
|
322
|
+
perUserAvailable: z.boolean().catch(false),
|
|
323
|
+
scopedAcrossDevices: z.boolean().catch(false),
|
|
324
|
+
ratesAsOf: z.string().catch(""),
|
|
325
|
+
});
|
|
326
|
+
/** One trend point (e-AC-10) → {@link import("../contracts.js").RoiTrendPoint}. */
|
|
327
|
+
export const RoiTrendPointSchema = z.object({
|
|
328
|
+
period: z.string().catch(""),
|
|
329
|
+
cents: roiCentsField,
|
|
330
|
+
});
|
|
331
|
+
/** One trend series (e-AC-10, dashed=modeled / solid=measured) → {@link import("../contracts.js").RoiTrendSeries}. */
|
|
332
|
+
export const RoiTrendSeriesSchema = z.object({
|
|
333
|
+
label: z.string().catch(""),
|
|
334
|
+
modeled: z.boolean().catch(false),
|
|
335
|
+
points: z.array(RoiTrendPointSchema).catch([]),
|
|
336
|
+
});
|
|
337
|
+
/** `GET /api/diagnostics/roi/trend` → {@link import("../contracts.js").RoiTrendView}. */
|
|
338
|
+
export const RoiTrendViewSchema = z.object({
|
|
339
|
+
status: RoiSectionStatusSchema,
|
|
340
|
+
series: z.array(RoiTrendSeriesSchema).catch([]),
|
|
341
|
+
startedAt: z.string().catch(""),
|
|
342
|
+
});
|
|
343
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
// PRD-042 — the Sync page union view-model (skills + agents) + the action acks.
|
|
345
|
+
// Every field defaults so a partial/malformed payload degrades to a safe state,
|
|
346
|
+
// NEVER a throw into React. NO secret rides these shapes by construction: no
|
|
347
|
+
// `native` blob, no author email, no org GUID — only presentation-safe fields +
|
|
348
|
+
// the `authoredByMe` boolean (the daemon derives it from the opaque author token).
|
|
349
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
|
+
/** One union row on the wire (mirrors the daemon `AssetSyncRow`). State badges drive off `state`. */
|
|
351
|
+
export const AssetSyncRowSchema = z.object({
|
|
352
|
+
assetType: z.enum(["skill", "agent"]).catch("skill"),
|
|
353
|
+
name: z.string().catch(""),
|
|
354
|
+
description: z.string().catch(""),
|
|
355
|
+
state: z.enum(["local", "pulled", "shared"]).catch("local"),
|
|
356
|
+
scope: z.string().catch(""),
|
|
357
|
+
sourceHarness: z.string().catch(""),
|
|
358
|
+
tier: z.string().catch(""),
|
|
359
|
+
style: z.string().catch(""),
|
|
360
|
+
version: z.number().catch(0),
|
|
361
|
+
honeycombId: z.string().catch(""),
|
|
362
|
+
// The page disables Demote when this is false (parent OQ-4) — the author TOKEN is never carried,
|
|
363
|
+
// only this boolean. `.catch(false)` so an older daemon (no field) reads "not author" (safe-closed).
|
|
364
|
+
authoredByMe: z.boolean().catch(false),
|
|
365
|
+
});
|
|
366
|
+
/** `GET /api/diagnostics/assets` → `{ skills, agents }`. A bad shape degrades to empty lists. */
|
|
367
|
+
export const AssetSyncViewSchema = z.object({
|
|
368
|
+
skills: z.array(AssetSyncRowSchema).catch([]),
|
|
369
|
+
agents: z.array(AssetSyncRowSchema).catch([]),
|
|
370
|
+
});
|
|
371
|
+
/** A sync-action ack (mirrors the daemon `SyncActionResult`). Carries NO blob/secret — id/state/version. */
|
|
372
|
+
export const SyncActionResultSchema = z.object({
|
|
373
|
+
ok: z.boolean().catch(false),
|
|
374
|
+
action: z.enum(["promote", "pull", "demote", "enable", "disable"]).catch("promote"),
|
|
375
|
+
assetType: z.enum(["skill", "agent"]).catch("skill"),
|
|
376
|
+
honeycombId: z.string().catch(""),
|
|
377
|
+
state: z.enum(["local", "pulled", "shared", ""]).catch(""),
|
|
378
|
+
version: z.number().catch(0),
|
|
379
|
+
});
|
|
380
|
+
/** The empty union view the page shows before the first load resolves (or on failure). */
|
|
381
|
+
export const EMPTY_ASSET_SYNC_VIEW = Object.freeze({ skills: [], agents: [] });
|
|
382
|
+
/**
|
|
383
|
+
* One recall hit on the wire — the `/api/memories/recall` response shape from
|
|
384
|
+
* `src/daemon/runtime/memories/recall.ts`. PRD-027 Wave 1 made the hit carry a REAL
|
|
385
|
+
* `{ source, id, text, score, kind, secondary }`: `score` is the fused RRF relevance
|
|
386
|
+
* (the engine already emits hits ranked DESC by it), `kind` is the provenance class
|
|
387
|
+
* (`"memory"` distilled vs `"session"` raw dump), and `secondary` is `true` iff the hit
|
|
388
|
+
* is a drill-down raw session row. The client renders the ENGINE score + ENGINE order —
|
|
389
|
+
* it NEVER fabricates a score (D-4 / AC-4 removed the old `1 - i*0.06` synthesis).
|
|
390
|
+
*
|
|
391
|
+
* The score/kind/secondary fields `.catch()` to safe defaults so an OLDER daemon that
|
|
392
|
+
* predates Wave 1 (no score on the wire) still renders (degrade gracefully). The LIVE
|
|
393
|
+
* daemon now always sends them. The kit's `MemoryCard` wants
|
|
394
|
+
* `{ memoryKey, snippet, source, score, scope, verified }`; we MAP the wire hit to those
|
|
395
|
+
* props in {@link recall} (id→memoryKey, text→snippet, ENGINE `score`→score, the arm name→
|
|
396
|
+
* scope hint, `kind`/`secondary`→the distilled-vs-drill-down demotion).
|
|
397
|
+
*/
|
|
398
|
+
export const RecallHitSchema = z.object({
|
|
399
|
+
source: z.string().catch(""),
|
|
400
|
+
id: z.string().catch(""),
|
|
401
|
+
text: z.string().catch(""),
|
|
402
|
+
// PRD-027 Wave 1 (AC-4): the ENGINE's fused relevance + provenance. `.catch()` defaults
|
|
403
|
+
// keep an older daemon (pre-score) renderable — a missing score degrades to 0, not a throw.
|
|
404
|
+
score: z.number().catch(0),
|
|
405
|
+
kind: z.enum(["memory", "session"]).catch("memory"),
|
|
406
|
+
secondary: z.boolean().catch(false),
|
|
407
|
+
});
|
|
408
|
+
export const RecallResponseSchema = z.object({
|
|
409
|
+
hits: z.array(RecallHitSchema).catch([]),
|
|
410
|
+
sources: z.array(z.string()).catch([]),
|
|
411
|
+
degraded: z.boolean().catch(true),
|
|
412
|
+
});
|
|
413
|
+
/**
|
|
414
|
+
* PRD-040 — one memory row on the wire. MIRRORS the daemon read-model
|
|
415
|
+
* `src/daemon/runtime/memories/reads.ts` `MemoryRecord` (the original thin shape PLUS the
|
|
416
|
+
* OQ-1 additive detail metadata). Every field `.catch()`-defaults so a partial/older payload —
|
|
417
|
+
* a daemon serving only the thin `{ id, type, content, confidence, agentId, createdAt,
|
|
418
|
+
* updatedAt }`, or a malformed body — degrades to a safe value rather than throwing into React.
|
|
419
|
+
* The five OQ-1 fields (`visibility`/`sourceType`/`sourceId`/`version`/`hasEmbedding`) are
|
|
420
|
+
* `.catch()`-defaulted EXACTLY so an older/thin daemon still renders the detail view (with those
|
|
421
|
+
* fields blank/false). No secret rides this shape — scope tag, provenance, a version, a boolean.
|
|
422
|
+
*/
|
|
423
|
+
export const MemoryRecordSchema = z.object({
|
|
424
|
+
id: z.string().catch(""),
|
|
425
|
+
type: z.string().catch(""),
|
|
426
|
+
content: z.string().catch(""),
|
|
427
|
+
confidence: z.number().catch(0),
|
|
428
|
+
agentId: z.string().catch(""),
|
|
429
|
+
createdAt: z.string().catch(""),
|
|
430
|
+
updatedAt: z.string().catch(""),
|
|
431
|
+
// OQ-1 additive metadata — `.catch()`-defaulted so the thin shape still parses.
|
|
432
|
+
visibility: z.string().catch(""),
|
|
433
|
+
sourceType: z.string().catch(""),
|
|
434
|
+
sourceId: z.string().catch(""),
|
|
435
|
+
version: z.number().catch(0),
|
|
436
|
+
hasEmbedding: z.boolean().catch(false),
|
|
437
|
+
});
|
|
438
|
+
/** `GET /api/memories` body: `{ memories: MemoryRecord[] }`. A bad shape degrades to `[]`. */
|
|
439
|
+
export const MemoryListResponseSchema = z.object({
|
|
440
|
+
memories: z.array(MemoryRecordSchema).catch([]),
|
|
441
|
+
});
|
|
442
|
+
/** `GET /api/memories/:id` body: `{ memory: MemoryRecord }`. A 404 is handled at the call site (→ null). */
|
|
443
|
+
export const MemoryGetResponseSchema = z.object({
|
|
444
|
+
memory: MemoryRecordSchema,
|
|
445
|
+
});
|
|
446
|
+
/** `POST /api/memories` (store) ack: `{ id, action }` (201). `id` is null when the daemon dedup-skips. */
|
|
447
|
+
export const StoreAckSchema = z.object({
|
|
448
|
+
id: z.string().nullable().catch(null),
|
|
449
|
+
action: z.string().catch(""),
|
|
450
|
+
});
|
|
451
|
+
/** `POST /api/memories/:id/modify` + `/forget` ack: `{ id, action, audited }`. */
|
|
452
|
+
export const WriteAckSchema = z.object({
|
|
453
|
+
id: z.string().nullable().catch(null),
|
|
454
|
+
action: z.string().catch(""),
|
|
455
|
+
audited: z.boolean().catch(false),
|
|
456
|
+
});
|
|
457
|
+
/**
|
|
458
|
+
* PRD-040c — one per-table compaction summary on the wire, MIRRORING the daemon
|
|
459
|
+
* `CompactTableResult` (`src/daemon/runtime/maintenance/compact-api.ts`): the table name + the
|
|
460
|
+
* reap counts + an `errored` count (>0 ⇒ "attempted, not completed"). Every field `.catch()`es
|
|
461
|
+
* so a partial body still renders. No secret — table names + integer counts only.
|
|
462
|
+
*/
|
|
463
|
+
export const CompactTableResultSchema = z.object({
|
|
464
|
+
table: z.string().catch(""),
|
|
465
|
+
keysScanned: z.number().catch(0),
|
|
466
|
+
keysCompacted: z.number().catch(0),
|
|
467
|
+
rowsReaped: z.number().catch(0),
|
|
468
|
+
keysSkipped: z.number().catch(0),
|
|
469
|
+
errored: z.number().catch(0),
|
|
470
|
+
});
|
|
471
|
+
/** `POST /api/diagnostics/compact` body: `{ ok, summaries, skippedTables }`. Degrades to an empty summary. */
|
|
472
|
+
export const CompactSummarySchema = z.object({
|
|
473
|
+
ok: z.boolean().catch(false),
|
|
474
|
+
summaries: z.array(CompactTableResultSchema).catch([]),
|
|
475
|
+
skippedTables: z.array(z.string()).catch([]),
|
|
476
|
+
});
|
|
477
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
478
|
+
// PRD-058d — the lifecycle operator-surface wire shapes. Every field `.catch()`-
|
|
479
|
+
// defaults so a partial/older/malformed payload degrades to a safe value (never a
|
|
480
|
+
// throw into React). No secret rides these shapes — ids, an enum verdict/status, a
|
|
481
|
+
// bounded score, a ref string, an ISO timestamp, the calibration metrics.
|
|
482
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
483
|
+
/** One conflict row from `GET /api/memories/conflicts` (the pair + verdict + status). */
|
|
484
|
+
export const LifecycleConflictSchema = z.object({
|
|
485
|
+
id: z.string().catch(""),
|
|
486
|
+
memoryAId: z.string().catch(""),
|
|
487
|
+
memoryBId: z.string().catch(""),
|
|
488
|
+
verdict: z.string().catch("review"),
|
|
489
|
+
winnerId: z.string().nullable().catch(null),
|
|
490
|
+
status: z.string().catch("open"),
|
|
491
|
+
contraScore: z.number().catch(0),
|
|
492
|
+
});
|
|
493
|
+
export const LifecycleConflictsResponseSchema = z.object({
|
|
494
|
+
conflicts: z.array(LifecycleConflictSchema).catch([]),
|
|
495
|
+
status: z.string().catch("open"),
|
|
496
|
+
});
|
|
497
|
+
/** One stale-ref row from `GET /api/memories/stale-refs` (the memory id + its unresolved refs). */
|
|
498
|
+
export const LifecycleStaleRefSchema = z.object({
|
|
499
|
+
memoryId: z.string().catch(""),
|
|
500
|
+
refStatus: z.string().catch("stale"),
|
|
501
|
+
staleRefs: z.array(z.string()).catch([]),
|
|
502
|
+
verifiedAt: z.string().nullable().catch(null),
|
|
503
|
+
});
|
|
504
|
+
export const LifecycleStaleRefsResponseSchema = z.object({
|
|
505
|
+
staleRefs: z.array(LifecycleStaleRefSchema).catch([]),
|
|
506
|
+
});
|
|
507
|
+
/** One lifecycle audit row from `GET /api/memories/history?type=lifecycle`. */
|
|
508
|
+
export const LifecycleHistorySchema = z.object({
|
|
509
|
+
id: z.string().catch(""),
|
|
510
|
+
memoryId: z.string().catch(""),
|
|
511
|
+
actor: z.string().catch("pipeline"),
|
|
512
|
+
operation: z.string().catch(""),
|
|
513
|
+
reason: z.string().catch(""),
|
|
514
|
+
confidence: z.number().catch(0),
|
|
515
|
+
timestamp: z.string().catch(""),
|
|
516
|
+
});
|
|
517
|
+
export const LifecycleHistoryResponseSchema = z.object({
|
|
518
|
+
history: z.array(LifecycleHistorySchema).catch([]),
|
|
519
|
+
type: z.string().catch("lifecycle"),
|
|
520
|
+
});
|
|
521
|
+
/** One reliability-diagram bin from `GET /api/memories/calibration` (the 058e introspection payload). */
|
|
522
|
+
export const ReliabilityBinSchema = z.object({
|
|
523
|
+
lower: z.number().catch(0),
|
|
524
|
+
upper: z.number().catch(0),
|
|
525
|
+
meanConfidence: z.number().catch(0),
|
|
526
|
+
accuracy: z.number().catch(0),
|
|
527
|
+
count: z.number().catch(0),
|
|
528
|
+
});
|
|
529
|
+
/** The `GET /api/memories/calibration` body (ece/brier/n_samples + reliability diagram, 058e). */
|
|
530
|
+
export const CalibrationSchema = z.object({
|
|
531
|
+
ece: z.number().catch(0),
|
|
532
|
+
brier: z.number().catch(0),
|
|
533
|
+
nSamples: z.number().catch(0),
|
|
534
|
+
fitAt: z.string().nullable().catch(null),
|
|
535
|
+
identity: z.boolean().catch(true),
|
|
536
|
+
reliabilityDiagram: z.array(ReliabilityBinSchema).catch([]),
|
|
537
|
+
});
|
|
538
|
+
/** The cold-start calibration view the panel shows before the first load (or on failure). */
|
|
539
|
+
export const EMPTY_CALIBRATION = Object.freeze({
|
|
540
|
+
ece: 0,
|
|
541
|
+
brier: 0,
|
|
542
|
+
nSamples: 0,
|
|
543
|
+
fitAt: null,
|
|
544
|
+
identity: true,
|
|
545
|
+
reliabilityDiagram: [],
|
|
546
|
+
});
|
|
547
|
+
/** One `/api/logs` record (the `RequestLogRecord` the ring buffer serves; no secret in it). */
|
|
548
|
+
export const LogRecordSchema = z.object({
|
|
549
|
+
time: z.string().catch(""),
|
|
550
|
+
method: z.string().catch(""),
|
|
551
|
+
path: z.string().catch(""),
|
|
552
|
+
status: z.number().catch(0),
|
|
553
|
+
durationMs: z.number().optional(),
|
|
554
|
+
mode: z.string().optional(),
|
|
555
|
+
org: z.string().optional(),
|
|
556
|
+
workspace: z.string().optional(),
|
|
557
|
+
});
|
|
558
|
+
export const LogsResponseSchema = z.object({
|
|
559
|
+
records: z.array(LogRecordSchema).catch([]),
|
|
560
|
+
count: z.number().catch(0),
|
|
561
|
+
});
|
|
562
|
+
/**
|
|
563
|
+
* PRD-043a/043b — `GET /api/logs/history` body: the durable, filterable, paginated request-log
|
|
564
|
+
* history (newest first) + the next-page cursor. MIRRORS the daemon `LogsHistoryResponse`
|
|
565
|
+
* (`src/daemon/runtime/logs/api.ts`). Reuses the SAME secret-free {@link LogRecordSchema} as the
|
|
566
|
+
* live snapshot, so the history table and the live tail share ONE row shape and never drift. Every
|
|
567
|
+
* field `.catch()`-defaults so a partial/older payload degrades to a safe empty page (never a throw
|
|
568
|
+
* into React). `nextCursor` is null on the last page; `persistent:false` means history is
|
|
569
|
+
* unavailable (the store could not open — the page shows its honest empty/unavailable state).
|
|
570
|
+
*/
|
|
571
|
+
export const LogsHistoryResponseSchema = z.object({
|
|
572
|
+
records: z.array(LogRecordSchema).catch([]),
|
|
573
|
+
count: z.number().catch(0),
|
|
574
|
+
nextCursor: z.string().nullable().catch(null),
|
|
575
|
+
persistent: z.boolean().catch(false),
|
|
576
|
+
});
|
|
577
|
+
/**
|
|
578
|
+
* PRD-043c — `GET /api/diagnostics/sessions` (paged) body: the browsable captured-TURNS history +
|
|
579
|
+
* the next-page cursor. Reuses the SAME {@link SessionRowSchema} as the legacy sessions view (so the
|
|
580
|
+
* Turns list and the dashboard panel share one row shape). Every field `.catch()`-defaults so a
|
|
581
|
+
* partial/older daemon payload (one WITHOUT `nextCursor`) degrades to a safe page with no further
|
|
582
|
+
* cursor, never a throw. The page LABELS these "Turns" (PRD-035a); the storage table stays `sessions`.
|
|
583
|
+
*/
|
|
584
|
+
export const TurnsHistoryResponseSchema = z.object({
|
|
585
|
+
sessions: z.array(SessionRowSchema).catch([]),
|
|
586
|
+
nextCursor: z.string().nullable().catch(null),
|
|
587
|
+
});
|
|
588
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
589
|
+
// PRD-039 — the harness registry + last-seen telemetry (the data backbone). Mirrors
|
|
590
|
+
// `src/daemon/runtime/dashboard/harness-api.ts` (`HarnessStatus`) + the folded 039c
|
|
591
|
+
// capability descriptor. Every field defaults so a partial/malformed payload degrades
|
|
592
|
+
// to a safe zeroed state, NEVER a throw into React (AC-8 defensive parsing). NO secret
|
|
593
|
+
// rides this shape by construction — ids, booleans, a count, an ISO timestamp, statics.
|
|
594
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
595
|
+
/** Cursor's `cursor-agent` agents descriptor (039c) — present for Cursor, absent for Claude Code. */
|
|
596
|
+
export const HarnessAgentsSchema = z.object({
|
|
597
|
+
kind: z.string().catch(""),
|
|
598
|
+
binary: z.string().catch(""),
|
|
599
|
+
fallbackBin: z.string().optional(),
|
|
600
|
+
});
|
|
601
|
+
/** One harness's host-CLI descriptor (mirrors the shim `HostCli`). */
|
|
602
|
+
export const HarnessHostCliSchema = z.object({
|
|
603
|
+
bin: z.string().catch(""),
|
|
604
|
+
args: z.array(z.string()).catch([]),
|
|
605
|
+
fallbackBin: z.string().optional(),
|
|
606
|
+
});
|
|
607
|
+
/**
|
|
608
|
+
* The data-driven capability descriptor folded into each `HarnessStatus` server-side (039c / c-OQ-2).
|
|
609
|
+
* The OPTIONAL fields are the genuine shim divergences — a missing field omits that harness's panel
|
|
610
|
+
* on the detail page (c-AC-3), never a blank. Each field `.catch()`es so a partial body still renders.
|
|
611
|
+
*/
|
|
612
|
+
export const HarnessCapabilitiesSchema = z.object({
|
|
613
|
+
name: z.string().catch(""),
|
|
614
|
+
runtimePath: z.string().catch(""),
|
|
615
|
+
contextChannel: z.string().catch(""),
|
|
616
|
+
hostCli: HarnessHostCliSchema.catch({ bin: "", args: [] }),
|
|
617
|
+
lifecycleEvents: z.array(z.string()).catch([]),
|
|
618
|
+
agents: HarnessAgentsSchema.optional(),
|
|
619
|
+
workspaceRoots: z.boolean().optional(),
|
|
620
|
+
mcpRegistration: z.boolean().optional(),
|
|
621
|
+
contractedTools: z.boolean().optional(),
|
|
622
|
+
agentsMdContext: z.boolean().optional(),
|
|
623
|
+
userVisibleLogin: z.boolean().optional(),
|
|
624
|
+
});
|
|
625
|
+
/** One harness's status row (mirrors the daemon `HarnessStatus`). `lastSeen` is null when never seen. */
|
|
626
|
+
export const HarnessStatusSchema = z.object({
|
|
627
|
+
name: z.string().catch(""),
|
|
628
|
+
installed: z.boolean().catch(false),
|
|
629
|
+
active: z.boolean().catch(false),
|
|
630
|
+
lastSeen: z.string().nullable().catch(null),
|
|
631
|
+
turnsCaptured: z.number().catch(0),
|
|
632
|
+
runtimePath: z.string().catch(""),
|
|
633
|
+
capabilities: HarnessCapabilitiesSchema.catch({
|
|
634
|
+
name: "",
|
|
635
|
+
runtimePath: "",
|
|
636
|
+
contextChannel: "",
|
|
637
|
+
hostCli: { bin: "", args: [] },
|
|
638
|
+
lifecycleEvents: [],
|
|
639
|
+
}),
|
|
640
|
+
});
|
|
641
|
+
export const HarnessStatusResponseSchema = z.object({
|
|
642
|
+
harnesses: z.array(HarnessStatusSchema).catch([]),
|
|
643
|
+
});
|
|
644
|
+
/** The Wave-1 Pollinate ack (`POST /api/diagnostics/pollinate` → 202 + this body). */
|
|
645
|
+
export const PollinateAckSchema = z.object({
|
|
646
|
+
triggered: z.boolean().catch(false),
|
|
647
|
+
status: z.string().catch("skipped"),
|
|
648
|
+
reason: z.string().optional(),
|
|
649
|
+
});
|
|
650
|
+
/**
|
|
651
|
+
* The `POST /api/graph/build` ack (the codebase-graph build worker's data body). The daemon runs the
|
|
652
|
+
* REAL worker end-to-end — discover → tree-sitter extract → aggregate → finalize → write the LOCAL
|
|
653
|
+
* snapshot — and returns `{ built, snapshotSha256, nodeCount, edgeCount, fileCount, parseErrorCount,
|
|
654
|
+
* cacheStats, localPath, push }`. We only validate the four fields the UI needs (`built` + the counts);
|
|
655
|
+
* the rest of the body is ignored (zod drops extra keys). Every field `.catch()`-defaults so the 500
|
|
656
|
+
* error body (`{ error, reason }`) — or any malformed/partial payload — degrades to a safe
|
|
657
|
+
* `{ built: false, … }` ack rather than throwing into React (the page surfaces an honest inline error).
|
|
658
|
+
*/
|
|
659
|
+
export const BuildGraphAckSchema = z.object({
|
|
660
|
+
built: z.boolean().catch(false),
|
|
661
|
+
nodeCount: z.number().catch(0),
|
|
662
|
+
edgeCount: z.number().catch(0),
|
|
663
|
+
fileCount: z.number().catch(0),
|
|
664
|
+
});
|
|
665
|
+
/**
|
|
666
|
+
* The honest failure ack the wire returns when the build POST is rejected, times out, or the body is
|
|
667
|
+
* malformed (never a throw into React). The page keeps its empty state + shows the inline error line.
|
|
668
|
+
*/
|
|
669
|
+
export const FAILED_BUILD_GRAPH_ACK = Object.freeze({ built: false, nodeCount: 0, edgeCount: 0, fileCount: 0 });
|
|
670
|
+
/**
|
|
671
|
+
* The generous client-side timeout (ms) for `buildGraph()`. The build parses the WHOLE repo with
|
|
672
|
+
* tree-sitter and can take many seconds to tens of seconds; a short default fetch timeout would abort
|
|
673
|
+
* a legitimate in-progress build. 120s gives the worker ample headroom — well past the realistic build
|
|
674
|
+
* time — while still bounding a truly hung request so the button never spins forever.
|
|
675
|
+
*/
|
|
676
|
+
export const BUILD_GRAPH_TIMEOUT_MS = 120_000;
|
|
677
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
678
|
+
// Dashboard imperative actions (`/api/actions/*`) — logout / embeddings / restart / uninstall.
|
|
679
|
+
// Each ack body carries booleans / ids / a command string only; NO token/secret by construction.
|
|
680
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
681
|
+
/** The `POST /api/actions/logout` ack (`{ ok }`). */
|
|
682
|
+
export const ActionOkSchema = z.object({ ok: z.boolean().catch(false) });
|
|
683
|
+
/**
|
|
684
|
+
* The `POST /api/actions/embeddings` ack: `{ ok, enabled }` (the new persisted on/off state). Both
|
|
685
|
+
* fields are STRICT (no `.catch` default): a malformed/partial body must FAIL the parse → `postJson`
|
|
686
|
+
* returns null → `setEmbeddings` reports failure. Defaulting `enabled` to `false` would let a
|
|
687
|
+
* `setEmbeddings(false)` call falsely "succeed" against a response that never echoed the real state.
|
|
688
|
+
*/
|
|
689
|
+
export const EmbeddingsActionSchema = z.object({ ok: z.boolean(), enabled: z.boolean() });
|
|
690
|
+
/** The `POST /api/actions/restart` ack: `{ ok, restarting }`. */
|
|
691
|
+
export const RestartActionSchema = z.object({ ok: z.boolean().catch(false), restarting: z.boolean().catch(false) });
|
|
692
|
+
/** The `POST /api/actions/uninstall` result: detected harnesses + the exact CLI command + a note. */
|
|
693
|
+
export const UninstallResultSchema = z.object({
|
|
694
|
+
ok: z.boolean().catch(false),
|
|
695
|
+
harnesses: z.array(z.string()).catch([]),
|
|
696
|
+
removed: z.boolean().catch(false),
|
|
697
|
+
command: z.string().catch("honeycomb uninstall"),
|
|
698
|
+
note: z.string().catch(""),
|
|
699
|
+
});
|
|
700
|
+
/**
|
|
701
|
+
* The PRD-029 per-subsystem `/health` `reasons` block (D-2 render). This MIRRORS the
|
|
702
|
+
* Wave-1 daemon contract `HealthReasons` in `src/daemon/runtime/health.ts` verbatim — a
|
|
703
|
+
* closed enum per subsystem, NO secret (D-5: no token/org/endpoint/header rides these,
|
|
704
|
+
* only subsystem names + coarse states). The dashboard is LOCAL-mode so the daemon's
|
|
705
|
+
* `/health` body carries this block; on a non-local public body it is absent (the strip
|
|
706
|
+
* renders nothing — handled at the call site by a `null` reasons).
|
|
707
|
+
*
|
|
708
|
+
* Each field `.catch()`es to its HEALTHY value so a malformed/partial `reasons` degrades
|
|
709
|
+
* to "looks ok" rather than throwing into React — the body crosses the untyped IO boundary
|
|
710
|
+
* exactly like every other endpoint here. An UNKNOWN enum value (a future daemon adds a
|
|
711
|
+
* state) also `.catch()`es to the healthy default, never a throw.
|
|
712
|
+
*/
|
|
713
|
+
export const HealthReasonsSchema = z.object({
|
|
714
|
+
storage: z.enum(["reachable", "unreachable"]).catch("reachable"),
|
|
715
|
+
embeddings: z.enum(["on", "off"]).catch("on"),
|
|
716
|
+
schema: z.enum(["ok", "missing_table"]).catch("ok"),
|
|
717
|
+
// PRD-063b (b-AC-7): the Portkey gateway reason. `.catch("off")` so a pre-063b daemon (no
|
|
718
|
+
// `portkey` field) or an unknown future state degrades to "off" (Portkey not in force) rather
|
|
719
|
+
// than throwing — the field is purely additive render data, like every other reason here.
|
|
720
|
+
portkey: z.enum(["off", "ok", "unconfigured", "unreachable"]).catch("off"),
|
|
721
|
+
});
|
|
722
|
+
/**
|
|
723
|
+
* The `/health` body the daemon serves (the coarse bit + the additive PRD-029 `reasons`).
|
|
724
|
+
* `reasons` is OPTIONAL — absent on the mode-gated public team/hybrid body, present in
|
|
725
|
+
* local (which the dashboard always is). The whole body `.catch()`es a bad shape so a
|
|
726
|
+
* malformed `/health` degrades to "no reasons" (coarse pill only), never a throw.
|
|
727
|
+
*
|
|
728
|
+
* NOTE the coarse liveness the app's view-swap keys off (`daemonUp`) comes from the HTTP
|
|
729
|
+
* `res.ok` (a 503-on-degraded still parses a body), NOT from this `status` field — so the
|
|
730
|
+
* reasons are purely ADDITIVE render data and never change the existing up/down behaviour.
|
|
731
|
+
*/
|
|
732
|
+
export const HealthBodySchema = z.object({
|
|
733
|
+
status: z.string().catch("ok"),
|
|
734
|
+
reasons: HealthReasonsSchema.optional(),
|
|
735
|
+
});
|
|
736
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
737
|
+
// PRD-032c — the vault `setting`-class surface (`GET`/`POST /api/settings`) + the
|
|
738
|
+
// curated provider→model catalog. Every field defaults so a partial/malformed payload
|
|
739
|
+
// degrades to a safe empty state, NEVER a throw into React (AC-5 defensive parsing).
|
|
740
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
741
|
+
/**
|
|
742
|
+
* One provider entry in the catalog `GET /api/settings` returns — MIRRORS the Wave-1
|
|
743
|
+
* `ProviderEntry` in `src/daemon/runtime/vault/catalog.ts` (D-6). `models` is the curated,
|
|
744
|
+
* ordered model list (`models[0]` is the selector default); `openEnded` marks OpenRouter,
|
|
745
|
+
* whose model id is a free-form passthrough (the panel renders a text input for it).
|
|
746
|
+
*/
|
|
747
|
+
export const ProviderEntrySchema = z.object({
|
|
748
|
+
id: z.string().catch(""),
|
|
749
|
+
label: z.string().catch(""),
|
|
750
|
+
models: z.array(z.string()).catch([]),
|
|
751
|
+
openEnded: z.boolean().catch(false),
|
|
752
|
+
});
|
|
753
|
+
/**
|
|
754
|
+
* A single `setting` value on the wire — the vault `setting` class stores a JSON SCALAR
|
|
755
|
+
* (string | number | boolean), so the panel renders exactly those. A non-scalar (a future
|
|
756
|
+
* structured setting) would register its own class; here a bad value `.catch()`es to a
|
|
757
|
+
* harmless empty string so the panel never throws.
|
|
758
|
+
*/
|
|
759
|
+
export const SettingValueWireSchema = z.union([z.string(), z.number(), z.boolean()]).catch("");
|
|
760
|
+
/**
|
|
761
|
+
* The `GET /api/settings` body the Wave-1 daemon serves: `{ settings, catalog }`. `settings`
|
|
762
|
+
* is the current key→value map of the `setting` class (the active provider/model + the
|
|
763
|
+
* pollinating toggle + dashboard prefs); `catalog` is the static provider→model list. NO secret
|
|
764
|
+
* is in this body by construction (the surface reads only the `setting` class). Each field
|
|
765
|
+
* `.catch()`es to an empty default so a partial body degrades to "nothing selected".
|
|
766
|
+
*/
|
|
767
|
+
export const VaultSettingsSchema = z.object({
|
|
768
|
+
settings: z.record(z.string(), SettingValueWireSchema).catch({}),
|
|
769
|
+
catalog: z.array(ProviderEntrySchema).catch([]),
|
|
770
|
+
});
|
|
771
|
+
/**
|
|
772
|
+
* The `GET /api/secrets` body (PRD-012a, names-only) — `{ names: string[] }`. The panel reads
|
|
773
|
+
* this ONLY to show a provider key's PRESENCE ("set ✓" / "not set") by name; there is NO
|
|
774
|
+
* value-returning route and the panel never asks for one (AC-5 / D-4). A malformed body
|
|
775
|
+
* `.catch()`es to an empty name list (every provider reads as "not set", never a throw).
|
|
776
|
+
*/
|
|
777
|
+
export const SecretNamesSchema = z.object({
|
|
778
|
+
names: z.array(z.string()).catch([]),
|
|
779
|
+
});
|
|
780
|
+
/** The empty vault-settings view the panel shows before the first load (or on failure). */
|
|
781
|
+
export const EMPTY_VAULT_SETTINGS = Object.freeze({ settings: {}, catalog: [] });
|
|
782
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
783
|
+
// PRD-044a — the REDACTED `/api/auth/status` read-model (the Settings page auth
|
|
784
|
+
// section). Every field `.catch()`-defaults so a partial/failed payload degrades to
|
|
785
|
+
// a DISCONNECTED status (never a throw into React). THE SCHEMA HAS NO `token` FIELD
|
|
786
|
+
// BY CONSTRUCTION — a token in the body is IGNORED by the schema (D-3, the token is
|
|
787
|
+
// sacred). `expiresAt` is optional: present only when a real `TokenClaims.exp` exists.
|
|
788
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
789
|
+
/**
|
|
790
|
+
* The `GET /api/auth/status` body the auth section reads (PRD-044a). Metadata ONLY: org id +
|
|
791
|
+
* name, workspace, agent, the credentials `source` (`env` | `file` | `none`), `savedAt`, and an
|
|
792
|
+
* optional `expiresAt`. There is deliberately NO `token` field — zod drops any extra `token` key
|
|
793
|
+
* in the body, so a token can never reach React even if a buggy daemon sent one. Every field
|
|
794
|
+
* `.catch()`-defaults to a disconnected-safe value (AC-4: degrade, never throw).
|
|
795
|
+
*/
|
|
796
|
+
export const AuthStatusSchema = z.object({
|
|
797
|
+
connected: z.boolean().catch(false),
|
|
798
|
+
orgId: z.string().catch(""),
|
|
799
|
+
orgName: z.string().catch(""),
|
|
800
|
+
workspace: z.string().catch(""),
|
|
801
|
+
agentId: z.string().catch(""),
|
|
802
|
+
source: z.enum(["env", "file", "none"]).catch("none"),
|
|
803
|
+
savedAt: z.string().catch(""),
|
|
804
|
+
// Present ONLY when a real token `exp` exists; absent → the section shows "expiry unknown"
|
|
805
|
+
// (never a fabricated date). `.optional()` so an absent field is honestly undefined.
|
|
806
|
+
expiresAt: z.number().optional(),
|
|
807
|
+
});
|
|
808
|
+
/**
|
|
809
|
+
* The honest disconnected status the auth section shows before the first load, on any failure,
|
|
810
|
+
* or when no credentials resolve (AC-4). Never a blank panel, never a fabricated org.
|
|
811
|
+
*/
|
|
812
|
+
export const DISCONNECTED_AUTH_STATUS = Object.freeze({
|
|
813
|
+
connected: false,
|
|
814
|
+
orgId: "",
|
|
815
|
+
orgName: "",
|
|
816
|
+
workspace: "",
|
|
817
|
+
agentId: "",
|
|
818
|
+
source: "none",
|
|
819
|
+
savedAt: "",
|
|
820
|
+
});
|
|
821
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
822
|
+
// PRD-050b — the pre-auth guided-setup STATE read (`GET /setup/state`). Drives the
|
|
823
|
+
// fresh-install vs already-linked render (b-AC-6) and the live pre-auth→authenticated
|
|
824
|
+
// transition (b-AC-3). Every field `.catch()`-defaults so a partial/failed/non-local
|
|
825
|
+
// (404) payload degrades to a SAFE fresh-install state (never a throw into React). The
|
|
826
|
+
// body carries NO token/secret/PII by construction (install metadata only). The shape
|
|
827
|
+
// mirrors `src/daemon/runtime/dashboard/setup-state.ts` `SetupStateBody`; 050d EXTENDS it
|
|
828
|
+
// additively, so unknown future fields are simply ignored here (zod drops extras).
|
|
829
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
830
|
+
/** Per-tool credential/state directory presence (mirrors the daemon `SetupCredentialsPresence`). */
|
|
831
|
+
export const SetupCredentialsSchema = z.object({
|
|
832
|
+
deeplake: z.boolean().catch(false),
|
|
833
|
+
honeycomb: z.boolean().catch(false),
|
|
834
|
+
hivemind: z.boolean().catch(false),
|
|
835
|
+
});
|
|
836
|
+
/** The embeddings warmup signal (b-AC-5) — observable, never blocking. */
|
|
837
|
+
export const SetupWarmupSchema = z.object({
|
|
838
|
+
enabled: z.boolean().catch(false),
|
|
839
|
+
live: z.boolean().catch(false),
|
|
840
|
+
warm: z.boolean().catch(false),
|
|
841
|
+
});
|
|
842
|
+
/**
|
|
843
|
+
* The `GET /setup/state` body the guided-setup shell reads (PRD-050b). `authenticated` is the
|
|
844
|
+
* DERIVED auth source of truth (a valid credential loads), NOT the `phase` hint. Every field
|
|
845
|
+
* `.catch()`-defaults to a fresh-install-safe value so a failed/non-local read renders the
|
|
846
|
+
* guided-setup state (the safe default), never a throw.
|
|
847
|
+
*/
|
|
848
|
+
export const SetupStateSchema = z.object({
|
|
849
|
+
credentials: SetupCredentialsSchema.catch({ deeplake: false, honeycomb: false, hivemind: false }),
|
|
850
|
+
phase: z.enum(["fresh", "installed", "linking", "linked", "migrating", "migrated"]).catch("fresh"),
|
|
851
|
+
priorTool: z
|
|
852
|
+
.object({ hivemind: z.enum(["absent", "present", "migrated"]).catch("absent") })
|
|
853
|
+
.catch({ hivemind: "absent" }),
|
|
854
|
+
firstTimeSetupComplete: z.boolean().catch(false),
|
|
855
|
+
// THE load-bearing field for b-AC-6: when false the "First time setup" button shows; when true
|
|
856
|
+
// the dashboard renders the authenticated state instead. Defaults FALSE (safe: show guided-setup).
|
|
857
|
+
authenticated: z.boolean().catch(false),
|
|
858
|
+
warmup: SetupWarmupSchema.catch({ enabled: false, live: false, warm: false }),
|
|
859
|
+
// PRD-050d (d-AC-7) — the durable Hivemind→Honeycomb migration marker, present ONLY while a
|
|
860
|
+
// migration is in flight or terminal. A NON-TERMINAL phase (`backup`/`uninstall`/`link`) means the
|
|
861
|
+
// migration was interrupted → the dashboard offers RESUME or ROLL BACK. `.optional()` so a machine
|
|
862
|
+
// that never migrated simply omits it; `backupPath` rides for the rollback affordance. No secret.
|
|
863
|
+
migration: z
|
|
864
|
+
.object({
|
|
865
|
+
phase: z.enum(["backup", "uninstall", "link", "done", "rolled_back"]).catch("backup"),
|
|
866
|
+
startedAt: z.string().catch(""),
|
|
867
|
+
backupPath: z.string().optional(),
|
|
868
|
+
})
|
|
869
|
+
.optional(),
|
|
870
|
+
});
|
|
871
|
+
/**
|
|
872
|
+
* The honest fresh-install setup state the shell shows before the first load, on any failure, or in
|
|
873
|
+
* non-local mode (the route 404s). `authenticated:false` ⇒ the guided-setup state renders (b-AC-6).
|
|
874
|
+
*/
|
|
875
|
+
export const FRESH_SETUP_STATE = Object.freeze({
|
|
876
|
+
credentials: { deeplake: false, honeycomb: false, hivemind: false },
|
|
877
|
+
phase: "fresh",
|
|
878
|
+
priorTool: { hivemind: "absent" },
|
|
879
|
+
firstTimeSetupComplete: false,
|
|
880
|
+
authenticated: false,
|
|
881
|
+
warmup: { enabled: false, live: false, warm: false },
|
|
882
|
+
});
|
|
883
|
+
/**
|
|
884
|
+
* The `POST /setup/login` render payload (PRD-050c, consumed by 050b's "First time setup" button).
|
|
885
|
+
* The body carries ONLY the `user_code` to display + the verification URIs — NEVER a token/device
|
|
886
|
+
* code (the schema has no token field by construction). Every field `.catch()`-defaults so a partial
|
|
887
|
+
* body degrades safely; `verification_uri_complete` is optional (present only when https-validated).
|
|
888
|
+
*/
|
|
889
|
+
export const SetupLoginSchema = z.object({
|
|
890
|
+
user_code: z.string().catch(""),
|
|
891
|
+
verification_uri: z.string().catch(""),
|
|
892
|
+
verification_uri_complete: z.string().optional(),
|
|
893
|
+
});
|
|
894
|
+
/**
|
|
895
|
+
* PRD-050d — the `POST /setup/migrate-from-hivemind` response (consumed by the "Proceed with Honeycomb"
|
|
896
|
+
* button). Carries the terminal `phase` + a plain-language `message` + the `backupPath` reversibility
|
|
897
|
+
* anchor + the `needsLogin`/`migrated` flags. NO token/secret rides this shape by construction. Every
|
|
898
|
+
* field `.catch()`-defaults so a partial/failed body degrades to a safe "not ok" state, never a throw.
|
|
899
|
+
* `needsLogin:true` ⇒ the page runs the 050c device flow; `migrated:true` ⇒ the silent-adopt completed.
|
|
900
|
+
*/
|
|
901
|
+
export const SetupMigrateSchema = z.object({
|
|
902
|
+
ok: z.boolean().catch(false),
|
|
903
|
+
phase: z.enum(["backup", "uninstall", "link", "done", "rolled_back"]).catch("backup"),
|
|
904
|
+
message: z.string().catch(""),
|
|
905
|
+
backupPath: z.string().optional(),
|
|
906
|
+
needsLogin: z.boolean().optional(),
|
|
907
|
+
migrated: z.boolean().optional(),
|
|
908
|
+
});
|
|
909
|
+
/** The honest "migration unavailable" ack the wizard shows on a non-2xx / network failure (never a throw). */
|
|
910
|
+
export const FAILED_SETUP_MIGRATE = Object.freeze({
|
|
911
|
+
ok: false,
|
|
912
|
+
phase: "backup",
|
|
913
|
+
message: "Migration is unavailable right now. Retry, or run the uninstall + `honeycomb login` in your terminal.",
|
|
914
|
+
});
|
|
915
|
+
/** PRD-050d — the `POST /setup/migrate-from-hivemind/rollback` response (the d-AC-7 roll-back affordance). */
|
|
916
|
+
export const SetupMigrateRollbackSchema = z.object({
|
|
917
|
+
ok: z.boolean().catch(false),
|
|
918
|
+
phase: z.literal("rolled_back").catch("rolled_back"),
|
|
919
|
+
message: z.string().catch(""),
|
|
920
|
+
});
|
|
921
|
+
/** The honest "rollback unavailable" ack the wizard shows on a non-2xx / network failure (never a throw). */
|
|
922
|
+
export const FAILED_SETUP_MIGRATE_ROLLBACK = Object.freeze({
|
|
923
|
+
ok: false,
|
|
924
|
+
phase: "rolled_back",
|
|
925
|
+
message: "Rollback is unavailable right now. Retry.",
|
|
926
|
+
});
|
|
927
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
928
|
+
// PRD-049e — the scope-switcher enumeration schemas. Every field `.catch()`-defaults so a
|
|
929
|
+
// partial/failed/non-local (404) body degrades to a SAFE empty list (never a throw into React).
|
|
930
|
+
// NO token rides any of these bodies by construction (the daemon enumerates id+name only).
|
|
931
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
932
|
+
/** One enumerated org (id + display name) from `GET /api/diagnostics/scope/orgs`. */
|
|
933
|
+
export const ScopeOrgSchema = z.object({
|
|
934
|
+
id: z.string().catch(""),
|
|
935
|
+
name: z.string().catch(""),
|
|
936
|
+
});
|
|
937
|
+
/** The `GET /api/diagnostics/scope/orgs` body — `{ orgs }`. A failure degrades to an empty list. */
|
|
938
|
+
export const ScopeOrgsSchema = z.object({
|
|
939
|
+
orgs: z.array(ScopeOrgSchema).catch([]),
|
|
940
|
+
});
|
|
941
|
+
/** One enumerated workspace (id + display name) from `GET /api/diagnostics/scope/workspaces`. */
|
|
942
|
+
export const ScopeWorkspaceSchema = z.object({
|
|
943
|
+
id: z.string().catch(""),
|
|
944
|
+
name: z.string().catch(""),
|
|
945
|
+
});
|
|
946
|
+
/**
|
|
947
|
+
* The `GET /api/diagnostics/scope/workspaces` body — `{ workspaces, org, reminted }`. `reminted` is
|
|
948
|
+
* true when the daemon re-minted the org-bound token before enumerating (49e-AC-3 observability).
|
|
949
|
+
*/
|
|
950
|
+
export const ScopeWorkspacesSchema = z.object({
|
|
951
|
+
workspaces: z.array(ScopeWorkspaceSchema).catch([]),
|
|
952
|
+
org: z.string().catch(""),
|
|
953
|
+
reminted: z.boolean().catch(false),
|
|
954
|
+
});
|
|
955
|
+
/**
|
|
956
|
+
* One enumerated project (the workspace's synced 049a registry copy) — id + display name + the
|
|
957
|
+
* PRD-059d `boundLocally` bit. `boundLocally` is true when a local folder→project binding targets
|
|
958
|
+
* this project on THIS device (the Projects page shows those as ACTIVE); false ⇒ registry-only,
|
|
959
|
+
* IMPORTABLE (the 059d "Import project from cloud" list shows those). `.catch(false)` so an OLDER
|
|
960
|
+
* daemon payload that predates the field degrades to "not bound here" (safe: it would appear in the
|
|
961
|
+
* import list, never silently as active) rather than throwing into React.
|
|
962
|
+
*/
|
|
963
|
+
export const ScopeProjectSchema = z.object({
|
|
964
|
+
projectId: z.string().catch(""),
|
|
965
|
+
name: z.string().catch(""),
|
|
966
|
+
boundLocally: z.boolean().catch(false),
|
|
967
|
+
// PRD-059c c-AC-1 — the per-project STATE the Wave-3 daemon now aggregates onto each registry row.
|
|
968
|
+
// Every field `.catch()`-defaults so an OLDER daemon that predates the enrichment (or a partial body)
|
|
969
|
+
// degrades to a safe empty/zero value rather than throwing into React — the page renders the honest
|
|
970
|
+
// fallback ("—"/"never") for the missing field. NO secret rides any of these (paths, a git remote
|
|
971
|
+
// slug, integer counts, an ISO timestamp). `boundPaths` is this device's bound folder path(s) (empty
|
|
972
|
+
// for an importable/registry-only project); `remote` is `host/owner/repo` or `''`; the counts are
|
|
973
|
+
// best-effort (`0` on a backend flap); `lastCapture` is an ISO timestamp or `null` ("never captured").
|
|
974
|
+
boundPaths: z.array(z.string()).catch([]),
|
|
975
|
+
remote: z.string().catch(""),
|
|
976
|
+
memoryCount: z.number().catch(0),
|
|
977
|
+
sessionCount: z.number().catch(0),
|
|
978
|
+
lastCapture: z.string().nullable().catch(null),
|
|
979
|
+
});
|
|
980
|
+
/** The `GET /api/diagnostics/scope/projects` body — `{ projects, org, workspace }`. */
|
|
981
|
+
export const ScopeProjectsSchema = z.object({
|
|
982
|
+
projects: z.array(ScopeProjectSchema).catch([]),
|
|
983
|
+
org: z.string().catch(""),
|
|
984
|
+
workspace: z.string().catch(""),
|
|
985
|
+
});
|
|
986
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
987
|
+
// PRD-059b/059c/059d — the folder-picker + bind schemas. Every field `.catch()`-defaults so a
|
|
988
|
+
// partial/failed/non-local (404) body degrades to a SAFE empty/false state (never a throw into
|
|
989
|
+
// React). NO token rides any of these bodies by construction (the daemon serves paths + ids only).
|
|
990
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
991
|
+
/** One immediate child directory in a browse listing (059b) — basename + absolute path + git-repo marker. */
|
|
992
|
+
export const BrowseChildSchema = z.object({
|
|
993
|
+
name: z.string().catch(""),
|
|
994
|
+
path: z.string().catch(""),
|
|
995
|
+
isGitRepo: z.boolean().catch(false),
|
|
996
|
+
});
|
|
997
|
+
/**
|
|
998
|
+
* The `GET /api/diagnostics/fs/browse` body (059b): the resolved dir + its immediate child
|
|
999
|
+
* directories. `parent` is null at the allowed root (no traversal above it); `error` is a redacted
|
|
1000
|
+
* reason when the dir could not be read (still a clean body with empty children). A
|
|
1001
|
+
* malformed/absent/non-local body degrades to {@link EMPTY_BROWSE} so the picker shows an honest
|
|
1002
|
+
* empty/unavailable state (never a throw).
|
|
1003
|
+
*/
|
|
1004
|
+
export const BrowseBodySchema = z.object({
|
|
1005
|
+
path: z.string().catch(""),
|
|
1006
|
+
root: z.string().catch(""),
|
|
1007
|
+
parent: z.string().nullable().catch(null),
|
|
1008
|
+
children: z.array(BrowseChildSchema).catch([]),
|
|
1009
|
+
error: z.string().optional(),
|
|
1010
|
+
});
|
|
1011
|
+
/** The honest empty browse body the picker shows when the daemon is unreachable / local-mode is off (b-AC-5). */
|
|
1012
|
+
export const EMPTY_BROWSE = Object.freeze({ path: "", root: "", parent: null, children: [] });
|
|
1013
|
+
/**
|
|
1014
|
+
* The `POST /api/diagnostics/projects/bind` (+ `/bind-existing`) ack (059b/059d): the recorded
|
|
1015
|
+
* absolute path + the project it bound to. `bound` is false on a rejected bind (the reserved inbox,
|
|
1016
|
+
* a degenerate name, a non-absolute path), with a redacted `error`. A malformed/failed body degrades
|
|
1017
|
+
* to a not-bound ack so the caller surfaces an honest failure (never a throw).
|
|
1018
|
+
*/
|
|
1019
|
+
export const BindAckSchema = z.object({
|
|
1020
|
+
bound: z.boolean().catch(false),
|
|
1021
|
+
path: z.string().catch(""),
|
|
1022
|
+
projectId: z.string().catch(""),
|
|
1023
|
+
error: z.string().optional(),
|
|
1024
|
+
});
|
|
1025
|
+
/** The honest "bind failed" ack the picker shows on a non-2xx / network failure (never a throw). */
|
|
1026
|
+
export const FAILED_BIND_ACK = Object.freeze({ bound: false, path: "", projectId: "", error: "unavailable" });
|
|
1027
|
+
/** The `POST /api/diagnostics/projects/unbind` ack (059c): whether a LOCAL binding was removed. */
|
|
1028
|
+
export const UnbindAckSchema = z.object({
|
|
1029
|
+
unbound: z.boolean().catch(false),
|
|
1030
|
+
path: z.string().catch(""),
|
|
1031
|
+
});
|
|
1032
|
+
/** The honest "unbind failed" ack the page shows on a non-2xx / network failure (never a throw). */
|
|
1033
|
+
export const FAILED_UNBIND_ACK = Object.freeze({ unbound: false, path: "" });
|
|
1034
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1035
|
+
// IRD-122 — the scope-switch PERSISTENCE acks. Every field `.catch()`-defaults so a partial/failed/
|
|
1036
|
+
// non-local body degrades to a SAFE not-switched ack (never a throw). NO token rides either body.
|
|
1037
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1038
|
+
/**
|
|
1039
|
+
* The `POST /api/diagnostics/scope/org-switch` ack (122-AC-2): the now-active org + whether a token
|
|
1040
|
+
* re-mint ran. `switched:false` with a redacted `error` on a failed switch (no credential / unknown
|
|
1041
|
+
* org / re-mint error). `reminted` is true when a fresh org-bound token was minted (the org changed).
|
|
1042
|
+
* NO token in the body by construction.
|
|
1043
|
+
*/
|
|
1044
|
+
export const OrgSwitchAckSchema = z.object({
|
|
1045
|
+
switched: z.boolean().catch(false),
|
|
1046
|
+
org: z.string().catch(""),
|
|
1047
|
+
orgName: z.string().optional(),
|
|
1048
|
+
reminted: z.boolean().catch(false),
|
|
1049
|
+
error: z.string().optional(),
|
|
1050
|
+
});
|
|
1051
|
+
/** The honest "org switch failed" ack the switcher shows on a non-2xx / network failure (never a throw). */
|
|
1052
|
+
export const FAILED_ORG_SWITCH_ACK = Object.freeze({ switched: false, org: "", reminted: false, error: "unavailable" });
|
|
1053
|
+
/** The `POST /api/diagnostics/scope/workspace-switch` ack (IRD-122): the now-active workspace (no re-mint). */
|
|
1054
|
+
export const WorkspaceSwitchAckSchema = z.object({
|
|
1055
|
+
switched: z.boolean().catch(false),
|
|
1056
|
+
workspace: z.string().catch(""),
|
|
1057
|
+
error: z.string().optional(),
|
|
1058
|
+
});
|
|
1059
|
+
/** The honest "workspace switch failed" ack the switcher shows on a non-2xx / network failure (never a throw). */
|
|
1060
|
+
export const FAILED_WORKSPACE_SWITCH_ACK = Object.freeze({ switched: false, workspace: "", error: "unavailable" });
|
|
1061
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1062
|
+
// The typed fetch client. Every method validates its payload through zod, so the
|
|
1063
|
+
// React tree never sees an untyped/garbage value (AC-2 empty states are free).
|
|
1064
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1065
|
+
/**
|
|
1066
|
+
* The non-tenant SESSION headers every dashboard request stamps (PRD-024 Wave 3).
|
|
1067
|
+
*
|
|
1068
|
+
* The `/api/memories` group sits behind the runtime-path + session middleware
|
|
1069
|
+
* (`src/daemon/runtime/middleware/runtime-path.ts`), which REQUIRES
|
|
1070
|
+
* `x-honeycomb-runtime-path: plugin|legacy` AND a non-empty `x-honeycomb-session`. The
|
|
1071
|
+
* dashboard web app is a legitimate loopback thin client — exactly like the CLI/SDK/MCP, which
|
|
1072
|
+
* stamp these — so it must send them too. They carry NO tenant identity (no org GUID) and NO
|
|
1073
|
+
* credential (D-4), so they are safe to stamp client-side; the org comes from the daemon's
|
|
1074
|
+
* LOCAL default scope (the daemon-side Fix A). We deliberately do NOT send `x-honeycomb-org`:
|
|
1075
|
+
* sending a wrong/empty org would trip the cross-tenant guard.
|
|
1076
|
+
*
|
|
1077
|
+
* The session id is a FIXED, clearly-labeled loopback viewer id ("dashboard-web"): a single
|
|
1078
|
+
* long-lived viewer that idempotently re-claims its OWN session (the claim map is per-session),
|
|
1079
|
+
* never conflicting with a harness which uses real session ids. A fixed id keeps the bundle
|
|
1080
|
+
* deterministic and the contract testable (no `Math.random`/`Date.now`).
|
|
1081
|
+
*/
|
|
1082
|
+
export const DASHBOARD_SESSION_HEADERS = Object.freeze({
|
|
1083
|
+
"x-honeycomb-runtime-path": "plugin",
|
|
1084
|
+
"x-honeycomb-session": "dashboard-web",
|
|
1085
|
+
});
|
|
1086
|
+
// Federation is SERVER-SIDE (hive ADR-0002): the browser talks only to hive's own origin
|
|
1087
|
+
// and hive proxies each `/api/*` and `/setup/*` request over loopback to the owning workload
|
|
1088
|
+
// daemon. This client therefore fetches same-origin exactly like honeycomb's original dashboard
|
|
1089
|
+
// wire; the daemon-base routing and the loopback-trust gate now live on the hive server
|
|
1090
|
+
// (`src/daemon/proxy.ts` + `src/daemon/registry.ts`), not in the browser.
|
|
1091
|
+
/**
|
|
1092
|
+
* GET + zod-parse a JSON endpoint, returning the parsed value or `null` on any failure. `extraHeaders`
|
|
1093
|
+
* (PRD-049e) carries the optional `x-honeycomb-project` selection header so a project-scoped read
|
|
1094
|
+
* narrows server-side; absent it, the read is unchanged (back-compat).
|
|
1095
|
+
*/
|
|
1096
|
+
async function getJson(fetchImpl, url, schema, extraHeaders = {}) {
|
|
1097
|
+
try {
|
|
1098
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json", ...DASHBOARD_SESSION_HEADERS, ...extraHeaders } });
|
|
1099
|
+
if (!res.ok)
|
|
1100
|
+
return null;
|
|
1101
|
+
const body = await res.json();
|
|
1102
|
+
const parsed = schema.safeParse(body);
|
|
1103
|
+
return parsed.success ? parsed.data : null;
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
// A network error / abort / non-JSON body → null; the caller renders the empty state.
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* POST a JSON body + zod-parse the response, returning the parsed value or `null` on any non-2xx /
|
|
1112
|
+
* network / parse failure. The single shared writer for the PRD-040 mutations (add/modify/forget/
|
|
1113
|
+
* compact) so the four call sites do not duplicate the fetch+guard boilerplate (jscpd discipline).
|
|
1114
|
+
* Every request stamps the session headers; no `any` crosses the boundary (the body is `unknown`).
|
|
1115
|
+
*/
|
|
1116
|
+
async function postJson(fetchImpl, url, body, schema) {
|
|
1117
|
+
try {
|
|
1118
|
+
const res = await fetchImpl(url, {
|
|
1119
|
+
method: "POST",
|
|
1120
|
+
headers: { "content-type": "application/json", accept: "application/json", ...DASHBOARD_SESSION_HEADERS },
|
|
1121
|
+
body: JSON.stringify(body),
|
|
1122
|
+
});
|
|
1123
|
+
if (!res.ok)
|
|
1124
|
+
return null;
|
|
1125
|
+
const parsed = schema.safeParse(await res.json());
|
|
1126
|
+
return parsed.success ? parsed.data : null;
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
// A network error / abort / non-JSON body → null; the caller surfaces "save failed" + re-reads.
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** The arm name → a human scope hint for a recalled memory (honest, derived from the wire). */
|
|
1134
|
+
function scopeForSource(source) {
|
|
1135
|
+
if (source === "memories")
|
|
1136
|
+
return "team";
|
|
1137
|
+
if (source === "memory")
|
|
1138
|
+
return "org";
|
|
1139
|
+
return "session";
|
|
1140
|
+
}
|
|
1141
|
+
/** The max characters a recalled snippet renders before it is truncated with an ellipsis. */
|
|
1142
|
+
export const MAX_SNIPPET_CHARS = 280;
|
|
1143
|
+
/**
|
|
1144
|
+
* Pull a short, human detail out of a captured tool-call's input (the file it read, the
|
|
1145
|
+
* command it ran, the pattern it searched). Returns "" when none of the known input keys
|
|
1146
|
+
* carry a usable string, so the caller falls back to the bare tool name.
|
|
1147
|
+
*/
|
|
1148
|
+
function toolInputDetail(input) {
|
|
1149
|
+
if (typeof input !== "object" || input === null)
|
|
1150
|
+
return "";
|
|
1151
|
+
const rec = input;
|
|
1152
|
+
for (const key of ["file_path", "path", "command", "pattern", "query", "url", "prompt", "description"]) {
|
|
1153
|
+
const v = rec[key];
|
|
1154
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
1155
|
+
return v.trim();
|
|
1156
|
+
}
|
|
1157
|
+
return "";
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Turn a RAW captured-session turn (the JSONB `sessions.message`, forwarded verbatim as a
|
|
1161
|
+
* recall hit's `text`) into ONE readable line. The stored shape varies across harnesses and
|
|
1162
|
+
* plugin versions (`kind` vs `type`, `tool` vs `tool_name`, `input` vs `tool_input`, `text`
|
|
1163
|
+
* vs `prompt`), so every field is read defensively. Returns `null` when the text is not a
|
|
1164
|
+
* JSON object we recognize, so the caller renders the original text untouched (a distilled
|
|
1165
|
+
* prose fact is never mangled).
|
|
1166
|
+
*/
|
|
1167
|
+
function humanizeSessionTurn(raw) {
|
|
1168
|
+
let parsed;
|
|
1169
|
+
try {
|
|
1170
|
+
parsed = JSON.parse(raw);
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
return null; // not JSON: distilled prose, render as-is.
|
|
1174
|
+
}
|
|
1175
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
1176
|
+
return null;
|
|
1177
|
+
const rec = parsed;
|
|
1178
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
1179
|
+
const kind = str(rec.kind) || str(rec.type);
|
|
1180
|
+
const tool = str(rec.tool) || str(rec.tool_name);
|
|
1181
|
+
const body = str(rec.text) || str(rec.prompt) || str(rec.message);
|
|
1182
|
+
// A user / assistant turn: render the speaker + the message body.
|
|
1183
|
+
if (kind.startsWith("assistant") && body !== "")
|
|
1184
|
+
return `assistant: ${body}`;
|
|
1185
|
+
if (kind.startsWith("user") && body !== "")
|
|
1186
|
+
return `user: ${body}`;
|
|
1187
|
+
// A tool call: render the tool name + its most informative input detail.
|
|
1188
|
+
if (tool !== "" || kind === "tool_call") {
|
|
1189
|
+
const detail = toolInputDetail(rec.input ?? rec.tool_input);
|
|
1190
|
+
const name = tool !== "" ? tool : "tool";
|
|
1191
|
+
return detail !== "" ? `${name} · ${detail}` : name;
|
|
1192
|
+
}
|
|
1193
|
+
// A recognized object that carries SOME body text under an unknown kind: render the body.
|
|
1194
|
+
if (body !== "")
|
|
1195
|
+
return body;
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Format a recall hit's matched `text` into the snippet the {@link MemoryCard} renders. A raw
|
|
1200
|
+
* `session` dump (the JSONB capture turn) is collapsed to one readable line via
|
|
1201
|
+
* {@link humanizeSessionTurn} so the card never shows escaped JSON; a distilled `memory` fact
|
|
1202
|
+
* passes through as prose. EITHER way the result is whitespace-collapsed and capped at
|
|
1203
|
+
* {@link MAX_SNIPPET_CHARS} so a long row can never blow out the card. Exported so a test can
|
|
1204
|
+
* drive it deterministically (mirrors {@link formatLogLine}).
|
|
1205
|
+
*/
|
|
1206
|
+
export function formatRecallSnippet(text, kind) {
|
|
1207
|
+
const trimmed = text.trim();
|
|
1208
|
+
let display = trimmed;
|
|
1209
|
+
// A session hit (or any text that looks like a JSON object/array) is humanized; clean prose
|
|
1210
|
+
// is left alone so a distilled fact is never reshaped.
|
|
1211
|
+
if (kind === "session" || trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
1212
|
+
const human = humanizeSessionTurn(trimmed);
|
|
1213
|
+
if (human !== null)
|
|
1214
|
+
display = human;
|
|
1215
|
+
}
|
|
1216
|
+
display = display.replace(/\s+/g, " ").trim();
|
|
1217
|
+
if (display.length <= MAX_SNIPPET_CHARS)
|
|
1218
|
+
return display;
|
|
1219
|
+
return `${display.slice(0, MAX_SNIPPET_CHARS - 1).trimEnd()}…`;
|
|
1220
|
+
}
|
|
1221
|
+
/** The empty/zero KPIs the UI shows before the first load resolves (or on failure). */
|
|
1222
|
+
export const EMPTY_KPIS = { memoryCount: 0, sessionCount: 0, turnCount: 0, estimatedSavings: 0, teamSkillCount: 0 };
|
|
1223
|
+
/** The empty settings the header shows before the first load resolves. */
|
|
1224
|
+
export const EMPTY_SETTINGS = { orgId: "", orgName: "", workspace: "", settings: {} };
|
|
1225
|
+
/** The empty graph (renders the kit's "no graph built" empty-state). */
|
|
1226
|
+
export const EMPTY_GRAPH = { built: false, nodes: [], edges: [] };
|
|
1227
|
+
/**
|
|
1228
|
+
* The client-side render cap (the graph memory cap — memory-aware). The daemon already bounds the
|
|
1229
|
+
* codebase graph (`GET /api/graph` ships ≤ ~750 nodes), but this is the shared defense-in-depth backstop
|
|
1230
|
+
* so NO consumer mounts an unbounded number of SVG node groups, whatever the source (a large memory
|
|
1231
|
+
* graph, an older uncapped daemon, a future endpoint). Set above the daemon budget so the daemon's cap
|
|
1232
|
+
* normally governs and this only catches outliers. Single source for both the full Graph page and the
|
|
1233
|
+
* mini-widget so the policy can never drift between them.
|
|
1234
|
+
*/
|
|
1235
|
+
export const MAX_RENDER_NODES = 1500;
|
|
1236
|
+
/**
|
|
1237
|
+
* The companion EDGE cap. Nodes are `<g>` groups but edges are `<line>` elements — a dense graph under
|
|
1238
|
+
* the node cap can still carry hundreds of thousands of edges and lock up layout/SVG. So the helper
|
|
1239
|
+
* bounds edges too. Sized so nodes+edges together stay a few thousand SVG elements (well within budget).
|
|
1240
|
+
*/
|
|
1241
|
+
export const MAX_RENDER_EDGES = 5000;
|
|
1242
|
+
/**
|
|
1243
|
+
* Bound a graph to {@link MAX_RENDER_NODES}-ish nodes AND {@link MAX_RENDER_EDGES} edges for rendering
|
|
1244
|
+
* (the shared cap helper). Within BOTH limits → returned unchanged (same ref, so a memoized layout is
|
|
1245
|
+
* not invalidated). Over EITHER → the first `limit` nodes plus at most `MAX_RENDER_EDGES` of the edges
|
|
1246
|
+
* whose both endpoints survive, with `capped:true`. Capping edges as well as nodes is what makes this a
|
|
1247
|
+
* real backstop: a dense graph (or an old uncapped daemon) can no longer flood the DOM with `<line>`s
|
|
1248
|
+
* even when the node count is bounded. Pure — order is whatever the caller already settled (the daemon
|
|
1249
|
+
* ships its bounded set importance-ranked, so "first N" here is already the meaningful head).
|
|
1250
|
+
*/
|
|
1251
|
+
export function capGraphForRender(graph, limit) {
|
|
1252
|
+
const tooManyNodes = graph.nodes.length > limit;
|
|
1253
|
+
const tooManyEdges = graph.edges.length > MAX_RENDER_EDGES;
|
|
1254
|
+
if (!tooManyNodes && !tooManyEdges)
|
|
1255
|
+
return { graph, capped: false };
|
|
1256
|
+
const nodes = tooManyNodes ? graph.nodes.slice(0, limit) : graph.nodes;
|
|
1257
|
+
const kept = new Set(nodes.map((n) => n.id));
|
|
1258
|
+
// Keep only edges between surviving nodes, stopping at the edge budget so a dense subgraph can't
|
|
1259
|
+
// hand the canvas an unbounded `<line>` count.
|
|
1260
|
+
const edges = [];
|
|
1261
|
+
for (const edge of graph.edges) {
|
|
1262
|
+
if (!kept.has(edge.from) || !kept.has(edge.to))
|
|
1263
|
+
continue;
|
|
1264
|
+
edges.push(edge);
|
|
1265
|
+
if (edges.length >= MAX_RENDER_EDGES)
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
return { graph: { ...graph, nodes, edges }, capped: true };
|
|
1269
|
+
}
|
|
1270
|
+
/** The empty log-history page the table shows before the first load (or on failure / unavailable store). */
|
|
1271
|
+
export const EMPTY_LOGS_HISTORY = Object.freeze({ records: [], count: 0, nextCursor: null, persistent: false });
|
|
1272
|
+
/** The empty turns-history page the Turns section shows before the first load (or on failure). */
|
|
1273
|
+
export const EMPTY_TURNS_HISTORY = Object.freeze({ sessions: [], nextCursor: null });
|
|
1274
|
+
/**
|
|
1275
|
+
* Build the `/api/logs/history` query string from the SET filters only (an undefined filter is
|
|
1276
|
+
* omitted so the daemon applies its default). Every value is `encodeURIComponent`-escaped so a
|
|
1277
|
+
* path/org/cursor with special characters is a safe single query value (never a query injection).
|
|
1278
|
+
*/
|
|
1279
|
+
export function buildHistoryQueryString(filters) {
|
|
1280
|
+
const parts = [];
|
|
1281
|
+
const add = (key, value) => {
|
|
1282
|
+
if (value === undefined || value === "")
|
|
1283
|
+
return;
|
|
1284
|
+
parts.push(`${key}=${encodeURIComponent(String(value))}`);
|
|
1285
|
+
};
|
|
1286
|
+
add("since", filters.since);
|
|
1287
|
+
add("until", filters.until);
|
|
1288
|
+
add("status", filters.status);
|
|
1289
|
+
add("path", filters.path);
|
|
1290
|
+
add("org", filters.org);
|
|
1291
|
+
add("limit", filters.limit);
|
|
1292
|
+
add("cursor", filters.cursor);
|
|
1293
|
+
return parts.length > 0 ? `?${parts.join("&")}` : "";
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Build the typed wire client (AC-2..AC-6). The `origin` is prefixed onto every path so the
|
|
1297
|
+
* same app works same-origin (served by the host) or against an explicit daemon URL; the
|
|
1298
|
+
* `fetchImpl` is injected so a unit test drives the app with a mocked fetch and no live
|
|
1299
|
+
* network (the test contract). No method ever throws — every failure degrades to an empty
|
|
1300
|
+
* value so the UI shows an honest empty state instead of crashing.
|
|
1301
|
+
*/
|
|
1302
|
+
export function createWireClient(options = {}) {
|
|
1303
|
+
const origin = options.origin ?? "";
|
|
1304
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1305
|
+
const url = (path) => `${origin}${path}`;
|
|
1306
|
+
return {
|
|
1307
|
+
async kpis(projectId) {
|
|
1308
|
+
// PRD-049e: stamp the selected-project header so the KPI band re-scopes on a dashboard scope
|
|
1309
|
+
// change (parity with graph/roi/recall/memories). An empty selection omits it → workspace-wide.
|
|
1310
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.kpis), KpisSchema, projectHeader(projectId))) ?? EMPTY_KPIS;
|
|
1311
|
+
},
|
|
1312
|
+
async sessions() {
|
|
1313
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.sessions), SessionsSchema);
|
|
1314
|
+
return v?.sessions ?? [];
|
|
1315
|
+
},
|
|
1316
|
+
async settings() {
|
|
1317
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.settings), SettingsSchema)) ?? EMPTY_SETTINGS;
|
|
1318
|
+
},
|
|
1319
|
+
async rules() {
|
|
1320
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.rules), RulesSchema);
|
|
1321
|
+
return v?.rules ?? [];
|
|
1322
|
+
},
|
|
1323
|
+
async skills() {
|
|
1324
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.skills), SkillsSchema);
|
|
1325
|
+
return v?.skills ?? [];
|
|
1326
|
+
},
|
|
1327
|
+
async graph(projectId) {
|
|
1328
|
+
// PRD-049e: stamp the selected-project header so the read re-scopes on a dashboard scope change.
|
|
1329
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.graph), GraphSchema, projectHeader(projectId))) ?? EMPTY_GRAPH;
|
|
1330
|
+
},
|
|
1331
|
+
async memoryGraph(projectId) {
|
|
1332
|
+
// Same shape as the codebase graph (a `GraphView`-shaped source) — validated through the
|
|
1333
|
+
// shared GraphSchema. A failure / `built:false` empty graph degrades to EMPTY_GRAPH so the
|
|
1334
|
+
// page renders the honest "no memory graph yet" state (never a throw). PRD-049e: project-stamped.
|
|
1335
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.memoryGraph), GraphSchema, projectHeader(projectId))) ?? EMPTY_GRAPH;
|
|
1336
|
+
},
|
|
1337
|
+
async roi(projectId) {
|
|
1338
|
+
// The page is a PURE function of this; a failed/malformed body degrades to the honest-empty
|
|
1339
|
+
// view (every section `absent`, net NOT computed) so the page shows a dash, never `$0.00`.
|
|
1340
|
+
// PRD-049e: stamp the selected-project header so the read re-scopes on a dashboard scope change.
|
|
1341
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.roi), RoiViewSchema, projectHeader(projectId))) ?? EMPTY_ROI_VIEW;
|
|
1342
|
+
},
|
|
1343
|
+
async roiTrend(range, projectId) {
|
|
1344
|
+
// `range` selects the trend window (e.g. `30d`); it rides as a query param. A failed/malformed
|
|
1345
|
+
// body — or a genuine no-history-yet state — degrades to the honest-empty trend (never a throw).
|
|
1346
|
+
const qs = range !== "" ? `?range=${encodeURIComponent(range)}` : "";
|
|
1347
|
+
return ((await getJson(fetchImpl, url(`${ENDPOINTS.roiTrend}${qs}`), RoiTrendViewSchema, projectHeader(projectId))) ??
|
|
1348
|
+
EMPTY_ROI_TREND);
|
|
1349
|
+
},
|
|
1350
|
+
async recall(query, projectId) {
|
|
1351
|
+
try {
|
|
1352
|
+
const res = await fetchImpl(url(ENDPOINTS.recall), {
|
|
1353
|
+
method: "POST",
|
|
1354
|
+
// PRD-049e: stamp the selected-project header so recall narrows to the dashboard's project.
|
|
1355
|
+
headers: { "content-type": "application/json", accept: "application/json", ...DASHBOARD_SESSION_HEADERS, ...projectHeader(projectId) },
|
|
1356
|
+
body: JSON.stringify({ query }),
|
|
1357
|
+
});
|
|
1358
|
+
if (!res.ok)
|
|
1359
|
+
return { memories: [], degraded: true };
|
|
1360
|
+
const parsed = RecallResponseSchema.safeParse(await res.json());
|
|
1361
|
+
if (!parsed.success)
|
|
1362
|
+
return { memories: [], degraded: true };
|
|
1363
|
+
// The engine already returns hits RANKED DESC by the fused RRF score (distilled
|
|
1364
|
+
// `[memory]` facts above raw `[sessions]` drill-downs). We render them in THAT
|
|
1365
|
+
// order verbatim — NO client-side re-sort — and carry the ENGINE `score` through
|
|
1366
|
+
// (PRD-027 AC-4 deleted the old `1 - i*0.06` fabrication). `kind`/`secondary` ride
|
|
1367
|
+
// along so the card can demote raw session rows. `scope`/`verified` stay derived
|
|
1368
|
+
// honestly from the arm name.
|
|
1369
|
+
const memories = parsed.data.hits.map((h, i) => ({
|
|
1370
|
+
memoryKey: h.id !== "" ? h.id : `hit-${i + 1}`,
|
|
1371
|
+
// A raw `session` hit's `text` is the JSONB capture turn (escaped JSON); humanize it to
|
|
1372
|
+
// one readable line so the card never shows the raw dump. A distilled fact passes through.
|
|
1373
|
+
snippet: formatRecallSnippet(h.text, h.kind),
|
|
1374
|
+
source: h.source,
|
|
1375
|
+
score: h.score,
|
|
1376
|
+
scope: scopeForSource(h.source),
|
|
1377
|
+
verified: h.source === "memories",
|
|
1378
|
+
kind: h.kind,
|
|
1379
|
+
secondary: h.secondary,
|
|
1380
|
+
}));
|
|
1381
|
+
return { memories, degraded: parsed.data.degraded };
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
return { memories: [], degraded: true };
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
async listMemories(limit = DEFAULT_MEMORY_LIST_LIMIT, projectId) {
|
|
1388
|
+
// GET the scoped tenant's memories (newest-first); a malformed/absent body degrades to []
|
|
1389
|
+
// so the page renders its honest empty state. The daemon clamps `limit` to MAX_LIST_LIMIT.
|
|
1390
|
+
// PRD-049e: stamp the selected-project header so the list re-scopes to the dashboard's project.
|
|
1391
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.memories}?limit=${limit}`), MemoryListResponseSchema, projectHeader(projectId));
|
|
1392
|
+
return v?.memories ?? [];
|
|
1393
|
+
},
|
|
1394
|
+
async getMemory(id) {
|
|
1395
|
+
// A 404 (unknown OR forgotten id) → null via getJson's non-ok guard; the page renders the
|
|
1396
|
+
// honest "forgotten" state. The id is path-encoded so it is one safe segment.
|
|
1397
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.memories}/${encodeURIComponent(id)}`), MemoryGetResponseSchema);
|
|
1398
|
+
return v?.memory ?? null;
|
|
1399
|
+
},
|
|
1400
|
+
async addMemory(input) {
|
|
1401
|
+
// POST the store body (content + optional type/agent); the daemon's zod is the source of
|
|
1402
|
+
// truth (a 400 → null here, surfaced as "save failed"). The caller re-LISTS after a 201.
|
|
1403
|
+
const body = { content: input.content };
|
|
1404
|
+
if (input.type !== undefined)
|
|
1405
|
+
body.type = input.type;
|
|
1406
|
+
if (input.agentId !== undefined)
|
|
1407
|
+
body.agentId = input.agentId;
|
|
1408
|
+
return postJson(fetchImpl, url(ENDPOINTS.memories), body, StoreAckSchema);
|
|
1409
|
+
},
|
|
1410
|
+
async modifyMemory(id, input) {
|
|
1411
|
+
// POST content + the REQUIRED reason → a version-bumped, audited edit (never a hard-update).
|
|
1412
|
+
// A non-2xx (e.g. an empty reason the client should have caught) → null; the caller re-reads.
|
|
1413
|
+
const body = { content: input.content, reason: input.reason };
|
|
1414
|
+
if (input.agentId !== undefined)
|
|
1415
|
+
body.agentId = input.agentId;
|
|
1416
|
+
return postJson(fetchImpl, url(`${ENDPOINTS.memories}/${encodeURIComponent(id)}/modify`), body, WriteAckSchema);
|
|
1417
|
+
},
|
|
1418
|
+
async forgetMemory(id, input) {
|
|
1419
|
+
// POST the required reason → a reason-gated soft-delete (a tombstone version). null on failure.
|
|
1420
|
+
return postJson(fetchImpl, url(`${ENDPOINTS.memories}/${encodeURIComponent(id)}/forget`), { reason: input.reason }, WriteAckSchema);
|
|
1421
|
+
},
|
|
1422
|
+
async compact(table) {
|
|
1423
|
+
// POST the optional `{ table }` selector (omitted ⇒ all allow-listed tables). The daemon
|
|
1424
|
+
// matches it against its allow-list, so the page sends only a known name — no attacker SQL.
|
|
1425
|
+
const body = table !== undefined && table !== "" ? { table } : {};
|
|
1426
|
+
return postJson(fetchImpl, url(ENDPOINTS.compact), body, CompactSummarySchema);
|
|
1427
|
+
},
|
|
1428
|
+
async lifecycleConflicts(status = "open") {
|
|
1429
|
+
// GET the scoped conflict queue; a malformed/absent body degrades to []. `status` is
|
|
1430
|
+
// encodeURIComponent-escaped (one safe query value); the daemon scope-filters the read.
|
|
1431
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.lifecycleConflicts}?status=${encodeURIComponent(status)}`), LifecycleConflictsResponseSchema);
|
|
1432
|
+
return v?.conflicts ?? [];
|
|
1433
|
+
},
|
|
1434
|
+
async resolveConflict(id, input) {
|
|
1435
|
+
try {
|
|
1436
|
+
// POST the 058b resolve endpoint — the SAME path/code the CLI uses (no parallel logic).
|
|
1437
|
+
// The id is path-encoded (one safe segment); the verdict/winner/reason ride the body.
|
|
1438
|
+
const body = { verdict: input.verdict };
|
|
1439
|
+
if (input.winnerId !== undefined && input.winnerId !== "")
|
|
1440
|
+
body.winnerId = input.winnerId;
|
|
1441
|
+
if (input.reason !== undefined && input.reason !== "")
|
|
1442
|
+
body.reason = input.reason;
|
|
1443
|
+
const res = await fetchImpl(url(`${ENDPOINTS.lifecycleConflicts}/${encodeURIComponent(id)}/resolve`), {
|
|
1444
|
+
method: "POST",
|
|
1445
|
+
headers: { "content-type": "application/json", accept: "application/json", ...DASHBOARD_SESSION_HEADERS },
|
|
1446
|
+
body: JSON.stringify(body),
|
|
1447
|
+
});
|
|
1448
|
+
// 2xx → accepted; any non-2xx (400 invalid verdict, 404 not found, 409 already resolved)
|
|
1449
|
+
// reads as "not accepted" so the caller re-reads + polls rather than optimistically flipping.
|
|
1450
|
+
return res.ok;
|
|
1451
|
+
}
|
|
1452
|
+
catch {
|
|
1453
|
+
return false;
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
async lifecycleStaleRefs() {
|
|
1457
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.lifecycleStaleRefs), LifecycleStaleRefsResponseSchema);
|
|
1458
|
+
return v?.staleRefs ?? [];
|
|
1459
|
+
},
|
|
1460
|
+
async lifecycleHistory() {
|
|
1461
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.lifecycleHistory}?type=lifecycle`), LifecycleHistoryResponseSchema);
|
|
1462
|
+
return v?.history ?? [];
|
|
1463
|
+
},
|
|
1464
|
+
async calibration() {
|
|
1465
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.calibration), CalibrationSchema)) ?? EMPTY_CALIBRATION;
|
|
1466
|
+
},
|
|
1467
|
+
async logs(limit = 40) {
|
|
1468
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.logs}?limit=${limit}`), LogsResponseSchema);
|
|
1469
|
+
return v?.records ?? [];
|
|
1470
|
+
},
|
|
1471
|
+
logsStream(onRecord) {
|
|
1472
|
+
// FOLLOW the SSE tail (c-AC-2). Guard for the non-browser env (jsdom/SSR has no EventSource):
|
|
1473
|
+
// degrade to an inert no-op so the page keeps its snapshot and tests never crash. The
|
|
1474
|
+
// `event: "log"` records are parsed through the SAME zod schema as the snapshot, so a malformed
|
|
1475
|
+
// frame is dropped (never thrown into React). The returned unsubscribe closes the stream.
|
|
1476
|
+
const ES = globalThis.EventSource;
|
|
1477
|
+
if (ES === undefined)
|
|
1478
|
+
return () => { };
|
|
1479
|
+
let source = null;
|
|
1480
|
+
const handler = (ev) => {
|
|
1481
|
+
const record = parseLogRecordEvent(ev.data);
|
|
1482
|
+
if (record !== null)
|
|
1483
|
+
onRecord(record);
|
|
1484
|
+
};
|
|
1485
|
+
try {
|
|
1486
|
+
// Same-origin: EventSource hits hive's own `/api/logs/stream`, which the server-side
|
|
1487
|
+
// proxy forwards to the owning daemon's SSE tail. `event: "log"` frames are parsed through
|
|
1488
|
+
// the SAME zod schema as the snapshot, so a malformed frame is dropped (never thrown).
|
|
1489
|
+
source = new ES(url(ENDPOINTS.logsStream));
|
|
1490
|
+
source.addEventListener("log", handler);
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
// If EventSource construction fails, the stream is simply unavailable; the page keeps its snapshot.
|
|
1494
|
+
}
|
|
1495
|
+
return () => {
|
|
1496
|
+
try {
|
|
1497
|
+
source?.removeEventListener("log", handler);
|
|
1498
|
+
source?.close();
|
|
1499
|
+
}
|
|
1500
|
+
catch {
|
|
1501
|
+
// Closing an already-closed/errored EventSource must never throw into unmount.
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
},
|
|
1505
|
+
async logsHistory(filters = {}) {
|
|
1506
|
+
// Build the query string from only the SET filters (an undefined filter is omitted, so the
|
|
1507
|
+
// daemon applies its default). A malformed/absent body degrades to the empty page.
|
|
1508
|
+
const qs = buildHistoryQueryString(filters);
|
|
1509
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.logs}/history${qs}`), LogsHistoryResponseSchema);
|
|
1510
|
+
return v ?? EMPTY_LOGS_HISTORY;
|
|
1511
|
+
},
|
|
1512
|
+
async turnsHistory(opts = {}) {
|
|
1513
|
+
const parts = [];
|
|
1514
|
+
if (opts.limit !== undefined)
|
|
1515
|
+
parts.push(`limit=${encodeURIComponent(String(opts.limit))}`);
|
|
1516
|
+
if (opts.cursor !== undefined && opts.cursor !== "")
|
|
1517
|
+
parts.push(`cursor=${encodeURIComponent(opts.cursor)}`);
|
|
1518
|
+
const qs = parts.length > 0 ? `?${parts.join("&")}` : "";
|
|
1519
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.sessions}${qs}`), TurnsHistoryResponseSchema);
|
|
1520
|
+
return v ?? EMPTY_TURNS_HISTORY;
|
|
1521
|
+
},
|
|
1522
|
+
async harnesses() {
|
|
1523
|
+
// GET the six canonical harness statuses; a malformed/absent body degrades to the empty
|
|
1524
|
+
// list so the page renders its honest empty/zero state (never a throw). Single backbone (D-3).
|
|
1525
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.harnesses), HarnessStatusResponseSchema);
|
|
1526
|
+
return v?.harnesses ?? [];
|
|
1527
|
+
},
|
|
1528
|
+
async assetsView() {
|
|
1529
|
+
// GET the union view-model; a malformed/absent body degrades to the empty view so the page
|
|
1530
|
+
// renders its honest empty state (never a throw). No secret rides this shape (zod-validated).
|
|
1531
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.assets), AssetSyncViewSchema)) ?? EMPTY_ASSET_SYNC_VIEW;
|
|
1532
|
+
},
|
|
1533
|
+
async syncAction(action, input) {
|
|
1534
|
+
// POST the action body; the daemon performs the REAL pipeline + a poll-convergent read-back
|
|
1535
|
+
// and returns the converged ack. A non-2xx → null (the caller re-reads the union — no
|
|
1536
|
+
// optimistic flip). The body carries no secret; the daemon resolves tenancy from the session.
|
|
1537
|
+
const body = { assetType: input.assetType, name: input.name };
|
|
1538
|
+
if (input.native !== undefined)
|
|
1539
|
+
body.native = input.native;
|
|
1540
|
+
if (input.honeycombId !== undefined && input.honeycombId !== "")
|
|
1541
|
+
body.honeycombId = input.honeycombId;
|
|
1542
|
+
if (input.harness !== undefined && input.harness !== "")
|
|
1543
|
+
body.harness = input.harness;
|
|
1544
|
+
return postJson(fetchImpl, url(`${ENDPOINTS.syncAction}/${action}`), body, SyncActionResultSchema);
|
|
1545
|
+
},
|
|
1546
|
+
async health() {
|
|
1547
|
+
try {
|
|
1548
|
+
const res = await fetchImpl(url(ENDPOINTS.health), { headers: { accept: "application/json", ...DASHBOARD_SESSION_HEADERS } });
|
|
1549
|
+
// Liveness is the HTTP status (a 503-on-degraded still has a parseable body);
|
|
1550
|
+
// the existing view-swap keys off this `up` flag, unchanged (PRD-029 is additive).
|
|
1551
|
+
const up = res.ok;
|
|
1552
|
+
// Parse the body for the additive PRD-029 `reasons`. Every failure mode — a
|
|
1553
|
+
// non-JSON body (the bare "" the 503 path and some 200s send), a malformed shape,
|
|
1554
|
+
// an absent `reasons` (the mode-gated public body) — degrades to `null` reasons
|
|
1555
|
+
// (coarse pill only), NEVER a throw. The IO boundary is fully defended.
|
|
1556
|
+
let reasons = null;
|
|
1557
|
+
try {
|
|
1558
|
+
const body = await res.json();
|
|
1559
|
+
const parsed = HealthBodySchema.safeParse(body);
|
|
1560
|
+
reasons = parsed.success ? parsed.data.reasons ?? null : null;
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
reasons = null;
|
|
1564
|
+
}
|
|
1565
|
+
return { up, reasons };
|
|
1566
|
+
}
|
|
1567
|
+
catch {
|
|
1568
|
+
return { up: false, reasons: null };
|
|
1569
|
+
}
|
|
1570
|
+
},
|
|
1571
|
+
async pollinate() {
|
|
1572
|
+
try {
|
|
1573
|
+
const res = await fetchImpl(url(ENDPOINTS.pollinate), { method: "POST", headers: { ...DASHBOARD_SESSION_HEADERS } });
|
|
1574
|
+
const parsed = PollinateAckSchema.safeParse(await res.json());
|
|
1575
|
+
return parsed.success ? parsed.data : { triggered: false, status: "skipped", reason: "unavailable" };
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
return { triggered: false, status: "skipped", reason: "unavailable" };
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
async buildGraph() {
|
|
1582
|
+
// POST the build trigger, mirroring pollinate()'s header stamping. The build is SLOW (whole-repo
|
|
1583
|
+
// tree-sitter parse), so we give this call its OWN generous abort timeout — the default fetch must
|
|
1584
|
+
// not abort a legitimate in-progress build. A non-2xx (incl. the 500 `{ error, reason }` body), a
|
|
1585
|
+
// timeout/abort, a network error, or a malformed body all degrade to the failure ack (never a throw).
|
|
1586
|
+
const ac = new AbortController();
|
|
1587
|
+
const timer = setTimeout(() => ac.abort(), BUILD_GRAPH_TIMEOUT_MS);
|
|
1588
|
+
try {
|
|
1589
|
+
const res = await fetchImpl(url(ENDPOINTS.graphBuild), {
|
|
1590
|
+
method: "POST",
|
|
1591
|
+
headers: { accept: "application/json", ...DASHBOARD_SESSION_HEADERS },
|
|
1592
|
+
signal: ac.signal,
|
|
1593
|
+
});
|
|
1594
|
+
if (!res.ok)
|
|
1595
|
+
return FAILED_BUILD_GRAPH_ACK;
|
|
1596
|
+
const parsed = BuildGraphAckSchema.safeParse(await res.json());
|
|
1597
|
+
return parsed.success ? parsed.data : FAILED_BUILD_GRAPH_ACK;
|
|
1598
|
+
}
|
|
1599
|
+
catch {
|
|
1600
|
+
// A timeout/abort, network error, or non-JSON body → the honest failure ack.
|
|
1601
|
+
return FAILED_BUILD_GRAPH_ACK;
|
|
1602
|
+
}
|
|
1603
|
+
finally {
|
|
1604
|
+
clearTimeout(timer);
|
|
1605
|
+
}
|
|
1606
|
+
},
|
|
1607
|
+
async vaultSettings() {
|
|
1608
|
+
// GET the `setting` class + catalog; a malformed/absent body degrades to the empty
|
|
1609
|
+
// view (nothing selected, no catalog) so the panel renders its empty state, never throws.
|
|
1610
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.vaultSettings), VaultSettingsSchema)) ?? EMPTY_VAULT_SETTINGS;
|
|
1611
|
+
},
|
|
1612
|
+
async setSetting(key, value) {
|
|
1613
|
+
try {
|
|
1614
|
+
// POST /api/settings/:key with a JSON `{ value }` body — the Wave-1 handler's shape.
|
|
1615
|
+
// The key is path-encoded so a `dashboard.*` dotted key (or any caller key) is a safe
|
|
1616
|
+
// single path segment. The panel only ever sends its own known keys.
|
|
1617
|
+
const res = await fetchImpl(url(`${ENDPOINTS.vaultSettings}/${encodeURIComponent(key)}`), {
|
|
1618
|
+
method: "POST",
|
|
1619
|
+
headers: { "content-type": "application/json", accept: "application/json", ...DASHBOARD_SESSION_HEADERS },
|
|
1620
|
+
body: JSON.stringify({ value }),
|
|
1621
|
+
});
|
|
1622
|
+
// The daemon answers 201 on a successful write; any non-2xx (400 invalid value,
|
|
1623
|
+
// 502 store failure) reads as "not accepted" so the caller does not optimistically
|
|
1624
|
+
// reflect an un-persisted change — it re-reads and shows whatever actually persisted.
|
|
1625
|
+
return res.ok;
|
|
1626
|
+
}
|
|
1627
|
+
catch {
|
|
1628
|
+
return false;
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
async secretNames() {
|
|
1632
|
+
// Names-only (D-4): the panel uses this purely for provider-key PRESENCE. A failure
|
|
1633
|
+
// degrades to an empty list → every provider shows "not set" (never a throw, never a value).
|
|
1634
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.secrets), SecretNamesSchema);
|
|
1635
|
+
return v?.names ?? [];
|
|
1636
|
+
},
|
|
1637
|
+
async setSecret(name, value) {
|
|
1638
|
+
try {
|
|
1639
|
+
// POST /api/secrets/:name with a JSON `{ value }` body — the secrets handler's shape.
|
|
1640
|
+
// The name is path-encoded so a conventional key name is one safe segment (the daemon
|
|
1641
|
+
// also validates `[A-Za-z0-9_.-]+`, traversal-proof). The value rides the body and is
|
|
1642
|
+
// NEVER echoed: the daemon's 201 returns the NAME only, so nothing here reads a value
|
|
1643
|
+
// back (AC-3 write-only). We deliberately do NOT parse/return the response body — a
|
|
1644
|
+
// boolean accept is all the caller needs (it re-reads `secretNames` for presence).
|
|
1645
|
+
const res = await fetchImpl(url(`${ENDPOINTS.secrets}/${encodeURIComponent(name)}`), {
|
|
1646
|
+
method: "POST",
|
|
1647
|
+
headers: { "content-type": "application/json", accept: "application/json", ...DASHBOARD_SESSION_HEADERS },
|
|
1648
|
+
body: JSON.stringify({ value }),
|
|
1649
|
+
});
|
|
1650
|
+
// 2xx (the daemon answers 201) → accepted; any non-2xx (400 invalid name/value, 502
|
|
1651
|
+
// store failure) reads as "not accepted" so the caller surfaces "not accepted" and the
|
|
1652
|
+
// input is NOT cleared on a rejected write.
|
|
1653
|
+
return res.ok;
|
|
1654
|
+
}
|
|
1655
|
+
catch {
|
|
1656
|
+
// A network error → not accepted (never a throw into React).
|
|
1657
|
+
return false;
|
|
1658
|
+
}
|
|
1659
|
+
},
|
|
1660
|
+
async logout() {
|
|
1661
|
+
// POST the logout action; the daemon removes the shared + legacy credential files. A 2xx +
|
|
1662
|
+
// `{ ok: true }` is success; any non-2xx / network failure → false (the section stays as-is).
|
|
1663
|
+
const ack = await postJson(fetchImpl, url(ENDPOINTS.actionsLogout), {}, ActionOkSchema);
|
|
1664
|
+
return ack?.ok === true;
|
|
1665
|
+
},
|
|
1666
|
+
async setEmbeddings(enabled) {
|
|
1667
|
+
// POST the toggle; the daemon actuates the supervisor live + persists the choice. Success is a
|
|
1668
|
+
// 2xx whose echoed `enabled` matches the request; a non-2xx / network failure → false.
|
|
1669
|
+
const ack = await postJson(fetchImpl, url(ENDPOINTS.actionsEmbeddings), { enabled }, EmbeddingsActionSchema);
|
|
1670
|
+
return ack?.ok === true && ack.enabled === enabled;
|
|
1671
|
+
},
|
|
1672
|
+
async restartDaemon() {
|
|
1673
|
+
// POST the restart; the daemon respawns + shuts down. Success is a 2xx `{ restarting: true }`.
|
|
1674
|
+
// (The connection may drop as the daemon goes down right after — postJson degrades that to null.)
|
|
1675
|
+
const ack = await postJson(fetchImpl, url(ENDPOINTS.actionsRestart), {}, RestartActionSchema);
|
|
1676
|
+
return ack?.restarting === true;
|
|
1677
|
+
},
|
|
1678
|
+
async uninstall() {
|
|
1679
|
+
// POST the uninstall; the daemon returns the guided result (detected harnesses + the CLI
|
|
1680
|
+
// command). A non-2xx / network / malformed body → null (the page shows an honest error).
|
|
1681
|
+
return postJson(fetchImpl, url(ENDPOINTS.actionsUninstall), {}, UninstallResultSchema);
|
|
1682
|
+
},
|
|
1683
|
+
async authStatus() {
|
|
1684
|
+
// GET the redacted auth status; a malformed/absent/failed body degrades to the
|
|
1685
|
+
// disconnected status so the section renders its honest "Not connected" state (never a
|
|
1686
|
+
// throw — AC-4). The schema has NO token field, so a token in the body is dropped.
|
|
1687
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.authStatus), AuthStatusSchema)) ?? DISCONNECTED_AUTH_STATUS;
|
|
1688
|
+
},
|
|
1689
|
+
async setupState() {
|
|
1690
|
+
// GET the guided-setup state; a malformed/absent/failed body OR a non-local 404 (getJson's
|
|
1691
|
+
// non-ok guard → null) degrades to the FRESH-INSTALL state so the shell renders the
|
|
1692
|
+
// guided-setup wizard (the safe default, b-AC-6), never a throw. No token rides this body.
|
|
1693
|
+
return (await getJson(fetchImpl, url(ENDPOINTS.setupState), SetupStateSchema)) ?? FRESH_SETUP_STATE;
|
|
1694
|
+
},
|
|
1695
|
+
async setupLogin() {
|
|
1696
|
+
// POST to begin the device flow; the daemon returns user_code + URIs the moment the grant
|
|
1697
|
+
// arrives (it keeps polling → persist in the background). A 502 (device-flow-unavailable) or
|
|
1698
|
+
// a network failure → null (the button shows an honest error). The body carries NO token.
|
|
1699
|
+
return postJson(fetchImpl, url(ENDPOINTS.setupLogin), {}, SetupLoginSchema);
|
|
1700
|
+
},
|
|
1701
|
+
async migrateFromHivemind() {
|
|
1702
|
+
// POST the migration trigger; the daemon runs the guarded backup->uninstall->adopt transaction
|
|
1703
|
+
// and returns the terminal phase + message + backup path (+ needsLogin/migrated flags). A non-2xx
|
|
1704
|
+
// / network / malformed body degrades to the honest failure ack (never a throw). NO token rides it.
|
|
1705
|
+
return (await postJson(fetchImpl, url(ENDPOINTS.setupMigrate), {}, SetupMigrateSchema)) ?? FAILED_SETUP_MIGRATE;
|
|
1706
|
+
},
|
|
1707
|
+
async rollbackMigration() {
|
|
1708
|
+
// POST the rollback trigger (the d-AC-7 affordance); the daemon restores the backup + stamps
|
|
1709
|
+
// migration.phase rolled_back. A failure degrades to the honest rollback-unavailable ack.
|
|
1710
|
+
return ((await postJson(fetchImpl, url(ENDPOINTS.setupMigrateRollback), {}, SetupMigrateRollbackSchema)) ??
|
|
1711
|
+
FAILED_SETUP_MIGRATE_ROLLBACK);
|
|
1712
|
+
},
|
|
1713
|
+
async scopeOrgs() {
|
|
1714
|
+
// GET the privilege-scoped org list; a failed/absent/non-local read degrades to [] so the
|
|
1715
|
+
// switcher shows an empty/needs-login state (never a throw). No token rides the body.
|
|
1716
|
+
const v = await getJson(fetchImpl, url(ENDPOINTS.scopeOrgs), ScopeOrgsSchema);
|
|
1717
|
+
return v?.orgs ?? [];
|
|
1718
|
+
},
|
|
1719
|
+
async scopeWorkspaces(org) {
|
|
1720
|
+
// GET the org's workspaces; the daemon re-mints the org-bound token (PRD-011) before
|
|
1721
|
+
// enumerating when `org` differs from its credential org (49e-AC-3). A failed read degrades
|
|
1722
|
+
// to an empty workspace list. The `org` query is encodeURIComponent-escaped (one safe value).
|
|
1723
|
+
const qs = org !== undefined && org !== "" ? `?org=${encodeURIComponent(org)}` : "";
|
|
1724
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.scopeWorkspaces}${qs}`), ScopeWorkspacesSchema);
|
|
1725
|
+
return v ?? { workspaces: [], org: org ?? "", reminted: false };
|
|
1726
|
+
},
|
|
1727
|
+
async scopeProjects(opts = {}) {
|
|
1728
|
+
// GET the workspace's synced registry projects (049a cache). `unbound` stamps `?unbound=1`
|
|
1729
|
+
// for the 059d import list (registry-only projects). A failed/absent read degrades to [] so
|
|
1730
|
+
// the switcher shows no projects (and the pages render the needs-selection state).
|
|
1731
|
+
const qs = opts.unbound === true ? "?unbound=1" : "";
|
|
1732
|
+
const v = await getJson(fetchImpl, url(`${ENDPOINTS.scopeProjects}${qs}`), ScopeProjectsSchema);
|
|
1733
|
+
return v?.projects ?? [];
|
|
1734
|
+
},
|
|
1735
|
+
async fsBrowse(path) {
|
|
1736
|
+
// GET the daemon-served dirs-only tree (059b). The `path` query is encodeURIComponent-escaped
|
|
1737
|
+
// (one safe value). A failed/absent/non-local (404) read degrades to EMPTY_BROWSE so the picker
|
|
1738
|
+
// renders its honest empty/unavailable state (the CLI fallback — b-AC-5), never a throw.
|
|
1739
|
+
const qs = path !== undefined && path !== "" ? `?path=${encodeURIComponent(path)}` : "";
|
|
1740
|
+
return (await getJson(fetchImpl, url(`${ENDPOINTS.fsBrowse}${qs}`), BrowseBodySchema)) ?? EMPTY_BROWSE;
|
|
1741
|
+
},
|
|
1742
|
+
async bindProject(input) {
|
|
1743
|
+
// POST the chosen absolute path (+ optional name) → bind a NEW/named project (059b). The
|
|
1744
|
+
// daemon's zod is the source of truth (a 400 degrades to the failed ack). NO secret in the body.
|
|
1745
|
+
const body = { path: input.path };
|
|
1746
|
+
if (input.name !== undefined && input.name !== "")
|
|
1747
|
+
body.name = input.name;
|
|
1748
|
+
return (await postJson(fetchImpl, url(ENDPOINTS.projectsBind), body, BindAckSchema)) ?? FAILED_BIND_ACK;
|
|
1749
|
+
},
|
|
1750
|
+
async bindExistingProject(input) {
|
|
1751
|
+
// POST the chosen absolute path + the existing registry project_id → the 059d import. A non-2xx
|
|
1752
|
+
// degrades to the failed ack so the modal surfaces an honest error (never an optimistic flip).
|
|
1753
|
+
const body = { path: input.path, projectId: input.projectId };
|
|
1754
|
+
return (await postJson(fetchImpl, url(ENDPOINTS.projectsBindExisting), body, BindAckSchema)) ?? FAILED_BIND_ACK;
|
|
1755
|
+
},
|
|
1756
|
+
async unbindProject(input) {
|
|
1757
|
+
// POST the absolute path → remove the LOCAL binding only (059c); the registry row is untouched.
|
|
1758
|
+
// A non-2xx degrades to the failed ack so the page re-reads and reflects what actually persisted.
|
|
1759
|
+
return (await postJson(fetchImpl, url(ENDPOINTS.projectsUnbind), { path: input.path }, UnbindAckSchema)) ?? FAILED_UNBIND_ACK;
|
|
1760
|
+
},
|
|
1761
|
+
async switchOrg(org) {
|
|
1762
|
+
// POST the target org → the daemon re-mints + persists (IRD-122 / 122-AC-2). A non-2xx /
|
|
1763
|
+
// network failure degrades to the failed ack so the switcher shows an honest "could not
|
|
1764
|
+
// switch" rather than a silent no-op (122-AC-4). NO token rides the ack.
|
|
1765
|
+
return (await postJson(fetchImpl, url(ENDPOINTS.scopeOrgSwitch), { org }, OrgSwitchAckSchema)) ?? FAILED_ORG_SWITCH_ACK;
|
|
1766
|
+
},
|
|
1767
|
+
async switchWorkspace(workspace) {
|
|
1768
|
+
// POST the target workspace → the daemon persists the workspace id (no re-mint). A failure
|
|
1769
|
+
// degrades to the failed ack so the switcher surfaces an honest error, never a no-op.
|
|
1770
|
+
return (await postJson(fetchImpl, url(ENDPOINTS.scopeWorkspaceSwitch), { workspace }, WorkspaceSwitchAckSchema)) ?? FAILED_WORKSPACE_SWITCH_ACK;
|
|
1771
|
+
},
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* PRD-042c — parse one SSE `event: "log"` frame's `data` payload (a JSON-stringified
|
|
1776
|
+
* {@link RequestLogRecord}) into a validated {@link LogRecordWire}, or `null` on any malformed /
|
|
1777
|
+
* non-JSON / shape-mismatch frame. Validated through the SAME {@link LogRecordSchema} the snapshot
|
|
1778
|
+
* uses, so the SSE tail and the backfill agree on the shape and a bad frame is DROPPED (never thrown
|
|
1779
|
+
* into React). Exported so the page can drive the follow-handler deterministically in tests without a
|
|
1780
|
+
* live EventSource. No secret rides a record by construction (logger.ts).
|
|
1781
|
+
*/
|
|
1782
|
+
export function parseLogRecordEvent(data) {
|
|
1783
|
+
if (typeof data !== "string")
|
|
1784
|
+
return null;
|
|
1785
|
+
try {
|
|
1786
|
+
const parsed = LogRecordSchema.safeParse(JSON.parse(data));
|
|
1787
|
+
return parsed.success ? parsed.data : null;
|
|
1788
|
+
}
|
|
1789
|
+
catch {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Format one `/api/logs` record into a single mono log line for the LiveLog panel. The
|
|
1795
|
+
* record carries NO token/secret/header (the logger redacts by construction); this
|
|
1796
|
+
* formatter introduces none either — it renders only time + method + path + status (AC-4).
|
|
1797
|
+
*/
|
|
1798
|
+
export function formatLogLine(r) {
|
|
1799
|
+
const time = (r.time || "").slice(11, 19) || (r.time || "").slice(0, 8);
|
|
1800
|
+
const status = r.status > 0 ? ` ${r.status}` : "";
|
|
1801
|
+
return `${time} ${r.method} ${r.path}${status}`.trimEnd();
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* PRD-042c — the `/api/logs` path prefix the Sync activity feed filters on. The Sync action POSTs
|
|
1805
|
+
* land on `/api/diagnostics/sync/{promote,pull,demote,enable,disable}`, and the daemon's
|
|
1806
|
+
* request-logging middleware records EACH (method + path + status, no secret) into the same
|
|
1807
|
+
* `/api/logs` ring buffer the feed reads. Filtering on this prefix yields exactly the sync events
|
|
1808
|
+
* (publish/pull/tombstone), newest first, with no parallel event store (D-6).
|
|
1809
|
+
*/
|
|
1810
|
+
export const SYNC_ACTIVITY_PATH = "/api/diagnostics/sync/";
|
|
1811
|
+
/** True iff a `/api/logs` record is a Sync action event (its path is under {@link SYNC_ACTIVITY_PATH}). */
|
|
1812
|
+
export function isSyncActivityRecord(r) {
|
|
1813
|
+
return (r.path ?? "").startsWith(SYNC_ACTIVITY_PATH);
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* PRD-042c — the human action label for a Sync activity record's path (`promote` → "published",
|
|
1817
|
+
* `pull` → "pulled", `demote` → "tombstoned"). The status code drives the success/failure flavor at
|
|
1818
|
+
* the render site. No secret is in a record (logger.ts), so the formatted line is safe to render.
|
|
1819
|
+
*/
|
|
1820
|
+
export function syncActivityVerb(r) {
|
|
1821
|
+
const tail = (r.path ?? "").slice(SYNC_ACTIVITY_PATH.length).split("/")[0] ?? "";
|
|
1822
|
+
switch (tail) {
|
|
1823
|
+
case "promote":
|
|
1824
|
+
return "published";
|
|
1825
|
+
case "pull":
|
|
1826
|
+
return "pulled";
|
|
1827
|
+
case "demote":
|
|
1828
|
+
return "tombstoned";
|
|
1829
|
+
case "enable":
|
|
1830
|
+
return "enabled";
|
|
1831
|
+
case "disable":
|
|
1832
|
+
return "disabled";
|
|
1833
|
+
default:
|
|
1834
|
+
return tail;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
//# sourceMappingURL=wire.js.map
|