feat: add multi-session desktop workspace
This commit is contained in:
+1192
-346
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
||||
export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
||||
export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024;
|
||||
|
||||
const supportedCommands = new Set([
|
||||
@@ -18,6 +19,8 @@ const supportedCommands = new Set([
|
||||
"get_messages",
|
||||
"get_available_models",
|
||||
"get_commands",
|
||||
"set_session_name",
|
||||
"compact",
|
||||
"set_model",
|
||||
"set_thinking_level",
|
||||
]);
|
||||
@@ -126,6 +129,7 @@ export function startPiRpcAdapter({
|
||||
onEvent = () => {},
|
||||
onError = () => {},
|
||||
commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
compactCommandTimeoutMs = COMPACT_COMMAND_TIMEOUT_MS,
|
||||
maxFrameBytes = MAX_PI_RPC_FRAME_BYTES,
|
||||
}) {
|
||||
assertAbsolutePath(cwd, "cwd");
|
||||
@@ -316,6 +320,10 @@ export function startPiRpcAdapter({
|
||||
}
|
||||
|
||||
const id = `bridge-${randomUUID()}`;
|
||||
const timeoutMs =
|
||||
commandInput.type === "compact"
|
||||
? compactCommandTimeoutMs
|
||||
: commandTimeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
@@ -325,7 +333,7 @@ export function startPiRpcAdapter({
|
||||
`Pi RPC command timed out: ${commandInput.type}`,
|
||||
),
|
||||
);
|
||||
}, commandTimeoutMs);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
pending.set(id, { resolve, reject, timeout });
|
||||
try {
|
||||
|
||||
+52
-1
@@ -1,5 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js";
|
||||
import {
|
||||
createAgentRegistry,
|
||||
UnknownAgentError,
|
||||
UnknownRuntimeError,
|
||||
} from "./agent-registry.js";
|
||||
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
|
||||
import { ProtocolError } from "../protocol/index.js";
|
||||
import { createRuntimePaths, startBridgeServer } from "./runtime.js";
|
||||
@@ -32,6 +36,35 @@ export async function startBridgeService({
|
||||
};
|
||||
case "forget_directory":
|
||||
return registry.forgetDirectory(request.payload.worktreePath);
|
||||
case "get_workspace":
|
||||
return registry.getWorkspace();
|
||||
case "get_workspace_summary":
|
||||
return registry.getWorkspaceSummary();
|
||||
case "create_session_runtime":
|
||||
return {
|
||||
runtime: await registry.createSessionRuntime(
|
||||
request.payload.worktreePath,
|
||||
),
|
||||
};
|
||||
case "open_session_runtime":
|
||||
return {
|
||||
runtime: await registry.openSessionRuntime(
|
||||
request.payload.worktreePath,
|
||||
request.payload.sessionPath,
|
||||
),
|
||||
};
|
||||
case "close_session_runtime":
|
||||
return registry.closeSessionRuntime(request.payload.runtimeId);
|
||||
case "list_directory_sessions":
|
||||
return {
|
||||
sessions: await registry.listDirectorySessions(
|
||||
request.payload.worktreePath,
|
||||
),
|
||||
};
|
||||
case "get_session_runtime_snapshot":
|
||||
return registry.getSessionRuntimeSnapshot(request.payload.runtimeId);
|
||||
case "subscribe_workspace":
|
||||
return registry.workspaceEventsAfter(request.payload.cursor ?? 0);
|
||||
case "subscribe":
|
||||
return {
|
||||
events: registry.eventsAfter(
|
||||
@@ -51,6 +84,8 @@ export async function startBridgeService({
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownAgentError)
|
||||
throw new ProtocolError("unknown_agent", error.message);
|
||||
if (error instanceof UnknownRuntimeError)
|
||||
throw new ProtocolError("unknown_runtime", error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -59,6 +94,22 @@ export async function startBridgeService({
|
||||
runtimeDir,
|
||||
handleRequest: dispatch,
|
||||
subscribe: (request, notify) => {
|
||||
if (request.op === "subscribe_workspace") {
|
||||
const subscription = registry.subscribeWorkspace(
|
||||
request.payload.cursor ?? 0,
|
||||
notify,
|
||||
);
|
||||
return {
|
||||
result: {
|
||||
bridgeInstanceId: subscription.bridgeInstanceId,
|
||||
firstAvailableSeq: subscription.firstAvailableSeq,
|
||||
latestSeq: subscription.latestSeq,
|
||||
truncated: subscription.truncated,
|
||||
events: subscription.events,
|
||||
},
|
||||
unsubscribe: subscription.unsubscribe,
|
||||
};
|
||||
}
|
||||
const subscription = registry.subscribe(
|
||||
request.agentId,
|
||||
request.payload.cursor ?? 0,
|
||||
|
||||
@@ -88,7 +88,10 @@ export async function startUnixSocketServer({
|
||||
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
|
||||
try {
|
||||
const request = parseRequestFrame(normalized, maxFrameBytes);
|
||||
if (request.op === "subscribe" && subscribe) {
|
||||
if (
|
||||
(request.op === "subscribe" || request.op === "subscribe_workspace") &&
|
||||
subscribe
|
||||
) {
|
||||
await handleSubscription(request);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const WORKSPACE_VERSION = 2;
|
||||
const WORKSPACE_FILE = "bridge-workspace-v2.json";
|
||||
const DIRECTORY_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
|
||||
function validRuntime(record) {
|
||||
return (
|
||||
record &&
|
||||
typeof record === "object" &&
|
||||
typeof record.runtimeId === "string" &&
|
||||
record.runtimeId.length > 0 &&
|
||||
typeof record.worktreePath === "string" &&
|
||||
path.isAbsolute(record.worktreePath) &&
|
||||
(record.sessionPath === undefined ||
|
||||
(typeof record.sessionPath === "string" &&
|
||||
path.isAbsolute(record.sessionPath))) &&
|
||||
(record.sessionId === undefined || typeof record.sessionId === "string") &&
|
||||
(record.legacyDefault === undefined ||
|
||||
typeof record.legacyDefault === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeManifest(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
value.version !== WORKSPACE_VERSION ||
|
||||
!Array.isArray(value.runtimes) ||
|
||||
!value.runtimes.every(validRuntime) ||
|
||||
(value.directories !== undefined &&
|
||||
(!Array.isArray(value.directories) ||
|
||||
!value.directories.every(
|
||||
(directory) =>
|
||||
typeof directory === "string" && path.isAbsolute(directory),
|
||||
)))
|
||||
)
|
||||
throw new Error("workspace manifest has an unsupported or invalid shape");
|
||||
const runtimeIds = new Set();
|
||||
const sessionPaths = new Set();
|
||||
const directories = new Set(value.directories ?? []);
|
||||
if (directories.size !== (value.directories ?? []).length)
|
||||
throw new Error("workspace manifest repeats a directory");
|
||||
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
|
||||
for (const runtime of value.runtimes) {
|
||||
if (runtimeIds.has(runtime.runtimeId))
|
||||
throw new Error(
|
||||
`workspace manifest repeats runtime ${runtime.runtimeId}`,
|
||||
);
|
||||
runtimeIds.add(runtime.runtimeId);
|
||||
if (runtime.sessionPath) {
|
||||
if (sessionPaths.has(runtime.sessionPath))
|
||||
throw new Error(
|
||||
`workspace manifest repeats session ${runtime.sessionPath}`,
|
||||
);
|
||||
sessionPaths.add(runtime.sessionPath);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: WORKSPACE_VERSION,
|
||||
migrated: value.migrated === true,
|
||||
directories: [...directories].sort(),
|
||||
runtimes: value.runtimes.map((runtime) => ({ ...runtime })),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceStore(sessionRoot) {
|
||||
if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot))
|
||||
throw new TypeError("sessionRoot must be an absolute path");
|
||||
const filePath = path.join(sessionRoot, WORKSPACE_FILE);
|
||||
let writeQueue = Promise.resolve();
|
||||
|
||||
async function load() {
|
||||
await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE });
|
||||
await chmod(sessionRoot, DIRECTORY_MODE);
|
||||
try {
|
||||
return {
|
||||
manifest: normalizeManifest(
|
||||
JSON.parse(await readFile(filePath, "utf8")),
|
||||
),
|
||||
migrating: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return {
|
||||
manifest: {
|
||||
version: WORKSPACE_VERSION,
|
||||
migrated: false,
|
||||
directories: [],
|
||||
runtimes: [],
|
||||
},
|
||||
migrating: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
version: WORKSPACE_VERSION,
|
||||
migrated: true,
|
||||
directories: [],
|
||||
runtimes: [],
|
||||
},
|
||||
migrating: false,
|
||||
issue: {
|
||||
code: "invalid_workspace_manifest",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function save(manifest) {
|
||||
const normalized = normalizeManifest({ ...manifest, migrated: true });
|
||||
const operation = writeQueue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE });
|
||||
await chmod(sessionRoot, DIRECTORY_MODE);
|
||||
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(normalized, null, 2)}\n`,
|
||||
{
|
||||
encoding: "utf8",
|
||||
mode: FILE_MODE,
|
||||
},
|
||||
);
|
||||
await chmod(temporaryPath, FILE_MODE);
|
||||
await rename(temporaryPath, filePath);
|
||||
await chmod(filePath, FILE_MODE);
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
writeQueue = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { filePath, load, save };
|
||||
}
|
||||
|
||||
export const workspaceManifestVersion = WORKSPACE_VERSION;
|
||||
export const workspaceManifestFile = WORKSPACE_FILE;
|
||||
Reference in New Issue
Block a user