@saadnvd1/agent-os 0.3.794 → 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 +24 -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/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,215 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { tempDir } from "@/lib/test-temp";
|
|
8
|
+
|
|
9
|
+
// The real scripts/update-run.mjs and scripts/redeploy on a scratch clone of
|
|
10
|
+
// a scratch remote: install and build are stubs, and the "server" is a tiny
|
|
11
|
+
// node script the restart command starts, answering health with the commit
|
|
12
|
+
// it was started from. A commit carrying a `crash` file makes it exit on
|
|
13
|
+
// boot, so the update has to roll itself back.
|
|
14
|
+
const exec = promisify(execFile);
|
|
15
|
+
const here = import.meta.dirname;
|
|
16
|
+
const SCRIPTS = [
|
|
17
|
+
"redeploy",
|
|
18
|
+
"update-run.mjs",
|
|
19
|
+
"lib/build-swap.sh",
|
|
20
|
+
"lib/pnpm.sh",
|
|
21
|
+
"lib/deps-swap.sh",
|
|
22
|
+
"lib/update-verify.mjs",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const SERVER = `
|
|
26
|
+
const http = require("http");
|
|
27
|
+
const fs = require("fs");
|
|
28
|
+
const { execSync } = require("child_process");
|
|
29
|
+
if (fs.existsSync("crash")) process.exit(1);
|
|
30
|
+
const build = execSync("git rev-parse HEAD").toString().trim();
|
|
31
|
+
const startedAt = new Date().toISOString();
|
|
32
|
+
http.createServer((req, res) => {
|
|
33
|
+
res.setHeader("content-type", "application/json");
|
|
34
|
+
if (req.url === "/api/updates/health")
|
|
35
|
+
return res.end(JSON.stringify({ ok: true, build, startedAt, migrations: { known: 1, applied: 1 } }));
|
|
36
|
+
res.end("{}");
|
|
37
|
+
}).listen(Number(process.env.PORT), "127.0.0.1");
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
let root: string;
|
|
41
|
+
let live: string;
|
|
42
|
+
let port: string;
|
|
43
|
+
|
|
44
|
+
// Nothing of the machine's git config: no global identity or default branch
|
|
45
|
+
// to lean on, as on a CI runner. Every git here and in the scripts under
|
|
46
|
+
// test gets this environment.
|
|
47
|
+
const GIT_ENV = {
|
|
48
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
49
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
50
|
+
GIT_AUTHOR_NAME: "t",
|
|
51
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
52
|
+
GIT_COMMITTER_NAME: "t",
|
|
53
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
54
|
+
};
|
|
55
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
56
|
+
execFileSync("git", args, {
|
|
57
|
+
cwd,
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
env: { ...process.env, ...GIT_ENV },
|
|
60
|
+
}).trim();
|
|
61
|
+
const commit = (cwd: string, msg: string) => {
|
|
62
|
+
git(cwd, "add", "-A");
|
|
63
|
+
git(cwd, "commit", "-q", "--allow-empty", "-m", msg);
|
|
64
|
+
git(cwd, "push", "-q", "origin", "HEAD:main");
|
|
65
|
+
return git(cwd, "rev-parse", "HEAD");
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const freePort = () =>
|
|
69
|
+
new Promise<string>((resolve) => {
|
|
70
|
+
const s = net.createServer().listen(0, "127.0.0.1", () => {
|
|
71
|
+
const p = (s.address() as net.AddressInfo).port;
|
|
72
|
+
s.close(() => resolve(String(p)));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const health = async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/updates/health`);
|
|
79
|
+
return ((await res.json()) as { build: string }).build;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
root = tempDir("aos-e2e-");
|
|
87
|
+
port = await freePort();
|
|
88
|
+
const origin = path.join(root, "origin.git");
|
|
89
|
+
const src = path.join(root, "src");
|
|
90
|
+
live = path.join(root, "live");
|
|
91
|
+
git(root, "init", "-q", "-b", "main", "--bare", origin);
|
|
92
|
+
git(root, "init", "-q", "-b", "main", src);
|
|
93
|
+
git(src, "remote", "add", "origin", origin);
|
|
94
|
+
for (const f of SCRIPTS) {
|
|
95
|
+
fs.mkdirSync(path.dirname(path.join(src, "scripts", f)), {
|
|
96
|
+
recursive: true,
|
|
97
|
+
});
|
|
98
|
+
fs.copyFileSync(path.join(here, f), path.join(src, "scripts", f));
|
|
99
|
+
}
|
|
100
|
+
fs.chmodSync(path.join(src, "scripts/redeploy"), 0o755);
|
|
101
|
+
fs.writeFileSync(path.join(src, "server.js"), SERVER);
|
|
102
|
+
for (const f of ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"])
|
|
103
|
+
fs.writeFileSync(path.join(src, f), "{}");
|
|
104
|
+
fs.writeFileSync(
|
|
105
|
+
path.join(src, ".gitignore"),
|
|
106
|
+
".next*\n.deps\nnode_modules\n*.log\n"
|
|
107
|
+
);
|
|
108
|
+
commit(src, "first");
|
|
109
|
+
git(root, "clone", "-q", origin, live);
|
|
110
|
+
fs.mkdirSync(path.join(live, ".next"));
|
|
111
|
+
fs.writeFileSync(path.join(live, ".next/BUILD_ID"), "first");
|
|
112
|
+
fs.mkdirSync(path.join(root, "bin"));
|
|
113
|
+
fs.writeFileSync(path.join(root, "bin/pnpm"), "#!/bin/sh\nexit 0\n", {
|
|
114
|
+
mode: 0o755,
|
|
115
|
+
});
|
|
116
|
+
// Stop the server on the port (if any), then start this checkout's.
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
path.join(root, "restart"),
|
|
119
|
+
`#!/usr/bin/env bash
|
|
120
|
+
[ -f "${root}/pid" ] && kill "$(cat "${root}/pid")" 2>/dev/null
|
|
121
|
+
for _ in $(seq 1 50); do curl -s -o /dev/null "http://127.0.0.1:${port}/" || break; sleep 0.1; done
|
|
122
|
+
cd "${live}" || exit 1
|
|
123
|
+
nohup node server.js > "${root}/server.log" 2>&1 < /dev/null &
|
|
124
|
+
echo $! > "${root}/pid"
|
|
125
|
+
`,
|
|
126
|
+
{ mode: 0o755 }
|
|
127
|
+
);
|
|
128
|
+
execFileSync(path.join(root, "restart"), {
|
|
129
|
+
env: { ...process.env, ...GIT_ENV, PORT: port },
|
|
130
|
+
});
|
|
131
|
+
for (let i = 0; i < 50 && !(await health()); i++)
|
|
132
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
try {
|
|
137
|
+
process.kill(Number(fs.readFileSync(path.join(root, "pid"), "utf8")));
|
|
138
|
+
} catch {}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function update(from: string, to: string) {
|
|
142
|
+
const stateFile = path.join(root, "state.json");
|
|
143
|
+
fs.writeFileSync(
|
|
144
|
+
stateFile,
|
|
145
|
+
JSON.stringify({
|
|
146
|
+
id: "e2e",
|
|
147
|
+
phase: "starting",
|
|
148
|
+
from,
|
|
149
|
+
startedAt: Date.now(),
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
const env: NodeJS.ProcessEnv = {
|
|
153
|
+
...process.env,
|
|
154
|
+
...GIT_ENV,
|
|
155
|
+
HOME: root,
|
|
156
|
+
PATH: `${path.join(root, "bin")}:${process.env.PATH}`,
|
|
157
|
+
PORT: port,
|
|
158
|
+
AGENTOS_UPDATE_STATE: stateFile,
|
|
159
|
+
AGENTOS_RESTART: path.join(root, "restart"),
|
|
160
|
+
AGENTOS_UPDATE_TO: to,
|
|
161
|
+
AGENTOS_INSTALL_CMD: "mkdir -p node_modules",
|
|
162
|
+
AGENTOS_NATIVE_CHECK: "true",
|
|
163
|
+
AGENTOS_BUILD_CMD:
|
|
164
|
+
'mkdir -p "$AGENTOS_DIST_DIR" && git rev-parse HEAD > "$AGENTOS_DIST_DIR/BUILD_ID"',
|
|
165
|
+
AGENTOS_UP_WAIT: "3",
|
|
166
|
+
AGENTOS_UPDATE_MIN_FREE_GB: "0",
|
|
167
|
+
AGENTOS_VERIFY_UP_MS: "5000",
|
|
168
|
+
AGENTOS_VERIFY_EVERY_MS: "50",
|
|
169
|
+
AGENTOS_VERIFY_CHECKS: "2",
|
|
170
|
+
};
|
|
171
|
+
for (const k of [
|
|
172
|
+
"AGENTOS_UPDATE_DB",
|
|
173
|
+
"AGENTOS_UPDATE_ROOT",
|
|
174
|
+
"AGENTOS_UPDATE_REDEPLOY",
|
|
175
|
+
"NODE_ENV",
|
|
176
|
+
])
|
|
177
|
+
delete env[k];
|
|
178
|
+
await exec(process.execPath, [path.join(live, "scripts/update-run.mjs")], {
|
|
179
|
+
env,
|
|
180
|
+
timeout: 60_000,
|
|
181
|
+
}).catch(() => {});
|
|
182
|
+
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe("an automatic update, end to end", () => {
|
|
186
|
+
it("updates to a good commit, then rolls back one whose server won't start", async () => {
|
|
187
|
+
const src = path.join(root, "src");
|
|
188
|
+
const first = git(live, "rev-parse", "HEAD");
|
|
189
|
+
expect(await health()).toBe(first);
|
|
190
|
+
|
|
191
|
+
fs.writeFileSync(path.join(src, "feature.txt"), "good");
|
|
192
|
+
const good = commit(src, "good");
|
|
193
|
+
// A newer head the update must not take: it is pinned to `good`.
|
|
194
|
+
fs.writeFileSync(path.join(src, "later.txt"), "later");
|
|
195
|
+
commit(src, "later");
|
|
196
|
+
const s1 = await update(first, good);
|
|
197
|
+
expect(s1).toMatchObject({ phase: "done", outcome: "done" });
|
|
198
|
+
expect(git(live, "rev-parse", "HEAD")).toBe(good);
|
|
199
|
+
expect(await health()).toBe(good);
|
|
200
|
+
expect(
|
|
201
|
+
fs.readFileSync(path.join(live, ".next/BUILD_ID"), "utf8").trim()
|
|
202
|
+
).toBe(good);
|
|
203
|
+
|
|
204
|
+
fs.writeFileSync(path.join(src, "crash"), "");
|
|
205
|
+
const bad = commit(src, "bad");
|
|
206
|
+
const s2 = await update(good, bad);
|
|
207
|
+
expect(s2).toMatchObject({ phase: "rolled_back", outcome: "rolled_back" });
|
|
208
|
+
expect(git(live, "rev-parse", "HEAD")).toBe(good);
|
|
209
|
+
expect(fs.existsSync(path.join(live, "crash"))).toBe(false);
|
|
210
|
+
expect(
|
|
211
|
+
fs.readFileSync(path.join(live, ".next/BUILD_ID"), "utf8").trim()
|
|
212
|
+
).toBe(good);
|
|
213
|
+
expect(await health()).toBe(good);
|
|
214
|
+
}, 90_000);
|
|
215
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs before the server starts (npm prestart): repairs an update that was
|
|
3
|
+
// cut off (the machine slept, lost power, the runner was killed), so the
|
|
4
|
+
// server never starts on half of one.
|
|
5
|
+
// - .next missing between swap_build's two renames: finish the rename.
|
|
6
|
+
// - a run whose runner is gone, stopped at:
|
|
7
|
+
// pulled → the source goes back to where it was
|
|
8
|
+
// deps_swapped → the dependencies and the source go back
|
|
9
|
+
// build_swapped → nothing: source, deps and build all agree (new)
|
|
10
|
+
// and the run is marked interrupted, so the next quiet window retries.
|
|
11
|
+
// Never fails the start: anything unexpected is logged and left alone.
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
const ACTIVE = new Set([
|
|
18
|
+
"starting",
|
|
19
|
+
"backing_up",
|
|
20
|
+
"pulling",
|
|
21
|
+
"installing",
|
|
22
|
+
"building",
|
|
23
|
+
"restarting",
|
|
24
|
+
"verifying",
|
|
25
|
+
"rolling_back",
|
|
26
|
+
]);
|
|
27
|
+
const STALE_MS = 2 * 60_000;
|
|
28
|
+
const OLD_STALE_MS = 30 * 60_000;
|
|
29
|
+
|
|
30
|
+
const alive = (pid) => {
|
|
31
|
+
if (!pid) return false;
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return error.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** .next is absent only between two renames; finish whichever was under way. */
|
|
41
|
+
export function recoverBuild(root) {
|
|
42
|
+
const live = path.join(root, ".next");
|
|
43
|
+
if (fs.existsSync(path.join(live, "BUILD_ID"))) return null;
|
|
44
|
+
for (const from of [".next-build", ".next-prev"]) {
|
|
45
|
+
const dir = path.join(root, from);
|
|
46
|
+
if (fs.existsSync(path.join(dir, "BUILD_ID"))) {
|
|
47
|
+
if (fs.existsSync(live))
|
|
48
|
+
fs.rmSync(live, { recursive: true, force: true });
|
|
49
|
+
fs.renameSync(dir, live);
|
|
50
|
+
return from;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when the run is in an active phase and nothing is running it. */
|
|
57
|
+
export function isInterrupted(run, now = Date.now()) {
|
|
58
|
+
if (!run || !ACTIVE.has(run.phase)) return false;
|
|
59
|
+
if (run.updatedAt) return now - run.updatedAt > STALE_MS && !alive(run.pid);
|
|
60
|
+
return now - (run.startedAt ?? 0) > OLD_STALE_MS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function recoverRun(root, stateFile, now = Date.now()) {
|
|
64
|
+
let run;
|
|
65
|
+
try {
|
|
66
|
+
run = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!isInterrupted(run, now)) return null;
|
|
71
|
+
const done = [];
|
|
72
|
+
const git = (...args) =>
|
|
73
|
+
execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "pipe"] })
|
|
74
|
+
.toString()
|
|
75
|
+
.trim();
|
|
76
|
+
// Only a run that recorded its stages says what to undo.
|
|
77
|
+
if (
|
|
78
|
+
run.pid &&
|
|
79
|
+
run.from &&
|
|
80
|
+
(run.stage === "pulled" || run.stage === "deps_swapped")
|
|
81
|
+
) {
|
|
82
|
+
const prev = path.join(root, ".deps/prev");
|
|
83
|
+
if (run.stage === "deps_swapped" && fs.existsSync(prev)) {
|
|
84
|
+
fs.rmSync(path.join(root, "node_modules"), {
|
|
85
|
+
recursive: true,
|
|
86
|
+
force: true,
|
|
87
|
+
});
|
|
88
|
+
fs.renameSync(prev, path.join(root, "node_modules"));
|
|
89
|
+
done.push("dependencies");
|
|
90
|
+
}
|
|
91
|
+
if (git("rev-parse", "HEAD") !== run.from) {
|
|
92
|
+
git("reset", "-q", "--keep", run.from);
|
|
93
|
+
done.push("source");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const next = {
|
|
97
|
+
...run,
|
|
98
|
+
phase: "failed",
|
|
99
|
+
outcome: "interrupted",
|
|
100
|
+
finishedAt: now,
|
|
101
|
+
updatedAt: now,
|
|
102
|
+
error:
|
|
103
|
+
"The update was cut off (the computer slept or restarted), so AgentOS stayed on the version it had. It will try again.",
|
|
104
|
+
};
|
|
105
|
+
const tmp = `${stateFile}.${process.pid}.tmp`;
|
|
106
|
+
fs.writeFileSync(tmp, JSON.stringify(next));
|
|
107
|
+
fs.renameSync(tmp, stateFile);
|
|
108
|
+
return done;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
process.argv[1] &&
|
|
113
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
114
|
+
) {
|
|
115
|
+
const root = process.cwd();
|
|
116
|
+
const dir = process.env.DB_PATH
|
|
117
|
+
? path.dirname(path.resolve(process.env.DB_PATH))
|
|
118
|
+
: root;
|
|
119
|
+
try {
|
|
120
|
+
const moved = recoverBuild(root);
|
|
121
|
+
if (moved) console.log(`Update recovery: put ${moved} back as .next`);
|
|
122
|
+
const undone = recoverRun(root, path.join(dir, "agent-os-update.json"));
|
|
123
|
+
if (undone)
|
|
124
|
+
console.log(
|
|
125
|
+
`Update recovery: marked a cut-off update as interrupted${undone.length ? `; put back the ${undone.join(" and ")}` : ""}`
|
|
126
|
+
);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error("Update recovery skipped:", error.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { tempDir } from "@/lib/test-temp";
|
|
6
|
+
import { isInterrupted, recoverBuild, recoverRun } from "./update-recover.mjs";
|
|
7
|
+
|
|
8
|
+
let dir: string;
|
|
9
|
+
const state = () => path.join(dir, "agent-os-update.json");
|
|
10
|
+
const git = (...args: string[]) =>
|
|
11
|
+
execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim();
|
|
12
|
+
const commit = (msg: string) => {
|
|
13
|
+
git(
|
|
14
|
+
"-c",
|
|
15
|
+
"user.email=t@t",
|
|
16
|
+
"-c",
|
|
17
|
+
"user.name=t",
|
|
18
|
+
"commit",
|
|
19
|
+
"-q",
|
|
20
|
+
"--allow-empty",
|
|
21
|
+
"-m",
|
|
22
|
+
msg
|
|
23
|
+
);
|
|
24
|
+
return git("rev-parse", "HEAD");
|
|
25
|
+
};
|
|
26
|
+
const write = (run: object) => fs.writeFileSync(state(), JSON.stringify(run));
|
|
27
|
+
const read = () => JSON.parse(fs.readFileSync(state(), "utf8"));
|
|
28
|
+
const NOW = Date.now();
|
|
29
|
+
const GONE = 2 ** 22 + 4321;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
dir = tempDir("aos-recover-");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("recoverBuild", () => {
|
|
36
|
+
it("finishes a swap cut between its two renames", () => {
|
|
37
|
+
fs.mkdirSync(path.join(dir, ".next-build"));
|
|
38
|
+
fs.writeFileSync(path.join(dir, ".next-build/BUILD_ID"), "new");
|
|
39
|
+
fs.mkdirSync(path.join(dir, ".next-prev"));
|
|
40
|
+
fs.writeFileSync(path.join(dir, ".next-prev/BUILD_ID"), "old");
|
|
41
|
+
expect(recoverBuild(dir)).toBe(".next-build");
|
|
42
|
+
expect(fs.readFileSync(path.join(dir, ".next/BUILD_ID"), "utf8")).toBe(
|
|
43
|
+
"new"
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("puts the previous build back when there's no new one", () => {
|
|
48
|
+
fs.mkdirSync(path.join(dir, ".next-prev"));
|
|
49
|
+
fs.writeFileSync(path.join(dir, ".next-prev/BUILD_ID"), "old");
|
|
50
|
+
expect(recoverBuild(dir)).toBe(".next-prev");
|
|
51
|
+
expect(fs.readFileSync(path.join(dir, ".next/BUILD_ID"), "utf8")).toBe(
|
|
52
|
+
"old"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("leaves a live build alone", () => {
|
|
57
|
+
fs.mkdirSync(path.join(dir, ".next"));
|
|
58
|
+
fs.writeFileSync(path.join(dir, ".next/BUILD_ID"), "live");
|
|
59
|
+
fs.mkdirSync(path.join(dir, ".next-build"));
|
|
60
|
+
fs.writeFileSync(path.join(dir, ".next-build/BUILD_ID"), "built");
|
|
61
|
+
expect(recoverBuild(dir)).toBeNull();
|
|
62
|
+
expect(fs.readFileSync(path.join(dir, ".next/BUILD_ID"), "utf8")).toBe(
|
|
63
|
+
"live"
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("recoverRun", () => {
|
|
69
|
+
let from: string;
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
git("init", "-q");
|
|
72
|
+
from = commit("one");
|
|
73
|
+
commit("two");
|
|
74
|
+
});
|
|
75
|
+
const run = (over: object = {}) => ({
|
|
76
|
+
id: "r",
|
|
77
|
+
phase: "building",
|
|
78
|
+
from,
|
|
79
|
+
startedAt: NOW - 10 * 60_000,
|
|
80
|
+
updatedAt: NOW - 5 * 60_000,
|
|
81
|
+
pid: GONE,
|
|
82
|
+
...over,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("a run cut off after the pull puts the source back", () => {
|
|
86
|
+
write(run({ stage: "pulled" }));
|
|
87
|
+
expect(recoverRun(dir, state(), NOW)).toEqual(["source"]);
|
|
88
|
+
expect(git("rev-parse", "HEAD")).toBe(from);
|
|
89
|
+
expect(read()).toMatchObject({ phase: "failed", outcome: "interrupted" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("a run cut off after the dependencies swapped puts them back too", () => {
|
|
93
|
+
fs.mkdirSync(path.join(dir, "node_modules"));
|
|
94
|
+
fs.writeFileSync(path.join(dir, "node_modules/which"), "new");
|
|
95
|
+
fs.mkdirSync(path.join(dir, ".deps/prev"), { recursive: true });
|
|
96
|
+
fs.writeFileSync(path.join(dir, ".deps/prev/which"), "old");
|
|
97
|
+
write(run({ stage: "deps_swapped" }));
|
|
98
|
+
expect(recoverRun(dir, state(), NOW)).toEqual(["dependencies", "source"]);
|
|
99
|
+
expect(fs.readFileSync(path.join(dir, "node_modules/which"), "utf8")).toBe(
|
|
100
|
+
"old"
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a run cut off after the build swapped keeps the new version", () => {
|
|
105
|
+
const head = git("rev-parse", "HEAD");
|
|
106
|
+
write(run({ stage: "build_swapped", phase: "restarting" }));
|
|
107
|
+
expect(recoverRun(dir, state(), NOW)).toEqual([]);
|
|
108
|
+
expect(git("rev-parse", "HEAD")).toBe(head);
|
|
109
|
+
expect(read().outcome).toBe("interrupted");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("leaves a run whose runner is alive, or that is finished", () => {
|
|
113
|
+
write(run({ stage: "pulled", pid: process.pid }));
|
|
114
|
+
expect(recoverRun(dir, state(), NOW)).toBeNull();
|
|
115
|
+
write(run({ stage: "pulled", phase: "done" }));
|
|
116
|
+
expect(recoverRun(dir, state(), NOW)).toBeNull();
|
|
117
|
+
write(run({ stage: "pulled", updatedAt: NOW - 30_000 }));
|
|
118
|
+
expect(recoverRun(dir, state(), NOW)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("an old-style run without stages is only marked, never reset", () => {
|
|
122
|
+
const head = git("rev-parse", "HEAD");
|
|
123
|
+
write({ id: "o", phase: "building", from, startedAt: NOW - 3_600_000 });
|
|
124
|
+
expect(recoverRun(dir, state(), NOW)).toEqual([]);
|
|
125
|
+
expect(git("rev-parse", "HEAD")).toBe(head);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("isInterrupted needs both a stale heartbeat and a dead runner", () => {
|
|
129
|
+
expect(isInterrupted(run(), NOW)).toBe(true);
|
|
130
|
+
expect(isInterrupted(run({ pid: process.pid }), NOW)).toBe(false);
|
|
131
|
+
expect(isInterrupted(null, NOW)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("runs as a script without failing the start", () => {
|
|
135
|
+
write(run({ stage: "pulled" }));
|
|
136
|
+
const out = execFileSync(
|
|
137
|
+
process.execPath,
|
|
138
|
+
[path.join(import.meta.dirname, "update-recover.mjs")],
|
|
139
|
+
{ cwd: dir, encoding: "utf8", env: { ...process.env, DB_PATH: "" } }
|
|
140
|
+
);
|
|
141
|
+
expect(out).toContain("interrupted");
|
|
142
|
+
expect(git("rev-parse", "HEAD")).toBe(from);
|
|
143
|
+
});
|
|
144
|
+
});
|