@saadnvd1/agent-os 0.3.845 → 0.3.851
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 +6 -0
- package/app/api/approvals/route.ts +5 -1
- package/app/api/health/config/route.ts +2 -0
- package/app/api/updates/run/route.ts +7 -3
- 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/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 +13 -8
- 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 +27 -27
- package/components/Shares/SharedLinksPanel.tsx +11 -14
- package/components/Updates/AutoUpdates.tsx +12 -8
- package/components/Updates/UpdateActions.tsx +18 -3
- package/components/Updates/UpdateToast.test.ts +17 -0
- package/components/Updates/UpdateToast.tsx +15 -5
- package/components/Updates/UpdatesSection.tsx +27 -18
- package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
- package/components/Workspaces/WorkspaceSettings.tsx +0 -2
- package/components/ui/switch.tsx +1 -1
- package/content/whats-new.json +24 -0
- package/data/approvals/index.ts +2 -2
- package/data/health/index.ts +3 -0
- package/hooks/useAutoSave.ts +18 -5
- package/hooks/useReloadWhenUpdated.ts +9 -0
- package/hooks/useSettingsUrl.ts +6 -3
- package/lib/approvals/describe.ts +64 -0
- package/lib/health/config.ts +70 -17
- package/lib/models/providers.ts +1 -1
- package/lib/recipes/settle.ts +22 -0
- package/lib/settings-url.ts +14 -2
- package/lib/updates/banner.ts +12 -2
- package/lib/updates/request.ts +29 -0
- package/lib/updates/run.ts +24 -5
- package/lib/updates/view.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +25 -3
- package/scripts/autodeploy.test.ts +47 -0
- package/components/Settings/NoApprovalsSection.tsx +0 -103
- package/components/Settings/SettingToggle.tsx +0 -36
- package/components/Settings/SetupSection.tsx +0 -46
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/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(
|
package/lib/models/providers.ts
CHANGED
|
@@ -190,5 +190,5 @@ export function describeSecretRef(ref: SecretRef | undefined): string {
|
|
|
190
190
|
return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
|
|
191
191
|
if (ref.kind === "keychain")
|
|
192
192
|
return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
|
|
193
|
-
return
|
|
193
|
+
return "an environment variable";
|
|
194
194
|
}
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
import { getRecipe, toRun } from "./store";
|
|
25
25
|
import type { Recipe, RecipeRun, RunStatus } from "./types";
|
|
26
26
|
|
|
27
|
+
// A live run with nothing running behind it for this long is given up on.
|
|
28
|
+
export const LOST_RUN_MS = 24 * 60 * 60_000;
|
|
27
29
|
// A session run that stops without a RESULT line settles from the model's
|
|
28
30
|
// reading after it has been idle this long.
|
|
29
31
|
export const IDLE_SETTLE_MS = 30 * 60_000;
|
|
@@ -379,6 +381,26 @@ async function settleOne(
|
|
|
379
381
|
session,
|
|
380
382
|
now
|
|
381
383
|
);
|
|
384
|
+
// Nothing live behind it for a day (a terminal session, an archived one, a
|
|
385
|
+
// worker that's gone): it isn't still running, so it must not say so.
|
|
386
|
+
if (
|
|
387
|
+
Number.isFinite(startedMs) &&
|
|
388
|
+
now - startedMs > LOST_RUN_MS &&
|
|
389
|
+
state !== "running" &&
|
|
390
|
+
state !== "waiting"
|
|
391
|
+
)
|
|
392
|
+
return void finish(
|
|
393
|
+
run,
|
|
394
|
+
{
|
|
395
|
+
status: "failed",
|
|
396
|
+
result: line,
|
|
397
|
+
source: "state",
|
|
398
|
+
error: "Lost track of it",
|
|
399
|
+
summary: "Lost track of it",
|
|
400
|
+
},
|
|
401
|
+
session,
|
|
402
|
+
now
|
|
403
|
+
);
|
|
382
404
|
const idle =
|
|
383
405
|
state === "idle" || session.archived_at != null || state === null;
|
|
384
406
|
if (idle && session.view === "chat") {
|
package/lib/settings-url.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export const SETTINGS_PARAM = "settings";
|
|
6
6
|
|
|
7
7
|
export const SETTINGS_SECTIONS = [
|
|
8
|
+
"general",
|
|
8
9
|
"merging",
|
|
9
10
|
"approvals",
|
|
10
11
|
"workspaces",
|
|
@@ -18,13 +19,19 @@ export const SETTINGS_SECTIONS = [
|
|
|
18
19
|
"schedules",
|
|
19
20
|
"audits",
|
|
20
21
|
"health",
|
|
21
|
-
"setup",
|
|
22
22
|
"updates",
|
|
23
23
|
"experiments",
|
|
24
24
|
] as const;
|
|
25
25
|
|
|
26
26
|
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
|
27
27
|
|
|
28
|
+
// Sections that were folded into another: an old link still lands in the
|
|
29
|
+
// right place.
|
|
30
|
+
export const SECTION_REDIRECTS: Record<string, SettingsSection> = {
|
|
31
|
+
setup: "health",
|
|
32
|
+
"no-approvals": "approvals",
|
|
33
|
+
};
|
|
34
|
+
|
|
28
35
|
export interface SettingsLocation {
|
|
29
36
|
open: boolean;
|
|
30
37
|
// null: the list on a phone, the first section on a wider screen.
|
|
@@ -40,7 +47,12 @@ export const isSettingsSection = (v: unknown): v is SettingsSection =>
|
|
|
40
47
|
export function settingsFromHref(href: string): SettingsLocation {
|
|
41
48
|
const value = new URL(href, BASE).searchParams.get(SETTINGS_PARAM);
|
|
42
49
|
if (value === null) return { open: false, section: null };
|
|
43
|
-
|
|
50
|
+
const section = isSettingsSection(value)
|
|
51
|
+
? value
|
|
52
|
+
: value !== null && Object.hasOwn(SECTION_REDIRECTS, value)
|
|
53
|
+
? SECTION_REDIRECTS[value]
|
|
54
|
+
: null;
|
|
55
|
+
return { open: true, section };
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
/** The same address with Settings set (or removed), as path+query+hash. */
|
package/lib/updates/banner.ts
CHANGED
|
@@ -94,7 +94,7 @@ export type UpdateRefusal =
|
|
|
94
94
|
| "up_to_date"
|
|
95
95
|
| "unknown"
|
|
96
96
|
| "no_restart"
|
|
97
|
-
| "
|
|
97
|
+
| "deploying"
|
|
98
98
|
| "disk"
|
|
99
99
|
| "forbidden";
|
|
100
100
|
|
|
@@ -118,6 +118,12 @@ export interface UpdateView {
|
|
|
118
118
|
canUpdate: { ok: boolean; code?: UpdateRefusal; reason?: string };
|
|
119
119
|
run: UpdateRun | null;
|
|
120
120
|
pending: { requestedAt: number } | null;
|
|
121
|
+
/** autodeploy deploys this checkout (its heartbeat is fresh). */
|
|
122
|
+
managed: boolean;
|
|
123
|
+
/** An Update now click waiting for autodeploy to pick it up. */
|
|
124
|
+
queued: { requestedAt: number } | null;
|
|
125
|
+
/** What the server said about the click that produced this view. */
|
|
126
|
+
message?: string;
|
|
121
127
|
working: { sessions: number; reviews: number; setups: number };
|
|
122
128
|
auto?: AutoUpdateView;
|
|
123
129
|
}
|
|
@@ -126,6 +132,7 @@ export type BannerState =
|
|
|
126
132
|
| "hidden"
|
|
127
133
|
| "available"
|
|
128
134
|
| "pending"
|
|
135
|
+
| "queued"
|
|
129
136
|
| "running"
|
|
130
137
|
| "failed"
|
|
131
138
|
| "built"
|
|
@@ -173,8 +180,8 @@ export function bannerState({
|
|
|
173
180
|
)
|
|
174
181
|
return "updated";
|
|
175
182
|
}
|
|
183
|
+
if (view.queued) return "queued";
|
|
176
184
|
if (view.pending) return "pending";
|
|
177
|
-
if (view.canUpdate.code === "managed") return "hidden";
|
|
178
185
|
if (
|
|
179
186
|
view.available &&
|
|
180
187
|
view.bannerEnabled &&
|
|
@@ -185,5 +192,8 @@ export function bannerState({
|
|
|
185
192
|
return "hidden";
|
|
186
193
|
}
|
|
187
194
|
|
|
195
|
+
export const MANAGED_NOTE =
|
|
196
|
+
"Updates on this machine are deployed by autodeploy. Update now deploys it within a minute.";
|
|
197
|
+
|
|
188
198
|
export const workingTotal = (w: UpdateView["working"]) =>
|
|
189
199
|
w.sessions + w.reviews + w.setups;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The "deploy now" request a click leaves for scripts/autodeploy, which owns
|
|
2
|
+
// deploys on its machine: $AGENT_OS_HOME/autodeploy/now. The script skips the
|
|
3
|
+
// restart gap and the settle wait for it and removes it once the deploy ran.
|
|
4
|
+
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
/** autodeploy ignores a request older than this (its find -mmin +30). */
|
|
9
|
+
export const REQUEST_FRESH_MS = 30 * 60_000;
|
|
10
|
+
|
|
11
|
+
export function readDeployRequest(
|
|
12
|
+
file: string,
|
|
13
|
+
now: number
|
|
14
|
+
): { requestedAt: number } | null {
|
|
15
|
+
try {
|
|
16
|
+
const at = Number(fs.readFileSync(file, "utf8").trim());
|
|
17
|
+
if (Number.isFinite(at) && now - at < REQUEST_FRESH_MS)
|
|
18
|
+
return { requestedAt: at };
|
|
19
|
+
} catch {}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Written to a temp name and renamed, so the script never sees half a file. */
|
|
24
|
+
export function writeDeployRequest(file: string, now: number): void {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmp, `${now}\n`);
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
}
|
package/lib/updates/run.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { git } from "@/lib/git-exec";
|
|
|
15
15
|
import { buildId } from "@/lib/build";
|
|
16
16
|
import { agentosHome } from "@/lib/agentos-home";
|
|
17
17
|
import { dbPath } from "@/lib/db/path";
|
|
18
|
+
import { readDeployRequest, writeDeployRequest } from "./request";
|
|
18
19
|
import {
|
|
19
20
|
isActivePhase,
|
|
20
21
|
workingTotal,
|
|
@@ -87,6 +88,8 @@ export interface RunDeps {
|
|
|
87
88
|
managed: () => boolean;
|
|
88
89
|
/** True while scripts/autodeploy is deploying (its lock directory exists). */
|
|
89
90
|
deployLocked: () => boolean;
|
|
91
|
+
/** The file an Update now click leaves for scripts/autodeploy. */
|
|
92
|
+
requestFile: string;
|
|
90
93
|
/** What a restart would interrupt, as short labels; empty when nothing. */
|
|
91
94
|
busy: () => Promise<string[]>;
|
|
92
95
|
/** The database the runner backs up first, and where to. */
|
|
@@ -217,6 +220,7 @@ export function realRunDeps(): RunDeps {
|
|
|
217
220
|
}
|
|
218
221
|
},
|
|
219
222
|
deployLocked: () => fs.existsSync(agentosHome("autodeploy", "lock")),
|
|
223
|
+
requestFile: agentosHome("autodeploy", "now"),
|
|
220
224
|
busy: busyLabels,
|
|
221
225
|
dbFile: path.resolve(dbPath()),
|
|
222
226
|
backupDir: agentosHome("backups"),
|
|
@@ -358,12 +362,13 @@ export async function preflight(
|
|
|
358
362
|
error:
|
|
359
363
|
"AgentOS isn't running from a git checkout, so it can't update itself.",
|
|
360
364
|
};
|
|
361
|
-
if (deps.
|
|
365
|
+
if (deps.deployLocked())
|
|
362
366
|
return {
|
|
363
|
-
code: "
|
|
364
|
-
error:
|
|
365
|
-
"This machine is updated by its own deploy job, so updating from here would race it. It deploys new versions by itself.",
|
|
367
|
+
code: "deploying",
|
|
368
|
+
error: "A deploy is running on this machine now. Try again when it ends.",
|
|
366
369
|
};
|
|
370
|
+
// autodeploy owns restarts, disk and the restart command on a machine it manages.
|
|
371
|
+
const managed = deps.managed();
|
|
367
372
|
const run = readRun(deps);
|
|
368
373
|
if (run && isActivePhase(run.phase))
|
|
369
374
|
return {
|
|
@@ -404,6 +409,7 @@ export async function preflight(
|
|
|
404
409
|
};
|
|
405
410
|
if (!check.available)
|
|
406
411
|
return { code: "up_to_date", error: "AgentOS is up to date." };
|
|
412
|
+
if (managed) return null;
|
|
407
413
|
if (!(await deps.restartCommand()))
|
|
408
414
|
return { code: "no_restart", error: NO_RESTART };
|
|
409
415
|
const free = deps.freeBytes();
|
|
@@ -418,8 +424,12 @@ export async function preflight(
|
|
|
418
424
|
|
|
419
425
|
// ── starting ─────────────────────────────────────────────────────────────
|
|
420
426
|
|
|
427
|
+
export const QUEUED_MESSAGE =
|
|
428
|
+
"Queued for autodeploy. It will deploy within about a minute.";
|
|
429
|
+
|
|
421
430
|
export type StartResult =
|
|
422
|
-
{ ok: true; pending: boolean
|
|
431
|
+
| { ok: true; pending: boolean; queued?: boolean; message?: string }
|
|
432
|
+
| ({ ok: false } & Refusal);
|
|
423
433
|
|
|
424
434
|
/** Takes the right to start, atomically, so two requests can't both run. */
|
|
425
435
|
function claimStart(deps: Pick<RunDeps, "stateFile" | "now">): boolean {
|
|
@@ -484,6 +494,12 @@ async function begin(
|
|
|
484
494
|
await discardGenerated(deps);
|
|
485
495
|
const refused = await preflight(result, deps);
|
|
486
496
|
if (refused) return { ok: false, ...refused };
|
|
497
|
+
// autodeploy deploys this checkout: starting a second updater would race
|
|
498
|
+
// it, so the click goes to it as a request instead.
|
|
499
|
+
if (deps.managed()) {
|
|
500
|
+
writeDeployRequest(deps.requestFile, deps.now());
|
|
501
|
+
return { ok: true, pending: false, queued: true, message: QUEUED_MESSAGE };
|
|
502
|
+
}
|
|
487
503
|
if (when === "idle") {
|
|
488
504
|
deps.putSetting(
|
|
489
505
|
PENDING_KEY,
|
|
@@ -553,6 +569,9 @@ export function readPending(
|
|
|
553
569
|
return null;
|
|
554
570
|
}
|
|
555
571
|
|
|
572
|
+
export const readQueued = (deps: Pick<RunDeps, "requestFile" | "now">) =>
|
|
573
|
+
readDeployRequest(deps.requestFile, deps.now());
|
|
574
|
+
|
|
556
575
|
export const cancelPending = (
|
|
557
576
|
deps: Pick<RunDeps, "putSetting"> = realRunDeps()
|
|
558
577
|
) => deps.putSetting(PENDING_KEY, null);
|
package/lib/updates/view.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
preflight,
|
|
9
9
|
readLog,
|
|
10
10
|
readPending,
|
|
11
|
+
readQueued,
|
|
11
12
|
readRun,
|
|
12
13
|
realRunDeps,
|
|
13
14
|
recordFinishedRun,
|
|
@@ -102,6 +103,8 @@ export async function updateView(
|
|
|
102
103
|
: { ok: true },
|
|
103
104
|
run,
|
|
104
105
|
pending,
|
|
106
|
+
managed: deps.managed(),
|
|
107
|
+
queued: readQueued(deps),
|
|
105
108
|
auto: await autoView(deps, check, refusal).then((auto) =>
|
|
106
109
|
mayUpdate
|
|
107
110
|
? auto
|
package/package.json
CHANGED
package/scripts/autodeploy
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
# Restarts are spaced AGENTOS_MIN_GAP seconds apart (an hour; 0 turns it
|
|
10
10
|
# off): a head that lands inside the gap waits and is deployed, whatever it
|
|
11
11
|
# has become by then, when the gap ends. A commit message with [deploy-now]
|
|
12
|
-
# (in any commit the deploy would take) skips the gap
|
|
12
|
+
# (in any commit the deploy would take) skips the gap, and so does the request
|
|
13
|
+
# file $state/now, which the in-app Update now button writes
|
|
14
|
+
# (lib/updates/run.ts requestDeploy): it also skips the settle wait, is removed
|
|
15
|
+
# once the deploy ran or origin/main is already deployed, and is ignored when
|
|
16
|
+
# older than 30 minutes. Each hold logs one line:
|
|
13
17
|
# HELD <sha> <seconds> left in the restart gap (since <last restart sha>)
|
|
14
18
|
# Another machine (a server) runs it with AGENTOS_PUBLISH=0: deploy
|
|
15
19
|
# only, and its own AGENTOS_RESTART. Only one machine publishes to npm.
|
|
@@ -54,12 +58,26 @@ done
|
|
|
54
58
|
mkdir "$state/lock" 2>/dev/null || exit 0
|
|
55
59
|
trap 'rmdir "$state/lock"' EXIT
|
|
56
60
|
|
|
61
|
+
# An "Update now" click (lib/updates/run.ts requestDeploy) leaves this file.
|
|
62
|
+
now_file="$state/now"
|
|
63
|
+
want_now=0
|
|
64
|
+
if [ -f "$now_file" ]; then
|
|
65
|
+
if [ -n "$(find "$now_file" -maxdepth 0 -mmin +30)" ]; then
|
|
66
|
+
rm -f "$now_file"
|
|
67
|
+
else
|
|
68
|
+
want_now=1
|
|
69
|
+
fi
|
|
70
|
+
fi
|
|
71
|
+
|
|
57
72
|
git fetch -q origin main
|
|
58
73
|
head=$(git rev-parse origin/main)
|
|
59
74
|
deployed=$(cat "$state/deployed" 2>/dev/null || true)
|
|
60
75
|
published=$(cat "$state/published" 2>/dev/null || true)
|
|
61
76
|
[ "${AGENTOS_PUBLISH:-1}" = 0 ] && published=$head
|
|
62
|
-
[ "$head" = "$deployed" ]
|
|
77
|
+
if [ "$head" = "$deployed" ]; then
|
|
78
|
+
rm -f "$now_file"
|
|
79
|
+
[ "$head" = "$published" ] && exit 0
|
|
80
|
+
fi
|
|
63
81
|
log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
|
|
64
82
|
|
|
65
83
|
# shellcheck source=lib/generated.sh
|
|
@@ -75,6 +93,7 @@ if [ "$head" != "$deployed" ] && [ -n "$deployed" ] &&
|
|
|
75
93
|
git merge -q --ff-only "$head" 2>/dev/null; then
|
|
76
94
|
deployed=$head
|
|
77
95
|
git rev-parse HEAD > "$state/deployed"
|
|
96
|
+
rm -f "$now_file"
|
|
78
97
|
[ "$head" = "$published" ] && exit 0
|
|
79
98
|
fi
|
|
80
99
|
|
|
@@ -82,7 +101,7 @@ fi
|
|
|
82
101
|
# minutes) waits for the next run, so a burst is one build and one restart.
|
|
83
102
|
# Its commit time is the merge's, so this never holds a deploy forever.
|
|
84
103
|
settle="${AGENTOS_SETTLE:-120}"
|
|
85
|
-
if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] &&
|
|
104
|
+
if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] && [ "$want_now" = 0 ] &&
|
|
86
105
|
[ $(( $(date +%s) - $(git log -1 --format=%ct "$head") )) -lt "$settle" ]; then
|
|
87
106
|
exit 0
|
|
88
107
|
fi
|
|
@@ -98,6 +117,8 @@ if [ "$head" != "$deployed" ] && [ "$gap" -gt 0 ]; then
|
|
|
98
117
|
msgs=$(git log --format=%B "${deployed:+$deployed..}$head" 2>/dev/null || git log -1 --format=%B "$head")
|
|
99
118
|
if printf '%s' "$msgs" | grep -qF '[deploy-now]'; then
|
|
100
119
|
log "Restart gap skipped for ${head:0:7}: [deploy-now]"
|
|
120
|
+
elif [ "$want_now" = 1 ]; then
|
|
121
|
+
log "Restart gap skipped for ${head:0:7}: update requested"
|
|
101
122
|
else
|
|
102
123
|
# Once per head, not once per minute, so the log counts holds.
|
|
103
124
|
if [ "$(cat "$state/held" 2>/dev/null)" != "$head" ]; then
|
|
@@ -136,6 +157,7 @@ if [ "$head" != "$deployed" ]; then
|
|
|
136
157
|
# The pull takes what landed while it waited, too.
|
|
137
158
|
git rev-parse HEAD > "$state/deployed"
|
|
138
159
|
date +%s > "$state/restarted"
|
|
160
|
+
rm -f "$now_file"
|
|
139
161
|
# A link into an older clone would run an old aos on this machine.
|
|
140
162
|
scripts/sync-cli-links || log "CLI link sync failed"
|
|
141
163
|
fi
|
|
@@ -143,6 +143,53 @@ describe("autodeploy restart gap", () => {
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
describe("autodeploy update request", () => {
|
|
147
|
+
const request = (t: ReturnType<typeof setup>, ageMs = 0) => {
|
|
148
|
+
fs.mkdirSync(t.state, { recursive: true });
|
|
149
|
+
const file = path.join(t.state, "now");
|
|
150
|
+
fs.writeFileSync(file, `${Date.now()}\n`);
|
|
151
|
+
const at = new Date(Date.now() - ageMs);
|
|
152
|
+
fs.utimesSync(file, at, at);
|
|
153
|
+
return file;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
it("skips the gap and the settle wait, then removes the request", () => {
|
|
157
|
+
const t = setup();
|
|
158
|
+
t.commit("a");
|
|
159
|
+
t.run();
|
|
160
|
+
const b = t.commit("b");
|
|
161
|
+
const file = request(t);
|
|
162
|
+
const out = t.run({ AGENTOS_SETTLE: "3600" });
|
|
163
|
+
expect(out).toMatch(
|
|
164
|
+
/Restart gap skipped for [0-9a-f]{7}: update requested/
|
|
165
|
+
);
|
|
166
|
+
expect(t.deployed()).toBe(b);
|
|
167
|
+
expect(t.restarts()).toBe(2);
|
|
168
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is removed straight away when origin/main is already deployed", () => {
|
|
172
|
+
const t = setup();
|
|
173
|
+
t.commit("a");
|
|
174
|
+
t.run();
|
|
175
|
+
const file = request(t);
|
|
176
|
+
expect(t.run()).toBe("");
|
|
177
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
178
|
+
expect(t.restarts()).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("ignores, and removes, a request older than 30 minutes", () => {
|
|
182
|
+
const t = setup();
|
|
183
|
+
t.commit("a");
|
|
184
|
+
t.run();
|
|
185
|
+
const b = t.commit("b");
|
|
186
|
+
const file = request(t, 31 * 60_000);
|
|
187
|
+
expect(t.run()).toMatch(/HELD/);
|
|
188
|
+
expect(t.deployed()).not.toBe(b);
|
|
189
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
146
193
|
describe("autodeploy changelog commits", () => {
|
|
147
194
|
const changelog = (t: ReturnType<typeof setup>, also?: string) => {
|
|
148
195
|
const work = path.join(t.root, "work");
|