feat: project settings page with paseo.json editor

Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
This commit is contained in:
Mohamed Boudra
2026-04-26 11:37:47 +07:00
parent 18903bcecb
commit 219050a7ab
36 changed files with 6447 additions and 81 deletions
@@ -1171,6 +1171,125 @@ test("requests branch suggestions via RPC", async () => {
});
});
test("reads project config via correlated RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "read_project_config_request"; requestId: string; repoRoot: string };
};
expect(request.message).toEqual({
type: "read_project_config_request",
requestId: "read-project-config-1",
repoRoot: "/repo/app",
});
mock.triggerMessage(
wrapSessionMessage({
type: "read_project_config_response",
payload: {
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
});
});
test("writes project config via correlated RPC and returns inline failures", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.writeProjectConfig({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "write_project_config_request";
requestId: string;
repoRoot: string;
config: unknown;
expectedRevision: unknown;
};
};
expect(request.message).toEqual({
type: "write_project_config_request",
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
mock.triggerMessage(
wrapSessionMessage({
type: "write_project_config_response",
payload: {
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
});
});
test("requests directory suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -68,6 +68,8 @@ import type {
SessionOutboundMessage,
SendAgentMessageRequest,
EditorTargetId,
PaseoConfigRaw,
PaseoConfigRevision,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -274,11 +276,25 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ReadProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "read_project_config_response" }
>["payload"];
type WriteProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "write_project_config_response" }
>["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
export interface WriteProjectConfigInput {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
requestId?: string;
}
interface ListCommandsOptions {
requestId?: string;
draftConfig?: ListCommandsDraftConfig;
@@ -2940,6 +2956,32 @@ export class DaemonClient {
});
}
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "read_project_config_request",
repoRoot,
},
responseType: "read_project_config_response",
timeout: 10000,
});
}
async writeProjectConfig(input: WriteProjectConfigInput): Promise<WriteProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "write_project_config_request",
repoRoot: input.repoRoot,
config: input.config,
expectedRevision: input.expectedRevision,
},
responseType: "write_project_config_response",
timeout: 10000,
});
}
async refreshProvidersSnapshot(options?: {
cwd?: string;
providers?: AgentProvider[];
+391 -3
View File
@@ -1,5 +1,5 @@
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
import { homedir, tmpdir } from "os";
import { join } from "path";
import pino from "pino";
@@ -200,6 +200,7 @@ function createSessionForTest(options?: {
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: unknown;
scriptRouteStore?: unknown;
scriptRuntimeStore?: unknown;
@@ -238,10 +239,21 @@ function createSessionForTest(options?: {
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-home",
agentManager: {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
} as unknown as SessionOptions["agentManager"],
agentStorage: {} as unknown as SessionOptions["agentStorage"],
projectRegistry: {} as unknown as SessionOptions["projectRegistry"],
agentStorage: {
list: vi.fn().mockResolvedValue([]),
} as unknown as SessionOptions["agentStorage"],
projectRegistry: (options?.projectRegistry ?? {
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
upsert: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
initialize: vi.fn(),
existsOnDisk: vi.fn(),
}) as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: (options?.workspaceRegistry ?? {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
@@ -270,6 +282,242 @@ function createSessionForTest(options?: {
});
}
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
return {
projectId: `project:${rootPath}`,
rootPath,
kind: "git" as const,
displayName: "Project",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt,
};
}
describe("project config RPC authorization", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "project-config-session-test-")));
tempDirs.push(root);
return root;
}
test("read_project_config_request accepts the same root with a trailing slash", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-trailing-slash-1",
repoRoot: `${repoRoot}/`,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-trailing-slash-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("read_project_config_request accepts a symlink to an active project root", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir");
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-symlink-1",
repoRoot: linkRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-symlink-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
const archivedRoot = makeRoot();
const unknownRoot = makeRoot();
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi
.fn()
.mockResolvedValue([createProjectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")]),
},
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "archived-1",
repoRoot: archivedRoot,
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "unknown-1",
repoRoot: unknownRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "archived-1",
repoRoot: archivedRoot,
ok: false,
error: { code: "project_not_found" },
},
},
{
type: "read_project_config_response",
payload: {
requestId: "unknown-1",
repoRoot: unknownRoot,
ok: false,
error: { code: "project_not_found" },
},
},
]);
});
test("read_project_config_request emits raw lifecycle forms for a known project root", async () => {
const repoRoot = makeRoot();
writeFileSync(
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm install", teardown: ["npm run clean"] } }),
);
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-1",
repoRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm install", teardown: ["npm run clean"] } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("write_project_config_request emits stale and write-failed inline domain failures", async () => {
const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const writeFailedRoot = join(makeRoot(), "not-a-directory");
writeFileSync(writeFailedRoot, "file");
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi
.fn()
.mockResolvedValue([
createProjectRecord(staleRoot),
createProjectRecord(writeFailedRoot),
]),
},
});
await session.handleMessage({
type: "write_project_config_request",
requestId: "stale-1",
repoRoot: staleRoot,
config: { worktree: { setup: "new" } },
expectedRevision: { mtimeMs: 1, size: 1 },
});
await session.handleMessage({
type: "write_project_config_request",
requestId: "write-failed-1",
repoRoot: writeFailedRoot,
config: { worktree: { setup: "new" } },
expectedRevision: null,
});
expect(messages).toEqual([
{
type: "write_project_config_response",
payload: {
requestId: "stale-1",
repoRoot: staleRoot,
ok: false,
error: {
code: "stale_project_config",
currentRevision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
},
{
type: "write_project_config_response",
payload: {
requestId: "write-failed-1",
repoRoot: writeFailedRoot,
ok: false,
error: { code: "write_failed" },
},
},
]);
});
});
function createWorkspaceGitSnapshot(
cwd: string,
overrides?: {
@@ -1131,6 +1379,146 @@ describe("session checkout status handling", () => {
});
describe("session workspace descriptors", () => {
test("fetch_workspaces_request includes project placement for a GitHub-backed workspace", async () => {
const messages: unknown[] = [];
const workspace = {
workspaceId: "ws-gh",
projectId: "remote:github.com/acme/app",
cwd: "/repo/app",
kind: "local_checkout" as const,
displayName: "app",
archivedAt: null,
};
const project = {
projectId: "remote:github.com/acme/app",
rootPath: "/repo/app",
kind: "git" as const,
displayName: "acme/app",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const session = createSessionForTest({
messages,
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
workspaceGitService: {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(() =>
createWorkspaceGitSnapshot("/repo/app", {
git: {
remoteUrl: "https://github.com/acme/app.git",
currentBranch: "main",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
}),
),
registerWorkspace: vi.fn(() => () => {}),
},
});
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "fetch-workspaces-gh",
});
expect(messages).toContainEqual({
type: "fetch_workspaces_response",
payload: expect.objectContaining({
requestId: "fetch-workspaces-gh",
entries: [
expect.objectContaining({
id: "ws-gh",
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "app",
remoteUrl: null,
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
],
}),
});
});
test("fetch_workspaces_request includes repo-root fallback placement for a workspace without remote", async () => {
const messages: unknown[] = [];
const workspace = {
workspaceId: "ws-local",
projectId: "/repo/local",
cwd: "/repo/local",
kind: "local_checkout" as const,
displayName: "local",
archivedAt: null,
};
const project = {
projectId: "/repo/local",
rootPath: "/repo/local",
kind: "git" as const,
displayName: "local",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const session = createSessionForTest({
messages,
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
workspaceGitService: {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(() =>
createWorkspaceGitSnapshot("/repo/local", {
git: {
remoteUrl: null,
currentBranch: "main",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
}),
),
registerWorkspace: vi.fn(() => () => {}),
},
});
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "fetch-workspaces-local",
});
expect(messages).toContainEqual({
type: "fetch_workspaces_response",
payload: expect.objectContaining({
requestId: "fetch-workspaces-local",
entries: [
expect.objectContaining({
id: "ws-local",
project: {
projectKey: "/repo/local",
projectName: "local",
checkout: {
cwd: "/repo/local",
isGit: true,
currentBranch: "local",
remoteUrl: null,
worktreeRoot: "/repo/local",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
],
}),
});
});
test("reads descriptor diff stat from the workspace git service snapshot", async () => {
const workspaceGitService = {
getSnapshot: vi.fn(),
+174
View File
@@ -2,6 +2,7 @@ import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { TTLCache } from "@isaacs/ttlcache";
import pMemoize from "p-memoize";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { resolve, sep } from "path";
import { homedir } from "node:os";
@@ -162,6 +163,11 @@ import {
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../utils/paseo-config-file.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
@@ -216,6 +222,46 @@ import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
interface ResolveKnownProjectRootForConfigInput {
repoRoot: string;
projectRegistry: Pick<ProjectRegistry, "list">;
}
async function resolveKnownProjectRootForConfig(
input: ResolveKnownProjectRootForConfigInput,
): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(input.repoRoot);
const projects = await input.projectRegistry.list();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
return null;
}
function canonicalizeConfigRoot(repoRoot: string): string {
const resolved = resolve(repoRoot);
try {
return stripTrailingPathSeparators(realpathSync(resolved));
} catch {
return stripTrailingPathSeparators(resolved);
}
}
function stripTrailingPathSeparators(path: string): string {
let normalized = path;
while (normalized.length > 1 && normalized.endsWith(sep)) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
type GitMutationRefreshReason =
| "commit-changes"
| "pull"
@@ -1846,11 +1892,134 @@ export class Session {
},
});
return undefined;
case "read_project_config_request":
return this.handleReadProjectConfigRequest(msg);
case "write_project_config_request":
return this.handleWriteProjectConfigRequest(msg);
default:
return undefined;
}
}
private async handleReadProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
): Promise<void> {
const repoRoot = await resolveKnownProjectRootForConfig({
repoRoot: msg.repoRoot,
projectRegistry: this.projectRegistry,
});
if (!repoRoot) {
this.emitProjectConfigReadFailure(msg, { code: "project_not_found" });
return;
}
const result = readPaseoConfigForEdit(repoRoot);
if (!result.ok) {
this.sessionLogger.warn(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Failed to read project config",
);
this.emitProjectConfigReadFailure(msg, result.error, repoRoot);
return;
}
if (result.config === null) {
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "missing_project_config" },
"Project config missing",
);
}
this.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private async handleWriteProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
): Promise<void> {
const repoRoot = await resolveKnownProjectRootForConfig({
repoRoot: msg.repoRoot,
projectRegistry: this.projectRegistry,
});
if (!repoRoot) {
this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" });
return;
}
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
"Writing project config",
);
const result = writePaseoConfigForEdit({
repoRoot,
config: msg.config,
expectedRevision: msg.expectedRevision,
});
if (!result.ok) {
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Project config write did not complete",
);
this.emitProjectConfigWriteFailure(msg, result.error, repoRoot);
return;
}
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "written" },
"Project config written",
);
this.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private emitProjectConfigReadFailure(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private emitProjectConfigWriteFailure(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "checkout_status_request":
@@ -5908,6 +6077,11 @@ export class Session {
resolveHealth: this.resolveScriptHealth ?? undefined,
})
: [],
...(resolvedProjectRecord
? {
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
}
: {}),
};
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, test } from "vitest";
import { SessionOutboundMessageSchema } from "./messages.js";
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
return {
id: "ws-1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "app",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
...overrides,
};
}
function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
return {
type: "fetch_workspaces_response",
payload: {
requestId: "req-1",
entries: [workspace],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
};
}
describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(workspaceDescriptor()),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]?.project).toBeUndefined();
});
test("new-shaped fetch_workspaces_response with project placement parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(
workspaceDescriptor({
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]?.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
test("adding project does not narrow existing descriptor fields", () => {
const parsed = SessionOutboundMessageSchema.parse(
fetchWorkspacesResponse(
workspaceDescriptor({
workspaceDirectory: undefined,
projectKind: "non_git",
workspaceKind: "directory",
gitRuntime: null,
githubRuntime: null,
project: {
projectKey: "/repo/local",
projectName: "local",
checkout: {
cwd: "/repo/local",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
}),
),
);
expect(parsed.type).toBe("fetch_workspaces_response");
if (parsed.type !== "fetch_workspaces_response") {
throw new Error("Expected fetch_workspaces_response");
}
expect(parsed.payload.entries[0]).toMatchObject({
projectKind: "non_git",
workspaceKind: "directory",
workspaceDirectory: "/repo/app",
gitRuntime: null,
githubRuntime: null,
});
});
});
+79
View File
@@ -47,6 +47,28 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigRevisionSchema,
ProjectConfigRpcErrorSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
} from "../utils/paseo-config-schema.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
};
// ---------------------------------------------------------------------------
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
@@ -872,6 +894,20 @@ export const SetDaemonConfigRequestMessageSchema = z.object({
config: MutableDaemonConfigPatchSchema,
});
export const ReadProjectConfigRequestMessageSchema = z.object({
type: z.literal("read_project_config_request"),
requestId: z.string(),
repoRoot: z.string(),
});
export const WriteProjectConfigRequestMessageSchema = z.object({
type: z.literal("write_project_config_request"),
requestId: z.string(),
repoRoot: z.string(),
config: PaseoConfigRawSchema,
expectedRevision: PaseoConfigRevisionSchema.nullable(),
});
// ============================================================================
// Dictation Streaming (lossless, resumable)
// ============================================================================
@@ -1601,6 +1637,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WaitForFinishRequestSchema,
GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema,
ReadProjectConfigRequestMessageSchema,
WriteProjectConfigRequestMessageSchema,
DictationStreamStartMessageSchema,
DictationStreamChunkMessageSchema,
DictationStreamFinishMessageSchema,
@@ -2110,6 +2148,7 @@ export const WorkspaceDescriptorPayloadSchema = z
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
gitRuntime: WorkspaceGitRuntimePayloadSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
project: ProjectPlacementPayloadSchema.optional(),
})
.transform((workspace) => ({
...workspace,
@@ -2407,6 +2446,44 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
.passthrough(),
});
export const ReadProjectConfigResponseMessageSchema = z.object({
type: z.literal("read_project_config_response"),
payload: z.discriminatedUnion("ok", [
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(true),
config: PaseoConfigRawSchema.nullable(),
revision: PaseoConfigRevisionSchema.nullable(),
}),
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(false),
error: ProjectConfigRpcErrorSchema,
}),
]),
});
export const WriteProjectConfigResponseMessageSchema = z.object({
type: z.literal("write_project_config_response"),
payload: z.discriminatedUnion("ok", [
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(true),
config: PaseoConfigRawSchema,
revision: PaseoConfigRevisionSchema,
}),
z.object({
requestId: z.string(),
repoRoot: z.string(),
ok: z.literal(false),
error: ProjectConfigRpcErrorSchema,
}),
]),
});
export const AgentPermissionRequestMessageSchema = z.object({
type: z.literal("agent_permission_request"),
payload: z.object({
@@ -3187,6 +3264,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetVoiceModeResponseMessageSchema,
GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema,
ReadProjectConfigResponseMessageSchema,
WriteProjectConfigResponseMessageSchema,
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
@@ -0,0 +1,186 @@
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
import {
readPaseoConfigForEdit,
statPaseoConfigPath,
writePaseoConfigForEdit,
} from "./paseo-config-file.js";
describe("paseo config file substrate", () => {
let tempDir: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "paseo-config-file-test-")));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns null config and revision when paseo.json is missing", () => {
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({ ok: true, config: null, revision: null });
});
it("returns invalid_project_config for invalid JSON", () => {
writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n");
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: false,
error: { code: "invalid_project_config" },
});
});
it("preserves raw lifecycle string and array forms with a revision token", () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "npm install",
teardown: ["npm run clean", "npm run reset"],
},
}),
);
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: true,
config: {
worktree: {
setup: "npm install",
teardown: ["npm run clean", "npm run reset"],
},
},
revision: statPaseoConfigPath(tempDir),
});
});
it("keeps runtime lifecycle commands normalized for execution", () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "npm install",
teardown: ["npm run clean", "", 42, "npm run reset"],
},
}),
);
expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
});
it("writes pretty JSON with a trailing newline when revision matches", () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "npm install" } },
expectedRevision,
});
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm install" } },
revision: statPaseoConfigPath(tempDir),
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
'{\n "worktree": {\n "setup": "npm install"\n }\n}\n',
);
});
it("rejects stale writes when the current revision changed before rename", () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
const currentRevision = statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "from editor" } },
expectedRevision,
});
expect(result).toEqual({
ok: false,
error: { code: "stale_project_config", currentRevision },
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
JSON.stringify({ worktree: { setup: "new" } }),
);
});
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
const config = {
extraTop: { keep: true },
worktree: {
setup: ["npm install"],
customWorktreeField: "preserve me",
},
scripts: {
dev: {
command: "npm run dev",
type: "service",
customScriptField: 123,
},
},
};
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config,
expectedRevision: null,
});
expect(result).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
});
expect(readPaseoConfigForEdit(tempDir)).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
});
});
it("returns write_failed for filesystem write exceptions", () => {
const fileRoot = join(tempDir, "not-a-directory");
writeFileSync(fileRoot, "file");
const result = writePaseoConfigForEdit({
repoRoot: fileRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: null,
});
expect(result).toEqual({
ok: false,
error: { code: "write_failed" },
});
});
it("creates paseo.json when the file is still missing and expected revision is null", () => {
mkdirSync(join(tempDir, "nested"));
const result = writePaseoConfigForEdit({
repoRoot: join(tempDir, "nested"),
config: { scripts: { dev: { command: "npm run dev" } } },
expectedRevision: null,
});
expect(result).toEqual({
ok: true,
config: { scripts: { dev: { command: "npm run dev" } } },
revision: statPaseoConfigPath(join(tempDir, "nested")),
});
});
});
@@ -0,0 +1,129 @@
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import {
PaseoConfigRawSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type ProjectConfigRpcError,
} from "./paseo-config-schema.js";
export {
PaseoConfigRevisionSchema,
ProjectConfigRpcErrorSchema,
type PaseoConfigRevision,
type ProjectConfigRpcError,
} from "./paseo-config-schema.js";
export const PASEO_CONFIG_FILE_NAME = "paseo.json";
export type ReadPaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
| { ok: false; error: ProjectConfigRpcError };
export type WritePaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
| { ok: false; error: ProjectConfigRpcError };
export interface WritePaseoConfigForEditInput {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
}
export function resolvePaseoConfigPath(repoRoot: string): string {
return join(repoRoot, PASEO_CONFIG_FILE_NAME);
}
export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
const stats = statSync(configPath);
return {
mtimeMs: stats.mtimeMs,
size: stats.size,
};
}
export function readPaseoConfigJson(repoRoot: string): unknown | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
return JSON.parse(readFileSync(configPath, "utf8"));
}
export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult {
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null, revision: null };
}
return {
ok: true,
config: PaseoConfigRawSchema.parse(json),
revision: statPaseoConfigPath(repoRoot),
};
} catch {
return {
ok: false,
error: { code: "invalid_project_config" },
};
}
}
export function writePaseoConfigForEdit(
input: WritePaseoConfigForEditInput,
): WritePaseoConfigForEditResult {
const parsed = PaseoConfigRawSchema.safeParse(input.config);
if (!parsed.success) {
return { ok: false, error: { code: "invalid_project_config" } };
}
const configPath = resolvePaseoConfigPath(input.repoRoot);
const tempPath = join(
input.repoRoot,
`.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`,
);
try {
writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
const currentRevision = statPaseoConfigPath(input.repoRoot);
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
removeTempPaseoConfig(tempPath);
return {
ok: false,
error: { code: "stale_project_config", currentRevision },
};
}
renameSync(tempPath, configPath);
const revision = statPaseoConfigPath(input.repoRoot);
if (!revision) {
return { ok: false, error: { code: "write_failed" } };
}
return { ok: true, config: parsed.data, revision };
} catch {
removeTempPaseoConfig(tempPath);
return { ok: false, error: { code: "write_failed" } };
}
}
function paseoConfigRevisionsEqual(
left: PaseoConfigRevision | null,
right: PaseoConfigRevision | null,
): boolean {
if (left === null || right === null) {
return left === right;
}
return left.mtimeMs === right.mtimeMs && left.size === right.size;
}
function removeTempPaseoConfig(tempPath: string): void {
try {
rmSync(tempPath, { force: true });
} catch {
// Best-effort cleanup only; callers need the original write outcome.
}
}
@@ -0,0 +1,75 @@
import { z } from "zod";
export function normalizeLifecycleCommands(commands: unknown): string[] {
if (typeof commands === "string") {
return commands.trim().length > 0 ? [commands] : [];
}
if (!Array.isArray(commands)) {
return [];
}
return commands.filter((command): command is string => {
return typeof command === "string" && command.trim().length > 0;
});
}
export const PaseoLifecycleCommandRawSchema = z.union([z.string(), z.array(z.string())]);
export const PaseoScriptEntryRawSchema = z
.object({
type: z.unknown().optional(),
command: z.unknown().optional(),
port: z.unknown().optional(),
})
.passthrough();
export const PaseoWorktreeConfigRawSchema = z
.object({
setup: PaseoLifecycleCommandRawSchema.optional(),
teardown: PaseoLifecycleCommandRawSchema.optional(),
terminals: z.unknown().optional(),
})
.passthrough();
export const PaseoConfigRawSchema = z
.object({
worktree: PaseoWorktreeConfigRawSchema.optional(),
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
})
.passthrough();
export const WorktreeConfigSchema = PaseoWorktreeConfigRawSchema.extend({
setup: z.unknown().transform(normalizeLifecycleCommands),
teardown: z.unknown().transform(normalizeLifecycleCommands),
})
.passthrough()
.catch({ setup: [], teardown: [] });
export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
})
.passthrough()
.catch({});
export const PaseoConfigRevisionSchema = z.object({
mtimeMs: z.number(),
size: z.number(),
});
export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
z.object({ code: z.literal("project_not_found") }),
z.object({ code: z.literal("invalid_project_config") }),
z.object({
code: z.literal("stale_project_config"),
currentRevision: PaseoConfigRevisionSchema.nullable(),
}),
z.object({ code: z.literal("write_failed") }),
]);
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;
+18 -51
View File
@@ -1,14 +1,24 @@
import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import stripAnsi from "strip-ansi";
import { z } from "zod";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import { readPaseoConfigJson } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigSchema,
type PaseoConfig,
type PaseoConfigRaw,
} from "./paseo-config-schema.js";
import { PaseoConfigSchema, type PaseoConfig } from "./paseo-config-schema.js";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
@@ -84,49 +94,6 @@ export interface WorktreeTerminalConfig {
command: string;
}
function normalizeLifecycleCommands(commands: unknown): string[] {
if (typeof commands === "string") {
return commands.trim().length > 0 ? [commands] : [];
}
if (!Array.isArray(commands)) {
return [];
}
return commands.filter((command): command is string => {
return typeof command === "string" && command.trim().length > 0;
});
}
const LifecycleCommandsSchema = z.unknown().transform(normalizeLifecycleCommands);
const WorktreeConfigSchema = z
.object({
setup: LifecycleCommandsSchema,
teardown: LifecycleCommandsSchema,
terminals: z.unknown().optional(),
})
.passthrough()
.catch({ setup: [], teardown: [] });
const ScriptEntrySchema = z
.object({
type: z.unknown(),
command: z.unknown(),
port: z.unknown(),
})
.partial()
.passthrough()
.catch({});
const PaseoConfigSchema = z
.object({
worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
})
.passthrough()
.catch({});
type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export interface PlainScriptConfig {
type?: undefined;
command: string;
@@ -229,13 +196,13 @@ export class UnknownBranchError extends Error {
}
}
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
const paseoConfigPath = join(repoRoot, "paseo.json");
if (!existsSync(paseoConfigPath)) {
return null;
}
export function readPaseoConfig(repoRoot: string): PaseoConfig | null {
try {
return PaseoConfigSchema.parse(JSON.parse(readFileSync(paseoConfigPath, "utf8")));
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return null;
}
return PaseoConfigSchema.parse(json);
} catch {
throw new Error(`Failed to parse paseo.json`);
}