mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f905f8a084 | ||
|
|
81a62d56ed | ||
|
|
b0b8e715ae | ||
|
|
2a533adb39 | ||
|
|
6f1dd9ef27 |
+1
-1
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user