@saadnvd1/agent-os 0.3.605 → 0.3.606

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/lib/env-setup.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import { bringDependencies, runCommand } from "./worktree-deps";
12
+ import { inside, realExisting } from "./worktree-deps/paths";
13
+ import { seedBuildCaches } from "./worktree-deps/caches";
12
14
  import { loadProjectConfig, projectEnv, withPorts } from "./project-config";
13
15
  import { recordPlaced } from "./worktree-placed";
14
16
  import { databaseEnv, ensureSessionDatabase } from "./project-config/database";
@@ -79,18 +81,6 @@ export async function copyEnvFiles(
79
81
  return copied;
80
82
  }
81
83
 
82
- const inside = (root: string, p: string) =>
83
- p === root || p.startsWith(root + path.sep);
84
-
85
- // The real path of `p`'s deepest part that exists.
86
- async function realExisting(p: string): Promise<string> {
87
- for (let at = p; ; at = path.dirname(at)) {
88
- const real = await fs.promises.realpath(at).catch(() => null);
89
- if (real) return real;
90
- if (path.dirname(at) === at) return at;
91
- }
92
- }
93
-
94
84
  /**
95
85
  * agentos.json's `copy`: files or folders from the main checkout, each one
96
86
  * resolved (symlinks included) to a place inside it, so a project can't
@@ -272,6 +262,31 @@ export async function setupWorktree(options: {
272
262
  );
273
263
  }
274
264
 
265
+ // Build caches, so the first typecheck, test or build is warm. An
266
+ // optimisation: it never fails a setup.
267
+ if (config.caches?.length !== 0 && sourcePath !== worktreePath) {
268
+ progress?.onStage?.("deps");
269
+ try {
270
+ const seeded = await seedBuildCaches({
271
+ sourcePath,
272
+ worktreePath,
273
+ paths: config.caches,
274
+ });
275
+ if (seeded.seeded.length || seeded.skipped.length)
276
+ result.steps.push({
277
+ name: "Seed build caches",
278
+ command: "cp (build caches)",
279
+ success: true,
280
+ output: [
281
+ ...seeded.seeded.map((c) => `${c.rel} (${c.how})`),
282
+ ...seeded.skipped.map((c) => `skipped ${c.rel}: ${c.why}`),
283
+ ].join("\n"),
284
+ });
285
+ } catch (error) {
286
+ console.error("Seeding build caches failed:", error);
287
+ }
288
+ }
289
+
275
290
  // 4. The config's setup commands
276
291
  if (config.setup && config.setup.length > 0) {
277
292
  progress?.onStage?.("script");
@@ -48,6 +48,20 @@
48
48
  "pattern": "^(?![/~\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$)).+$"
49
49
  }
50
50
  },
51
+ "caches": {
52
+ "type": "array",
53
+ "items": {
54
+ "type": "string",
55
+ "allOf": [
56
+ {
57
+ "pattern": "^(?![/~\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$)).+$"
58
+ },
59
+ {
60
+ "pattern": "^([^*]*[/\\\\])?[^*/\\\\]*\\*?[^*/\\\\]*$"
61
+ }
62
+ ]
63
+ }
64
+ },
51
65
  "dev": {
52
66
  "type": "string",
53
67
  "minLength": 1
@@ -28,6 +28,15 @@ const projectPath = z
28
28
  "must be a path inside the project (relative, no '..')"
29
29
  );
30
30
 
31
+ // Build caches to seed into a worktree: a path inside the project, with `*`
32
+ // allowed in the last segment (`*.tsbuildinfo`), never `.git`.
33
+ const cachePath = projectPath
34
+ .regex(
35
+ /^([^*]*[/\\])?[^*/\\]*\*?[^*/\\]*$/,
36
+ "`*` is allowed in the last segment only"
37
+ )
38
+ .refine((p) => !/(^|[/\\])\.git([/\\]|$)/.test(p), "not .git");
39
+
31
40
  const port = z.number().int().min(1024).max(65000);
32
41
  const notes = z.union([z.string(), z.array(z.string())]);
33
42
 
