Compare commits

...
5 changed files with 226 additions and 121 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ than downloading a published desktop release.
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -1,9 +1,12 @@
import { expect, test } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, mkdir, writeFile, readdir, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { execFileSync, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { AgentStorage, parseStoredAgentRecord } from "../agent-storage.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { readPersistedConfig } from "../../persisted-config.js";
import { createRestartRecovery } from "./index.js";
test("recovery saves intent before normalization, clears before dispatch, and resumes each agent independently", async () => {
@@ -103,3 +106,130 @@ test("opt-out consumes pending recovery and excludes archived, private, owned an
await rm(root, { recursive: true, force: true });
}
});
const bashAvailable =
process.platform !== "win32" &&
spawnSync("bash", ["-c", "true"], { stdio: "ignore" }).status === 0;
for (const seeder of ["setup", "shell", "setup-bom"] as const) {
test.skipIf(seeder === "shell" && !bashAvailable)(
`${seeder} seed never dispatches copied running or pending agents and excludes automation`,
async () => {
const root = await mkdtemp(join(tmpdir(), "inert-seed-"));
try {
const source = join(root, "source");
const targetRoot = join(root, "target");
const target = join(targetRoot, ".dev/paseo-home");
const storage = new AgentStorage(join(root, "source-records"), createTestLogger());
for (const [id, lastStatus, resumeOnStartup] of [
["live", "running", false],
["crashed", "running", true],
["stopped", "closed", true],
] as const) {
await storage.upsert(
parseStoredAgentRecord({
id,
lastStatus,
resumeOnStartup,
provider: "codex",
cwd: root,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
);
}
await storage.flush();
await mkdir(source, { recursive: true });
await symlink(
join(root, "source-records"),
join(source, "agents"),
process.platform === "win32" ? "junction" : "dir",
);
await mkdir(join(source, "schedules"), { recursive: true });
await writeFile(join(source, "schedules", "schedule.json"), '{"kind":"scheduled"}');
await writeFile(join(source, "schedules", "heartbeat.json"), '{"kind":"heartbeat"}');
await writeFile(
join(source, "config.json"),
(seeder === "setup-bom" ? "\uFEFF" : "") +
JSON.stringify({
version: 1,
agents: { resumeOnStartup: true, resumePrompt: "Continue" },
}),
);
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
const env = {
...process.env,
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
PASEO_WORKTREE_PATH: targetRoot,
PASEO_DEV_SEED_HOME: source,
PASEO_HOME: target,
};
if (seeder !== "shell")
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
env,
});
else {
const linkedRepo = join(root, "linked-checkout");
await symlink(repo, linkedRepo, "dir");
execFileSync("bash", [join(linkedRepo, "scripts/dev-home.sh")], { env });
}
expect((await readdir(target)).sort()).toEqual(["agents", "config.json"]);
const config = readPersistedConfig(target);
const copied = new AgentStorage(join(target, "agents"), createTestLogger());
const dispatched: string[] = [];
const recovery = createRestartRecovery({
storage: copied,
settings: () => ({
enabled: config.agents?.resumeOnStartup === true,
prompt: "Continue",
}),
eligible: async () => true,
dispatch: async (id) => {
dispatched.push(id);
},
report: () => {},
});
await recovery.prepareStartup();
recovery.resumePending();
for (const record of await copied.listAll())
await recovery.waitForResume(record.id, new AbortController().signal);
expect(dispatched).toEqual([]);
expect((await copied.listAll()).map((r) => [r.lastStatus, r.resumeOnStartup])).toEqual([
["closed", false],
["closed", false],
["closed", false],
]);
expect(readPersistedConfig(source).agents?.resumeOnStartup).toBe(true);
const unchangedSource = new AgentStorage(join(root, "source-records"), createTestLogger());
expect((await unchangedSource.get("live"))?.lastStatus).toBe("running");
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
}
test("seeding skips an existing home without reading invalid source config", async () => {
const root = await mkdtemp(join(tmpdir(), "seed-skip-"));
try {
const source = join(root, "source");
const targetRoot = join(root, "target");
const target = join(targetRoot, ".dev/paseo-home");
await mkdir(source, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(join(source, "config.json"), "invalid JSON");
await writeFile(join(target, "keep.json"), "{}");
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
env: {
...process.env,
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
PASEO_WORKTREE_PATH: targetRoot,
PASEO_DEV_SEED_HOME: source,
PASEO_DEV_RESET_HOME: "0",
},
});
expect(await readdir(target)).toEqual(["keep.json"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+3 -55
View File
@@ -4,62 +4,10 @@ default_dev_paseo_root() {
git rev-parse --show-toplevel 2>/dev/null || pwd
}
copy_json_tree() {
local source_dir="$1"
local target_dir="$2"
if [ ! -d "$source_dir" ]; then
return
fi
mkdir -p "$target_dir"
if command -v rsync >/dev/null 2>&1; then
rsync -a --include='*/' --include='*.json' --exclude='*' "$source_dir/" "$target_dir/"
return
fi
while IFS= read -r -d '' source_file; do
local relative_path="${source_file#"$source_dir"/}"
local target_file="$target_dir/$relative_path"
mkdir -p "$(dirname "$target_file")"
cp "$source_file" "$target_file"
done < <(find "$source_dir" -type f -name '*.json' -print0)
}
has_files() {
[ -d "$1" ] && [ -n "$(find "$1" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]
}
seed_worktree_paseo_home() {
local source_home="${PASEO_DEV_SEED_HOME:-$HOME/.paseo}"
local target_home="$1"
if [ ! -d "$source_home" ]; then
echo " Seed: skipped (${source_home} missing)"
return
fi
if [ "$source_home" = "$target_home" ]; then
echo " Seed: skipped (source is target)"
return
fi
if [ "${PASEO_DEV_RESET_HOME:-0}" = "1" ]; then
rm -rf "$target_home"
elif has_files "$target_home"; then
echo " Seed: skipped (${target_home} already has data)"
return
fi
mkdir -p "$target_home"
echo " Seed: copying metadata from ${source_home}"
copy_json_tree "$source_home/agents" "$target_home/agents"
copy_json_tree "$source_home/projects" "$target_home/projects"
if [ -f "$source_home/config.json" ]; then
cp "$source_home/config.json" "$target_home/config.json"
fi
echo " Seed: copied metadata from ${source_home}"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
node "$script_dir/seed-dev-home.mjs" "${PASEO_DEV_SEED_HOME:-$HOME/.paseo}" "$1"
}
configure_dev_daemon_config() {
+84
View File
@@ -0,0 +1,84 @@
import {
cpSync,
realpathSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
export function seedPaseoHome(source, target) {
if (!existsSync(source)) {
console.log(` Seed: skipped (${source} missing)`);
return;
}
if (source === target) {
console.log(" Seed: skipped (source is target)");
return;
}
if (process.env.PASEO_DEV_RESET_HOME !== "1" && hasEntries(target)) {
console.log(` Seed: skipped (${target} already has data)`);
return;
}
const configPath = join(source, "config.json");
const config = existsSync(configPath)
? JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, ""))
: { version: 1 };
if (process.env.PASEO_DEV_RESET_HOME === "1") {
rmSync(target, { recursive: true, force: true });
}
mkdirSync(target, { recursive: true });
console.log(` Seed: copying metadata from ${source}`);
copyJsonTree(join(source, "agents"), join(target, "agents"));
copyJsonTree(join(source, "projects"), join(target, "projects"));
// A snapshot does not own the source daemon's pending work. Recovery normalizes
// copied running records on startup without sending a prompt when disabled.
config.agents = { ...config.agents, resumeOnStartup: false };
writeFileSync(join(target, "config.json"), JSON.stringify(config, null, 2));
console.log(` Seed: copied metadata from ${source}`);
}
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
function copyJsonTree(source, target) {
if (!existsSync(source)) {
return;
}
cpSync(source, target, {
recursive: true,
dereference: true,
filter: (path) => isDirectory(path) || path.endsWith(".json"),
});
}
function hasEntries(path) {
try {
return readdirSync(path).length > 0;
} catch {
return false;
}
}
function isDirectory(path) {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
seedPaseoHome(process.argv[2], process.argv[3]);
}
+7 -64
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import { cpSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
// Worktree setup runs through a stable script shell: bash on macOS/Linux, but PowerShell
@@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
// `./scripts/*.sh`) cannot express this step portably, so the seeding lives in Node — the
// one interpreter every Paseo checkout already depends on.
import { seedPaseoHome } from "./seed-dev-home.mjs";
const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH;
const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd();
@@ -14,55 +16,12 @@ if (!sourceRoot || sourceRoot === targetRoot) {
process.exit(0);
}
seedPaseoHome();
seedPaseoHome(
process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home"),
join(targetRoot, ".dev/paseo-home"),
);
copyServerEnv();
function seedPaseoHome() {
const source = process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home");
const target = join(targetRoot, ".dev/paseo-home");
if (!existsSync(source)) {
console.log(` Seed: skipped (${source} missing)`);
return;
}
if (source === target) {
console.log(" Seed: skipped (source is target)");
return;
}
if (process.env.PASEO_DEV_RESET_HOME === "1") {
rmSync(target, { recursive: true, force: true });
} else if (hasEntries(target)) {
console.log(` Seed: skipped (${target} already has data)`);
return;
}
mkdirSync(target, { recursive: true });
console.log(` Seed: copying metadata from ${source}`);
copyJsonTree(join(source, "agents"), join(target, "agents"));
copyJsonTree(join(source, "projects"), join(target, "projects"));
const config = join(source, "config.json");
if (existsSync(config)) {
cpSync(config, join(target, "config.json"));
}
console.log(` Seed: copied metadata from ${source}`);
}
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
function copyJsonTree(source, target) {
if (!existsSync(source)) {
return;
}
cpSync(source, target, {
recursive: true,
filter: (path) => isDirectory(path) || path.endsWith(".json"),
});
}
function copyServerEnv() {
const source = join(sourceRoot, "packages/server/.env");
const target = join(targetRoot, "packages/server/.env");
@@ -77,19 +36,3 @@ function copyServerEnv() {
cpSync(source, target);
console.log(` Env: copied ${source}`);
}
function hasEntries(path) {
try {
return readdirSync(path).length > 0;
} catch {
return false;
}
}
function isDirectory(path) {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}