@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.
- package/README.md +1 -0
- package/app/api/health/anomalies/route.ts +45 -0
- package/app/api/health/config/route.ts +31 -0
- package/app/api/health/notes/route.ts +33 -0
- package/components/Health/HealthDot.tsx +35 -0
- package/components/Health/HealthPanel.tsx +412 -0
- package/components/Pane/DesktopTabBar.tsx +3 -0
- package/components/Pane/MobileTabBar.tsx +4 -0
- package/components/Pane/MobileViewPicker.test.ts +8 -0
- package/components/Pane/MobileViewPicker.tsx +4 -1
- package/components/Pane/PaneViewToggle.test.ts +82 -0
- package/components/Pane/PaneViewToggle.tsx +12 -7
- package/components/Pane/git-visibility.ts +9 -0
- package/components/Pane/index.tsx +18 -2
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/data/git/queries.ts +16 -3
- package/data/health/index.ts +111 -0
- package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
- package/lib/health/boots.ts +79 -0
- package/lib/health/ci.ts +90 -0
- package/lib/health/config.ts +79 -0
- package/lib/health/detectors.ts +227 -0
- package/lib/health/errors.ts +90 -0
- package/lib/health/evidence.ts +172 -0
- package/lib/health/facts.ts +159 -0
- package/lib/health/fixtures.ts +30 -0
- package/lib/health/notes.ts +76 -0
- package/lib/health/pieces.ts +120 -0
- package/lib/health/route.ts +113 -0
- package/lib/health/store.ts +212 -0
- package/lib/health/tick.ts +149 -0
- package/lib/health/types.ts +148 -0
- package/lib/health/view.ts +120 -0
- package/lib/health/watchers.ts +176 -0
- package/lib/load/alert.ts +4 -8
- package/lib/models/plan-step.ts +18 -3
- package/lib/models/roles.ts +6 -0
- package/lib/orchestrator/deliver.ts +1 -0
- package/lib/orchestrator/events.ts +13 -4
- package/lib/orchestrator/watcher.ts +12 -0
- package/lib/plans/release.ts +7 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/refresh-base.ts +78 -0
- package/package.json +1 -1
- package/scripts/autodeploy +2 -2
- package/scripts/autodeploy.test.ts +2 -2
- 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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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":
|
package/data/git/queries.ts
CHANGED
|
@@ -43,15 +43,28 @@ async function fetchGitCheck(path: string): Promise<GitCheck> {
|
|
|
43
43
|
return res.json();
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function useGitCheck(
|
|
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;
|
package/lib/health/ci.ts
ADDED
|
@@ -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
|
+
}
|