@saadnvd1/agent-os 0.3.620 → 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 (48) 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/Health/HealthDot.tsx +35 -0
  6. package/components/Health/HealthPanel.tsx +412 -0
  7. package/components/Pane/DesktopTabBar.tsx +3 -0
  8. package/components/Pane/MobileTabBar.tsx +4 -0
  9. package/components/Pane/MobileViewPicker.test.ts +8 -0
  10. package/components/Pane/MobileViewPicker.tsx +4 -1
  11. package/components/Pane/PaneViewToggle.test.ts +82 -0
  12. package/components/Pane/PaneViewToggle.tsx +12 -7
  13. package/components/Pane/git-visibility.ts +9 -0
  14. package/components/Pane/index.tsx +18 -2
  15. package/components/SessionList/SidebarHeader.tsx +2 -0
  16. package/components/Settings/SettingsDialog.tsx +9 -0
  17. package/data/git/queries.ts +16 -3
  18. package/data/health/index.ts +111 -0
  19. package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
  20. package/lib/health/boots.ts +79 -0
  21. package/lib/health/ci.ts +90 -0
  22. package/lib/health/config.ts +79 -0
  23. package/lib/health/detectors.ts +227 -0
  24. package/lib/health/errors.ts +90 -0
  25. package/lib/health/evidence.ts +172 -0
  26. package/lib/health/facts.ts +159 -0
  27. package/lib/health/fixtures.ts +30 -0
  28. package/lib/health/notes.ts +76 -0
  29. package/lib/health/pieces.ts +120 -0
  30. package/lib/health/route.ts +113 -0
  31. package/lib/health/store.ts +212 -0
  32. package/lib/health/tick.ts +149 -0
  33. package/lib/health/types.ts +148 -0
  34. package/lib/health/view.ts +120 -0
  35. package/lib/health/watchers.ts +176 -0
  36. package/lib/load/alert.ts +4 -8
  37. package/lib/models/plan-step.ts +18 -3
  38. package/lib/models/roles.ts +6 -0
  39. package/lib/orchestrator/deliver.ts +1 -0
  40. package/lib/orchestrator/events.ts +13 -4
  41. package/lib/orchestrator/watcher.ts +12 -0
  42. package/lib/plans/release.ts +7 -0
  43. package/lib/settings-url.ts +1 -0
  44. package/lib/tasks/refresh-base.ts +78 -0
  45. package/package.json +1 -1
  46. package/scripts/autodeploy +2 -2
  47. package/scripts/autodeploy.test.ts +2 -2
  48. package/server.ts +11 -0
@@ -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
+ });
@@ -22,6 +22,8 @@ interface PaneViewToggleProps {
22
22
  isChat?: boolean;
23
23
  viewMode: ViewMode;
24
24
  isLocalSession: boolean;
25
+ // Local, and the folder is not known to be outside a git repository.
26
+ showGit: boolean;
25
27
  isConductor: boolean;
26
28
  // The task merged and has a log.
27
29
  hasLog?: boolean;
@@ -80,6 +82,7 @@ export function PaneViewToggle({
80
82
  isChat,
81
83
  viewMode,
82
84
  isLocalSession,
85
+ showGit,
83
86
  isConductor,
84
87
  hasLog,
85
88
  workerCount,
@@ -107,13 +110,15 @@ export function PaneViewToggle({
107
110
  >
108
111
  <FolderOpen className="h-3.5 w-3.5" />
109
112
  </ToggleButton>
110
- <ToggleButton
111
- label="Git"
112
- active={gitDrawerOpen}
113
- onClick={onGitDrawerToggle}
114
- >
115
- <GitBranch className="h-3.5 w-3.5" />
116
- </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
+ )}
117
122
  <ToggleButton
118
123
  label="Shell"
119
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":
@@ -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
+ }
@@ -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;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * How long CI takes, learned from what the orchestrator already sees. Each
3
+ * diff it reads every open PR's verdict; the first time a head is pending is
4
+ * when its run started (to within a diff), and the first time it isn't is
5
+ * when it settled. No extra gh call.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import type { SessionFacts } from "../orchestrator/facts";
10
+ import { MIN, type CiFact } from "./types";
11
+
12
+ const BASELINE_RUNS = 20;
13
+ // A pending row older than this is a lost one, not a slow run.
14
+ const LOST_AFTER_MS = 24 * 60 * MIN;
15
+
16
+ export function observeCi(
17
+ workspaceId: string,
18
+ facts: Pick<SessionFacts, "project" | "task">[],
19
+ now: number
20
+ ): void {
21
+ db.transaction(() => {
22
+ for (const f of facts) {
23
+ const pr = f.task?.pr;
24
+ const project = f.project;
25
+ if (!pr || !project) continue;
26
+ const head = pr.head;
27
+ if (pr.state !== "OPEN" || !head) {
28
+ db.prepare(
29
+ `DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
30
+ AND settled_at IS NULL`
31
+ ).run(project, pr.number);
32
+ continue;
33
+ }
34
+ if (pr.checks === "pending") {
35
+ // A newer head replaces the run still pending on an older one.
36
+ db.prepare(
37
+ `DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
38
+ AND head != ? AND settled_at IS NULL`
39
+ ).run(project, pr.number, head);
40
+ db.prepare(
41
+ `INSERT OR IGNORE INTO ci_runs
42
+ (project_id, workspace_id, pr_number, head, pending_since)
43
+ VALUES (?, ?, ?, ?, ?)`
44
+ ).run(project, workspaceId, pr.number, head, now);
45
+ } else if (pr.checks === "pass" || pr.checks === "fail") {
46
+ db.prepare(
47
+ `UPDATE ci_runs SET settled_at = ?
48
+ WHERE project_id = ? AND pr_number = ? AND head = ?
49
+ AND settled_at IS NULL`
50
+ ).run(now, project, pr.number, head);
51
+ }
52
+ }
53
+ })();
54
+ }
55
+
56
+ export function pendingCi(now: number): CiFact[] {
57
+ const rows = db
58
+ .prepare(
59
+ `SELECT * FROM ci_runs WHERE settled_at IS NULL AND pending_since >= ?`
60
+ )
61
+ .all(now - LOST_AFTER_MS) as {
62
+ project_id: string;
63
+ workspace_id: string | null;
64
+ pr_number: number;
65
+ head: string;
66
+ pending_since: number;
67
+ }[];
68
+ return rows.map((r) => ({
69
+ projectId: r.project_id,
70
+ workspaceId: r.workspace_id,
71
+ prNumber: r.pr_number,
72
+ head: r.head,
73
+ pendingSince: r.pending_since,
74
+ baseline: baseline(r.project_id),
75
+ }));
76
+ }
77
+
78
+ export function baseline(projectId: string): number[] {
79
+ return (
80
+ db
81
+ .prepare(
82
+ `SELECT settled_at - pending_since AS ms FROM ci_runs
83
+ WHERE project_id = ? AND settled_at IS NOT NULL
84
+ ORDER BY settled_at DESC LIMIT ?`
85
+ )
86
+ .all(projectId, BASELINE_RUNS) as { ms: number }[]
87
+ )
88
+ .map((r) => r.ms)
89
+ .filter((ms) => ms > 0);
90
+ }