@saadnvd1/agent-os 0.3.792 → 0.3.799
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/busy/route.ts +2 -2
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/components/Chat/ChatPanel.tsx +28 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/SidebarFooter.tsx +2 -0
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/content/tips.json +456 -0
- package/content/whats-new.json +32 -0
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/lib/busy.ts +23 -1
- package/lib/chat/drivers/copilot-approvals.ts +2 -0
- package/lib/chat/drivers/copilot-ask.ts +232 -0
- package/lib/chat/drivers/copilot-mapper.ts +9 -0
- package/lib/chat/drivers/copilot.ts +18 -4
- package/lib/db/migrations.ts +21 -0
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/tips.ts +14 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const CONDITIONS = [
|
|
4
|
+
"no-paired-device",
|
|
5
|
+
"no-push-device",
|
|
6
|
+
"connect-not-linked",
|
|
7
|
+
"connect-linked",
|
|
8
|
+
"no-schedules",
|
|
9
|
+
"no-recipes",
|
|
10
|
+
"no-shares",
|
|
11
|
+
"no-groups",
|
|
12
|
+
"no-bookmarks",
|
|
13
|
+
] as const;
|
|
14
|
+
export type Condition = (typeof CONDITIONS)[number];
|
|
15
|
+
|
|
16
|
+
const media = z.discriminatedUnion("kind", [
|
|
17
|
+
z.object({
|
|
18
|
+
kind: z.literal("image"),
|
|
19
|
+
src: z.string().startsWith("/tips/"),
|
|
20
|
+
srcDark: z.string().startsWith("/tips/").optional(),
|
|
21
|
+
alt: z.string().min(1).max(200),
|
|
22
|
+
width: z.number().int().positive(),
|
|
23
|
+
height: z.number().int().positive(),
|
|
24
|
+
}),
|
|
25
|
+
z.object({
|
|
26
|
+
kind: z.literal("video"),
|
|
27
|
+
src: z.string().startsWith("/tips/"),
|
|
28
|
+
poster: z.string().startsWith("/tips/"),
|
|
29
|
+
alt: z.string().min(1).max(200),
|
|
30
|
+
width: z.number().int().positive(),
|
|
31
|
+
height: z.number().int().positive(),
|
|
32
|
+
durationMs: z.number().int().positive(),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
export type Media = z.infer<typeof media>;
|
|
36
|
+
|
|
37
|
+
const mediaBrief = z.object({
|
|
38
|
+
kind: z.enum(["screenshot", "crop", "clip"]),
|
|
39
|
+
shot: z.string().min(1).max(300),
|
|
40
|
+
theme: z.enum(["light", "dark", "both"]).optional(),
|
|
41
|
+
});
|
|
42
|
+
export type MediaBrief = z.infer<typeof mediaBrief>;
|
|
43
|
+
|
|
44
|
+
export const tipSchema = z.object({
|
|
45
|
+
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
|
|
46
|
+
title: z.string().min(1).max(60),
|
|
47
|
+
body: z.string().min(1).max(240),
|
|
48
|
+
guide: z
|
|
49
|
+
.object({
|
|
50
|
+
file: z.string().regex(/^[a-z0-9-]+$/),
|
|
51
|
+
anchor: z.string().optional(),
|
|
52
|
+
})
|
|
53
|
+
.optional(),
|
|
54
|
+
when: z.array(z.enum(CONDITIONS)).optional(),
|
|
55
|
+
added: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
56
|
+
hidden: z.boolean().optional(),
|
|
57
|
+
media: media.optional(),
|
|
58
|
+
mediaBrief: mediaBrief.optional(),
|
|
59
|
+
});
|
|
60
|
+
export type Tip = z.infer<typeof tipSchema>;
|
|
61
|
+
|
|
62
|
+
export const fileSchema = z.object({
|
|
63
|
+
v: z.literal(1),
|
|
64
|
+
tips: z.array(tipSchema),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** What a client gets: the guide resolved to a link, the brief left out. */
|
|
68
|
+
export interface ClientTip {
|
|
69
|
+
id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
body: string;
|
|
72
|
+
url?: string;
|
|
73
|
+
media?: Media;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TipsView {
|
|
77
|
+
off: boolean;
|
|
78
|
+
/** Setup isn't finished: the button works but shows no dot. */
|
|
79
|
+
quiet: boolean;
|
|
80
|
+
today: { id: string; read: boolean } | null;
|
|
81
|
+
deck: ClientTip[];
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getSetting, putSetting } from "@/lib/settings-store";
|
|
2
|
+
import type { Pinned } from "./pick";
|
|
3
|
+
|
|
4
|
+
const OFF = "tips.off";
|
|
5
|
+
const SEEN = "tips.seen";
|
|
6
|
+
const TODAY = "tips.today";
|
|
7
|
+
|
|
8
|
+
function json<T>(key: string, fallback: T): T {
|
|
9
|
+
try {
|
|
10
|
+
const raw = getSetting(key);
|
|
11
|
+
return raw ? (JSON.parse(raw) as T) : fallback;
|
|
12
|
+
} catch {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const tipsOff = (): boolean => getSetting(OFF) === "1";
|
|
18
|
+
export const setTipsOff = (off: boolean) => putSetting(OFF, off ? "1" : null);
|
|
19
|
+
|
|
20
|
+
/** Tip id -> the day it was last shown. */
|
|
21
|
+
export const readSeen = () => json<Record<string, string>>(SEEN, {});
|
|
22
|
+
export const writeSeen = (seen: Record<string, string>) =>
|
|
23
|
+
putSetting(SEEN, JSON.stringify(seen));
|
|
24
|
+
|
|
25
|
+
export const readPinned = () => json<Pinned | null>(TODAY, null);
|
|
26
|
+
export const writePinned = (p: Pinned) => putSetting(TODAY, JSON.stringify(p));
|
package/lib/tips/view.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readCompletedAt } from "@/lib/welcome";
|
|
2
|
+
import { tipApplies } from "./conditions";
|
|
3
|
+
import { loadTips } from "./load";
|
|
4
|
+
import { orderDeck, pickToday } from "./pick";
|
|
5
|
+
import type { ClientTip, Tip, TipsView } from "./schema";
|
|
6
|
+
import {
|
|
7
|
+
readPinned,
|
|
8
|
+
readSeen,
|
|
9
|
+
setTipsOff,
|
|
10
|
+
tipsOff,
|
|
11
|
+
writePinned,
|
|
12
|
+
writeSeen,
|
|
13
|
+
} from "./state";
|
|
14
|
+
|
|
15
|
+
const GUIDES = "https://github.com/saadnvd1/agent-os/blob/main/docs/guides";
|
|
16
|
+
|
|
17
|
+
export const guideUrl = (g: NonNullable<Tip["guide"]>) =>
|
|
18
|
+
`${GUIDES}/${g.file}.md${g.anchor ? `#${g.anchor}` : ""}`;
|
|
19
|
+
|
|
20
|
+
const forClient = ({ id, title, body, guide, media }: Tip): ClientTip => ({
|
|
21
|
+
id,
|
|
22
|
+
title,
|
|
23
|
+
body,
|
|
24
|
+
...(guide && { url: guideUrl(guide) }),
|
|
25
|
+
...(media && { media }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const todayLocal = () => new Date().toLocaleDateString("sv");
|
|
29
|
+
|
|
30
|
+
/** The tips that apply now, and which one is today's. */
|
|
31
|
+
export function tipsView(day: string = todayLocal()): TipsView {
|
|
32
|
+
const quiet = readCompletedAt() === null;
|
|
33
|
+
if (tipsOff()) return { off: true, quiet, today: null, deck: [] };
|
|
34
|
+
const deck = orderDeck(loadTips().filter((t) => tipApplies(t)));
|
|
35
|
+
const pinned = readPinned();
|
|
36
|
+
const today = pickToday({ deck, seen: readSeen(), pinned, day });
|
|
37
|
+
if (today && (today.id !== pinned?.id || today.day !== pinned?.day))
|
|
38
|
+
writePinned(today);
|
|
39
|
+
return {
|
|
40
|
+
off: false,
|
|
41
|
+
quiet,
|
|
42
|
+
today: today && { id: today.id, read: today.read },
|
|
43
|
+
deck: deck.map(forClient),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Records that these tips were shown; today's one becomes read. */
|
|
48
|
+
export function markSeen(ids: string[], day: string = todayLocal()): void {
|
|
49
|
+
const known = new Set(loadTips().map((t) => t.id));
|
|
50
|
+
const seen = Object.fromEntries(
|
|
51
|
+
Object.entries(readSeen()).filter(([id]) => known.has(id))
|
|
52
|
+
);
|
|
53
|
+
for (const id of ids) if (known.has(id)) seen[id] = day;
|
|
54
|
+
writeSeen(seen);
|
|
55
|
+
const pinned = readPinned();
|
|
56
|
+
if (pinned && pinned.day === day && ids.includes(pinned.id) && !pinned.read)
|
|
57
|
+
writePinned({ ...pinned, read: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { setTipsOff };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// When an automatic update starts, as one pure decision so it can be tested
|
|
2
|
+
// as a table. lib/updates/run.ts gathers the input and acts on the answer.
|
|
3
|
+
//
|
|
4
|
+
// 1. Switched off, pinned off, deployed by autodeploy, or refused (local
|
|
5
|
+
// changes, diverged, dev server, no restart command...) → skip, say why.
|
|
6
|
+
// 2. The target is the newest commit on the remote's first-parent line
|
|
7
|
+
// that has been there `soakMs`, so a bad push can be reverted before an
|
|
8
|
+
// install takes it, and a busy day of merges can't hold it off. A
|
|
9
|
+
// commit that was rolled back, or failed to build recently, is passed
|
|
10
|
+
// over for an older one still newer than what's installed.
|
|
11
|
+
// 3. Nothing a restart would interrupt is in flight (agents working, chat
|
|
12
|
+
// turns, reviews, task setups, simulator starts, merges, stack
|
|
13
|
+
// landings, an update or deploy), and has not been for `idleMs`.
|
|
14
|
+
// 4. Once a release has waited `deadlineMs` past its soak, a lull of
|
|
15
|
+
// `lullMs` is enough. It never interrupts work; it keeps waiting.
|
|
16
|
+
|
|
17
|
+
import type { UpdateAttempt } from "./banner";
|
|
18
|
+
import { blockFor } from "./history";
|
|
19
|
+
|
|
20
|
+
export interface Candidate {
|
|
21
|
+
sha: string;
|
|
22
|
+
/** Commit time, ms. */
|
|
23
|
+
at: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AutoInput {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
locked: boolean;
|
|
29
|
+
managed: boolean;
|
|
30
|
+
refusal: string | null;
|
|
31
|
+
/** First-parent commits newer than the installed one, newest first. */
|
|
32
|
+
candidates: Candidate[];
|
|
33
|
+
attempts: UpdateAttempt[];
|
|
34
|
+
/** What is working now; empty when nothing is. */
|
|
35
|
+
busy: string[];
|
|
36
|
+
/** Since when nothing has been working, as far as anyone watched. */
|
|
37
|
+
quietSince: number | null;
|
|
38
|
+
now: number;
|
|
39
|
+
soakMs: number;
|
|
40
|
+
idleMs: number;
|
|
41
|
+
deadlineMs: number;
|
|
42
|
+
lullMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AutoDecision =
|
|
46
|
+
| { action: "start"; to: string }
|
|
47
|
+
| { action: "wait"; code: AutoWait; text: string; to?: string }
|
|
48
|
+
| { action: "skip"; code: AutoSkip; text: string };
|
|
49
|
+
|
|
50
|
+
export type AutoSkip = "off" | "locked" | "managed" | "refused" | "up_to_date";
|
|
51
|
+
export type AutoWait = "soaking" | "blocked" | "busy" | "quiet";
|
|
52
|
+
|
|
53
|
+
export const LULL_MS = 2 * 60_000;
|
|
54
|
+
|
|
55
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
56
|
+
const mins = (ms: number) => Math.max(1, Math.ceil(ms / 60_000));
|
|
57
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
58
|
+
|
|
59
|
+
/** The commit to update to, or why there's none yet. */
|
|
60
|
+
export function pickTarget(
|
|
61
|
+
input: Pick<AutoInput, "candidates" | "attempts" | "now" | "soakMs">
|
|
62
|
+
):
|
|
63
|
+
| { to: string; eligibleSince: number }
|
|
64
|
+
| {
|
|
65
|
+
to?: undefined;
|
|
66
|
+
code: "soaking" | "blocked";
|
|
67
|
+
text: string;
|
|
68
|
+
until?: number;
|
|
69
|
+
} {
|
|
70
|
+
const { candidates, attempts, now, soakMs } = input;
|
|
71
|
+
const soaked = candidates.filter((c) => c.at + soakMs <= now);
|
|
72
|
+
if (!soaked.length) {
|
|
73
|
+
const next = Math.min(...candidates.map((c) => c.at + soakMs));
|
|
74
|
+
return {
|
|
75
|
+
code: "soaking",
|
|
76
|
+
text: `A new version came out recently; it updates once it has been out ${plural(mins(soakMs), "minute")}.`,
|
|
77
|
+
until: next,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
let first: string | null = null;
|
|
81
|
+
for (const c of soaked) {
|
|
82
|
+
const block = blockFor(c.sha, attempts, now);
|
|
83
|
+
if (!block)
|
|
84
|
+
return {
|
|
85
|
+
to: c.sha,
|
|
86
|
+
// The oldest waiting release says how long updates have been put off.
|
|
87
|
+
eligibleSince: soaked[soaked.length - 1].at + soakMs,
|
|
88
|
+
};
|
|
89
|
+
first ??= `${short(c.sha)}: ${block.reason}`;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
code: "blocked",
|
|
93
|
+
text: `Not updating to ${first}. Waiting for a newer version.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function decideAutoUpdate(input: AutoInput): AutoDecision {
|
|
98
|
+
if (input.locked)
|
|
99
|
+
return {
|
|
100
|
+
action: "skip",
|
|
101
|
+
code: "locked",
|
|
102
|
+
text: "Automatic updates are turned off by AGENTOS_AUTO_UPDATE.",
|
|
103
|
+
};
|
|
104
|
+
if (!input.enabled)
|
|
105
|
+
return { action: "skip", code: "off", text: "Automatic updates are off." };
|
|
106
|
+
if (input.managed)
|
|
107
|
+
return {
|
|
108
|
+
action: "skip",
|
|
109
|
+
code: "managed",
|
|
110
|
+
text: "Updates on this machine are deployed by autodeploy.",
|
|
111
|
+
};
|
|
112
|
+
if (input.refusal)
|
|
113
|
+
return {
|
|
114
|
+
action: "skip",
|
|
115
|
+
code: "refused",
|
|
116
|
+
text: `Automatic updates are paused. ${input.refusal}`,
|
|
117
|
+
};
|
|
118
|
+
if (!input.candidates.length)
|
|
119
|
+
return {
|
|
120
|
+
action: "skip",
|
|
121
|
+
code: "up_to_date",
|
|
122
|
+
text: "AgentOS is up to date.",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const target = pickTarget(input);
|
|
126
|
+
if (!("eligibleSince" in target))
|
|
127
|
+
return { action: "wait", code: target.code, text: target.text };
|
|
128
|
+
|
|
129
|
+
if (input.busy.length)
|
|
130
|
+
return {
|
|
131
|
+
action: "wait",
|
|
132
|
+
code: "busy",
|
|
133
|
+
to: target.to,
|
|
134
|
+
text: `Updates when nothing is working (now: ${input.busy.join(", ")}).`,
|
|
135
|
+
};
|
|
136
|
+
const late = input.now - target.eligibleSince >= input.deadlineMs;
|
|
137
|
+
const need = late ? (input.lullMs ?? LULL_MS) : input.idleMs;
|
|
138
|
+
const quietFor = input.quietSince === null ? 0 : input.now - input.quietSince;
|
|
139
|
+
if (quietFor < need)
|
|
140
|
+
return {
|
|
141
|
+
action: "wait",
|
|
142
|
+
code: "quiet",
|
|
143
|
+
to: target.to,
|
|
144
|
+
text: late
|
|
145
|
+
? "Has waited a long time for a quiet moment; updates at the next short lull."
|
|
146
|
+
: `Updates after everything has been idle for ${plural(mins(input.idleMs), "minute")} (${plural(mins(need - quietFor), "minute")} to go).`,
|
|
147
|
+
};
|
|
148
|
+
return { action: "start", to: target.to };
|
|
149
|
+
}
|
package/lib/updates/banner.ts
CHANGED
|
@@ -3,22 +3,33 @@
|
|
|
3
3
|
|
|
4
4
|
export type UpdatePhase =
|
|
5
5
|
| "starting"
|
|
6
|
+
| "backing_up"
|
|
6
7
|
| "pulling"
|
|
7
8
|
| "installing"
|
|
8
9
|
| "building"
|
|
9
10
|
| "restarting"
|
|
11
|
+
| "verifying"
|
|
12
|
+
| "rolling_back"
|
|
10
13
|
| "done"
|
|
11
14
|
| "built"
|
|
15
|
+
| "rolled_back"
|
|
12
16
|
| "failed";
|
|
13
17
|
|
|
14
18
|
export const ACTIVE_PHASES: readonly UpdatePhase[] = [
|
|
15
19
|
"starting",
|
|
20
|
+
"backing_up",
|
|
16
21
|
"pulling",
|
|
17
22
|
"installing",
|
|
18
23
|
"building",
|
|
19
24
|
"restarting",
|
|
25
|
+
"verifying",
|
|
26
|
+
"rolling_back",
|
|
20
27
|
];
|
|
21
28
|
|
|
29
|
+
/** How a finished run ended; written by scripts/update-run.mjs. */
|
|
30
|
+
export type UpdateOutcome =
|
|
31
|
+
"done" | "build_failed" | "rolled_back" | "broken" | "interrupted";
|
|
32
|
+
|
|
22
33
|
export const isActivePhase = (p: UpdatePhase | undefined | null) =>
|
|
23
34
|
!!p && ACTIVE_PHASES.includes(p);
|
|
24
35
|
|
|
@@ -32,6 +43,44 @@ export interface UpdateRun {
|
|
|
32
43
|
finishedAt?: number;
|
|
33
44
|
error?: string;
|
|
34
45
|
log?: string[];
|
|
46
|
+
/** Started by automatic updates, not by someone. */
|
|
47
|
+
auto?: boolean;
|
|
48
|
+
/** The commit it updates to. */
|
|
49
|
+
to?: string;
|
|
50
|
+
outcome?: UpdateOutcome;
|
|
51
|
+
/** How far redeploy got: pulled, deps_swapped, build_swapped. */
|
|
52
|
+
stage?: string;
|
|
53
|
+
/** The runner's heartbeat and pid, so a cut-off run is told from a slow one. */
|
|
54
|
+
updatedAt?: number;
|
|
55
|
+
pid?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One finished update, kept in the history Settings shows. */
|
|
59
|
+
export interface UpdateAttempt {
|
|
60
|
+
id: string;
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
fromVersion: string;
|
|
64
|
+
toVersion: string;
|
|
65
|
+
auto: boolean;
|
|
66
|
+
startedAt: number;
|
|
67
|
+
finishedAt: number;
|
|
68
|
+
outcome: UpdateOutcome;
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The idle times Settings offers for automatic updates. */
|
|
73
|
+
export const IDLE_MINUTES = [5, 10, 15, 30, 60] as const;
|
|
74
|
+
|
|
75
|
+
/** What automatic updates are doing, for Settings. */
|
|
76
|
+
export interface AutoUpdateView {
|
|
77
|
+
enabled: boolean;
|
|
78
|
+
locked: boolean;
|
|
79
|
+
managed: boolean;
|
|
80
|
+
idleMinutes: number;
|
|
81
|
+
idleLocked: boolean;
|
|
82
|
+
status: { code: string; text: string } | null;
|
|
83
|
+
attempts: UpdateAttempt[];
|
|
35
84
|
}
|
|
36
85
|
|
|
37
86
|
export type UpdateRefusal =
|
|
@@ -45,6 +94,7 @@ export type UpdateRefusal =
|
|
|
45
94
|
| "up_to_date"
|
|
46
95
|
| "unknown"
|
|
47
96
|
| "no_restart"
|
|
97
|
+
| "disk"
|
|
48
98
|
| "forbidden";
|
|
49
99
|
|
|
50
100
|
export interface UpdateView {
|
|
@@ -68,10 +118,18 @@ export interface UpdateView {
|
|
|
68
118
|
run: UpdateRun | null;
|
|
69
119
|
pending: { requestedAt: number } | null;
|
|
70
120
|
working: { sessions: number; reviews: number; setups: number };
|
|
121
|
+
auto?: AutoUpdateView;
|
|
71
122
|
}
|
|
72
123
|
|
|
73
124
|
export type BannerState =
|
|
74
|
-
|
|
125
|
+
| "hidden"
|
|
126
|
+
| "available"
|
|
127
|
+
| "pending"
|
|
128
|
+
| "running"
|
|
129
|
+
| "failed"
|
|
130
|
+
| "built"
|
|
131
|
+
| "updated"
|
|
132
|
+
| "rolled_back";
|
|
75
133
|
|
|
76
134
|
export const DISMISSED_VERSION_KEY = "agentos.update.dismissed";
|
|
77
135
|
export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
|
|
@@ -79,23 +137,40 @@ export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
|
|
|
79
137
|
/**
|
|
80
138
|
* A result of something the person started (running, failed, built) always
|
|
81
139
|
* shows until its run is dismissed; "available" is the one that can be
|
|
82
|
-
* switched off or dismissed, and only for the version dismissed.
|
|
140
|
+
* switched off or dismissed, and only for the version dismissed. An
|
|
141
|
+
* automatic update says it happened ("updated") or that it was rolled back;
|
|
142
|
+
* an automatic one that failed to build changed nothing, so only Settings
|
|
143
|
+
* shows it, unless it left the server broken.
|
|
83
144
|
*/
|
|
145
|
+
export const UPDATED_NOTE_MS = 3 * 24 * 60 * 60_000;
|
|
146
|
+
|
|
84
147
|
export function bannerState({
|
|
85
148
|
view,
|
|
86
149
|
dismissedVersion,
|
|
87
150
|
dismissedRun,
|
|
151
|
+
now = Date.now(),
|
|
88
152
|
}: {
|
|
89
153
|
view: UpdateView | null | undefined;
|
|
90
154
|
dismissedVersion: string | null;
|
|
91
155
|
dismissedRun: string | null;
|
|
156
|
+
now?: number;
|
|
92
157
|
}): BannerState {
|
|
93
158
|
if (!view) return "hidden";
|
|
94
159
|
const run = view.run;
|
|
95
160
|
if (isActivePhase(run?.phase)) return "running";
|
|
96
161
|
if (run && dismissedRun !== run.id) {
|
|
97
|
-
if (run.phase === "
|
|
162
|
+
if (run.phase === "rolled_back") return "rolled_back";
|
|
163
|
+
if (run.phase === "failed" && (!run.auto || run.outcome === "broken"))
|
|
164
|
+
return "failed";
|
|
98
165
|
if (run.phase === "built") return "built";
|
|
166
|
+
// A device that wasn't open for days doesn't need the news.
|
|
167
|
+
if (
|
|
168
|
+
run.phase === "done" &&
|
|
169
|
+
run.auto &&
|
|
170
|
+
view.bannerEnabled &&
|
|
171
|
+
now - (run.finishedAt ?? 0) < UPDATED_NOTE_MS
|
|
172
|
+
)
|
|
173
|
+
return "updated";
|
|
99
174
|
}
|
|
100
175
|
if (view.pending) return "pending";
|
|
101
176
|
if (
|
package/lib/updates/check.ts
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
// or mirror works too. The working tree is never touched.
|
|
4
4
|
|
|
5
5
|
import { git, GIT_TIMEOUT_MS } from "@/lib/git-exec";
|
|
6
|
-
import {
|
|
6
|
+
import type { Candidate } from "./auto";
|
|
7
|
+
import { autoEnabled, bannerEnabled, getSetting, putSetting } from "./settings";
|
|
7
8
|
import { isNewer, versionFor } from "./version";
|
|
8
9
|
|
|
9
10
|
export const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
|
11
|
+
/** With automatic updates on, a release is seen within about an hour. */
|
|
12
|
+
export const AUTO_CHECK_EVERY_MS = 60 * 60_000;
|
|
13
|
+
const CANDIDATES_MAX = 50;
|
|
10
14
|
export const FORCE_FLOOR_MS = 60_000;
|
|
11
15
|
const FIRST_CHECK_MS = 2 * 60_000;
|
|
12
16
|
const NOTES_MAX = 8;
|
|
@@ -26,6 +30,8 @@ export interface CheckResult {
|
|
|
26
30
|
branch: string | null;
|
|
27
31
|
checkedAt: number;
|
|
28
32
|
error: string | null;
|
|
33
|
+
/** First-parent commits between HEAD and the remote, newest first. */
|
|
34
|
+
candidates?: Candidate[];
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export interface CheckDeps {
|
|
@@ -74,7 +80,7 @@ const NO_REPO: Omit<CheckResult, "checkedAt"> = {
|
|
|
74
80
|
error: null,
|
|
75
81
|
};
|
|
76
82
|
|
|
77
|
-
async function versionAt(
|
|
83
|
+
export async function versionAt(
|
|
78
84
|
g: CheckDeps["git"],
|
|
79
85
|
ref: string
|
|
80
86
|
): Promise<{ version: string; sha: string }> {
|
|
@@ -145,6 +151,22 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
|
|
|
145
151
|
.map((s) => s.trim())
|
|
146
152
|
.filter(Boolean)
|
|
147
153
|
: [];
|
|
154
|
+
const candidates =
|
|
155
|
+
behind > 0 && fastForward
|
|
156
|
+
? (
|
|
157
|
+
await g([
|
|
158
|
+
"log",
|
|
159
|
+
"--first-parent",
|
|
160
|
+
"--format=%H %ct",
|
|
161
|
+
`-n${CANDIDATES_MAX}`,
|
|
162
|
+
`HEAD..${remoteRef}`,
|
|
163
|
+
])
|
|
164
|
+
)
|
|
165
|
+
.split("\n")
|
|
166
|
+
.map((l) => l.trim().split(" "))
|
|
167
|
+
.filter(([sha, at]) => /^[0-9a-f]{40}$/.test(sha) && Number(at) > 0)
|
|
168
|
+
.map(([sha, at]) => ({ sha, at: Number(at) * 1000 }))
|
|
169
|
+
: [];
|
|
148
170
|
const status = behind > 0 && !fastForward ? "diverged" : "ok";
|
|
149
171
|
return {
|
|
150
172
|
status,
|
|
@@ -160,6 +182,7 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
|
|
|
160
182
|
branch,
|
|
161
183
|
checkedAt,
|
|
162
184
|
error: null,
|
|
185
|
+
candidates,
|
|
163
186
|
};
|
|
164
187
|
}
|
|
165
188
|
|
|
@@ -188,13 +211,36 @@ export async function reconcile(
|
|
|
188
211
|
available: false,
|
|
189
212
|
behind: 0,
|
|
190
213
|
current: { version: check.latest.version, sha: head },
|
|
214
|
+
candidates: [],
|
|
191
215
|
}
|
|
192
|
-
:
|
|
216
|
+
: {
|
|
217
|
+
...check,
|
|
218
|
+
// Commits the checkout already has aren't updates any more.
|
|
219
|
+
candidates: await keepNewer(check.candidates, deps),
|
|
220
|
+
};
|
|
193
221
|
} catch {
|
|
194
222
|
return check;
|
|
195
223
|
}
|
|
196
224
|
}
|
|
197
225
|
|
|
226
|
+
async function keepNewer(
|
|
227
|
+
candidates: Candidate[] | undefined,
|
|
228
|
+
deps: Pick<CheckDeps, "git">
|
|
229
|
+
): Promise<Candidate[]> {
|
|
230
|
+
const out: Candidate[] = [];
|
|
231
|
+
for (const c of candidates ?? []) {
|
|
232
|
+
const has = await deps
|
|
233
|
+
.git(["merge-base", "--is-ancestor", c.sha, "HEAD"])
|
|
234
|
+
.then(
|
|
235
|
+
() => true,
|
|
236
|
+
() => false
|
|
237
|
+
);
|
|
238
|
+
if (has) break;
|
|
239
|
+
out.push(c);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
198
244
|
let memo: CheckResult | null = null;
|
|
199
245
|
let inflight: Promise<CheckResult> | null = null;
|
|
200
246
|
|
|
@@ -214,7 +260,11 @@ export function cachedCheck(deps: CheckDeps = realDeps()): CheckResult | null {
|
|
|
214
260
|
* fetch keeps the last good answer and notes the error.
|
|
215
261
|
*/
|
|
216
262
|
export async function checkForUpdate(
|
|
217
|
-
{
|
|
263
|
+
{
|
|
264
|
+
force = false,
|
|
265
|
+
always = false,
|
|
266
|
+
maxAge = CHECK_EVERY_MS,
|
|
267
|
+
}: { force?: boolean; always?: boolean; maxAge?: number } = {},
|
|
218
268
|
deps: CheckDeps = realDeps()
|
|
219
269
|
): Promise<{ result: CheckResult; throttled?: number }> {
|
|
220
270
|
const last = cachedCheck(deps);
|
|
@@ -224,7 +274,7 @@ export async function checkForUpdate(
|
|
|
224
274
|
result: last,
|
|
225
275
|
throttled: Math.ceil((FORCE_FLOOR_MS - age) / 1000),
|
|
226
276
|
};
|
|
227
|
-
if (last && !force && age <
|
|
277
|
+
if (last && !force && age < maxAge) return { result: last };
|
|
228
278
|
inflight ??= compare(deps)
|
|
229
279
|
.catch((error: unknown): CheckResult => {
|
|
230
280
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -254,17 +304,23 @@ export function updateChecksEnabled(
|
|
|
254
304
|
|
|
255
305
|
const g = globalThis as unknown as { __agentosUpdateChecks?: NodeJS.Timeout };
|
|
256
306
|
|
|
257
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* First check two minutes after boot, then every six hours, or every hour
|
|
309
|
+
* with automatic updates on (jittered). Off when both the banner and
|
|
310
|
+
* automatic updates are.
|
|
311
|
+
*/
|
|
258
312
|
export function startUpdateChecks(): void {
|
|
259
313
|
if (g.__agentosUpdateChecks) return;
|
|
260
314
|
const schedule = (delay: number): void => {
|
|
261
315
|
g.__agentosUpdateChecks = setTimeout(async () => {
|
|
316
|
+
const every = autoEnabled() ? AUTO_CHECK_EVERY_MS : CHECK_EVERY_MS;
|
|
262
317
|
try {
|
|
263
|
-
if (bannerEnabled()
|
|
318
|
+
if (bannerEnabled() || autoEnabled())
|
|
319
|
+
await checkForUpdate({ maxAge: every - 5 * 60_000 });
|
|
264
320
|
} catch (error) {
|
|
265
321
|
console.error("Checking for updates failed:", error);
|
|
266
322
|
}
|
|
267
|
-
schedule(
|
|
323
|
+
schedule(every + Math.floor(Math.random() * 10 * 60_000));
|
|
268
324
|
}, delay);
|
|
269
325
|
g.__agentosUpdateChecks.unref();
|
|
270
326
|
};
|