@saadnvd1/agent-os 0.3.616 → 0.3.624

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 (53) hide show
  1. package/README.md +1 -0
  2. package/app/api/health/anomalies/route.ts +45 -0
  3. package/app/api/health/config/route.ts +31 -0
  4. package/app/api/health/notes/route.ts +33 -0
  5. package/components/Chat/ChatPanel.tsx +2 -1
  6. package/components/Health/HealthDot.tsx +35 -0
  7. package/components/Health/HealthPanel.tsx +412 -0
  8. package/components/Pane/DesktopTabBar.tsx +4 -0
  9. package/components/Pane/MobileTabBar.tsx +4 -0
  10. package/components/Pane/MobileViewPicker.test.ts +8 -0
  11. package/components/Pane/MobileViewPicker.tsx +4 -1
  12. package/components/Pane/PaneViewToggle.test.ts +82 -0
  13. package/components/Pane/PaneViewToggle.tsx +16 -8
  14. package/components/Pane/git-visibility.ts +9 -0
  15. package/components/Pane/index.tsx +18 -2
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/views/DesktopAppActions.tsx +23 -17
  19. package/components/views/DesktopView.tsx +1 -1
  20. package/data/git/queries.ts +16 -3
  21. package/data/health/index.ts +111 -0
  22. package/lib/chat/pending.ts +13 -1
  23. package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
  24. package/lib/health/boots.ts +79 -0
  25. package/lib/health/ci.ts +90 -0
  26. package/lib/health/config.ts +79 -0
  27. package/lib/health/detectors.ts +227 -0
  28. package/lib/health/errors.ts +90 -0
  29. package/lib/health/evidence.ts +172 -0
  30. package/lib/health/facts.ts +159 -0
  31. package/lib/health/fixtures.ts +30 -0
  32. package/lib/health/notes.ts +76 -0
  33. package/lib/health/pieces.ts +120 -0
  34. package/lib/health/route.ts +113 -0
  35. package/lib/health/store.ts +212 -0
  36. package/lib/health/tick.ts +149 -0
  37. package/lib/health/types.ts +148 -0
  38. package/lib/health/view.ts +120 -0
  39. package/lib/health/watchers.ts +176 -0
  40. package/lib/learning-cards/apply.ts +54 -8
  41. package/lib/load/alert.ts +4 -8
  42. package/lib/models/plan-step.ts +18 -3
  43. package/lib/models/roles.ts +6 -0
  44. package/lib/orchestrator/deliver.ts +1 -0
  45. package/lib/orchestrator/events.ts +13 -4
  46. package/lib/orchestrator/watcher.ts +12 -0
  47. package/lib/plans/release.ts +7 -0
  48. package/lib/settings-url.ts +1 -0
  49. package/lib/tasks/refresh-base.ts +78 -0
  50. package/package.json +1 -1
  51. package/scripts/autodeploy +2 -2
  52. package/scripts/autodeploy.test.ts +2 -2
  53. package/server.ts +11 -0
