@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.
- 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/Chat/ChatPanel.tsx +2 -1
- package/components/Health/HealthDot.tsx +35 -0
- package/components/Health/HealthPanel.tsx +412 -0
- package/components/Pane/DesktopTabBar.tsx +4 -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 +16 -8
- 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/components/views/DesktopAppActions.tsx +23 -17
- package/components/views/DesktopView.tsx +1 -1
- package/data/git/queries.ts +16 -3
- package/data/health/index.ts +111 -0
- package/lib/chat/pending.ts +13 -1
- 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/learning-cards/apply.ts +54 -8
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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>
|
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
|
+
}
|
package/lib/chat/pending.ts
CHANGED
|
@@ -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;
|