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:
@@ -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[];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user