@@ -28,6 +28,8 @@ export interface MobileView {
28
28
 
29
29
  export interface MobileViewAvailability {
30
30
  isLocalSession: boolean;
31
+ // Local, and the folder is not known to be outside a git repository.
32
+ showGit: boolean;
31
33
  hasLog?: boolean;
32
34
  isConductor: boolean;
33
35
  workerCount: number;
@@ -36,6 +38,7 @@ export interface MobileViewAvailability {
36
38
  // The one list of views on a phone; the picker renders it.
37
39
  export function mobileViews({
38
40
  isLocalSession,
41
+ showGit,
39
42
  hasLog,
40
43
  isConductor,
41
44
  workerCount,
@@ -43,7 +46,7 @@ export function mobileViews({
43
46
  const views: (MobileView | false)[] = [
44
47
  { mode: "terminal", label: "Chat", icon: TerminalIcon },
45
48
  isLocalSession && { mode: "files", label: "Files", icon: FolderOpen },
46
- isLocalSession && { mode: "git", label: "Git", icon: GitBranch },
49
+ showGit && { mode: "git", label: "Git", icon: GitBranch },
47
50
  { mode: "artifacts", label: "Artifacts", icon: Package },
48
51
  !!hasLog && { mode: "log", label: "Log", icon: ScrollText },
49
52
  isConductor && {
@@ -0,0 +1,82 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { showGitToggle } from "./git-visibility";
6
+ import { PaneViewToggle } from "./PaneViewToggle";
7
+
8
+ // Radix tooltips need a real browser; plain elements show what's rendered.
9
+ vi.mock("@/components/ui/tooltip", async () => {
10
+ const { createElement: h } = await import("react");
11
+ const part = (props: { children?: unknown }) =>
12
+ h("span", null, props.children as never);
13
+ return {
14
+ Tooltip: part,
15
+ TooltipTrigger: part,
16
+ TooltipContent: () => null,
17
+ };
18
+ });
19
+
20
+ (
21
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
22
+ ).IS_REACT_ACT_ENVIRONMENT = true;
23
+
24
+ describe("showGitToggle", () => {
25
+ it("shows for a local repo", () => {
26
+ expect(showGitToggle(true, true)).toBe(true);
27
+ });
28
+ it("keeps showing while the check is unknown", () => {
29
+ expect(showGitToggle(true, undefined)).toBe(true);
30
+ });
31
+ it("hides outside a repo", () => {
32
+ expect(showGitToggle(true, false)).toBe(false);
33
+ });
34
+ it("hides for a remote session", () => {
35
+ expect(showGitToggle(false, true)).toBe(false);
36
+ expect(showGitToggle(false, undefined)).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe("PaneViewToggle", () => {
41
+ let host: HTMLElement;
42
+ let root: Root;
43
+ beforeEach(() => {
44
+ host = document.createElement("div");
45
+ document.body.appendChild(host);
46
+ root = createRoot(host);
47
+ });
48
+ afterEach(() => {
49
+ act(() => root.unmount());
50
+ host.remove();
51
+ });
52
+ const render = (showGit: boolean) =>
53
+ act(() => {
54
+ root.render(
55
+ createElement(PaneViewToggle, {
56
+ viewMode: "terminal",
57
+ isLocalSession: true,
58
+ showGit,
59
+ isConductor: false,
60
+ workerCount: 0,
61
+ gitDrawerOpen: false,
62
+ shellDrawerOpen: false,
63
+ onViewModeChange: () => {},
64
+ onGitDrawerToggle: () => {},
65
+ onShellDrawerToggle: () => {},
66
+ })
67
+ );
68
+ });
69
+ const has = (label: string) =>
70
+ !!host.querySelector(`[aria-label="${label}"]`);
71
+
72
+ it("omits Git outside a repo but keeps Files and Shell", () => {
73
+ render(false);
74
+ expect(has("Git")).toBe(false);
75
+ expect(has("Files")).toBe(true);
76
+ expect(has("Shell")).toBe(true);
77
+ });
78
+ it("shows Git in a repo", () => {
79
+ render(true);
80
+ expect(has("Git")).toBe(true);
81
+ });
82
+ });
@@ -18,8 +18,12 @@ import type { ViewMode } from "@/contexts/PaneContext";
18
18
 
19
19
  interface PaneViewToggleProps {
20
20
  sessionId?: string;
21
+ // The session runs as a chat, so its main view is the chat, not a terminal.
22
+ isChat?: boolean;
21
23
  viewMode: ViewMode;
22
24
  isLocalSession: boolean;
25
+ // Local, and the folder is not known to be outside a git repository.
26
+ showGit: boolean;
23
27
  isConductor: boolean;
24
28
  // The task merged and has a log.
25
29
  hasLog?: boolean;
@@ -75,8 +79,10 @@ function ToggleButton({
75
79
  // session only gets the terminal (and workers).
76
80
  export function PaneViewToggle({
77
81
  sessionId,
82
+ isChat,
78
83
  viewMode,
79
84
  isLocalSession,
85
+ showGit,
80
86
  isConductor,
81
87
  hasLog,
82
88
  workerCount,
@@ -89,7 +95,7 @@ export function PaneViewToggle({
89
95
  return (
90
96
  <div className="bg-accent/50 flex shrink-0 items-center rounded-md p-0.5">
91
97
  <ToggleButton
92
- label="Terminal"
98
+ label={isChat ? "Chat" : "Terminal"}
93
99
  active={viewMode === "terminal"}
94
100
  onClick={() => onViewModeChange("terminal")}
95
101
  >
@@ -104,13 +110,15 @@ export function PaneViewToggle({
104
110
  >
105
111
  <FolderOpen className="h-3.5 w-3.5" />
106
112
  </ToggleButton>
107
- <ToggleButton
108
- label="Git"
109
- active={gitDrawerOpen}
110
- onClick={onGitDrawerToggle}
111
- >
112
- <GitBranch className="h-3.5 w-3.5" />
113
- </ToggleButton>
113
+ {showGit && (
114
+ <ToggleButton
115
+ label="Git"
116
+ active={gitDrawerOpen}
117
+ onClick={onGitDrawerToggle}
118
+ >
119
+ <GitBranch className="h-3.5 w-3.5" />
120
+ </ToggleButton>
121
+ )}
114
122
  <ToggleButton
115
123
  label="Shell"
116
124
  active={shellDrawerOpen}
@@ -0,0 +1,9 @@
1
+ // The Git toggle shows for a local session unless its folder is known not to
2
+ // be a repository. Unknown (still checking) keeps it, so it doesn't flicker
3
+ // away in real repos.
4
+ export function showGitToggle(
5
+ isLocalSession: boolean,
6
+ isGitRepo: boolean | undefined
7
+ ): boolean {
8
+ return isLocalSession && isGitRepo !== false;
9
+ }
@@ -34,6 +34,8 @@ import { ShellDrawer } from "@/components/ShellDrawer";
34
34
  import { useSnapshot } from "valtio";
35
35
  import { fileOpenStore, fileOpenActions } from "@/stores/fileOpen";
36
36
  import { useDemoMode } from "@/data/demo";
37
+ import { useIsGitRepo } from "@/data/git/queries";
38
+ import { showGitToggle } from "./git-visibility";
37
39
  import { DemoNotice } from "@/components/DemoNotice";
38
40
 
39
41
  // Dynamic imports for client-only components with loading states
@@ -170,7 +172,19 @@ export const Pane = memo(function Pane({
170
172
  : null;
171
173
  const isLocalSession = !session?.host_id || session.host_id === "local";
172
174
  // An orchestrator works through tools in a scratch folder: no repo to show.
173
- const showGitDrawer = gitDrawerOpen && session?.role !== "orchestrator";
175
+ const isGitRepo = useIsGitRepo(
176
+ session?.working_directory ?? undefined,
177
+ isLocalSession && !!session?.working_directory
178
+ );
179
+ const showGit = showGitToggle(isLocalSession, isGitRepo);
180
+ const showGitDrawer =
181
+ gitDrawerOpen && showGit && session?.role !== "orchestrator";
182
+ // The folder turned out not to be a repo: don't leave its git view open.
183
+ useEffect(() => {
184
+ if (isGitRepo !== false) return;
185
+ if (gitDrawerOpen) setGitDrawerOpen(false);
186
+ if (viewMode === "git") setViewMode("terminal");
187
+ }, [isGitRepo, gitDrawerOpen, viewMode, setGitDrawerOpen, setViewMode]);
174
188
  // A tab whose session runs as chat shows the conversation, not a terminal.
175
189
  const chatSessionFor = (tab: { sessionId: string | null }) => {
176
190
  const s = tab.sessionId
@@ -458,6 +472,7 @@ export const Pane = memo(function Pane({
458
472
  isConductor={isConductor}
459
473
  hasLog={hasLog}
460
474
  workerCount={workerCount}
475
+ showGit={showGit}
461
476
  onMenuClick={onMenuClick}
462
477
  onViewModeChange={setViewMode}
463
478
  onSelectSession={onSelectSession}
@@ -484,6 +499,7 @@ export const Pane = memo(function Pane({
484
499
  canClose={canClose}
485
500
  hasAttachedTmux={!!activeTab?.attachedTmux}
486
501
  gitDrawerOpen={gitDrawerOpen}
502
+ showGit={showGit}
487
503
  shellDrawerOpen={shellDrawerOpen}
488
504
  onTabSwitch={(tabId) => switchTab(paneId, tabId)}
489
505
  onTabClose={(tabId) => closeTab(paneId, tabId)}
@@ -532,7 +548,7 @@ export const Pane = memo(function Pane({
532
548
  )}
533
549
 
534
550
  {/* Git - mobile only */}
535
- {session?.working_directory && (
551
+ {session?.working_directory && showGit && (
536
552
  <div className={viewMode === "git" ? "h-full" : "hidden"}>
537
553
  <GitPanel
538
554
  workingDirectory={session.working_directory}
@@ -39,6 +39,7 @@ import { settingsUiActions } from "@/stores/settingsUi";
39
39
  import { usageUiActions } from "@/stores/usageUi";
40
40
  import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
41
41
  import { LoadGauge } from "./LoadGauge";
42
+ import { HealthDot } from "@/components/Health/HealthDot";
42
43
  import { AwayToggle } from "@/components/Away/AwayToggle";
43
44
  import { NoApprovalsBadge } from "@/components/Approvals/NoApprovalsBadge";
44
45
  import { useSchedulesGlance } from "./useSchedulesGlance";
@@ -71,6 +72,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
71
72
  current={props.workspace}
72
73
  />
73
74
  <LoadGauge />
75
+ <HealthDot />
74
76
  </div>
75
77
  <div className="ml-auto flex shrink-0 items-center gap-0.5">
76
78
  <NoApprovalsBadge />
@@ -11,6 +11,7 @@ import {
11
11
  Clock,
12
12
  Cpu,
13
13
  GitMerge,
14
+ HeartPulse,
14
15
  LayoutGrid,
15
16
  Link2,
16
17
  ListChecks,
@@ -30,6 +31,7 @@ import {
30
31
  } from "@/components/ui/dialog";
31
32
  import { Button } from "@/components/ui/button";
32
33
  import { AuditsPanel } from "@/components/Audits/AuditsPanel";
34
+ import { HealthPanel } from "@/components/Health/HealthPanel";
33
35
  import { DevicesPanel } from "@/components/Devices";
34
36
  import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
35
37
  import { ModelsSection } from "@/components/Models/ModelsSection";
@@ -105,6 +107,11 @@ export const SECTION_INFO: Record<
105
107
  hint: "Where sessions spent tokens and repeated work",
106
108
  icon: ClipboardList,
107
109
  },
110
+ health: {
111
+ label: "Health",
112
+ hint: "What the watchers caught, what normal looks like, thresholds",
113
+ icon: HeartPulse,
114
+ },
108
115
  setup: {
109
116
  label: "Setup & health",
110
117
  hint: "What this machine has, agents, first-run setup",
@@ -150,6 +157,8 @@ function SectionBody({
150
157
  return <SchedulesPanel />;
151
158
  case "audits":
152
159
  return <AuditsPanel />;
160
+ case "health":
161
+ return <HealthPanel />;
153
162
  case "setup":
154
163
  return <SetupSection />;
155
164
  case "updates":
@@ -45,29 +45,35 @@ export function SidebarToggle({
45
45
  );
46
46
  }
47
47
 
48
- export function DesktopAppActions() {
48
+ // A pinned sidebar has its own New button, so this one shows only while the
49
+ // sidebar is hidden.
50
+ export function DesktopAppActions({ showNew }: { showNew: boolean }) {
49
51
  return (
50
52
  <div
51
53
  className="flex shrink-0 items-center gap-0.5"
52
54
  onClick={(e) => e.stopPropagation()}
53
55
  >
54
56
  <InboxButton />
55
- <Tooltip>
56
- <TooltipTrigger asChild>
57
- <Button
58
- size="icon-sm"
59
- aria-label="New session"
60
- className="ml-1 h-7 w-7"
61
- onClick={() => newDraft({ kind: "current" })}
62
- >
63
- <Plus className="h-4 w-4" />
64
- </Button>
65
- </TooltipTrigger>
66
- <TooltipContent>
67
- <p>New session</p>
68
- <p className="text-muted-foreground text-xs">{DRAFT_KEYS.current}</p>
69
- </TooltipContent>
70
- </Tooltip>
57
+ {showNew && (
58
+ <Tooltip>
59
+ <TooltipTrigger asChild>
60
+ <Button
61
+ size="icon-sm"
62
+ aria-label="New session"
63
+ className="ml-1 h-7 w-7"
64
+ onClick={() => newDraft({ kind: "current" })}
65
+ >
66
+ <Plus className="h-4 w-4" />
67
+ </Button>
68
+ </TooltipTrigger>
69
+ <TooltipContent>
70
+ <p>New session</p>
71
+ <p className="text-muted-foreground text-xs">
72
+ {DRAFT_KEYS.current}
73
+ </p>
74
+ </TooltipContent>
75
+ </Tooltip>
76
+ )}
71
77
  </div>
72
78
  );
73
79
  }
@@ -59,7 +59,7 @@ export function DesktopView({
59
59
  leading={
60
60
  <SidebarToggle isPinned={isPinned} togglePin={togglePin} />
61
61
  }
62
- trailing={<DesktopAppActions />}
62
+ trailing={<DesktopAppActions showNew={!isPinned} />}
63
63
  >
64
64
  <PaneLayout renderPane={renderPane} />
65
65
  </PaneBarSlotsProvider>
@@ -43,15 +43,28 @@ async function fetchGitCheck(path: string): Promise<GitCheck> {
43
43
  return res.json();
44
44
  }
45
45
 
46
- export function useGitCheck(path: string) {
46
+ export function useGitCheck(
47
+ path: string,
48
+ options?: { enabled?: boolean; staleTime?: number }
49
+ ) {
47
50
  return useQuery({
48
51
  queryKey: gitKeys.check(path),
49
52
  queryFn: () => fetchGitCheck(path),
50
- staleTime: 10000,
51
- enabled: !!path && path !== "~",
53
+ staleTime: options?.staleTime ?? 10000,
54
+ enabled: !!path && path !== "~" && (options?.enabled ?? true),
52
55
  });
53
56
  }
54
57
 
58
+ // Whether a folder is a git repository; undefined while it isn't known
59
+ // (loading, disabled, or the check failed).
60
+ export function useIsGitRepo(
61
+ path: string | undefined,
62
+ enabled: boolean
63
+ ): boolean | undefined {
64
+ const { data } = useGitCheck(path ?? "", { enabled, staleTime: 60000 });
65
+ return typeof data?.isGitRepo === "boolean" ? data.isGitRepo : undefined;
66
+ }
67
+
55
68
  // --- Git Status ---
56
69
 
57
70
  async function fetchGitStatus(workingDir: string): Promise<GitStatus> {
@@ -0,0 +1,111 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { HealthView } from "@/lib/health/view";
3
+ import type { NoteRow } from "@/lib/health/notes";
4
+ import type { Area } from "@/lib/health/types";
5
+
6
+ export const healthKeys = {
7
+ all: ["health"] as const,
8
+ view: () => [...healthKeys.all, "view"] as const,
9
+ count: () => [...healthKeys.all, "count"] as const,
10
+ notes: () => [...healthKeys.all, "notes"] as const,
11
+ config: () => [...healthKeys.all, "config"] as const,
12
+ };
13
+
14
+ async function request<T>(url: string, init?: RequestInit): Promise<T> {
15
+ const res = await fetch(url, init);
16
+ const data = await res.json();
17
+ if (!res.ok) throw new Error(data.error || "Request failed");
18
+ return data as T;
19
+ }
20
+
21
+ const json = (method: string, body: unknown): RequestInit => ({
22
+ method,
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify(body),
25
+ });
26
+
27
+ const POLL_MS = 30_000;
28
+
29
+ export function useHealthQuery() {
30
+ return useQuery({
31
+ queryKey: healthKeys.view(),
32
+ queryFn: () => request<HealthView>("/api/health/anomalies"),
33
+ refetchInterval: POLL_MS,
34
+ });
35
+ }
36
+
37
+ // What the sidebar dot needs, and nothing more.
38
+ export function useHealthCount() {
39
+ return useQuery({
40
+ queryKey: healthKeys.count(),
41
+ queryFn: () =>
42
+ request<{ open: number; needsYou: number }>(
43
+ "/api/health/anomalies?count=1"
44
+ ),
45
+ refetchInterval: POLL_MS,
46
+ });
47
+ }
48
+
49
+ export function useDismissAnomaly() {
50
+ const qc = useQueryClient();
51
+ return useMutation({
52
+ mutationFn: (id: number) =>
53
+ request(
54
+ "/api/health/anomalies",
55
+ json("PATCH", { id, action: "dismiss" })
56
+ ),
57
+ onSuccess: () => qc.invalidateQueries({ queryKey: healthKeys.all }),
58
+ });
59
+ }
60
+
61
+ export interface NotesResponse {
62
+ seeds: Record<Area, string[]>;
63
+ notes: NoteRow[];
64
+ }
65
+
66
+ export function useHealthNotes() {
67
+ return useQuery({
68
+ queryKey: healthKeys.notes(),
69
+ queryFn: () => request<NotesResponse>("/api/health/notes"),
70
+ });
71
+ }
72
+
73
+ export function useAddNote() {
74
+ const qc = useQueryClient();
75
+ return useMutation({
76
+ mutationFn: (note: { area: Area; line: string }) =>
77
+ request("/api/health/notes", json("POST", note)),
78
+ onSuccess: () => qc.invalidateQueries({ queryKey: healthKeys.notes() }),
79
+ });
80
+ }
81
+
82
+ export function useDeleteNote() {
83
+ const qc = useQueryClient();
84
+ return useMutation({
85
+ mutationFn: (id: number) =>
86
+ request(`/api/health/notes?id=${id}`, { method: "DELETE" }),
87
+ onSuccess: () => qc.invalidateQueries({ queryKey: healthKeys.notes() }),
88
+ });
89
+ }
90
+
91
+ export interface ConfigResponse {
92
+ config: Record<string, number>;
93
+ defaults: Record<string, number>;
94
+ labels: Record<string, string>;
95
+ }
96
+
97
+ export function useHealthConfig() {
98
+ return useQuery({
99
+ queryKey: healthKeys.config(),
100
+ queryFn: () => request<ConfigResponse>("/api/health/config"),
101
+ });
102
+ }
103
+
104
+ export function useSaveConfig() {
105
+ const qc = useQueryClient();
106
+ return useMutation({
107
+ mutationFn: (patch: Record<string, number | null>) =>
108
+ request<ConfigResponse>("/api/health/config", json("PUT", { patch })),
109
+ onSuccess: (data) => qc.setQueryData(healthKeys.config(), data),
110
+ });
111
+ }
@@ -1,4 +1,4 @@
1
- import type { ChatImage } from "./events";
1
+ import type { ChatImage, ChatState } from "./events";
2
2
 
3
3
  // A message the composer sent that isn't in the chat or its queue yet. It
4
4
  // shows as a bubble at once, and stays (as failed, with Retry) if it never
@@ -113,6 +113,18 @@ export function pendingReducer(
113
113
  }
114
114
  }
115
115
 
116
+ // A compact-first send reads "Compacting first" from the server accepting it
117
+ // until compaction is over. The chat is idle in that gap (compaction starts
118
+ // it running), so idle means it hasn't begun; running with no compaction
119
+ // means it finished, or the server decided not to compact.
120
+ export const compactsFirst = (
121
+ p: Pick<PendingSend, "status" | "compact">,
122
+ chat: { state: ChatState; compacting: unknown }
123
+ ) =>
124
+ !!p.compact &&
125
+ p.status === "accepted" &&
126
+ (!!chat.compacting || chat.state === "idle");
127
+
116
128
  // What a new socket sends again: anything not failed and not over its sends.
117
129
  // Safe to repeat: the server drops an id already in the chat or its queue.
118
130
  export const toResend = (state: PendingSend[]) =>
@@ -0,0 +1,68 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261015T1500_health_watch",
5
+ up: (db) => {
6
+ // The health watcher (lib/health): anomalies its detectors open and
7
+ // the verdicts the watchers give, server boots, CI runs and the
8
+ // watchers' notes on what normal looks like. Additive; rows are kept.
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS health_anomalies (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ check_id TEXT NOT NULL,
13
+ subject TEXT NOT NULL,
14
+ area TEXT NOT NULL,
15
+ severity TEXT NOT NULL,
16
+ line TEXT NOT NULL,
17
+ detail TEXT NOT NULL DEFAULT '{}',
18
+ workspace_id TEXT,
19
+ opened_at INTEGER NOT NULL,
20
+ last_seen_at INTEGER NOT NULL,
21
+ closed_at INTEGER,
22
+ close_reason TEXT,
23
+ muted_until INTEGER,
24
+ hits INTEGER NOT NULL DEFAULT 1,
25
+ woken_severity TEXT,
26
+ woken_at INTEGER,
27
+ verdict TEXT,
28
+ tokens_in INTEGER,
29
+ tokens_out INTEGER,
30
+ delivered_at INTEGER,
31
+ phoned_at INTEGER
32
+ );
33
+ CREATE INDEX IF NOT EXISTS idx_health_anomalies_subject
34
+ ON health_anomalies(check_id, subject, closed_at);
35
+ CREATE INDEX IF NOT EXISTS idx_health_anomalies_opened
36
+ ON health_anomalies(opened_at);
37
+
38
+ CREATE TABLE IF NOT EXISTS health_boots (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ started_at INTEGER NOT NULL,
41
+ pid INTEGER NOT NULL,
42
+ head TEXT,
43
+ prev_seen_at INTEGER,
44
+ prev_bg TEXT
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS ci_runs (
48
+ project_id TEXT NOT NULL,
49
+ workspace_id TEXT,
50
+ pr_number INTEGER NOT NULL,
51
+ head TEXT NOT NULL,
52
+ pending_since INTEGER NOT NULL,
53
+ settled_at INTEGER,
54
+ PRIMARY KEY (project_id, pr_number, head)
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS watcher_notes (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ area TEXT NOT NULL,
60
+ line TEXT NOT NULL,
61
+ source TEXT NOT NULL,
62
+ created_at INTEGER NOT NULL
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_watcher_notes_area
65
+ ON watcher_notes(area, id);
66
+ `);
67
+ },
68
+ } satisfies Migration;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * When the server started, and what it was doing when the last one stopped.
3
+ * A restart is invisible from inside the new process, and a signal handler
4
+ * would change how the old one exits, so each health tick leaves a
5
+ * heartbeat (when it last ran, and the background jobs then live) and the
6
+ * next boot reads it back.
7
+ */
8
+
9
+ import { db } from "../db";
10
+ import { gitSync } from "../git-exec";
11
+ import { getSetting, putSetting } from "../settings-store";
12
+ import { MIN } from "./types";
13
+
14
+ const HEARTBEAT = "health.heartbeat";
15
+
16
+ export interface Heartbeat {
17
+ at: number;
18
+ bg: string[];
19
+ }
20
+
21
+ export function leaveHeartbeat(at: number, bg: string[]): void {
22
+ putSetting(HEARTBEAT, JSON.stringify({ at, bg: bg.slice(0, 20) }));
23
+ }
24
+
25
+ function lastHeartbeat(): Heartbeat | null {
26
+ try {
27
+ const raw = getSetting(HEARTBEAT);
28
+ return raw ? (JSON.parse(raw) as Heartbeat) : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function gitHead(): string | null {
35
+ try {
36
+ return gitSync(process.cwd(), ["rev-parse", "--short", "HEAD"], {
37
+ timeout: 3000,
38
+ }).trim();
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function recordBoot(now = Date.now(), pid = process.pid): void {
45
+ const prev = lastHeartbeat();
46
+ db.prepare(
47
+ `INSERT INTO health_boots (started_at, pid, head, prev_seen_at, prev_bg)
48
+ VALUES (?, ?, ?, ?, ?)`
49
+ ).run(
50
+ now,
51
+ pid,
52
+ gitHead(),
53
+ prev?.at ?? null,
54
+ prev ? JSON.stringify(prev.bg) : null
55
+ );
56
+ }
57
+
58
+ export interface BootRow {
59
+ id: number;
60
+ started_at: number;
61
+ pid: number;
62
+ head: string | null;
63
+ prev_seen_at: number | null;
64
+ prev_bg: string | null;
65
+ }
66
+
67
+ export const bootsSince = (since: number) =>
68
+ db
69
+ .prepare(
70
+ `SELECT * FROM health_boots WHERE started_at >= ? ORDER BY started_at`
71
+ )
72
+ .all(since) as BootRow[];
73
+
74
+ export const recentBoots = (n = 6) =>
75
+ db
76
+ .prepare(`SELECT * FROM health_boots ORDER BY started_at DESC LIMIT ?`)
77
+ .all(n) as BootRow[];
78
+
79
+ export const BOOT_WINDOW_MS = 24 * 60 * MIN;