feat: add project-grouped agent fetch with checkout-lite placement
This commit is contained in:
@@ -151,6 +151,56 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches project-grouped agents via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.fetchAgentsGroupedByProject({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "fetch_agents_grouped_by_project_request";
|
||||
requestId: string;
|
||||
filter?: { labels?: Record<string, string> };
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("fetch_agents_grouped_by_project_request");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fetch_agents_grouped_by_project_response",
|
||||
payload: {
|
||||
requestId: request.message.requestId,
|
||||
groups: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.message.requestId,
|
||||
groups: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("cancels waiters when send fails (no leaked timeouts)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -117,9 +117,7 @@ export type DaemonEvent =
|
||||
| {
|
||||
type: "agent_update";
|
||||
agentId: string;
|
||||
payload:
|
||||
| { kind: "upsert"; agent: AgentSnapshotPayload }
|
||||
| { kind: "remove"; agentId: string };
|
||||
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
type: "agent_stream";
|
||||
@@ -218,6 +216,10 @@ type AgentRefreshedStatusPayload = z.infer<
|
||||
type RestartRequestedStatusPayload = z.infer<
|
||||
typeof RestartRequestedStatusPayloadSchema
|
||||
>;
|
||||
type FetchAgentsGroupedByProjectPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agents_grouped_by_project_response" }
|
||||
>["payload"];
|
||||
|
||||
export type WaitForFinishResult = {
|
||||
status: "idle" | "error" | "permission" | "timeout";
|
||||
@@ -854,6 +856,33 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchAgentsGroupedByProject(options?: {
|
||||
filter?: { labels?: Record<string, string> };
|
||||
requestId?: string;
|
||||
}): Promise<FetchAgentsGroupedByProjectPayload> {
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agents_grouped_by_project_request",
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.filter ? { filter: options.filter } : {}),
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "fetch_agents_grouped_by_project_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async fetchAgent(agentId: string, requestId?: string): Promise<AgentSnapshotPayload | null> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -513,6 +513,14 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
notifyAgentState(agentId: string): void {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent || agent.internal) {
|
||||
return;
|
||||
}
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async clearAgentAttention(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.attention.requiresAttention) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type UnsubscribeTerminalRequest,
|
||||
type TerminalInput,
|
||||
type KillTerminalRequest,
|
||||
type ProjectCheckoutLitePayload,
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
||||
@@ -90,6 +92,7 @@ import {
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
NotGitRepoError,
|
||||
MergeConflictError,
|
||||
MergeFromBaseConflictError,
|
||||
@@ -119,6 +122,8 @@ const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
let restartRequested = false;
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||
const RESTART_EXIT_DELAY_MS = 250;
|
||||
const PROJECT_PLACEMENT_CACHE_TTL_MS = 10_000;
|
||||
const MAX_AGENTS_PER_PROJECT = 5;
|
||||
|
||||
/**
|
||||
* Default model used for auto-generating commit messages and PR descriptions.
|
||||
@@ -138,6 +143,78 @@ const VOICE_AGENT_SYSTEM_INSTRUCTION = [
|
||||
"Only use the paseo MCP tools.",
|
||||
].join(" ");
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let host: string | null = null;
|
||||
let path: string | null = null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||
if (scpLike) {
|
||||
host = scpLike[1] ?? null;
|
||||
path = scpLike[2] ?? null;
|
||||
} else if (trimmed.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || null;
|
||||
path = parsed.pathname ? parsed.pathname.replace(/^\//, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) {
|
||||
cleanedPath = cleanedPath.slice(0, -4);
|
||||
}
|
||||
if (!cleanedPath.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedHost = host.toLowerCase();
|
||||
if (cleanedHost === "github.com") {
|
||||
return `remote:github.com/${cleanedPath}`;
|
||||
}
|
||||
|
||||
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
|
||||
const remoteKey = deriveRemoteProjectKey(remoteUrl);
|
||||
if (remoteKey) {
|
||||
return remoteKey;
|
||||
}
|
||||
|
||||
const worktreeMarker = ".paseo/worktrees/";
|
||||
const idx = cwd.indexOf(worktreeMarker);
|
||||
if (idx !== -1) {
|
||||
return cwd.slice(0, idx).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
return cwd;
|
||||
}
|
||||
|
||||
function deriveProjectGroupingName(projectKey: string): string {
|
||||
const githubRemotePrefix = "remote:github.com/";
|
||||
if (projectKey.startsWith(githubRemotePrefix)) {
|
||||
return projectKey.slice(githubRemotePrefix.length) || projectKey;
|
||||
}
|
||||
|
||||
const segments = projectKey.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || projectKey;
|
||||
}
|
||||
|
||||
function escapeXmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
@@ -370,6 +447,10 @@ export class Session {
|
||||
filter?: { labels?: Record<string, string>; agentId?: string };
|
||||
}
|
||||
| null = null;
|
||||
private readonly projectPlacementCache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; promise: Promise<ProjectPlacementPayload> }
|
||||
>();
|
||||
private clientActivity: {
|
||||
deviceType: "web" | "mobile";
|
||||
focusedAgentId: string | null;
|
||||
@@ -819,6 +900,73 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
private toProjectCheckoutLite(
|
||||
cwd: string,
|
||||
status: Awaited<ReturnType<typeof getCheckoutStatusLite>>
|
||||
): ProjectCheckoutLitePayload {
|
||||
if (!status.isGit) {
|
||||
return this.buildFallbackProjectCheckout(cwd);
|
||||
}
|
||||
|
||||
if (status.isPaseoOwnedWorktree) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
||||
.catch(() => this.buildFallbackProjectCheckout(cwd));
|
||||
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
|
||||
return {
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
|
||||
private getProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||
const now = Date.now();
|
||||
const cached = this.projectPlacementCache.get(cwd);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const promise = this.buildProjectPlacement(cwd);
|
||||
this.projectPlacementCache.set(cwd, {
|
||||
expiresAt: now + PROJECT_PLACEMENT_CACHE_TTL_MS,
|
||||
promise,
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||
try {
|
||||
const subscription = this.agentUpdatesSubscription;
|
||||
@@ -830,9 +978,10 @@ export class Session {
|
||||
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
||||
|
||||
if (matches) {
|
||||
const project = await this.getProjectPlacement(payload.cwd);
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: { kind: "upsert", agent: payload },
|
||||
payload: { kind: "upsert", agent: payload, project },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -868,6 +1017,10 @@ export class Session {
|
||||
await this.handleFetchAgents(msg.requestId, msg.filter);
|
||||
break;
|
||||
|
||||
case "fetch_agents_grouped_by_project_request":
|
||||
await this.handleFetchAgentsGroupedByProject(msg.requestId, msg.filter);
|
||||
break;
|
||||
|
||||
case "fetch_agent_request":
|
||||
await this.handleFetchAgent(msg.agentId, msg.requestId);
|
||||
break;
|
||||
@@ -1254,6 +1407,7 @@ export class Session {
|
||||
archivedAt,
|
||||
});
|
||||
}
|
||||
this.agentManager.notifyAgentState(agentId);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
@@ -4076,6 +4230,87 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async listAgentsGroupedByProjectPayload(filter?: {
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<Array<{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
agents: Array<{
|
||||
agent: AgentSnapshotPayload;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}>;
|
||||
}>> {
|
||||
const agents = await this.listAgentPayloads(filter);
|
||||
const visibleAgents = agents
|
||||
.filter((agent) => !agent.archivedAt)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Date.parse(right.updatedAt || "") - Date.parse(left.updatedAt || "")
|
||||
);
|
||||
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
agents: Array<{
|
||||
agent: AgentSnapshotPayload;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Warm project placement status for all visible roots up front to avoid serial N+1 latency.
|
||||
for (const agent of visibleAgents) {
|
||||
void this.getProjectPlacement(agent.cwd);
|
||||
}
|
||||
|
||||
for (const agent of visibleAgents) {
|
||||
const project = await this.getProjectPlacement(agent.cwd);
|
||||
const projectKey = project.projectKey;
|
||||
|
||||
let group = grouped.get(projectKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
projectKey,
|
||||
projectName: project.projectName,
|
||||
agents: [],
|
||||
};
|
||||
grouped.set(projectKey, group);
|
||||
}
|
||||
|
||||
if (group.agents.length >= MAX_AGENTS_PER_PROJECT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
group.agents.push({ agent, checkout: project.checkout });
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
private async handleFetchAgentsGroupedByProject(
|
||||
requestId: string,
|
||||
filter?: { labels?: Record<string, string> }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const groups = await this.listAgentsGroupedByProjectPayload(filter);
|
||||
this.emit({
|
||||
type: "fetch_agents_grouped_by_project_response",
|
||||
payload: { requestId, groups },
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error },
|
||||
"Failed to handle fetch_agents_grouped_by_project_request"
|
||||
);
|
||||
this.emit({
|
||||
type: "fetch_agents_grouped_by_project_response",
|
||||
payload: { requestId, groups: [] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
||||
if (!resolved.ok) {
|
||||
|
||||
@@ -454,6 +454,16 @@ export const FetchAgentsRequestMessageSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const FetchAgentsGroupedByProjectRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agents_grouped_by_project_request"),
|
||||
requestId: z.string(),
|
||||
filter: z
|
||||
.object({
|
||||
labels: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const FetchAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_request"),
|
||||
requestId: z.string(),
|
||||
@@ -948,6 +958,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AbortRequestMessageSchema,
|
||||
AudioPlayedMessageSchema,
|
||||
FetchAgentsRequestMessageSchema,
|
||||
FetchAgentsGroupedByProjectRequestMessageSchema,
|
||||
FetchAgentRequestMessageSchema,
|
||||
SubscribeAgentUpdatesMessageSchema,
|
||||
UnsubscribeAgentUpdatesMessageSchema,
|
||||
@@ -1191,12 +1202,52 @@ export const ArtifactMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
isGit: z.literal(false),
|
||||
currentBranch: z.null(),
|
||||
remoteUrl: z.null(),
|
||||
isPaseoOwnedWorktree: z.literal(false),
|
||||
mainRepoRoot: z.null(),
|
||||
});
|
||||
|
||||
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
isGit: z.literal(true),
|
||||
currentBranch: z.string().nullable(),
|
||||
remoteUrl: z.string().nullable(),
|
||||
isPaseoOwnedWorktree: z.literal(false),
|
||||
mainRepoRoot: z.null(),
|
||||
});
|
||||
|
||||
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
isGit: z.literal(true),
|
||||
currentBranch: z.string().nullable(),
|
||||
remoteUrl: z.string().nullable(),
|
||||
isPaseoOwnedWorktree: z.literal(true),
|
||||
mainRepoRoot: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectCheckoutLitePayloadSchema = z.union([
|
||||
ProjectCheckoutLiteNotGitPayloadSchema,
|
||||
ProjectCheckoutLiteGitNonPaseoPayloadSchema,
|
||||
ProjectCheckoutLiteGitPaseoPayloadSchema,
|
||||
]);
|
||||
|
||||
export const ProjectPlacementPayloadSchema = z.object({
|
||||
projectKey: z.string(),
|
||||
projectName: z.string(),
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentUpdateMessageSchema = z.object({
|
||||
type: z.literal("agent_update"),
|
||||
payload: z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("upsert"),
|
||||
agent: AgentSnapshotPayloadSchema,
|
||||
project: ProjectPlacementPayloadSchema,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("remove"),
|
||||
@@ -1251,6 +1302,25 @@ export const FetchAgentsResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const ProjectGroupedAgentEntryPayloadSchema = z.object({
|
||||
agent: AgentSnapshotPayloadSchema,
|
||||
checkout: ProjectCheckoutLitePayloadSchema,
|
||||
});
|
||||
|
||||
const ProjectGroupPayloadSchema = z.object({
|
||||
projectKey: z.string(),
|
||||
projectName: z.string(),
|
||||
agents: z.array(ProjectGroupedAgentEntryPayloadSchema),
|
||||
});
|
||||
|
||||
export const FetchAgentsGroupedByProjectResponseMessageSchema = z.object({
|
||||
type: z.literal("fetch_agents_grouped_by_project_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
groups: z.array(ProjectGroupPayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const FetchAgentResponseMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_response"),
|
||||
payload: z.object({
|
||||
@@ -1715,6 +1785,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentStreamSnapshotMessageSchema,
|
||||
AgentStatusMessageSchema,
|
||||
FetchAgentsResponseMessageSchema,
|
||||
FetchAgentsGroupedByProjectResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
SendAgentMessageResponseMessageSchema,
|
||||
SetVoiceModeResponseMessageSchema,
|
||||
@@ -1772,9 +1843,14 @@ export type AgentStreamSnapshotMessage = z.infer<
|
||||
typeof AgentStreamSnapshotMessageSchema
|
||||
>;
|
||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||
export type FetchAgentsResponseMessage = z.infer<
|
||||
typeof FetchAgentsResponseMessageSchema
|
||||
>;
|
||||
export type FetchAgentsGroupedByProjectResponseMessage = z.infer<
|
||||
typeof FetchAgentsGroupedByProjectResponseMessageSchema
|
||||
>;
|
||||
export type FetchAgentResponseMessage = z.infer<
|
||||
typeof FetchAgentResponseMessageSchema
|
||||
>;
|
||||
@@ -1803,6 +1879,9 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||
// Type exports for inbound message types
|
||||
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
||||
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
||||
export type FetchAgentsGroupedByProjectRequestMessage = z.infer<
|
||||
typeof FetchAgentsGroupedByProjectRequestMessageSchema
|
||||
>;
|
||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
commitAll,
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
MergeConflictError,
|
||||
@@ -78,6 +79,14 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("returns lightweight checkout status for normal repos", async () => {
|
||||
const status = await getCheckoutStatusLite(repoDir);
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.currentBranch).toBe("main");
|
||||
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||
expect(status.mainRepoRoot).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes hasRemote when origin is configured", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare -b main ${remoteDir}`);
|
||||
@@ -196,6 +205,21 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("worktree update");
|
||||
});
|
||||
|
||||
it("returns lightweight checkout status for .paseo worktrees", async () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "lite-alpha",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||
expect(status.mainRepoRoot).toBe(repoDir);
|
||||
});
|
||||
|
||||
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
|
||||
const bareRepoDir = join(tempDir, "bare-repo");
|
||||
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
|
||||
|
||||
@@ -296,6 +296,35 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
|
||||
|
||||
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
||||
|
||||
export type CheckoutStatusLiteNotGit = {
|
||||
isGit: false;
|
||||
currentBranch: null;
|
||||
remoteUrl: null;
|
||||
isPaseoOwnedWorktree: false;
|
||||
mainRepoRoot: null;
|
||||
};
|
||||
|
||||
export type CheckoutStatusLiteGitNonPaseo = {
|
||||
isGit: true;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
isPaseoOwnedWorktree: false;
|
||||
mainRepoRoot: null;
|
||||
};
|
||||
|
||||
export type CheckoutStatusLiteGitPaseo = {
|
||||
isGit: true;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
isPaseoOwnedWorktree: true;
|
||||
mainRepoRoot: string;
|
||||
};
|
||||
|
||||
export type CheckoutStatusLiteResult =
|
||||
| CheckoutStatusLiteNotGit
|
||||
| CheckoutStatusLiteGitNonPaseo
|
||||
| CheckoutStatusLiteGitPaseo;
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
diff: string;
|
||||
structured?: ParsedDiffFile[];
|
||||
@@ -466,6 +495,11 @@ async function getConfiguredBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<ConfiguredBaseRefForCwd> {
|
||||
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
|
||||
if (!ownership.allowed) {
|
||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||
@@ -636,6 +670,43 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
||||
}
|
||||
}
|
||||
|
||||
type CheckoutInspectionContext = {
|
||||
worktreeRoot: string;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
configured: ConfiguredBaseRefForCwd;
|
||||
};
|
||||
|
||||
async function inspectCheckoutContext(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutInspectionContext | null> {
|
||||
try {
|
||||
const root = await getWorktreeRoot(cwd);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentBranch, remoteUrl, configured] = await Promise.all([
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
getConfiguredBaseRefForCwd(cwd, context),
|
||||
]);
|
||||
|
||||
return {
|
||||
worktreeRoot: root,
|
||||
currentBranch,
|
||||
remoteUrl,
|
||||
configured,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isGitError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
||||
@@ -762,25 +833,17 @@ export async function getCheckoutStatus(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutStatusResult> {
|
||||
let worktreeRoot: string;
|
||||
try {
|
||||
const root = await getWorktreeRoot(cwd);
|
||||
if (!root) {
|
||||
return { isGit: false };
|
||||
}
|
||||
worktreeRoot = root;
|
||||
} catch (error) {
|
||||
if (isGitError(error)) {
|
||||
return { isGit: false };
|
||||
}
|
||||
throw error;
|
||||
const inspected = await inspectCheckoutContext(cwd, context);
|
||||
if (!inspected) {
|
||||
return { isGit: false };
|
||||
}
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
const worktreeRoot = inspected.worktreeRoot;
|
||||
const currentBranch = inspected.currentBranch;
|
||||
const remoteUrl = inspected.remoteUrl;
|
||||
const configured = inspected.configured;
|
||||
const isDirty = await isWorkingTreeDirty(cwd);
|
||||
const remoteUrl = await getOriginRemoteUrl(cwd);
|
||||
const hasRemote = remoteUrl !== null;
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const aheadBehind =
|
||||
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
||||
@@ -818,6 +881,40 @@ export async function getCheckoutStatus(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutStatusLite(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutStatusLiteResult> {
|
||||
const inspected = await inspectCheckoutContext(cwd, context);
|
||||
if (!inspected) {
|
||||
return {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (inspected.configured.isPaseoOwnedWorktree) {
|
||||
return {
|
||||
isGit: true,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: await getMainRepoRoot(cwd),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isGit: true,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
|
||||
Reference in New Issue
Block a user