@@ -120,6 +129,7 @@ const shape = {
120
129
  setup: z.array(z.string().min(1)).optional(),
121
130
  copy: z.array(projectPath).optional(),
122
131
  clone: z.array(projectPath).optional(),
132
+ caches: z.array(cachePath).optional(),
123
133
  dev: z.string().min(1).optional(),
124
134
  ready: ready.optional(),
125
135
  deploy: deploy.optional(),
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Build caches seeded into a new worktree from the main checkout, so its
3
+ * first typecheck, test and build start warm: `.next/cache`, `.next/dev/cache`
4
+ * and `*.tsbuildinfo` for a Node project, or exactly agentos.json's `caches`.
5
+ * (Vitest's own cache is not a default: it only orders tests, and seeding it
6
+ * measured no gain.) A cache is seeded only when it is fresh, built since the
7
+ * worktree's base commit, and not huge: an old one makes the first run
8
+ * slower, not faster. Best effort: a cache that can't be seeded is skipped and never
9
+ * fails a setup.
10
+ *
11
+ * Only ever a copy: an APFS clone, a reflink, or (on a filesystem with
12
+ * neither) a plain copy of a small cache. Never a hardlink or a symlink, so
13
+ * nothing a worktree writes can reach the main checkout, and never from a
14
+ * place other than the main checkout.
15
+ */
16
+
17
+ import { execFile } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import { promisify } from "util";
21
+ import { gitResult, isGitError } from "../git-exec";
22
+ import { findNodeModules } from "./clone";
23
+ import { fastCopyMode } from "./fastcopy";
24
+ import { inside, realExisting } from "./paths";
25
+ import { copyTree } from "./spare";
26
+ import type { CopyMode } from "./spare";
27
+
28
+ const execFileAsync = promisify(execFile);
29
+
30
+ // Past these sizes a cache is skipped. A copy for real (no clone, no reflink)
31
+ // costs the disk; a clone of a huge one costs time, since the build then
32
+ // loads it: a main checkout's .next/cache that has grown to gigabytes built
33
+ // slower for a new worktree than starting empty (measured).
34
+ const DEFAULT_MAX_COPY_MB = 512;
35
+ const DEFAULT_MAX_CLONE_MB = 1024;
36
+
37
+ interface Stack {
38
+ name: string;
39
+ // Cache paths, relative to the checkout, for a project of this stack.
40
+ paths(sourcePath: string): Promise<string[]>;
41
+ }
42
+
43
+ const hasNext = (dir: string) => {
44
+ if (fs.readdirSync(dir).some((f) => /^next\.config\./.test(f))) return true;
45
+ try {
46
+ const pkg = JSON.parse(
47
+ fs.readFileSync(path.join(dir, "package.json"), "utf-8")
48
+ );
49
+ return !!(pkg.dependencies?.next ?? pkg.devDependencies?.next);
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+
55
+ async function nodePaths(sourcePath: string): Promise<string[]> {
56
+ const dirs = new Set<string>();
57
+ for (const rel of await findNodeModules(sourcePath))
58
+ dirs.add(path.dirname(rel));
59
+ if (fs.existsSync(path.join(sourcePath, "package.json"))) dirs.add(".");
60
+ const out: string[] = [];
61
+ for (const dir of dirs) {
62
+ const abs = path.join(sourcePath, dir);
63
+ if (!fs.existsSync(path.join(abs, "package.json"))) continue;
64
+ out.push(path.join(dir, "*.tsbuildinfo"));
65
+ if (hasNext(abs))
66
+ out.push(
67
+ path.join(dir, ".next", "cache"),
68
+ // Next 16's dev server keeps its Turbopack cache here.
69
+ path.join(dir, ".next", "dev", "cache")
70
+ );
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // One entry per stack. Add a stack here to give it defaults; a project can
76
+ // always name its own in agentos.json's `caches`.
77
+ export const STACKS: Stack[] = [
78
+ { name: "node", paths: nodePaths },
79
+ // iOS (DerivedData, Pods, ccache) is the agentos-mobile "Faster native iOS
80
+ // builds" task's: it plugs in here.
81
+ { name: "ios", paths: async () => [] },
82
+ // Rails is deliberately absent: bootsnap's load-path cache holds absolute
83
+ // paths, so a copy in another directory is stale on arrival.
84
+ ];
85
+
86
+ export async function defaultCaches(sourcePath: string): Promise<string[]> {
87
+ const all = await Promise.all(STACKS.map((s) => s.paths(sourcePath)));
88
+ return all.flat();
89
+ }
90
+
91
+ // `*` is allowed in the last segment only.
92
+ async function expand(sourcePath: string, rel: string): Promise<string[]> {
93
+ const base = path.basename(rel);
94
+ if (!base.includes("*")) return [rel];
95
+ const re = new RegExp(
96
+ "^" + base.split("*").map(escapeRe).join("[^/]*") + "$"
97
+ );
98
+ const dir = path.dirname(rel);
99
+ const names = await fs.promises
100
+ .readdir(path.join(sourcePath, dir))
101
+ .catch(() => [] as string[]);
102
+ return names.filter((n) => re.test(n)).map((n) => path.join(dir, n));
103
+ }
104
+ const escapeRe = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
105
+
106
+ // When the worktree's HEAD was committed; null when it can't be told.
107
+ async function headTimeMs(worktreePath: string): Promise<number | null> {
108
+ try {
109
+ const { stdout } = await gitResult(worktreePath, [
110
+ "log",
111
+ "-1",
112
+ "--format=%ct",
113
+ ]);
114
+ const seconds = parseInt(stdout, 10);
115
+ return Number.isFinite(seconds) ? seconds * 1000 : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ // The last write to a file, or to a folder or its direct children.
122
+ async function newestMs(p: string): Promise<number> {
123
+ const st = await fs.promises.stat(p);
124
+ let newest = st.mtimeMs;
125
+ if (st.isDirectory())
126
+ for (const name of await fs.promises.readdir(p)) {
127
+ const child = await fs.promises
128
+ .stat(path.join(p, name))
129
+ .catch(() => null);
130
+ if (child) newest = Math.max(newest, child.mtimeMs);
131
+ }
132
+ return newest;
133
+ }
134
+
135
+ async function sizeMb(p: string): Promise<number> {
136
+ const { stdout } = await execFileAsync("du", ["-sk", p]);
137
+ return parseInt(stdout, 10) / 1024;
138
+ }
139
+
140
+ // Git ignores it, or seeding it would leave an uncommitted file behind.
141
+ // Outside a repository there is no answer, and it is allowed.
142
+ async function notIgnored(worktreePath: string, rel: string): Promise<boolean> {
143
+ try {
144
+ await gitResult(worktreePath, ["check-ignore", "-q", "--", rel]);
145
+ return false;
146
+ } catch (error) {
147
+ return isGitError(error) && error.exitCode === 1;
148
+ }
149
+ }
150
+
151
+ export interface SeedResult {
152
+ seeded: Array<{ rel: string; how: CopyMode }>;
153
+ skipped: Array<{ rel: string; why: string }>;
154
+ }
155
+
156
+ export async function seedBuildCaches(opts: {
157
+ sourcePath: string;
158
+ worktreePath: string;
159
+ // agentos.json's `caches`; the stack defaults without it.
160
+ paths?: string[];
161
+ mode?: CopyMode;
162
+ platform?: NodeJS.Platform;
163
+ maxCopyMb?: number;
164
+ maxCloneMb?: number;
165
+ // Caches last written before this (ms) are stale. Default: the worktree's
166
+ // HEAD commit time; without one every cache counts as fresh.
167
+ freshAfterMs?: number;
168
+ }): Promise<SeedResult> {
169
+ const { sourcePath, worktreePath } = opts;
170
+ const result: SeedResult = { seeded: [], skipped: [] };
171
+ // The switch for measuring a start without seeding.
172
+ if (process.env.AGENTOS_NO_BUILD_CACHES) return result;
173
+ const declared = opts.paths ?? (await defaultCaches(sourcePath));
174
+ if (!declared.length) return result;
175
+
176
+ const fast = await fastCopyMode({
177
+ sourcePath,
178
+ worktreePath,
179
+ platform: opts.platform,
180
+ });
181
+ // Hardlinks are for dependencies from the store, never for a cache.
182
+ const mode: CopyMode =
183
+ opts.mode ?? (fast === "clone" || fast === "reflink" ? fast : "copy");
184
+ const max =
185
+ opts.maxCopyMb ??
186
+ Number(process.env.AGENTOS_CACHE_COPY_MAX_MB || DEFAULT_MAX_COPY_MB);
187
+ const maxClone =
188
+ opts.maxCloneMb ??
189
+ Number(process.env.AGENTOS_CACHE_CLONE_MAX_MB || DEFAULT_MAX_CLONE_MB);
190
+ const freshAfter = opts.freshAfterMs ?? (await headTimeMs(worktreePath));
191
+ const root = await fs.promises.realpath(sourcePath);
192
+ const target = await fs.promises.realpath(worktreePath);
193
+
194
+ const skip = (rel: string, why: string) => result.skipped.push({ rel, why });
195
+
196
+ for (const rel of (
197
+ await Promise.all(declared.map((d) => expand(sourcePath, d)))
198
+ ).flat()) {
199
+ const real = await fs.promises
200
+ .realpath(path.join(sourcePath, rel))
201
+ .catch(() => null);
202
+ if (!real) continue; // not built in the main checkout yet
203
+ if (!inside(root, real)) {
204
+ skip(rel, "resolves outside the project");
205
+ continue;
206
+ }
207
+ const dir = path.join(target, path.dirname(rel));
208
+ if (!inside(target, await realExisting(dir))) {
209
+ skip(rel, "its folder in the worktree resolves outside it");
210
+ continue;
211
+ }
212
+ const dest = path.join(dir, path.basename(rel));
213
+ if (await fs.promises.lstat(dest).catch(() => null)) continue; // already there
214
+ if (await notIgnored(worktreePath, rel)) {
215
+ skip(rel, "not ignored by git");
216
+ continue;
217
+ }
218
+ if (freshAfter !== null && (await newestMs(real)) < freshAfter) {
219
+ skip(rel, "older than the worktree's base commit");
220
+ continue;
221
+ }
222
+ const limit = mode === "copy" ? max : maxClone;
223
+ if ((await sizeMb(real).catch(() => Infinity)) > limit) {
224
+ skip(
225
+ rel,
226
+ mode === "copy"
227
+ ? `bigger than ${limit} MB and this filesystem can't clone`
228
+ : `bigger than ${limit} MB`
229
+ );
230
+ continue;
231
+ }
232
+ await fs.promises.mkdir(dir, { recursive: true });
233
+ if (!inside(target, await fs.promises.realpath(dir))) {
234
+ skip(rel, "its folder in the worktree resolves outside it");
235
+ continue;
236
+ }
237
+ const partial = `${dest}.aos-partial`;
238
+ await fs.promises.rm(partial, { recursive: true, force: true });
239
+ const ok =
240
+ (await copyTree(real, partial, mode)) &&
241
+ (await fs.promises.rename(partial, dest).then(
242
+ () => true,
243
+ () => false
244
+ ));
245
+ await fs.promises.rm(partial, { recursive: true, force: true });
246
+ if (ok) result.seeded.push({ rel, how: mode });
247
+ else skip(rel, "the copy failed");
248
+ }
249
+ return result;
250
+ }
@@ -10,6 +10,8 @@ import { createHash } from "crypto";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
12
  import { LOCKFILES } from "./install";
13
+ import { STORE_ROOT, fastCopyMode, unlinkMutable } from "./fastcopy";
14
+ import type { FastMode } from "./fastcopy";
13
15
  import {
14
16
  SPARE_ROOT,
15
17
  copyTree,
@@ -72,11 +74,15 @@ export async function findNodeModules(sourcePath: string): Promise<string[]> {
72
74
 
73
75
  // Cloned beside the target and renamed into place, so a clone cut off by a
74
76
  // restart or a failed cp never passes for a finished one.
75
- async function cloneWhole(from: string, target: string): Promise<boolean> {
77
+ async function cloneWhole(
78
+ from: string,
79
+ target: string,
80
+ mode: FastMode
81
+ ): Promise<boolean> {
76
82
  const partial = `${target}.aos-partial`;
77
83
  await fs.promises.rm(partial, { recursive: true, force: true });
78
84
  const ok =
79
- (await copyTree(from, partial)) && (await takeSpare(partial, target));
85
+ (await copyTree(from, partial, mode)) && (await takeSpare(partial, target));
80
86
  await fs.promises.rm(partial, { recursive: true, force: true });
81
87
  return ok;
82
88
  }
@@ -86,11 +92,14 @@ export interface CloneResult {
86
92
  // Why it didn't clone, when it didn't.
87
93
  reason?: string;
88
94
  cloned: Array<{ rel: string; from: "spare" | "clone" }>;
95
+ mode?: FastMode;
96
+ skipped?: string[];
89
97
  // The spares being made for the next start. Nothing waits on it but tests.
90
98
  refill?: Promise<unknown>;
91
99
  }
92
100
 
93
- export const NOT_MAC = "clones are macOS only";
101
+ export const NO_FAST_COPY =
102
+ "no clone, reflink or hardlink store is possible here";
94
103
 
95
104
  export async function cloneDeps(opts: {
96
105
  sourcePath: string;
@@ -99,13 +108,26 @@ export async function cloneDeps(opts: {
99
108
  // node_modules findNodeModules finds.
100
109
  only?: string[];
101
110
  spareRoot?: string;
111
+ // Where Linux keeps the hardlinked store (fastcopy.ts).
112
+ storeRoot?: string;
102
113
  platform?: NodeJS.Platform;
114
+ // Forced instead of detected; null is "none works" (tests).
115
+ mode?: FastMode | null;
103
116
  }): Promise<CloneResult> {
104
117
  const { sourcePath, worktreePath, only } = opts;
105
118
  const root = opts.spareRoot ?? SPARE_ROOT;
106
119
  const cloned: CloneResult["cloned"] = [];
107
- if ((opts.platform ?? process.platform) !== "darwin")
108
- return { ok: false, reason: NOT_MAC, cloned };
120
+ const storeRoot = opts.storeRoot ?? STORE_ROOT;
121
+ const mode =
122
+ opts.mode !== undefined
123
+ ? opts.mode
124
+ : await fastCopyMode({
125
+ sourcePath,
126
+ worktreePath,
127
+ storeRoot,
128
+ platform: opts.platform,
129
+ });
130
+ if (!mode) return { ok: false, reason: NO_FAST_COPY, cloned };
109
131
 
110
132
  const isModules = (rel: string) => path.basename(rel) === "node_modules";
111
133
  const all = only
@@ -124,6 +146,13 @@ export async function cloneDeps(opts: {
124
146
  };
125
147
  if (!lock)
126
148
  return { ok: false, reason: "the main checkout has no lockfile", cloned };
149
+ if (!(await nodeModulesMatchLock(sourcePath)))
150
+ return {
151
+ ok: false,
152
+ reason:
153
+ "the main checkout's node_modules is not what its lockfile lists",
154
+ cloned,
155
+ };
127
156
  if (lock !== (await lockfileHash(worktreePath)))
128
157
  return {
129
158
  ok: false,
@@ -133,16 +162,22 @@ export async function cloneDeps(opts: {
133
162
  }
134
163
 
135
164
  const spares: Array<{ rel: string; lock: string }> = [];
165
+ const skipped: string[] = [];
136
166
  for (const rel of all) {
137
167
  const from = path.join(sourcePath, rel);
138
168
  const target = path.join(worktreePath, rel);
139
169
  if (!(await isDir(from))) continue;
140
170
  // A directory this branch doesn't have, or the files already there.
141
171
  if (!(await isDir(path.dirname(target))) || fs.existsSync(target)) continue;
172
+ if (!isModules(rel) && mode === "hardlink") {
173
+ // Mutable, and hardlinks would write back: left to the setup commands.
174
+ skipped.push(`${rel} (not linked)`);
175
+ continue;
176
+ }
142
177
  if (!isModules(rel)) {
143
178
  // Anything else declared (a virtualenv, vendor/bundle) is cloned as
144
179
  // is: there is no lockfile AgentOS knows to check it against.
145
- if (!(await cloneWhole(from, target)))
180
+ if (!(await cloneWhole(from, target, mode)))
146
181
  return { ok: false, reason: `cloning ${rel} failed`, cloned };
147
182
  cloned.push({ rel, from: "clone" });
148
183
  continue;
@@ -154,10 +189,30 @@ export async function cloneDeps(opts: {
154
189
  if (rel !== "node_modules" && own !== ownHere) continue;
155
190
  const key = own ?? lock;
156
191
  if (!key) continue;
192
+ if (mode === "hardlink") {
193
+ // Linked from the store, never from the checkout; the store is made
194
+ // once per lockfile with a plain copy.
195
+ const stored = sparePath(storeRoot, sourcePath, rel, key);
196
+ if (!fs.existsSync(stored))
197
+ await replenishSpare(storeRoot, sourcePath, rel, key, "copy").catch(
198
+ () => undefined
199
+ );
200
+ if (!fs.existsSync(stored))
201
+ return {
202
+ ok: false,
203
+ reason: `making the store for ${rel} failed`,
204
+ cloned,
205
+ };
206
+ if (!(await cloneWhole(stored, target, mode)))
207
+ return { ok: false, reason: `linking ${rel} failed`, cloned };
208
+ await unlinkMutable(target).catch(() => undefined);
209
+ cloned.push({ rel, from: "clone" });
210
+ continue;
211
+ }
157
212
  const spare = sparePath(root, sourcePath, rel, key);
158
213
  if (await takeSpare(spare, target)) {
159
214
  cloned.push({ rel, from: "spare" });
160
- } else if (await cloneWhole(from, target)) {
215
+ } else if (await cloneWhole(from, target, mode)) {
161
216
  cloned.push({ rel, from: "clone" });
162
217
  } else {
163
218
  return { ok: false, reason: `cloning ${rel} failed`, cloned };
@@ -167,10 +222,40 @@ export async function cloneDeps(opts: {
167
222
 
168
223
  const refill = Promise.all(
169
224
  spares.map(({ rel, lock: key }) =>
170
- replenishSpare(root, sourcePath, rel, key).catch((error) =>
225
+ replenishSpare(root, sourcePath, rel, key, mode).catch((error) =>
171
226
  console.error(`Spare for ${rel} failed:`, error)
172
227
  )
173
228
  )
174
229
  );
175
- return { ok: true, cloned, refill };
230
+ return { ok: true, cloned, refill, mode, skipped };
231
+ }
232
+
233
+ // Whether the checkout's node_modules is what its npm lockfile says: every
234
+ // package the lockfile lists (optional ones, which a platform may skip, aside)
235
+ // is installed at that version. A checkout pulled but never reinstalled is
236
+ // not, and cloning it would hand the worktree missing dependencies. Other
237
+ // package managers have no hidden lockfile to check, so they pass.
238
+ export async function nodeModulesMatchLock(dir: string): Promise<boolean> {
239
+ const read = (file: string) =>
240
+ fs.promises
241
+ .readFile(file, "utf-8")
242
+ .then(
243
+ (text) =>
244
+ JSON.parse(text) as {
245
+ packages?: Record<string, { version?: string; optional?: boolean }>;
246
+ }
247
+ )
248
+ .catch(() => null);
249
+ const lock = await read(path.join(dir, "package-lock.json"));
250
+ if (!lock?.packages) return true;
251
+ const installed = await read(
252
+ path.join(dir, "node_modules", ".package-lock.json")
253
+ );
254
+ if (!installed?.packages) return false;
255
+ return Object.entries(lock.packages).every(
256
+ ([name, pkg]) =>
257
+ name === "" ||
258
+ pkg.optional ||
259
+ installed.packages![name]?.version === pkg.version
260
+ );
176
261
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * How a Linux (or macOS) worktree can be given a copy of the main checkout's
3
+ * files without paying for every byte: an APFS clone, a reflink where the
4
+ * filesystem has them, or hardlinks from a store AgentOS owns. Null when
5
+ * none works, and the caller installs or copies plainly instead.
6
+ */
7
+
8
+ import { execFile } from "child_process";
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+ import { promisify } from "util";
13
+ import type { CopyMode } from "./spare";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ export type FastMode = Exclude<CopyMode, "copy">;
18
+
19
+ // Where hardlinked dependencies are kept: one real copy per lockfile, so a
20
+ // write through a link can only ever reach this, never the main checkout.
21
+ export const STORE_ROOT = path.join(os.homedir(), ".agent-os", "deps-store");
22
+
23
+ // Files tools rewrite in place. A hardlink would carry the write back into
24
+ // the store, so these are real copies in every worktree.
25
+ export const MUTABLE_IN_MODULES = [".package-lock.json", ".cache", ".vite"];
26
+
27
+ const probed = new Map<string, boolean>();
28
+
29
+ // Whether `dir`'s filesystem reflinks: tried on a scratch file made there.
30
+ export async function canReflink(dir: string): Promise<boolean> {
31
+ const dev = String((await fs.promises.stat(dir)).dev);
32
+ const known = probed.get(dev);
33
+ if (known !== undefined) return known;
34
+ const probe = path.join(dir, `.aos-reflink-probe-${process.pid}`);
35
+ let ok = false;
36
+ try {
37
+ await fs.promises.writeFile(probe, "x");
38
+ await execFileAsync("cp", ["--reflink=always", probe, `${probe}.copy`]);
39
+ ok = true;
40
+ } catch {
41
+ ok = false;
42
+ } finally {
43
+ await fs.promises.rm(probe, { force: true });
44
+ await fs.promises.rm(`${probe}.copy`, { force: true });
45
+ }
46
+ probed.set(dev, ok);
47
+ return ok;
48
+ }
49
+
50
+ const device = (p: string) =>
51
+ fs.promises.stat(p).then(
52
+ (s) => s.dev,
53
+ () => null
54
+ );
55
+
56
+ export async function fastCopyMode(opts: {
57
+ sourcePath: string;
58
+ worktreePath: string;
59
+ storeRoot?: string;
60
+ platform?: NodeJS.Platform;
61
+ }): Promise<FastMode | null> {
62
+ const platform = opts.platform ?? process.platform;
63
+ if (platform === "darwin") return "clone";
64
+ if (platform !== "linux") return null;
65
+ const wt = await device(opts.worktreePath);
66
+ if (wt === null) return null;
67
+ if (
68
+ (await device(opts.sourcePath)) === wt &&
69
+ (await canReflink(opts.worktreePath))
70
+ )
71
+ return "reflink";
72
+ const store = opts.storeRoot ?? STORE_ROOT;
73
+ await fs.promises.mkdir(store, { recursive: true }).catch(() => undefined);
74
+ return (await device(store)) === wt ? "hardlink" : null;
75
+ }
76
+
77
+ // Replace the entries of a hardlinked node_modules that tools rewrite in
78
+ // place with real copies.
79
+ export async function unlinkMutable(modules: string): Promise<void> {
80
+ for (const name of MUTABLE_IN_MODULES) {
81
+ const entry = path.join(modules, name);
82
+ if (!(await fs.promises.lstat(entry).catch(() => null))) continue;
83
+ const copy = `${entry}.aos-copy`;
84
+ await fs.promises.rm(copy, { recursive: true, force: true });
85
+ await fs.promises.cp(entry, copy, { recursive: true });
86
+ await fs.promises.rm(entry, { recursive: true, force: true });
87
+ await fs.promises.rename(copy, entry);
88
+ }
89
+ }
@@ -6,11 +6,17 @@
6
6
  import { exec } from "child_process";
7
7
  import { promisify } from "util";
8
8
  import type { SetupResult } from "../env-setup";
9
- import { NOT_MAC, cloneDeps } from "./clone";
9
+ import { NO_FAST_COPY, cloneDeps } from "./clone";
10
10
  import { detectPackageManager, setupEnv } from "./install";
11
11
 
12
12
  const execAsync = promisify(exec);
13
13
 
14
+ const COMMANDS = {
15
+ clone: "cp -Rc",
16
+ reflink: "cp -a --reflink=always",
17
+ hardlink: "cp -al (from the store)",
18
+ };
19
+
14
20
  /**
15
21
  * Run a setup command in the worktree directory
16
22
  */
@@ -57,15 +63,18 @@ export async function bringDependencies(
57
63
  if (pm) result.packageManager = pm.name;
58
64
 
59
65
  const clone = await deps.clone({ sourcePath, worktreePath, only });
60
- // Off macOS only node_modules has a fallback (the install below).
61
- if (!pm && !clone.ok && !clone.cloned.length && clone.reason === NOT_MAC)
66
+ // Without a fast copy only node_modules has a fallback (the install below).
67
+ if (!pm && !clone.ok && !clone.cloned.length && clone.reason === NO_FAST_COPY)
62
68
  return;
63
69
  if (clone.ok || !pm) {
64
70
  result.steps.push({
65
71
  name: "Clone dependencies",
66
- command: "cp -Rc",
72
+ command: clone.mode ? COMMANDS[clone.mode] : "cp",
67
73
  success: clone.ok,
68
- output: clone.cloned.map((c) => `${c.rel} (${c.from})`).join(", "),
74
+ output: [
75
+ ...clone.cloned.map((c) => `${c.rel} (${c.from})`),
76
+ ...(clone.skipped ?? []).map((r) => `skipped ${r}`),
77
+ ].join(", "),
69
78
  error: clone.ok ? undefined : clone.reason,
70
79
  });
71
80
  if (!clone.ok) result.success = false;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Path checks shared by what setup places into a worktree (agentos.json's
3
+ * `copy`, `clone` and `caches`): everything resolved through symlinks to a
4
+ * place inside its checkout, so a link can't carry a copy somewhere else.
5
+ */
6
+
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+
10
+ export const inside = (root: string, p: string) =>
11
+ p === root || p.startsWith(root + path.sep);
12
+
13
+ // The real path of `p`'s deepest part that exists.
14
+ export async function realExisting(p: string): Promise<string> {
15
+ for (let at = p; ; at = path.dirname(at)) {
16
+ const real = await fs.promises.realpath(at).catch(() => null);
17
+ if (real) return real;
18
+ if (path.dirname(at) === at) return at;
19
+ }
20
+ }
@@ -49,18 +49,35 @@ export async function takeSpare(
49
49
  }
50
50
  }
51
51
 
52
- // macOS clones (APFS, no extra disk); elsewhere this is a plain copy, which
53
- // production never asks for: Linux worktrees install instead.
54
- export const cloneArgs = (from: string, to: string) =>
55
- process.platform === "darwin" ? ["-Rc", from, to] : ["-R", from, to];
52
+ // How a tree is copied: an APFS clone (macOS), a reflink (Linux on a
53
+ // copy-on-write filesystem), hardlinks (Linux, from AgentOS's own store), or
54
+ // a plain copy.
55
+ export type CopyMode = "clone" | "reflink" | "hardlink" | "copy";
56
+
57
+ export const copyArgs = (mode: CopyMode, from: string, to: string) =>
58
+ mode === "clone"
59
+ ? [process.platform === "darwin" ? "-Rc" : "-R", from, to]
60
+ : mode === "reflink"
61
+ ? ["-a", "--reflink=always", from, to]
62
+ : mode === "hardlink"
63
+ ? ["-al", from, to]
64
+ : ["-a", from, to];
65
+
66
+ // What a copy with no mode given does: clones on macOS, a plain copy elsewhere.
67
+ const defaultMode = (): CopyMode =>
68
+ process.platform === "darwin" ? "clone" : "copy";
56
69
 
57
70
  // A clone that stalls (a locked or dying volume) is killed rather than
58
71
  // holding a task's start forever.
59
72
  const COPY_TIMEOUT_MS = 10 * 60 * 1000;
60
73
 
61
- export function copyTree(from: string, to: string): Promise<boolean> {
74
+ export function copyTree(
75
+ from: string,
76
+ to: string,
77
+ mode: CopyMode = defaultMode()
78
+ ): Promise<boolean> {
62
79
  return new Promise((resolve) => {
63
- const child = spawn("cp", cloneArgs(from, to), {
80
+ const child = spawn("cp", copyArgs(mode, from, to), {
64
81
  stdio: "ignore",
65
82
  timeout: COPY_TIMEOUT_MS,
66
83
  killSignal: "SIGKILL",
@@ -104,7 +121,8 @@ export async function replenishSpare(
104
121
  root: string,
105
122
  sourcePath: string,
106
123
  rel: string,
107
- lockHash: string
124
+ lockHash: string,
125
+ mode?: CopyMode
108
126
  ): Promise<"made" | "exists" | "failed"> {
109
127
  const spare = sparePath(root, sourcePath, rel, lockHash);
110
128
  if (building.has(spare) || fs.existsSync(spare)) return "exists";
@@ -113,7 +131,7 @@ export async function replenishSpare(
113
131
  await fs.promises.mkdir(root, { recursive: true });
114
132
  await pruneSpares(root, sourcePath, rel, spare);
115
133
  const tmp = `${spare}.${process.pid}-${Date.now()}`;
116
- let made = await copyTree(path.join(sourcePath, rel), tmp);
134
+ let made = await copyTree(path.join(sourcePath, rel), tmp, mode);
117
135
  if (made) made = await takeSpare(tmp, spare);
118
136
  await fs.promises.rm(tmp, { recursive: true, force: true });
119
137
  return made ? "made" : "failed";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.605",
3
+ "version": "0.3.606",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Times a new worktree's setup and its first typecheck, test and build, and
3
+ * the disk it takes, against a checkout. Run it with the seeding on, then
4
+ * with AGENTOS_NO_BUILD_CACHES=1 (and, to see an install on Linux, --install).
5
+ *
6
+ * npx tsx scripts/bench/worktree-setup.ts <checkout> [--test <file>]
7
+ * [--build] [--install] [--cwd <subdir>] [--drop <seeded path>]
8
+ *
9
+ * It makes a throwaway git worktree of the checkout's HEAD in the temp
10
+ * directory and removes it at the end; it touches nothing else.
11
+ */
12
+
13
+ import { execFileSync, spawnSync } from "child_process";
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+ import * as path from "path";
17
+ import { setupWorktree } from "../../lib/env-setup";
18
+
19
+ const args = process.argv.slice(2);
20
+ const flag = (name: string) => args.includes(name);
21
+ const value = (name: string) => args[args.indexOf(name) + 1];
22
+ const source = path.resolve(args[0]);
23
+ const sub = flag("--cwd") ? value("--cwd") : ".";
24
+
25
+ const freeKb = (dir: string) =>
26
+ Number(
27
+ execFileSync("df", ["-Pk", dir], { encoding: "utf-8" })
28
+ .trim()
29
+ .split("\n")[1]
30
+ .split(/\s+/)[3]
31
+ );
32
+
33
+ function timed(command: string, cwd: string) {
34
+ const start = Date.now();
35
+ const run = spawnSync(command, { cwd, shell: true, stdio: "ignore" });
36
+ return { seconds: (Date.now() - start) / 1000, ok: run.status === 0 };
37
+ }
38
+
39
+ async function main() {
40
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-bench-"));
41
+ const worktree = path.join(root, "wt");
42
+ execFileSync("git", ["worktree", "add", "-q", "--detach", worktree, "HEAD"], {
43
+ cwd: source,
44
+ });
45
+ const out: Record<string, unknown> = {
46
+ source: path.basename(source),
47
+ platform: process.platform,
48
+ seeding: process.env.AGENTOS_NO_BUILD_CACHES ? "off" : "on",
49
+ };
50
+ try {
51
+ const before = freeKb(root);
52
+ const start = Date.now();
53
+ let steps: string[] = [];
54
+ if (flag("--install")) {
55
+ const r = timed("npm ci --include=dev", worktree);
56
+ steps = [`npm ci ok=${r.ok}`];
57
+ } else {
58
+ const result = await setupWorktree({
59
+ worktreePath: worktree,
60
+ sourcePath: source,
61
+ });
62
+ steps = result.steps.map(
63
+ (s) => `${s.name}: ${s.success ? "ok" : "failed"}`
64
+ );
65
+ }
66
+ out.setupSeconds = (Date.now() - start) / 1000;
67
+ out.setupSteps = steps;
68
+ out.diskMbAfterSetup = Math.round((before - freeKb(root)) / 1024);
69
+ // Take a seeded cache back out to measure without it.
70
+ if (flag("--drop"))
71
+ fs.rmSync(path.join(worktree, value("--drop")), {
72
+ recursive: true,
73
+ force: true,
74
+ });
75
+ const dir = path.join(worktree, sub);
76
+ const pkg = path.join(dir, "package.json");
77
+ const typecheck = fs.existsSync(pkg)
78
+ ? JSON.parse(fs.readFileSync(pkg, "utf-8")).scripts?.typecheck
79
+ : undefined;
80
+ if (typecheck) out.typecheck = timed("npm run typecheck", dir);
81
+ else if (fs.existsSync(path.join(dir, "tsconfig.json")))
82
+ out.typecheck = timed("npx tsc --noEmit", dir);
83
+ if (flag("--test"))
84
+ out.test = timed(`npx vitest run ${value("--test")}`, dir);
85
+ if (flag("--build")) {
86
+ out.build = timed("npx next build", dir);
87
+ out.diskMbAfterBuild = Math.round((before - freeKb(root)) / 1024);
88
+ }
89
+ } finally {
90
+ execFileSync("git", ["worktree", "remove", "--force", worktree], {
91
+ cwd: source,
92
+ });
93
+ fs.rmSync(root, { recursive: true, force: true });
94
+ }
95
+ console.log(JSON.stringify(out));
96
+ }
97
+
98
+ main().catch((error) => {
99
+ console.error(error);
100
+ process.exit(1);
101
+ });