Compare commits

..
8 changed files with 232 additions and 219 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,12 +1,5 @@
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
// A refused fetch proves the previous bridge stopped listening. Runtimes report
// it differently: Node puts the code on `cause`, Bun puts it on the error.
export function isRefused(error) {
const code = error?.code ?? error?.cause?.code;
return code === "ECONNREFUSED" || code === "ConnectionRefused";
}
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
// live, per-session bindings over the authenticated OpenCode RPC channel.
export async function registerSharedBridge(context) {
@@ -137,7 +130,12 @@ export async function registerSharedBridge(context) {
} catch (error) {
// Refusal or rejected credentials mean this endpoint no longer
// serves the previous binding. Other failures are inconclusive.
if (error?.status !== 401 && !isRefused(error)) throw error;
if (
error?.status !== 401 &&
error?.cause?.code !== "ECONNREFUSED" &&
error?.code !== "ConnectionRefused"
)
throw error;
}
if (active)
return { error: "OpenCode session is already bound to another Paseo bridge" };
@@ -1,6 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -169,88 +167,3 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
await rm(home, { recursive: true, force: true });
}
});
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
const previous = createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
response.end(JSON.stringify({ tools: [] }));
});
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
const previousPort = (previous.address() as AddressInfo).port;
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
current.setManifestCatalog({ tools: new Map() });
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
let dispose: (() => Promise<void>) | undefined;
let releaseCurrent: (() => void) | undefined;
try {
const plugin = {
package: await current.materializeV2Plugin(),
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
};
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
dispose = await module.default.setup({
options: {},
location: { directory: home },
provider: { reload: async () => {} },
model: { reload: async () => {} },
agent: { reload: async () => {} },
command: { reload: async () => {} },
skill: { reload: async () => {} },
rpc: {
register: async (_definition: unknown, handlers: typeof rpc) => {
rpc = handlers;
return { dispose: async () => {} };
},
},
tool: { transform: async () => ({ dispose: async () => {} }) },
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => {} }),
},
});
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
await current.start();
// The restarted daemon owns the agent again, so its bridge serves this session.
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
await new Promise<void>((resolve) => {
previous.close(() => resolve());
previous.closeAllConnections();
});
await expect(
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
).resolves.toEqual({});
} finally {
await dispose?.();
releaseCurrent?.();
await current.close();
await new Promise<void>((resolve) => previous.close(() => resolve()));
await rm(home, { recursive: true, force: true });
}
});
test("a refused connection is the only probe failure that clears a binding", async () => {
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
const nodeShape = Object.assign(new Error("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
});
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
code: "ConnectionRefused",
});
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
// leaves `cause` unset. Node puts the same failure on `cause` instead.
const bunShape = Object.assign(
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
{ code: "ECONNREFUSED" },
);
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
expect(isRefused(nodeShape)).toBe(true);
expect(isRefused(olderBunShape)).toBe(true);
expect(isRefused(bunShape)).toBe(true);
expect(isRefused(inconclusive)).toBe(false);
expect(isRefused(undefined)).toBe(false);
});
@@ -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 });
}
});
@@ -1238,9 +1238,6 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
paseoHome: path.join(path.dirname(repo), "home"),
deps: {
now: () => new Date(nowMs),
// Observing the workspace starts a background fetch of origin. A real one would run ssh
// against the alias host in the checkout, and can outlive disposal and hold the folder.
runGitFetch: async () => ({ changes: [], error: null }),
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
forgeOverrides: {
// The adapter is real; only the poll is driven by the test instead of a timer.
+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;
}
}