@saadnvd1/agent-os 0.3.553 → 0.3.556
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/sessions/[id]/done/route.test.ts +70 -0
- package/app/api/sessions/[id]/done/route.ts +32 -6
- package/app/api/updates/check/route.ts +17 -0
- package/app/api/updates/route.test.ts +205 -0
- package/app/api/updates/route.ts +11 -0
- package/app/api/updates/run/route.ts +50 -0
- package/app/api/updates/settings/route.ts +25 -0
- package/components/Chat/ChatPanel.tsx +18 -5
- package/components/Chat/Messages.test.ts +44 -0
- package/components/Chat/Messages.tsx +29 -7
- package/components/Chat/composer/ComposerBody.tsx +5 -2
- package/components/Chat/composer/hook.test.ts +24 -0
- package/components/Chat/composer/insert.ts +18 -0
- package/components/SessionList/useDoneAction.test.ts +149 -0
- package/components/SessionList/useDoneAction.ts +17 -12
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Updates/UpdateActions.tsx +93 -0
- package/components/Updates/UpdateBanner.test.ts +198 -0
- package/components/Updates/UpdateBanner.tsx +212 -0
- package/components/Updates/UpdatesSection.tsx +150 -0
- package/components/Updates/index.ts +2 -0
- package/components/views/DesktopView.tsx +2 -0
- package/components/views/MobileView.tsx +2 -0
- package/data/done/index.ts +33 -7
- package/data/updates/index.ts +63 -0
- package/hooks/useDismissedUpdate.ts +56 -0
- package/hooks/useReloadWhenUpdated.ts +19 -0
- package/lib/chat/quote.ts +5 -0
- package/lib/done/deadline.ts +45 -0
- package/lib/done/index.ts +4 -1
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +114 -0
- package/lib/updates/check.ts +240 -0
- package/lib/updates/run.ts +452 -0
- package/lib/updates/settings.ts +38 -0
- package/lib/updates/version.ts +30 -0
- package/lib/updates/view.ts +80 -0
- package/package.json +1 -1
- package/scripts/redeploy +4 -0
- package/scripts/update-run.mjs +82 -0
- package/scripts/update-run.test.ts +64 -0
- package/server.ts +16 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// What the update banner shows, from what the server says and what this
|
|
2
|
+
// device has dismissed. Pure, so a native client uses the same rules.
|
|
3
|
+
|
|
4
|
+
export type UpdatePhase =
|
|
5
|
+
| "starting"
|
|
6
|
+
| "pulling"
|
|
7
|
+
| "installing"
|
|
8
|
+
| "building"
|
|
9
|
+
| "restarting"
|
|
10
|
+
| "done"
|
|
11
|
+
| "built"
|
|
12
|
+
| "failed";
|
|
13
|
+
|
|
14
|
+
export const ACTIVE_PHASES: readonly UpdatePhase[] = [
|
|
15
|
+
"starting",
|
|
16
|
+
"pulling",
|
|
17
|
+
"installing",
|
|
18
|
+
"building",
|
|
19
|
+
"restarting",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const isActivePhase = (p: UpdatePhase | undefined | null) =>
|
|
23
|
+
!!p && ACTIVE_PHASES.includes(p);
|
|
24
|
+
|
|
25
|
+
export interface UpdateRun {
|
|
26
|
+
id: string;
|
|
27
|
+
phase: UpdatePhase;
|
|
28
|
+
from: string;
|
|
29
|
+
fromVersion: string;
|
|
30
|
+
toVersion: string;
|
|
31
|
+
startedAt: number;
|
|
32
|
+
finishedAt?: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
log?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UpdateRefusal =
|
|
38
|
+
| "dev"
|
|
39
|
+
| "not_git"
|
|
40
|
+
| "running"
|
|
41
|
+
| "building"
|
|
42
|
+
| "dirty"
|
|
43
|
+
| "branch"
|
|
44
|
+
| "diverged"
|
|
45
|
+
| "up_to_date"
|
|
46
|
+
| "unknown"
|
|
47
|
+
| "forbidden";
|
|
48
|
+
|
|
49
|
+
export interface UpdateView {
|
|
50
|
+
current: { version: string; sha: string } | null;
|
|
51
|
+
latest: {
|
|
52
|
+
version: string;
|
|
53
|
+
sha: string;
|
|
54
|
+
notes: string[];
|
|
55
|
+
url: string | null;
|
|
56
|
+
} | null;
|
|
57
|
+
available: boolean;
|
|
58
|
+
behind: number;
|
|
59
|
+
status: "ok" | "diverged" | "unknown";
|
|
60
|
+
checkedAt: number | null;
|
|
61
|
+
checkError: string | null;
|
|
62
|
+
bannerEnabled: boolean;
|
|
63
|
+
bannerLocked: boolean;
|
|
64
|
+
canUpdate: { ok: boolean; code?: UpdateRefusal; reason?: string };
|
|
65
|
+
run: UpdateRun | null;
|
|
66
|
+
pending: { requestedAt: number } | null;
|
|
67
|
+
working: { sessions: number; reviews: number; setups: number };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type BannerState =
|
|
71
|
+
| "hidden"
|
|
72
|
+
| "available"
|
|
73
|
+
| "pending"
|
|
74
|
+
| "running"
|
|
75
|
+
| "failed"
|
|
76
|
+
| "built";
|
|
77
|
+
|
|
78
|
+
export const DISMISSED_VERSION_KEY = "agentos.update.dismissed";
|
|
79
|
+
export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A result of something the person started (running, failed, built) always
|
|
83
|
+
* shows until its run is dismissed; "available" is the one that can be
|
|
84
|
+
* switched off or dismissed, and only for the version dismissed.
|
|
85
|
+
*/
|
|
86
|
+
export function bannerState({
|
|
87
|
+
view,
|
|
88
|
+
dismissedVersion,
|
|
89
|
+
dismissedRun,
|
|
90
|
+
}: {
|
|
91
|
+
view: UpdateView | null | undefined;
|
|
92
|
+
dismissedVersion: string | null;
|
|
93
|
+
dismissedRun: string | null;
|
|
94
|
+
}): BannerState {
|
|
95
|
+
if (!view) return "hidden";
|
|
96
|
+
const run = view.run;
|
|
97
|
+
if (isActivePhase(run?.phase)) return "running";
|
|
98
|
+
if (run && dismissedRun !== run.id) {
|
|
99
|
+
if (run.phase === "failed") return "failed";
|
|
100
|
+
if (run.phase === "built") return "built";
|
|
101
|
+
}
|
|
102
|
+
if (view.pending) return "pending";
|
|
103
|
+
if (
|
|
104
|
+
view.available &&
|
|
105
|
+
view.bannerEnabled &&
|
|
106
|
+
view.latest &&
|
|
107
|
+
dismissedVersion !== view.latest.version
|
|
108
|
+
)
|
|
109
|
+
return "available";
|
|
110
|
+
return "hidden";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const workingTotal = (w: UpdateView["working"]) =>
|
|
114
|
+
w.sessions + w.reviews + w.setups;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Is a newer AgentOS out? Asked of the install's own remote: fetch the
|
|
2
|
+
// default branch and compare, so there's no service to depend on and a fork
|
|
3
|
+
// or mirror works too. The working tree is never touched.
|
|
4
|
+
|
|
5
|
+
import { git, GIT_TIMEOUT_MS } from "@/lib/git-exec";
|
|
6
|
+
import { getSetting, putSetting, bannerEnabled } from "./settings";
|
|
7
|
+
import { isNewer, versionFor } from "./version";
|
|
8
|
+
|
|
9
|
+
export const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
|
10
|
+
export const FORCE_FLOOR_MS = 60_000;
|
|
11
|
+
const FIRST_CHECK_MS = 2 * 60_000;
|
|
12
|
+
const NOTES_MAX = 8;
|
|
13
|
+
const CACHE_KEY = "updates.check";
|
|
14
|
+
|
|
15
|
+
export interface CheckResult {
|
|
16
|
+
status: "ok" | "diverged" | "unknown";
|
|
17
|
+
current: { version: string; sha: string } | null;
|
|
18
|
+
latest: {
|
|
19
|
+
version: string;
|
|
20
|
+
sha: string;
|
|
21
|
+
notes: string[];
|
|
22
|
+
url: string | null;
|
|
23
|
+
} | null;
|
|
24
|
+
behind: number;
|
|
25
|
+
available: boolean;
|
|
26
|
+
branch: string | null;
|
|
27
|
+
checkedAt: number;
|
|
28
|
+
error: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CheckDeps {
|
|
32
|
+
git: (args: string[]) => Promise<string>;
|
|
33
|
+
now: () => number;
|
|
34
|
+
load: () => CheckResult | null;
|
|
35
|
+
save: (result: CheckResult) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const realDeps = (cwd = process.cwd()): CheckDeps => ({
|
|
39
|
+
git: (args) => git(cwd, args, { timeout: Math.min(GIT_TIMEOUT_MS, 30_000) }),
|
|
40
|
+
now: Date.now,
|
|
41
|
+
load: () => {
|
|
42
|
+
try {
|
|
43
|
+
const raw = getSetting(CACHE_KEY);
|
|
44
|
+
return raw ? (JSON.parse(raw) as CheckResult) : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
save: (result) => putSetting(CACHE_KEY, JSON.stringify(result)),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A browsable compare link for a GitHub remote, else null. Credentials are dropped. */
|
|
53
|
+
export function compareUrl(
|
|
54
|
+
remote: string,
|
|
55
|
+
from: string,
|
|
56
|
+
to: string
|
|
57
|
+
): string | null {
|
|
58
|
+
const m =
|
|
59
|
+
/^(?:https?:\/\/(?:[^/@]+@)?|git@|ssh:\/\/git@)github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(
|
|
60
|
+
remote.trim()
|
|
61
|
+
);
|
|
62
|
+
return m
|
|
63
|
+
? `https://github.com/${m[1]}/${m[2]}/compare/${from.slice(0, 12)}...${to.slice(0, 12)}`
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NO_REPO: Omit<CheckResult, "checkedAt"> = {
|
|
68
|
+
status: "unknown",
|
|
69
|
+
current: null,
|
|
70
|
+
latest: null,
|
|
71
|
+
behind: 0,
|
|
72
|
+
available: false,
|
|
73
|
+
branch: null,
|
|
74
|
+
error: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
async function versionAt(
|
|
78
|
+
g: CheckDeps["git"],
|
|
79
|
+
ref: string
|
|
80
|
+
): Promise<{ version: string; sha: string }> {
|
|
81
|
+
const [sha, count, pkg] = await Promise.all([
|
|
82
|
+
g(["rev-parse", ref]),
|
|
83
|
+
g(["rev-list", "--count", ref]),
|
|
84
|
+
g(["show", `${ref}:package.json`]),
|
|
85
|
+
]);
|
|
86
|
+
const version = versionFor(
|
|
87
|
+
(JSON.parse(pkg) as { version?: string }).version ?? "",
|
|
88
|
+
Number(count.trim())
|
|
89
|
+
);
|
|
90
|
+
if (!version) throw new Error("package.json has no version");
|
|
91
|
+
return { version, sha: sha.trim() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetches the default branch and compares it with HEAD. Throws when git can't say. */
|
|
95
|
+
export async function compare(deps: CheckDeps): Promise<CheckResult> {
|
|
96
|
+
const g = deps.git;
|
|
97
|
+
const checkedAt = deps.now();
|
|
98
|
+
if ((await g(["rev-parse", "--is-shallow-repository"])).trim() === "true")
|
|
99
|
+
return {
|
|
100
|
+
...NO_REPO,
|
|
101
|
+
checkedAt,
|
|
102
|
+
error: "This is a shallow clone, so versions can't be counted.",
|
|
103
|
+
};
|
|
104
|
+
const head = await g(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
|
|
105
|
+
.then((s) => s.trim().replace(/^origin\//, ""))
|
|
106
|
+
.catch(() => "main");
|
|
107
|
+
const branch =
|
|
108
|
+
/^[\w./-]+$/.test(head) && !head.startsWith("-") ? head : "main";
|
|
109
|
+
await g([
|
|
110
|
+
"fetch",
|
|
111
|
+
"-q",
|
|
112
|
+
"origin",
|
|
113
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`,
|
|
114
|
+
]);
|
|
115
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
116
|
+
const [current, latest, remote] = await Promise.all([
|
|
117
|
+
versionAt(g, "HEAD"),
|
|
118
|
+
versionAt(g, remoteRef),
|
|
119
|
+
g(["remote", "get-url", "origin"]).catch(() => ""),
|
|
120
|
+
]);
|
|
121
|
+
const behind = Number(
|
|
122
|
+
(await g(["rev-list", "--count", `HEAD..${remoteRef}`])).trim()
|
|
123
|
+
);
|
|
124
|
+
const fastForward = await g([
|
|
125
|
+
"merge-base",
|
|
126
|
+
"--is-ancestor",
|
|
127
|
+
"HEAD",
|
|
128
|
+
remoteRef,
|
|
129
|
+
]).then(
|
|
130
|
+
() => true,
|
|
131
|
+
() => false
|
|
132
|
+
);
|
|
133
|
+
const notes =
|
|
134
|
+
behind > 0 && fastForward
|
|
135
|
+
? (
|
|
136
|
+
await g([
|
|
137
|
+
"log",
|
|
138
|
+
"--first-parent",
|
|
139
|
+
"--format=%s",
|
|
140
|
+
`-n${NOTES_MAX}`,
|
|
141
|
+
`HEAD..${remoteRef}`,
|
|
142
|
+
])
|
|
143
|
+
)
|
|
144
|
+
.split("\n")
|
|
145
|
+
.map((s) => s.trim())
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
: [];
|
|
148
|
+
const status = behind > 0 && !fastForward ? "diverged" : "ok";
|
|
149
|
+
return {
|
|
150
|
+
status,
|
|
151
|
+
current,
|
|
152
|
+
latest: {
|
|
153
|
+
...latest,
|
|
154
|
+
notes,
|
|
155
|
+
url: compareUrl(remote, current.sha, latest.sha),
|
|
156
|
+
},
|
|
157
|
+
behind,
|
|
158
|
+
available:
|
|
159
|
+
status === "ok" && behind > 0 && isNewer(latest.version, current.version),
|
|
160
|
+
branch,
|
|
161
|
+
checkedAt,
|
|
162
|
+
error: null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let memo: CheckResult | null = null;
|
|
167
|
+
let inflight: Promise<CheckResult> | null = null;
|
|
168
|
+
|
|
169
|
+
export const resetCheckCache = () => {
|
|
170
|
+
memo = null;
|
|
171
|
+
inflight = null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export function cachedCheck(deps: CheckDeps = realDeps()): CheckResult | null {
|
|
175
|
+
return (memo ??= deps.load());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The cached answer, or a fresh one when it's older than six hours. `force`
|
|
180
|
+
* asks now, at most once a minute (`throttled`: the seconds left); `always`
|
|
181
|
+
* skips that floor, for a check owed after an update. A failed
|
|
182
|
+
* fetch keeps the last good answer and notes the error.
|
|
183
|
+
*/
|
|
184
|
+
export async function checkForUpdate(
|
|
185
|
+
{ force = false, always = false }: { force?: boolean; always?: boolean } = {},
|
|
186
|
+
deps: CheckDeps = realDeps()
|
|
187
|
+
): Promise<{ result: CheckResult; throttled?: number }> {
|
|
188
|
+
const last = cachedCheck(deps);
|
|
189
|
+
const age = last ? deps.now() - last.checkedAt : Infinity;
|
|
190
|
+
if (last && force && !always && age < FORCE_FLOOR_MS)
|
|
191
|
+
return {
|
|
192
|
+
result: last,
|
|
193
|
+
throttled: Math.ceil((FORCE_FLOOR_MS - age) / 1000),
|
|
194
|
+
};
|
|
195
|
+
if (last && !force && age < CHECK_EVERY_MS) return { result: last };
|
|
196
|
+
inflight ??= compare(deps)
|
|
197
|
+
.catch((error: unknown): CheckResult => {
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
199
|
+
return last
|
|
200
|
+
? { ...last, error: message }
|
|
201
|
+
: { ...NO_REPO, checkedAt: deps.now(), error: message };
|
|
202
|
+
})
|
|
203
|
+
.then((result) => {
|
|
204
|
+
memo = result;
|
|
205
|
+
deps.save(result);
|
|
206
|
+
return result;
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
inflight = null;
|
|
210
|
+
});
|
|
211
|
+
return { result: await inflight };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Dev servers and tests don't check unless asked; AGENTOS_UPDATE_CHECK=off pins it off. */
|
|
215
|
+
export function updateChecksEnabled(
|
|
216
|
+
env: Record<string, string | undefined> = process.env
|
|
217
|
+
): boolean {
|
|
218
|
+
if (env.AGENTOS_UPDATE_CHECK === "off") return false;
|
|
219
|
+
if (env.AGENTOS_UPDATE_CHECK === "on") return true;
|
|
220
|
+
return env.AGENTOS_DEV !== "1" && env.NODE_ENV === "production";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const g = globalThis as unknown as { __agentosUpdateChecks?: NodeJS.Timeout };
|
|
224
|
+
|
|
225
|
+
/** First check two minutes after boot, then every six hours (jittered). */
|
|
226
|
+
export function startUpdateChecks(): void {
|
|
227
|
+
if (g.__agentosUpdateChecks) return;
|
|
228
|
+
const schedule = (delay: number): void => {
|
|
229
|
+
g.__agentosUpdateChecks = setTimeout(async () => {
|
|
230
|
+
try {
|
|
231
|
+
if (bannerEnabled()) await checkForUpdate();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error("Checking for updates failed:", error);
|
|
234
|
+
}
|
|
235
|
+
schedule(CHECK_EVERY_MS + Math.floor(Math.random() * 10 * 60_000));
|
|
236
|
+
}, delay);
|
|
237
|
+
g.__agentosUpdateChecks.unref();
|
|
238
|
+
};
|
|
239
|
+
schedule(FIRST_CHECK_MS);
|
|
240
|
+
}
|