Centralize git/GitHub runtime state into WorkspaceGitService

Consolidate workspace git watch lifecycle, background fetch scheduling,
and GitHub PR polling from session.ts and BackgroundGitFetchManager into
a single daemon-scoped WorkspaceGitService. Sessions now subscribe to
the service for push-driven updates instead of coordinating watchers and
fetch intervals themselves. Workspace payloads gain optional gitRuntime
and githubRuntime fields for richer client-side display.
This commit is contained in:
Mohamed Boudra
2026-04-12 10:38:50 +07:00
parent f35b0b8b7d
commit 40e27f5a00
12 changed files with 1871 additions and 1001 deletions
+46
View File
@@ -1855,6 +1855,50 @@ export const ProjectPlacementPayloadSchema = z.object({
checkout: ProjectCheckoutLitePayloadSchema,
});
const WorkspaceGitRuntimePayloadSchema = z
.object({
currentBranch: z.string().nullable().optional(),
remoteUrl: z.string().nullable().optional(),
isPaseoOwnedWorktree: z.boolean().optional(),
isDirty: z.boolean().nullable().optional(),
aheadBehind: z
.object({
ahead: z.number(),
behind: z.number(),
})
.nullable()
.optional(),
aheadOfOrigin: z.number().nullable().optional(),
behindOfOrigin: z.number().nullable().optional(),
})
.optional()
.nullable();
const WorkspaceGitHubRuntimePayloadSchema = z
.object({
featuresEnabled: z.boolean().optional(),
pullRequest: z
.object({
url: z.string(),
title: z.string(),
state: z.string(),
baseRefName: z.string(),
headRefName: z.string(),
isMerged: z.boolean(),
})
.nullable()
.optional(),
error: z
.object({
message: z.string(),
})
.nullable()
.optional(),
refreshedAt: z.string().nullable().optional(),
})
.optional()
.nullable();
export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.string(),
projectId: z.string(),
@@ -1872,6 +1916,8 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
})
.nullable()
.optional(),
gitRuntime: WorkspaceGitRuntimePayloadSchema,
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
});
export const AgentUpdateMessageSchema = z.object({
@@ -1,3 +1,4 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
@@ -117,6 +118,162 @@ describe("workspace message schemas", () => {
expect(result.success).toBe(false);
});
test("parses fetch_workspaces_response with optional runtime fields", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "fetch_workspaces_response",
payload: {
requestId: "req-workspaces",
entries: [
{
id: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: {
additions: 3,
deletions: 1,
},
gitRuntime: {
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: true,
aheadBehind: {
ahead: 2,
behind: 1,
},
aheadOfOrigin: 2,
behindOfOrigin: 1,
},
githubRuntime: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
title: "Runtime payloads",
state: "open",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: false,
},
error: null,
refreshedAt: "2026-04-12T00:00:00.000Z",
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
});
expect(parsed.type).toBe("fetch_workspaces_response");
expect(parsed.payload.entries[0]?.gitRuntime).toMatchObject({
currentBranch: "main",
isDirty: true,
aheadOfOrigin: 2,
});
expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads");
});
test("older workspace parsers ignore additive runtime fields", () => {
const message = {
type: "fetch_workspaces_response",
payload: {
requestId: "req-workspaces",
entries: [
{
id: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
gitRuntime: {
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: {
ahead: 0,
behind: 0,
},
aheadOfOrigin: 0,
behindOfOrigin: 0,
},
githubRuntime: {
featuresEnabled: true,
pullRequest: null,
error: null,
refreshedAt: "2026-04-12T00:00:00.000Z",
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
};
const legacyWorkspaceSchema = z.object({
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git"]),
workspaceKind: z.enum(["local_checkout", "worktree", "directory"]),
name: z.string(),
status: z.enum(["needs_input", "failed", "running", "attention", "done"]),
activityAt: z.string().nullable(),
diffStat: z
.object({
additions: z.number(),
deletions: z.number(),
})
.nullable()
.optional(),
});
const legacyMessageSchema = z.object({
type: z.literal("fetch_workspaces_response"),
payload: z.object({
requestId: z.string(),
entries: z.array(legacyWorkspaceSchema),
pageInfo: z.object({
nextCursor: z.string().nullable(),
prevCursor: z.string().nullable(),
hasMore: z.boolean(),
}),
}),
});
const parsed = legacyMessageSchema.parse(message);
expect(parsed.payload.entries[0]).toEqual({
id: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
projectDisplayName: "acme/repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
});
});
test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "fetch_agents_response",