@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.
Files changed (190) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +262 -0
  3. package/dist/cli-commands.d.ts +41 -0
  4. package/dist/cli-commands.js +76 -0
  5. package/dist/cli-commands.js.map +1 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +46 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/daemon/dashboard/app.js +118 -0
  10. package/dist/daemon/dashboard/host.d.ts +90 -0
  11. package/dist/daemon/dashboard/host.js +172 -0
  12. package/dist/daemon/dashboard/host.js.map +1 -0
  13. package/dist/daemon/dashboard/web-assets.d.ts +88 -0
  14. package/dist/daemon/dashboard/web-assets.js +195 -0
  15. package/dist/daemon/dashboard/web-assets.js.map +1 -0
  16. package/dist/daemon/fleet-status.d.ts +15 -0
  17. package/dist/daemon/fleet-status.js +56 -0
  18. package/dist/daemon/fleet-status.js.map +1 -0
  19. package/dist/daemon/gate.d.ts +55 -0
  20. package/dist/daemon/gate.js +128 -0
  21. package/dist/daemon/gate.js.map +1 -0
  22. package/dist/daemon/proxy.d.ts +38 -0
  23. package/dist/daemon/proxy.js +126 -0
  24. package/dist/daemon/proxy.js.map +1 -0
  25. package/dist/daemon/registry.d.ts +31 -0
  26. package/dist/daemon/registry.js +120 -0
  27. package/dist/daemon/registry.js.map +1 -0
  28. package/dist/daemon/server.d.ts +41 -0
  29. package/dist/daemon/server.js +126 -0
  30. package/dist/daemon/server.js.map +1 -0
  31. package/dist/daemon/setup-auth.d.ts +37 -0
  32. package/dist/daemon/setup-auth.js +51 -0
  33. package/dist/daemon/setup-auth.js.map +1 -0
  34. package/dist/daemon/telemetry-proxy.d.ts +47 -0
  35. package/dist/daemon/telemetry-proxy.js +63 -0
  36. package/dist/daemon/telemetry-proxy.js.map +1 -0
  37. package/dist/dashboard/contracts.d.ts +235 -0
  38. package/dist/dashboard/contracts.js +43 -0
  39. package/dist/dashboard/contracts.js.map +1 -0
  40. package/dist/dashboard/web/app.d.ts +44 -0
  41. package/dist/dashboard/web/app.js +179 -0
  42. package/dist/dashboard/web/app.js.map +1 -0
  43. package/dist/dashboard/web/boot-route.d.ts +23 -0
  44. package/dist/dashboard/web/boot-route.js +29 -0
  45. package/dist/dashboard/web/boot-route.js.map +1 -0
  46. package/dist/dashboard/web/build-graph-button.d.ts +46 -0
  47. package/dist/dashboard/web/build-graph-button.js +81 -0
  48. package/dist/dashboard/web/build-graph-button.js.map +1 -0
  49. package/dist/dashboard/web/buzzing-screen.d.ts +30 -0
  50. package/dist/dashboard/web/buzzing-screen.js +135 -0
  51. package/dist/dashboard/web/buzzing-screen.js.map +1 -0
  52. package/dist/dashboard/web/folder-picker.d.ts +54 -0
  53. package/dist/dashboard/web/folder-picker.js +125 -0
  54. package/dist/dashboard/web/folder-picker.js.map +1 -0
  55. package/dist/dashboard/web/graph-layout.d.ts +101 -0
  56. package/dist/dashboard/web/graph-layout.js +0 -0
  57. package/dist/dashboard/web/graph-layout.js.map +1 -0
  58. package/dist/dashboard/web/harness-strip.d.ts +38 -0
  59. package/dist/dashboard/web/harness-strip.js +65 -0
  60. package/dist/dashboard/web/harness-strip.js.map +1 -0
  61. package/dist/dashboard/web/health-rail.d.ts +26 -0
  62. package/dist/dashboard/web/health-rail.js +61 -0
  63. package/dist/dashboard/web/health-rail.js.map +1 -0
  64. package/dist/dashboard/web/main.d.ts +22 -0
  65. package/dist/dashboard/web/main.js +51 -0
  66. package/dist/dashboard/web/main.js.map +1 -0
  67. package/dist/dashboard/web/needs-project.d.ts +51 -0
  68. package/dist/dashboard/web/needs-project.js +69 -0
  69. package/dist/dashboard/web/needs-project.js.map +1 -0
  70. package/dist/dashboard/web/page-frame.d.ts +95 -0
  71. package/dist/dashboard/web/page-frame.js +98 -0
  72. package/dist/dashboard/web/page-frame.js.map +1 -0
  73. package/dist/dashboard/web/pages/coming-soon.d.ts +23 -0
  74. package/dist/dashboard/web/pages/coming-soon.js +19 -0
  75. package/dist/dashboard/web/pages/coming-soon.js.map +1 -0
  76. package/dist/dashboard/web/pages/dashboard.d.ts +32 -0
  77. package/dist/dashboard/web/pages/dashboard.js +205 -0
  78. package/dist/dashboard/web/pages/dashboard.js.map +1 -0
  79. package/dist/dashboard/web/pages/graph.d.ts +72 -0
  80. package/dist/dashboard/web/pages/graph.js +362 -0
  81. package/dist/dashboard/web/pages/graph.js.map +1 -0
  82. package/dist/dashboard/web/pages/harnesses.d.ts +65 -0
  83. package/dist/dashboard/web/pages/harnesses.js +210 -0
  84. package/dist/dashboard/web/pages/harnesses.js.map +1 -0
  85. package/dist/dashboard/web/pages/health.d.ts +22 -0
  86. package/dist/dashboard/web/pages/health.js +91 -0
  87. package/dist/dashboard/web/pages/health.js.map +1 -0
  88. package/dist/dashboard/web/pages/lifecycle-panel.d.ts +60 -0
  89. package/dist/dashboard/web/pages/lifecycle-panel.js +187 -0
  90. package/dist/dashboard/web/pages/lifecycle-panel.js.map +1 -0
  91. package/dist/dashboard/web/pages/logs.d.ts +55 -0
  92. package/dist/dashboard/web/pages/logs.js +311 -0
  93. package/dist/dashboard/web/pages/logs.js.map +1 -0
  94. package/dist/dashboard/web/pages/memories.d.ts +35 -0
  95. package/dist/dashboard/web/pages/memories.js +439 -0
  96. package/dist/dashboard/web/pages/memories.js.map +1 -0
  97. package/dist/dashboard/web/pages/projects.d.ts +38 -0
  98. package/dist/dashboard/web/pages/projects.js +325 -0
  99. package/dist/dashboard/web/pages/projects.js.map +1 -0
  100. package/dist/dashboard/web/pages/roi-chart.d.ts +52 -0
  101. package/dist/dashboard/web/pages/roi-chart.js +133 -0
  102. package/dist/dashboard/web/pages/roi-chart.js.map +1 -0
  103. package/dist/dashboard/web/pages/roi.d.ts +93 -0
  104. package/dist/dashboard/web/pages/roi.js +375 -0
  105. package/dist/dashboard/web/pages/roi.js.map +1 -0
  106. package/dist/dashboard/web/pages/settings.d.ts +103 -0
  107. package/dist/dashboard/web/pages/settings.js +420 -0
  108. package/dist/dashboard/web/pages/settings.js.map +1 -0
  109. package/dist/dashboard/web/pages/sync.d.ts +59 -0
  110. package/dist/dashboard/web/pages/sync.js +280 -0
  111. package/dist/dashboard/web/pages/sync.js.map +1 -0
  112. package/dist/dashboard/web/panels.d.ts +178 -0
  113. package/dist/dashboard/web/panels.js +471 -0
  114. package/dist/dashboard/web/panels.js.map +1 -0
  115. package/dist/dashboard/web/primitives.d.ts +80 -0
  116. package/dist/dashboard/web/primitives.js +208 -0
  117. package/dist/dashboard/web/primitives.js.map +1 -0
  118. package/dist/dashboard/web/readiness-splash.d.ts +39 -0
  119. package/dist/dashboard/web/readiness-splash.js +201 -0
  120. package/dist/dashboard/web/readiness-splash.js.map +1 -0
  121. package/dist/dashboard/web/registry.d.ts +96 -0
  122. package/dist/dashboard/web/registry.js +99 -0
  123. package/dist/dashboard/web/registry.js.map +1 -0
  124. package/dist/dashboard/web/router.d.ts +48 -0
  125. package/dist/dashboard/web/router.js +76 -0
  126. package/dist/dashboard/web/router.js.map +1 -0
  127. package/dist/dashboard/web/scope-context.d.ts +155 -0
  128. package/dist/dashboard/web/scope-context.js +266 -0
  129. package/dist/dashboard/web/scope-context.js.map +1 -0
  130. package/dist/dashboard/web/service-icons.d.ts +33 -0
  131. package/dist/dashboard/web/service-icons.js +80 -0
  132. package/dist/dashboard/web/service-icons.js.map +1 -0
  133. package/dist/dashboard/web/setup-gate.d.ts +116 -0
  134. package/dist/dashboard/web/setup-gate.js +272 -0
  135. package/dist/dashboard/web/setup-gate.js.map +1 -0
  136. package/dist/dashboard/web/sidebar.d.ts +63 -0
  137. package/dist/dashboard/web/sidebar.js +91 -0
  138. package/dist/dashboard/web/sidebar.js.map +1 -0
  139. package/dist/dashboard/web/use-fleet-telemetry.d.ts +111 -0
  140. package/dist/dashboard/web/use-fleet-telemetry.js +278 -0
  141. package/dist/dashboard/web/use-fleet-telemetry.js.map +1 -0
  142. package/dist/dashboard/web/wire.d.ts +1937 -0
  143. package/dist/dashboard/web/wire.js +1837 -0
  144. package/dist/dashboard/web/wire.js.map +1 -0
  145. package/dist/errors.d.ts +5 -0
  146. package/dist/errors.js +11 -0
  147. package/dist/errors.js.map +1 -0
  148. package/dist/install/registry.d.ts +35 -0
  149. package/dist/install/registry.js +110 -0
  150. package/dist/install/registry.js.map +1 -0
  151. package/dist/lock.d.ts +10 -0
  152. package/dist/lock.js +87 -0
  153. package/dist/lock.js.map +1 -0
  154. package/dist/service/commands.d.ts +15 -0
  155. package/dist/service/commands.js +49 -0
  156. package/dist/service/commands.js.map +1 -0
  157. package/dist/service/index.d.ts +38 -0
  158. package/dist/service/index.js +174 -0
  159. package/dist/service/index.js.map +1 -0
  160. package/dist/service/platform.d.ts +37 -0
  161. package/dist/service/platform.js +67 -0
  162. package/dist/service/platform.js.map +1 -0
  163. package/dist/service/templates.d.ts +10 -0
  164. package/dist/service/templates.js +120 -0
  165. package/dist/service/templates.js.map +1 -0
  166. package/dist/shared/constants.d.ts +15 -0
  167. package/dist/shared/constants.js +19 -0
  168. package/dist/shared/constants.js.map +1 -0
  169. package/dist/shared/daemon-routing.d.ts +11 -0
  170. package/dist/shared/daemon-routing.js +38 -0
  171. package/dist/shared/daemon-routing.js.map +1 -0
  172. package/dist/shared/fleet-readiness.d.ts +18 -0
  173. package/dist/shared/fleet-readiness.js +9 -0
  174. package/dist/shared/fleet-readiness.js.map +1 -0
  175. package/dist/shared/fleet-telemetry.d.ts +68 -0
  176. package/dist/shared/fleet-telemetry.js +61 -0
  177. package/dist/shared/fleet-telemetry.js.map +1 -0
  178. package/dist/shared/lifecycle-flags.d.ts +34 -0
  179. package/dist/shared/lifecycle-flags.js +39 -0
  180. package/dist/shared/lifecycle-flags.js.map +1 -0
  181. package/dist/shared/memory-types.d.ts +55 -0
  182. package/dist/shared/memory-types.js +75 -0
  183. package/dist/shared/memory-types.js.map +1 -0
  184. package/dist/shared/service-status.d.ts +82 -0
  185. package/dist/shared/service-status.js +97 -0
  186. package/dist/shared/service-status.js.map +1 -0
  187. package/dist/telemetry/emit.d.ts +197 -0
  188. package/dist/telemetry/emit.js +196 -0
  189. package/dist/telemetry/emit.js.map +1 -0
  190. 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