@saadnvd1/agent-os 0.3.839 → 0.3.848
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/app/api/approvals/route.ts +5 -1
- package/app/api/health/config/route.ts +2 -0
- package/app/api/health/route.ts +4 -2
- package/app/globals.css +6 -0
- package/components/Audits/AuditsPanel.tsx +42 -35
- package/components/Chat/quote-selection.test.ts +6 -0
- package/components/Chat/quote-selection.ts +13 -10
- package/components/Devices/ConnectCard.tsx +52 -56
- package/components/Devices/DeviceGroupRow.tsx +65 -0
- package/components/Devices/DeviceRow.tsx +2 -2
- package/components/Devices/DevicesPanel.tsx +63 -49
- package/components/Devices/NetworkSection.tsx +29 -26
- package/components/Devices/TailscaleCard.tsx +9 -10
- package/components/Devices/group.test.ts +66 -0
- package/components/Devices/group.ts +67 -0
- package/components/Health/HealthPanel.tsx +5 -81
- package/components/Health/MachineChecks.tsx +20 -0
- package/components/Health/SweepRuns.tsx +46 -51
- package/components/Health/Thresholds.tsx +50 -0
- package/components/Merge/GlobalMergeSection.tsx +5 -6
- package/components/Merge/MergeApprovalToggle.tsx +20 -14
- package/components/Merge/MergeSettingsFields.tsx +0 -2
- package/components/Merge/WorktreeSweepSection.tsx +15 -34
- package/components/Models/AgentRoleCard.tsx +7 -7
- package/components/Models/ModelSelect.tsx +14 -3
- package/components/Models/ModelsSection.tsx +42 -37
- package/components/Models/ProviderCard.tsx +1 -1
- package/components/Models/RoleModelSelect.tsx +15 -3
- package/components/Models/SubagentModels.tsx +60 -45
- package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
- package/components/Recipes/RecipesPanel.tsx +20 -9
- package/components/Recipes/RunHistory.tsx +1 -1
- package/components/Schedules/SchedulesPanel.tsx +12 -10
- package/components/SessionList/GroupShelves.tsx +8 -3
- package/components/SessionList/OrchestratorPinRow.tsx +2 -2
- package/components/SessionList/RowParts.tsx +39 -35
- package/components/SessionList/RowRename.test.ts +86 -17
- package/components/SessionList/RowShell.tsx +2 -5
- package/components/SessionList/SessionRow.tsx +0 -1
- package/components/Settings/AppWideLimits.tsx +2 -2
- package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
- package/components/Settings/ApprovalsSection.tsx +93 -0
- package/components/Settings/ExperimentsSection.test.ts +20 -4
- package/components/Settings/ExperimentsSection.tsx +54 -34
- package/components/Settings/GeneralSection.tsx +39 -0
- package/components/Settings/ListSection.test.ts +85 -0
- package/components/Settings/ListSection.tsx +132 -0
- package/components/Settings/NotificationsSection.tsx +31 -29
- package/components/Settings/PickerSetting.tsx +5 -1
- package/components/Settings/SettingRow.tsx +91 -49
- package/components/Settings/SettingsDialog.test.ts +31 -4
- package/components/Settings/SettingsDialog.tsx +22 -17
- package/components/Settings/ValueLabel.tsx +31 -0
- package/components/Settings/settings-fields.test.ts +81 -2
- package/components/Settings/settings-fields.tsx +28 -27
- package/components/Shares/SharedLinksPanel.tsx +11 -14
- package/components/Updates/AutoUpdates.tsx +12 -8
- package/components/Updates/UpdatesSection.tsx +18 -17
- package/components/Workspaces/HqFolderForm.tsx +21 -19
- package/components/Workspaces/McpServersForm.tsx +20 -28
- package/components/Workspaces/ProjectTiersForm.tsx +25 -12
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +58 -31
- package/components/Workspaces/RulesForm.tsx +129 -58
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +40 -41
- package/components/Workspaces/WorkspacePanel.tsx +73 -0
- package/components/Workspaces/WorkspacePicker.tsx +44 -0
- package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
- package/components/Workspaces/WorkspaceSettings.tsx +51 -79
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/components/ui/switch.tsx +1 -1
- package/content/whats-new.json +40 -0
- package/data/approvals/index.ts +2 -2
- package/data/health/index.ts +3 -0
- package/hooks/useAutoSave.ts +18 -5
- package/hooks/useSettingsUrl.ts +6 -3
- package/lib/approvals/describe.ts +64 -0
- package/lib/away/deploy.ts +34 -7
- package/lib/health/config.ts +70 -17
- package/lib/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/models/providers.ts +1 -1
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/recipes/settle.ts +22 -0
- package/lib/settings-url.ts +14 -2
- package/lib/updates/banner.ts +2 -0
- package/lib/updates/run.ts +9 -0
- package/lib/updates/view.ts +28 -6
- package/lib/worktree-deps/paths.ts +9 -4
- package/lib/worktrees.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +11 -0
- package/scripts/autodeploy.test.ts +30 -0
- package/scripts/lib/update-verify.mjs +11 -0
- package/scripts/update-run.mjs +29 -0
- package/scripts/update-run.test.ts +62 -4
- package/components/Settings/NoApprovalsSection.tsx +0 -103
- package/components/Settings/SettingToggle.tsx +0 -36
- package/components/Settings/SetupSection.tsx +0 -46
|
@@ -1,31 +1,54 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useContext, useState } from "react";
|
|
3
4
|
import { settingsUiActions } from "@/stores/settingsUi";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { McpServersForm } from "./McpServersForm";
|
|
7
|
-
import { HqFolderForm } from "./HqFolderForm";
|
|
8
|
-
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
9
|
-
import { WorkspaceModeForm } from "./WorkspaceModeForm";
|
|
10
|
-
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
11
|
-
import { AppWideLimits } from "@/components/Settings/AppWideLimits";
|
|
5
|
+
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
6
|
+
import { useProjectsQuery } from "@/data/projects";
|
|
12
7
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from "@/components/Settings/
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
8
|
+
matchesQuery,
|
|
9
|
+
SettingsQueryContext,
|
|
10
|
+
} from "@/components/Settings/SettingRow";
|
|
11
|
+
import { WorkspacePanel } from "./WorkspacePanel";
|
|
12
|
+
import { WorkspacePicker } from "./WorkspacePicker";
|
|
13
|
+
import { useSettingsWorkspace } from "./useSettingsWorkspace";
|
|
18
14
|
|
|
19
|
-
//
|
|
15
|
+
// What every workspace's page has, for the search box to find.
|
|
16
|
+
const SHARED_ROWS =
|
|
17
|
+
"tasks at a time limit operating mode orchestrator full access wake ci failures reviews models hq folder session logs rules mcp servers projects production fast merge tier";
|
|
18
|
+
|
|
19
|
+
// Settings > Workspaces: one workspace's settings at a time.
|
|
20
20
|
export function WorkspaceSettings() {
|
|
21
21
|
const { data: workspaces, isPending, isError, error } = useWorkspacesQuery();
|
|
22
|
-
const
|
|
22
|
+
const { data: projects } = useProjectsQuery();
|
|
23
|
+
const query = useContext(SettingsQueryContext);
|
|
24
|
+
const { current, choose } = useSettingsWorkspace(workspaces ?? []);
|
|
25
|
+
// The search a hand-picked workspace overrides.
|
|
26
|
+
const [pinnedFor, setPinnedFor] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
// A search that matches only another workspace (its name, a project) shows
|
|
29
|
+
// that workspace; the shared rows are on every page, so they never move it.
|
|
30
|
+
const searchHit =
|
|
31
|
+
query.trim() &&
|
|
32
|
+
workspaces &&
|
|
33
|
+
current &&
|
|
34
|
+
pinnedFor !== query &&
|
|
35
|
+
!matchesQuery(query, SHARED_ROWS)
|
|
36
|
+
? workspaces.find((w) =>
|
|
37
|
+
matchesQuery(
|
|
38
|
+
query,
|
|
39
|
+
w.name,
|
|
40
|
+
...(projects ?? [])
|
|
41
|
+
.filter((p) => p.workspace_id === w.id)
|
|
42
|
+
.map((p) => p.name)
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
: undefined;
|
|
23
46
|
|
|
24
47
|
if (isPending)
|
|
25
48
|
return <div className="bg-muted/40 h-32 animate-pulse rounded-lg" />;
|
|
26
49
|
if (isError)
|
|
27
50
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
28
|
-
if (!workspaces.length)
|
|
51
|
+
if (!workspaces.length || !current)
|
|
29
52
|
return (
|
|
30
53
|
<p className="text-muted-foreground text-sm">
|
|
31
54
|
No workspaces yet. Make one from the workspace menu at the top of the
|
|
@@ -33,74 +56,23 @@ export function WorkspaceSettings() {
|
|
|
33
56
|
</p>
|
|
34
57
|
);
|
|
35
58
|
|
|
59
|
+
const shown = searchHit ?? current;
|
|
36
60
|
return (
|
|
37
|
-
<div
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
>
|
|
48
|
-
<h3 className="truncate text-sm font-medium">{w.name}</h3>
|
|
49
|
-
<SettingSection>
|
|
50
|
-
<NumberSetting
|
|
51
|
-
id={`task-limit-${w.id}`}
|
|
52
|
-
label="Tasks at a time"
|
|
53
|
-
help="Cap on tasks running at once. The rest wait in Queued and start in order."
|
|
54
|
-
value={w.max_running_tasks ?? 0}
|
|
55
|
-
defaultValue={0}
|
|
56
|
-
onSave={(n) =>
|
|
57
|
-
update.mutateAsync({
|
|
58
|
-
id: w.id,
|
|
59
|
-
maxRunningTasks: n === 0 ? null : n,
|
|
60
|
-
})
|
|
61
|
-
}
|
|
62
|
-
min={1}
|
|
63
|
-
integer
|
|
64
|
-
unit="at a time"
|
|
65
|
-
optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
|
|
66
|
-
/>
|
|
67
|
-
</SettingSection>
|
|
68
|
-
<WorkspaceModeForm workspaceId={w.id} />
|
|
69
|
-
<RulesForm workspaceId={w.id} />
|
|
70
|
-
<WorkspaceModelsForm workspaceId={w.id} />
|
|
71
|
-
<McpServersForm workspace={w} />
|
|
72
|
-
<HqFolderForm workspaceId={w.id} />
|
|
73
|
-
<ProjectTiersForm workspaceId={w.id} />
|
|
74
|
-
<SettingSection>
|
|
75
|
-
<SwitchSetting
|
|
76
|
-
id={`wake-${w.id}`}
|
|
77
|
-
label="Wake tasks on CI failures and reviews"
|
|
78
|
-
help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
|
|
79
|
-
checked={w.wake_tasks_on_pr}
|
|
80
|
-
onSave={(on) =>
|
|
81
|
-
update.mutateAsync({ id: w.id, wakeTasksOnPR: on })
|
|
82
|
-
}
|
|
83
|
-
/>
|
|
84
|
-
<SwitchSetting
|
|
85
|
-
id={`full-access-${w.id}`}
|
|
86
|
-
label="Orchestrator full access"
|
|
87
|
-
help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
|
|
88
|
-
checked={w.orch_full_access}
|
|
89
|
-
confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
|
|
90
|
-
confirmLabel="Turn on full access"
|
|
91
|
-
onSave={(on) =>
|
|
92
|
-
update.mutateAsync({ id: w.id, orchFullAccess: on })
|
|
93
|
-
}
|
|
94
|
-
/>
|
|
95
|
-
</SettingSection>
|
|
96
|
-
</section>
|
|
97
|
-
))}
|
|
98
|
-
<AppWideLimits />
|
|
61
|
+
<div>
|
|
62
|
+
<WorkspacePicker
|
|
63
|
+
workspaces={workspaces}
|
|
64
|
+
value={shown.id}
|
|
65
|
+
onChange={(id) => {
|
|
66
|
+
setPinnedFor(query);
|
|
67
|
+
choose(id);
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
<WorkspacePanel key={shown.id} workspace={shown} />
|
|
99
71
|
<p className="text-muted-foreground text-sm">
|
|
100
72
|
Fresh starts and compaction are app-wide:{" "}
|
|
101
73
|
<button
|
|
102
74
|
type="button"
|
|
103
|
-
className="text-primary underline-offset-2 hover:underline"
|
|
75
|
+
className="text-primary -my-3 min-h-11 underline-offset-2 hover:underline"
|
|
104
76
|
onClick={() => settingsUiActions.show("context")}
|
|
105
77
|
>
|
|
106
78
|
open Context
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
|
+
import type { Workspace } from "@/lib/db";
|
|
5
|
+
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
6
|
+
|
|
7
|
+
const KEY = "agentOS-settings-workspace";
|
|
8
|
+
|
|
9
|
+
const listeners = new Set<() => void>();
|
|
10
|
+
|
|
11
|
+
function subscribe(fn: () => void) {
|
|
12
|
+
listeners.add(fn);
|
|
13
|
+
window.addEventListener("storage", fn);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(fn);
|
|
16
|
+
window.removeEventListener("storage", fn);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function read(): string | null {
|
|
21
|
+
try {
|
|
22
|
+
return localStorage.getItem(KEY);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Which workspace Settings > Workspaces shows: the one picked here last,
|
|
29
|
+
// else the sidebar's, else the first. A remembered one since deleted falls
|
|
30
|
+
// through.
|
|
31
|
+
export function useSettingsWorkspace(workspaces: Workspace[]) {
|
|
32
|
+
const { workspace: sidebar } = useSelectedWorkspace();
|
|
33
|
+
const picked = useSyncExternalStore(subscribe, read, () => null);
|
|
34
|
+
const choose = useCallback((id: string) => {
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(KEY, id);
|
|
37
|
+
} catch {
|
|
38
|
+
// Not remembered; the choice still holds for this visit.
|
|
39
|
+
}
|
|
40
|
+
listeners.forEach((fn) => fn());
|
|
41
|
+
}, []);
|
|
42
|
+
const current =
|
|
43
|
+
workspaces.find((w) => w.id === picked) ??
|
|
44
|
+
workspaces.find((w) => w.id === sidebar?.id) ??
|
|
45
|
+
workspaces[0];
|
|
46
|
+
return { current, choose };
|
|
47
|
+
}
|
package/components/ui/switch.tsx
CHANGED
|
@@ -13,7 +13,7 @@ function Switch({
|
|
|
13
13
|
<SwitchPrimitive.Root
|
|
14
14
|
data-slot="switch"
|
|
15
15
|
className={cn(
|
|
16
|
-
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-
|
|
16
|
+
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/35 data-[state=unchecked]:border-foreground/15 focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-muted-foreground/40 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
|
17
17
|
className
|
|
18
18
|
)}
|
|
19
19
|
{...props}
|
package/content/whats-new.json
CHANGED
|
@@ -1985,6 +1985,46 @@
|
|
|
1985
1985
|
"area": "Settings",
|
|
1986
1986
|
"text": "More settings now save as you change them, and revoking a passkey or link asks inline instead of with a browser pop-up.",
|
|
1987
1987
|
"url": "https://github.com/saadnvd1/agent-os/pull/563"
|
|
1988
|
+
},
|
|
1989
|
+
{
|
|
1990
|
+
"id": 564,
|
|
1991
|
+
"date": "2026-11-02",
|
|
1992
|
+
"kind": "fixed",
|
|
1993
|
+
"area": "Updates",
|
|
1994
|
+
"text": "Updating AgentOS no longer rolls back a good update when the machine's automatic deploy restarts the server at the same moment.",
|
|
1995
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/564"
|
|
1996
|
+
},
|
|
1997
|
+
{
|
|
1998
|
+
"id": 565,
|
|
1999
|
+
"date": "2026-11-02",
|
|
2000
|
+
"kind": "improved",
|
|
2001
|
+
"area": "Settings",
|
|
2002
|
+
"text": "The Workspaces settings page now shows one workspace at a time, in clear groups, with rules you can read in full and edit in place.",
|
|
2003
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/565"
|
|
2004
|
+
},
|
|
2005
|
+
{
|
|
2006
|
+
"id": 568,
|
|
2007
|
+
"date": "2026-11-02",
|
|
2008
|
+
"kind": "fixed",
|
|
2009
|
+
"area": "Sidebar",
|
|
2010
|
+
"text": "Renaming a session for the first time no longer closes the name box before you can type.",
|
|
2011
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/568"
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
"id": 569,
|
|
2015
|
+
"date": "2026-11-02",
|
|
2016
|
+
"kind": "fixed",
|
|
2017
|
+
"area": "Chat",
|
|
2018
|
+
"text": "Pressing Enter with a selection that ends on a reply's button row no longer quotes it.",
|
|
2019
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/569"
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
"id": 570,
|
|
2023
|
+
"date": "2026-11-02",
|
|
2024
|
+
"kind": "improved",
|
|
2025
|
+
"area": "Settings",
|
|
2026
|
+
"text": "Settings is tidier: clearer switches, shorter lists, one Approvals page, and a General page for your name and alerts.",
|
|
2027
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/570"
|
|
1988
2028
|
}
|
|
1989
2029
|
]
|
|
1990
2030
|
}
|
package/data/approvals/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ApprovalLine } from "@/lib/approvals/describe";
|
|
3
3
|
import { provePresence } from "../presence";
|
|
4
4
|
import { usePollWhenOffline } from "../push/connection";
|
|
5
5
|
|
|
6
6
|
export interface ApprovalsMode {
|
|
7
7
|
on: boolean;
|
|
8
|
-
audit:
|
|
8
|
+
audit: ApprovalLine[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export const approvalsKeys = { all: ["approvals"] as const };
|
package/data/health/index.ts
CHANGED
|
@@ -91,10 +91,13 @@ export function useDeleteNote() {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
import type { HealthMeta } from "@/lib/health/config";
|
|
95
|
+
|
|
94
96
|
export interface ConfigResponse {
|
|
95
97
|
config: Record<string, number>;
|
|
96
98
|
defaults: Record<string, number>;
|
|
97
99
|
labels: Record<string, string>;
|
|
100
|
+
meta: Record<string, HealthMeta>;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export function useHealthConfig() {
|
package/hooks/useAutoSave.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
|
+
import { settingsUi } from "@/stores/settingsUi";
|
|
5
6
|
|
|
6
7
|
export type SaveStatus = "idle" | "saving" | "saved" | "undo";
|
|
7
8
|
|
|
@@ -13,6 +14,9 @@ export interface AutoSave<T> {
|
|
|
13
14
|
// rejected save drops the in-flight value, so this is the saved one again.
|
|
14
15
|
value: T;
|
|
15
16
|
status: SaveStatus;
|
|
17
|
+
// Why the last save was rejected; cleared by the next commit. Shown on the
|
|
18
|
+
// row while Settings is open (a toast would sit over the dialog).
|
|
19
|
+
error: string | null;
|
|
16
20
|
// Commit a new value. `undoable` keeps the previous one for Undo (large or
|
|
17
21
|
// destructive changes). Resolves false when the server rejected it.
|
|
18
22
|
commit: (next: T, opts?: { undoable?: boolean }) => Promise<boolean>;
|
|
@@ -27,7 +31,7 @@ export function useAutoSave<T>({
|
|
|
27
31
|
saved,
|
|
28
32
|
save,
|
|
29
33
|
equals = Object.is,
|
|
30
|
-
notify
|
|
34
|
+
notify,
|
|
31
35
|
}: {
|
|
32
36
|
saved: T;
|
|
33
37
|
save: (next: T) => Promise<unknown>;
|
|
@@ -36,6 +40,7 @@ export function useAutoSave<T>({
|
|
|
36
40
|
}): AutoSave<T> {
|
|
37
41
|
const [pending, setPending] = useState<{ value: T } | null>(null);
|
|
38
42
|
const [status, setStatus] = useState<SaveStatus>("idle");
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
44
|
const previous = useRef<{ value: T } | null>(null);
|
|
40
45
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
41
46
|
const seq = useRef(0);
|
|
@@ -56,6 +61,7 @@ export function useAutoSave<T>({
|
|
|
56
61
|
const mine = ++seq.current;
|
|
57
62
|
clearTimeout(timer.current);
|
|
58
63
|
setPending({ value: next });
|
|
64
|
+
setError(null);
|
|
59
65
|
setStatus("saving");
|
|
60
66
|
try {
|
|
61
67
|
await latest.current.save(next);
|
|
@@ -64,9 +70,10 @@ export function useAutoSave<T>({
|
|
|
64
70
|
setPending(null);
|
|
65
71
|
setStatus("idle");
|
|
66
72
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
73
|
+
const message = err instanceof Error ? err.message : "Couldn't save";
|
|
74
|
+
if (latest.current.notify) latest.current.notify(message);
|
|
75
|
+
else if (settingsUi.open) setError(message);
|
|
76
|
+
else toast.error(message);
|
|
70
77
|
return false;
|
|
71
78
|
}
|
|
72
79
|
if (mine !== seq.current) return true;
|
|
@@ -94,5 +101,11 @@ export function useAutoSave<T>({
|
|
|
94
101
|
return run(back.value, false, latest.current.saved);
|
|
95
102
|
}, [run]);
|
|
96
103
|
|
|
97
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
value: pending ? pending.value : saved,
|
|
106
|
+
status,
|
|
107
|
+
error,
|
|
108
|
+
commit,
|
|
109
|
+
undo,
|
|
110
|
+
};
|
|
98
111
|
}
|
package/hooks/useSettingsUrl.ts
CHANGED
|
@@ -19,8 +19,7 @@ function fromAddress() {
|
|
|
19
19
|
*/
|
|
20
20
|
export function useSettingsUrl() {
|
|
21
21
|
useEffect(() => {
|
|
22
|
-
|
|
23
|
-
const unsubscribe = subscribe(settingsUi, () => {
|
|
22
|
+
const toAddress = () => {
|
|
24
23
|
const href = window.location.href;
|
|
25
24
|
const next = hrefWithSettings(href, {
|
|
26
25
|
open: settingsUi.open,
|
|
@@ -29,7 +28,11 @@ export function useSettingsUrl() {
|
|
|
29
28
|
const url = new URL(href);
|
|
30
29
|
if (next !== url.pathname + url.search + url.hash)
|
|
31
30
|
window.history.replaceState(window.history.state, "", next);
|
|
32
|
-
}
|
|
31
|
+
};
|
|
32
|
+
fromAddress();
|
|
33
|
+
// An old link (?settings=setup) is rewritten to where it landed.
|
|
34
|
+
toAddress();
|
|
35
|
+
const unsubscribe = subscribe(settingsUi, toAddress);
|
|
33
36
|
window.addEventListener("popstate", fromAddress);
|
|
34
37
|
return () => {
|
|
35
38
|
unsubscribe();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// What an auto-approval reads as to a person: a sentence, with the command
|
|
2
|
+
// or other raw text behind a "details" toggle. The audit table keeps the raw
|
|
3
|
+
// subject; this is only how it is shown.
|
|
4
|
+
|
|
5
|
+
import type { AuditRow } from "./no-approvals";
|
|
6
|
+
|
|
7
|
+
export interface ApprovalLine {
|
|
8
|
+
id: number;
|
|
9
|
+
at: string;
|
|
10
|
+
// The source in words ("an ask", "a chat").
|
|
11
|
+
where: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
// Raw text (a shell command, a path) for the details toggle.
|
|
14
|
+
details: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const WHERE: Record<AuditRow["source"], string> = {
|
|
18
|
+
ask: "An ask",
|
|
19
|
+
brake: "A brake",
|
|
20
|
+
plan: "A plan",
|
|
21
|
+
chat: "A chat",
|
|
22
|
+
terminal: "A terminal",
|
|
23
|
+
"merge-approval": "A merge",
|
|
24
|
+
mode: "The mode",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const READ = new Set(["Read", "Glob", "Grep", "LS"]);
|
|
28
|
+
const CHANGE = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
29
|
+
|
|
30
|
+
const SHELL_START = /^(bash|run|shell|execute|command)\b[:\s]/i;
|
|
31
|
+
const SHELL_WORDS =
|
|
32
|
+
/(^|\s)(cd|ssh|scp|git|npm|npx|pnpm|rm|curl|sudo|cat|ls|sed|grep|kill|tmux|docker|kubectl|python3?|node)\s/;
|
|
33
|
+
const SHELL_CHARS = /&&|\|\||[|;`]|\$\(|\s\/[\w.-]+\/|~\//;
|
|
34
|
+
|
|
35
|
+
export const looksLikeCommand = (text: string) =>
|
|
36
|
+
SHELL_START.test(text) || SHELL_WORDS.test(text) || SHELL_CHARS.test(text);
|
|
37
|
+
|
|
38
|
+
export function describeApproval(row: AuditRow): ApprovalLine {
|
|
39
|
+
const base = {
|
|
40
|
+
id: row.id,
|
|
41
|
+
at: row.at,
|
|
42
|
+
where: WHERE[row.source] ?? row.source,
|
|
43
|
+
};
|
|
44
|
+
const subject = row.subject.trim();
|
|
45
|
+
if (row.source === "terminal") {
|
|
46
|
+
const tool = subject.replace(/ in a terminal session$/, "");
|
|
47
|
+
const summary =
|
|
48
|
+
tool === "Bash"
|
|
49
|
+
? "Ran a command"
|
|
50
|
+
: CHANGE.has(tool)
|
|
51
|
+
? "Changed a file"
|
|
52
|
+
: READ.has(tool)
|
|
53
|
+
? "Read files"
|
|
54
|
+
: `Used ${tool}`;
|
|
55
|
+
return { ...base, summary, details: null };
|
|
56
|
+
}
|
|
57
|
+
if (row.source === "chat" && looksLikeCommand(subject))
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
summary: "Ran a command",
|
|
61
|
+
details: subject.replace(SHELL_START, "").trim() || subject,
|
|
62
|
+
};
|
|
63
|
+
return { ...base, summary: subject, details: null };
|
|
64
|
+
}
|
package/lib/away/deploy.ts
CHANGED
|
@@ -33,6 +33,14 @@ export const HEALTH_GRACE_MS = 3 * 60 * 1000;
|
|
|
33
33
|
export const GAP_MARGIN_MS = 10 * 60 * 1000;
|
|
34
34
|
const DEFAULT_GAP_S = 3600;
|
|
35
35
|
|
|
36
|
+
type HealthAnswer = {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
why?: string;
|
|
39
|
+
startedAt?: number;
|
|
40
|
+
// The commit the running server was built from, when it says.
|
|
41
|
+
build?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
36
44
|
export interface DeployDeps {
|
|
37
45
|
// When the deploy last restarted the server, in ms; null when unknown.
|
|
38
46
|
restartedAt: (file: string) => number | null;
|
|
@@ -43,9 +51,7 @@ export interface DeployDeps {
|
|
|
43
51
|
contains: (repo: string, tip: string, sha: string) => Promise<boolean>;
|
|
44
52
|
// startedAt, when the server says when it started (AgentOS's
|
|
45
53
|
// /api/health does), so a deploy that never restarted it is caught.
|
|
46
|
-
health: (
|
|
47
|
-
url: string
|
|
48
|
-
) => Promise<{ ok: boolean; why?: string; startedAt?: number }>;
|
|
54
|
+
health: (url: string) => Promise<HealthAnswer>;
|
|
49
55
|
// When GitHub made the merge commit, in ms (0 when unknown): the time a
|
|
50
56
|
// restart has to come after. Not when AgentOS noticed the merge, which
|
|
51
57
|
// can be after a restart that already took it.
|
|
@@ -78,18 +84,25 @@ async function contains(
|
|
|
78
84
|
return ancestor();
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
async function health(
|
|
82
|
-
url: string
|
|
83
|
-
): Promise<{ ok: boolean; why?: string; startedAt?: number }> {
|
|
87
|
+
async function health(url: string): Promise<HealthAnswer> {
|
|
84
88
|
try {
|
|
85
89
|
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
86
90
|
if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
|
|
87
91
|
const body = (await res.json().catch(() => null)) as {
|
|
88
92
|
startedAt?: unknown;
|
|
93
|
+
build?: unknown;
|
|
89
94
|
} | null;
|
|
90
95
|
const startedAt =
|
|
91
96
|
typeof body?.startedAt === "string" ? Date.parse(body.startedAt) : NaN;
|
|
92
|
-
|
|
97
|
+
const build =
|
|
98
|
+
typeof body?.build === "string" && SHA.test(body.build)
|
|
99
|
+
? body.build
|
|
100
|
+
: undefined;
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
...(Number.isFinite(startedAt) ? { startedAt } : {}),
|
|
104
|
+
...(build ? { build } : {}),
|
|
105
|
+
};
|
|
93
106
|
} catch (error) {
|
|
94
107
|
return {
|
|
95
108
|
ok: false,
|
|
@@ -161,6 +174,20 @@ async function judge(
|
|
|
161
174
|
if (!config.health) return { state: "ok", detail: `${short} deployed` };
|
|
162
175
|
const url = healthUrl(config.health);
|
|
163
176
|
const answer = await deps.health(url);
|
|
177
|
+
// autodeploy's marker can name a build that isn't running (a rolled-back
|
|
178
|
+
// update); what the server says it runs decides.
|
|
179
|
+
if (
|
|
180
|
+
answer.ok &&
|
|
181
|
+
answer.build &&
|
|
182
|
+
!(await deps.contains(input.repo, answer.build, mergeSha))
|
|
183
|
+
) {
|
|
184
|
+
if (input.now - deployed.at < HEALTH_GRACE_MS)
|
|
185
|
+
return { state: "pending" };
|
|
186
|
+
return {
|
|
187
|
+
state: "failed",
|
|
188
|
+
detail: `not deployed: the server is running ${answer.build.slice(0, 7)}, which doesn't have ${short}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
164
191
|
const merged =
|
|
165
192
|
answer.ok && answer.startedAt !== undefined
|
|
166
193
|
? await deps.committedAt(input.repo, mergeSha)
|
package/lib/health/config.ts
CHANGED
|
@@ -30,25 +30,78 @@ export const HEALTH_DEFAULTS = {
|
|
|
30
30
|
export type HealthKey = keyof typeof HEALTH_DEFAULTS;
|
|
31
31
|
export type HealthConfig = Record<HealthKey, number>;
|
|
32
32
|
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
export interface HealthMeta {
|
|
34
|
+
label: string;
|
|
35
|
+
unit?: string;
|
|
36
|
+
min: number;
|
|
37
|
+
max?: number;
|
|
38
|
+
integer?: boolean;
|
|
39
|
+
// A 1/0 value shown as a switch.
|
|
40
|
+
switch?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// How each threshold reads in Settings: the server owns it, so a new
|
|
44
|
+
// threshold needs no client change.
|
|
45
|
+
export const HEALTH_META: Record<HealthKey, HealthMeta> = {
|
|
46
|
+
loadRedMinutes: { label: "Machine load red for", unit: "min", min: 1 },
|
|
47
|
+
queueHeldMinutes: { label: "Queued task held for", unit: "min", min: 1 },
|
|
48
|
+
bgQuietMinutes: { label: "Background job quiet for", unit: "min", min: 1 },
|
|
49
|
+
portWaitMinutes: {
|
|
50
|
+
label: "Waiting on a port someone else holds for",
|
|
51
|
+
unit: "min",
|
|
52
|
+
min: 1,
|
|
53
|
+
},
|
|
54
|
+
pieceDupOverlap: {
|
|
55
|
+
label: "Plan piece matches merged work",
|
|
56
|
+
min: 0,
|
|
57
|
+
max: 1,
|
|
58
|
+
integer: false,
|
|
59
|
+
},
|
|
60
|
+
restartsMax: { label: "Server restarts allowed per window", min: 1 },
|
|
61
|
+
restartsWindowMinutes: { label: "Restart window", unit: "min", min: 1 },
|
|
62
|
+
ciSlowFallbackMinutes: {
|
|
63
|
+
label: "CI pending, when no history yet",
|
|
64
|
+
unit: "min",
|
|
65
|
+
min: 1,
|
|
66
|
+
},
|
|
67
|
+
armedStuckMinutes: {
|
|
68
|
+
label: "PR armed without merging for",
|
|
69
|
+
unit: "min",
|
|
70
|
+
min: 1,
|
|
71
|
+
},
|
|
72
|
+
serverErrorsMin: {
|
|
73
|
+
label: "Server errors per 10 minutes, at least",
|
|
74
|
+
min: 1,
|
|
75
|
+
},
|
|
76
|
+
serverErrorsFactor: {
|
|
77
|
+
label: "Server errors, times the usual rate",
|
|
78
|
+
unit: "×",
|
|
79
|
+
min: 1,
|
|
80
|
+
integer: false,
|
|
81
|
+
},
|
|
82
|
+
envBurstSessions: {
|
|
83
|
+
label: "Environment failure: sessions in the window to alert",
|
|
84
|
+
min: 1,
|
|
85
|
+
},
|
|
86
|
+
envBurstHours: { label: "Environment failure window", unit: "hours", min: 1 },
|
|
87
|
+
envQuietDays: {
|
|
88
|
+
label: "Environment failure back after quiet for",
|
|
89
|
+
unit: "days",
|
|
90
|
+
min: 1,
|
|
91
|
+
},
|
|
92
|
+
wakeUsageCeilingPct: {
|
|
93
|
+
label: "Stop waking watchers above usage",
|
|
94
|
+
unit: "%",
|
|
95
|
+
min: 1,
|
|
96
|
+
max: 100,
|
|
97
|
+
},
|
|
98
|
+
watchersOn: { label: "Watchers", min: 0, max: 1, switch: true },
|
|
50
99
|
};
|
|
51
100
|
|
|
101
|
+
export const HEALTH_LABELS = Object.fromEntries(
|
|
102
|
+
Object.entries(HEALTH_META).map(([k, m]) => [k, m.label])
|
|
103
|
+
) as Record<HealthKey, string>;
|
|
104
|
+
|
|
52
105
|
const settingKey = (k: HealthKey) => `health.${k}`;
|
|
53
106
|
|
|
54
107
|
export function healthConfig(
|