mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
fix(server): preserve workspace PR status across reconnects (#7638)
Workspace runtime snapshots lived inside refcounted watch targets and vanished when the last session disconnected. Keep the last observed git and forge data in daemon memory so reconnecting clients receive it before refresh completes, while watch lifetime continues to govern refresh only.
This commit is contained in:
@@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store.
|
||||
Workspace descriptors, checkout diff subscriptions, and script metadata read the last known
|
||||
snapshot from that store, so reconnecting clients receive it before refresh completes.
|
||||
Refcounted git watches feed the store through snapshot-updated events; their lifetime controls
|
||||
refresh only. Session cleanup and workspace archive retain the data. Removing the final active
|
||||
workspace for a directory clears it. The store lives in memory and resets on daemon restart.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
|
||||
@@ -47,8 +47,8 @@ test("bridge preserves tool failure messages and classifies invalid requests as
|
||||
logger,
|
||||
),
|
||||
projectRegistry: new FileBackedProjectRegistry(path.join(paseoHome, "projects.json"), logger),
|
||||
workspaceGitService: {
|
||||
peekSnapshot: () => {
|
||||
workspaceRuntimeStore: {
|
||||
get: () => {
|
||||
throw new Error("Unexpected git lookup");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry";
|
||||
import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
|
||||
import express from "express";
|
||||
@@ -905,7 +906,17 @@ export async function createPaseoDaemon(
|
||||
forgeOverrides: { github },
|
||||
},
|
||||
});
|
||||
workspaceRegistry.subscribeToMutations((mutation) => {
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
workspaceGitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
workspaceRegistry.subscribeToMutations(async (mutation) => {
|
||||
if (mutation.kind === "remove" && mutation.workspace) {
|
||||
const cwd = path.resolve(mutation.workspace.cwd);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const hasActiveWorkspace = workspaces.some(
|
||||
(workspace) => !workspace.archivedAt && path.resolve(workspace.cwd) === cwd,
|
||||
);
|
||||
if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd);
|
||||
}
|
||||
if (mutation.kind === "archive" && mutation.workspace) {
|
||||
pluginRuntime.emit("workspace.archived", {
|
||||
workspace: describeHookWorkspace(mutation.workspace),
|
||||
@@ -1016,6 +1027,7 @@ export async function createPaseoDaemon(
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
workspaceGitService,
|
||||
workspaceRuntimeStore,
|
||||
});
|
||||
const archiveWorkspaceRecordExternal = async (
|
||||
workspaceId: string,
|
||||
@@ -1434,7 +1446,7 @@ export async function createPaseoDaemon(
|
||||
terminalManager,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService,
|
||||
workspaceRuntimeStore,
|
||||
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
||||
serviceProxyPublicBaseUrl,
|
||||
@@ -1710,6 +1722,7 @@ export async function createPaseoDaemon(
|
||||
startPaused: true,
|
||||
},
|
||||
workspaceAutoName,
|
||||
workspaceRuntimeStore,
|
||||
daemonAuth,
|
||||
speechService,
|
||||
terminalManager,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { toCheckoutErrorMock } = vi.hoisted(() => ({
|
||||
@@ -67,7 +68,7 @@ function createPendingManager() {
|
||||
const workspaceGitService = {
|
||||
getCheckoutDiff: async () => ({ diff: "", structured: [] }),
|
||||
getSnapshot: async () => createWorkspaceSnapshot(),
|
||||
peekSnapshot: () => null,
|
||||
|
||||
registerWorkspace: () => ({ unsubscribe: () => {} }),
|
||||
requestWorkingTreeWatch: (cwd: string, onChange: () => void) => {
|
||||
const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>();
|
||||
@@ -90,6 +91,7 @@ function createPendingManager() {
|
||||
};
|
||||
const logger = { child: () => logger, warn: () => {} };
|
||||
const manager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
logger: logger as unknown as pino.Logger,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
workspaceGitService,
|
||||
@@ -125,7 +127,7 @@ describe("CheckoutDiffManager", () => {
|
||||
|
||||
const workspaceGitService = {
|
||||
subscribe: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
|
||||
registerWorkspace: vi.fn(
|
||||
(_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => {
|
||||
onWorkspaceSnapshot = listener;
|
||||
@@ -147,6 +149,7 @@ describe("CheckoutDiffManager", () => {
|
||||
};
|
||||
|
||||
const manager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
logger: logger as unknown as pino.Logger,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
workspaceGitService: workspaceGitService as unknown as WorkspaceGitService,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type pino from "pino";
|
||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
@@ -8,11 +9,7 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
||||
|
||||
type CheckoutDiffWorkspace = Pick<
|
||||
WorkspaceGitService,
|
||||
| "getCheckoutDiff"
|
||||
| "getSnapshot"
|
||||
| "peekSnapshot"
|
||||
| "registerWorkspace"
|
||||
| "requestWorkingTreeWatch"
|
||||
"getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch"
|
||||
>;
|
||||
|
||||
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
|
||||
@@ -63,14 +60,17 @@ export interface CheckoutDiffSubscription {
|
||||
|
||||
export class CheckoutDiffManager {
|
||||
private readonly workspaceGitService: CheckoutDiffWorkspace;
|
||||
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
||||
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
paseoHome: string;
|
||||
workspaceGitService: CheckoutDiffWorkspace;
|
||||
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
}) {
|
||||
this.workspaceGitService = options.workspaceGitService;
|
||||
this.workspaceRuntimeStore = options.workspaceRuntimeStore;
|
||||
}
|
||||
|
||||
async read(
|
||||
@@ -366,7 +366,7 @@ export class CheckoutDiffManager {
|
||||
private async openTarget(target: CheckoutDiffWatchTarget): Promise<void> {
|
||||
if (target.compare.mode !== "uncommitted") {
|
||||
const snapshot =
|
||||
this.workspaceGitService.peekSnapshot(target.cwd) ??
|
||||
this.workspaceRuntimeStore.get(target.cwd) ??
|
||||
(await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false }));
|
||||
target.diffCwd = snapshot.git.repoRoot ?? target.cwd;
|
||||
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
|
||||
|
||||
@@ -1221,7 +1221,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
registerWorkspace: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||
|
||||
getCheckout: async (cwd) => {
|
||||
try {
|
||||
const snapshot = createWorkspaceGitSnapshot(cwd);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
createWorkspaceRuntimeStore,
|
||||
type WorkspaceRuntimeStore,
|
||||
} from "./workspace-runtime-store/index.js";
|
||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
@@ -289,6 +293,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
interface SessionForTestOptions {
|
||||
workspaceRuntimeStore?: WorkspaceRuntimeStore;
|
||||
sendToSource?: SessionOptions["onMessageToSource"];
|
||||
clientId?: string;
|
||||
permissions?: readonly DaemonPermission[];
|
||||
@@ -302,7 +307,6 @@ interface SessionForTestOptions {
|
||||
getSnapshot?: ReturnType<typeof vi.fn>;
|
||||
suggestBranchesForCwd?: ReturnType<typeof vi.fn>;
|
||||
listStashes?: ReturnType<typeof vi.fn>;
|
||||
peekSnapshot?: ReturnType<typeof vi.fn>;
|
||||
validateBranchRef?: ReturnType<typeof vi.fn>;
|
||||
hasLocalBranch?: ReturnType<typeof vi.fn>;
|
||||
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
|
||||
@@ -353,7 +357,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
getSnapshot: vi.fn(),
|
||||
suggestBranchesForCwd: vi.fn(),
|
||||
listStashes: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
|
||||
validateBranchRef: vi.fn(),
|
||||
hasLocalBranch: vi.fn(),
|
||||
resolveRepoRemoteUrl: vi.fn(),
|
||||
@@ -369,6 +373,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
const sessionOptions: SessionOptions = {
|
||||
workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -3898,11 +3903,11 @@ describe("session checkout refresh handling", () => {
|
||||
describe("session checkout status handling", () => {
|
||||
test("returns checkout status from the workspace git service snapshot", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_status_request",
|
||||
@@ -3938,6 +3943,7 @@ describe("session checkout status handling", () => {
|
||||
|
||||
test("returns fresh service data on the first checkout status read for a cwd", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(
|
||||
createWorkspaceGitSnapshot("/tmp/cold-worktree", {
|
||||
@@ -3950,9 +3956,8 @@ describe("session checkout status handling", () => {
|
||||
},
|
||||
}),
|
||||
),
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_status_request",
|
||||
@@ -3960,7 +3965,7 @@ describe("session checkout status handling", () => {
|
||||
requestId: "request-cold-status",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceRuntimeStore.get).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout_status_response",
|
||||
@@ -4000,15 +4005,9 @@ describe("session workspace descriptors", () => {
|
||||
archivedAt: null,
|
||||
};
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([project]),
|
||||
get: vi.fn().mockResolvedValue(project),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
workspaceRuntimeStore: {
|
||||
...createWorkspaceRuntimeStore(),
|
||||
get: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/repo/app", {
|
||||
git: {
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
@@ -4018,6 +4017,16 @@ describe("session workspace descriptors", () => {
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([project]),
|
||||
get: vi.fn().mockResolvedValue(project),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
|
||||
registerWorkspace: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
@@ -4076,12 +4085,9 @@ describe("session workspace descriptors", () => {
|
||||
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(() =>
|
||||
workspaceRuntimeStore: {
|
||||
...createWorkspaceRuntimeStore(),
|
||||
get: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/repo/local", {
|
||||
git: {
|
||||
remoteUrl: null,
|
||||
@@ -4091,6 +4097,13 @@ describe("session workspace descriptors", () => {
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
|
||||
registerWorkspace: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
@@ -4127,16 +4140,19 @@ describe("session workspace descriptors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("reads descriptor diff stat from the workspace git service snapshot", async () => {
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
test("reads descriptor diff stat from the workspace runtime store", async () => {
|
||||
const workspaceRuntimeStore = {
|
||||
...createWorkspaceRuntimeStore(),
|
||||
get: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/tmp/workspace", {
|
||||
git: { diffStat: { additions: 7, deletions: 2 } },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService });
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService });
|
||||
checkoutGitMocks.getCachedCheckoutShortstat.mockReturnValue({
|
||||
additions: 99,
|
||||
deletions: 88,
|
||||
@@ -4158,7 +4174,7 @@ describe("session workspace descriptors", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace");
|
||||
expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace");
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled();
|
||||
expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled();
|
||||
@@ -4166,11 +4182,11 @@ describe("session workspace descriptors", () => {
|
||||
});
|
||||
|
||||
test("does not cold-load git data while describing a workspace", async () => {
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")),
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService });
|
||||
|
||||
const descriptor = await asSessionInternals(session).describeWorkspaceRecordWithGitData(
|
||||
{
|
||||
@@ -4188,7 +4204,7 @@ describe("session workspace descriptors", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace");
|
||||
expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace");
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(descriptor.diffStat).toBeNull();
|
||||
expect(descriptor.gitRuntime).toBeUndefined();
|
||||
@@ -4198,14 +4214,15 @@ describe("session workspace descriptors", () => {
|
||||
describe("session branch validation", () => {
|
||||
test("validates branches through the workspace git service", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
|
||||
validateBranchRef: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: "remote-only", name: "feature", remoteRef: "origin/feature" }),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "validate_branch_request",
|
||||
@@ -4243,12 +4260,17 @@ describe("session branch validation", () => {
|
||||
execSync("git tag v1", { cwd: repoDir });
|
||||
|
||||
const messages: unknown[] = [];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
|
||||
validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({
|
||||
workspaceRuntimeStore,
|
||||
workspaceGitService,
|
||||
messages,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "validate_branch_request",
|
||||
@@ -4323,11 +4345,11 @@ describe("session checkout switch branch handling", () => {
|
||||
describe("session checkout rename branch handling", () => {
|
||||
test("rejects invalid branch slugs without renaming", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout.rename_branch.request",
|
||||
@@ -4356,11 +4378,11 @@ describe("session checkout rename branch handling", () => {
|
||||
|
||||
test("reports null current branch when branch rename fails", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists"));
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -4374,7 +4396,7 @@ describe("session checkout rename branch handling", () => {
|
||||
"/tmp/repo",
|
||||
"feature/new-name",
|
||||
);
|
||||
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceRuntimeStore.get).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout.rename_branch.response",
|
||||
@@ -4394,6 +4416,14 @@ describe("session checkout rename branch handling", () => {
|
||||
test("forces workspace git refresh after renaming the current branch", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const github = { invalidate: vi.fn() };
|
||||
const workspaceRuntimeStore = {
|
||||
...createWorkspaceRuntimeStore(),
|
||||
get: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: { currentBranch: "feature/old-name" },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(
|
||||
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
@@ -4403,13 +4433,13 @@ describe("session checkout rename branch handling", () => {
|
||||
},
|
||||
}),
|
||||
),
|
||||
peekSnapshot: vi.fn(() =>
|
||||
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||
git: { currentBranch: "feature/old-name" },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||
const session = createSessionForTest({
|
||||
workspaceRuntimeStore,
|
||||
github,
|
||||
workspaceGitService,
|
||||
messages,
|
||||
});
|
||||
checkoutGitMocks.renameCurrentBranch.mockResolvedValue({
|
||||
previousBranch: "feature/old-name",
|
||||
currentBranch: "feature/new-name",
|
||||
@@ -4528,12 +4558,12 @@ describe("session branch suggestions handling", () => {
|
||||
const branchDetails = [
|
||||
{ name: "feature/service", committerDate: 10, hasLocal: true, hasRemote: false },
|
||||
];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
suggestBranchesForCwd: vi.fn().mockResolvedValue(branchDetails),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "branch_suggestions_request",
|
||||
@@ -4572,12 +4602,12 @@ describe("session stash list handling", () => {
|
||||
isPaseo: true,
|
||||
},
|
||||
];
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() };
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(),
|
||||
listStashes: vi.fn().mockResolvedValue(entries),
|
||||
peekSnapshot: vi.fn(),
|
||||
};
|
||||
const session = createSessionForTest({ workspaceGitService, messages });
|
||||
const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "stash_list_request",
|
||||
@@ -4723,8 +4753,8 @@ describe("session workspace script handling", () => {
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
},
|
||||
});
|
||||
const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => snapshot) };
|
||||
const workspaceGitService = {
|
||||
peekSnapshot: vi.fn(() => snapshot),
|
||||
getProjectSlug: vi.fn().mockResolvedValue("paseo"),
|
||||
};
|
||||
const workspaceRegistry = {
|
||||
@@ -4738,6 +4768,7 @@ describe("session workspace script handling", () => {
|
||||
terminalId: "terminal-1",
|
||||
});
|
||||
const session = createSessionForTest({
|
||||
workspaceRuntimeStore,
|
||||
workspaceGitService,
|
||||
workspaceRegistry,
|
||||
terminalManager: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
@@ -477,6 +478,7 @@ export interface SessionOptions {
|
||||
// defaults to the real checkout-git implementation.
|
||||
renameCurrentBranch?: typeof renameCurrentBranchDefault;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
workspaceAutoName: WorkspaceAutoName;
|
||||
daemonConfigStore: DaemonConfigStore;
|
||||
pluginRuntime?: {
|
||||
@@ -747,6 +749,7 @@ export class Session {
|
||||
private readonly github: ForgeService;
|
||||
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
private readonly workspaceAutoName: WorkspaceAutoName;
|
||||
private readonly gitMutation: GitMutationService;
|
||||
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
||||
@@ -850,6 +853,7 @@ export class Session {
|
||||
github,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService,
|
||||
workspaceRuntimeStore,
|
||||
workspaceAutoName,
|
||||
daemonConfigStore,
|
||||
pluginRuntime,
|
||||
@@ -930,6 +934,7 @@ export class Session {
|
||||
this.github = github ?? createGitHubService();
|
||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||
this.workspaceGitService = workspaceGitService;
|
||||
this.workspaceRuntimeStore = workspaceRuntimeStore;
|
||||
this.gitMutation = createGitMutationService({
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
logger: this.sessionLogger,
|
||||
@@ -1202,7 +1207,7 @@ export class Session {
|
||||
terminalManager: this.terminalManager,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
workspaceRuntimeStore: this.workspaceRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
@@ -2141,7 +2146,7 @@ export class Session {
|
||||
if (!project) {
|
||||
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
|
||||
}
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const snapshot = this.workspaceRuntimeStore.get(workspace.cwd);
|
||||
const checkout = checkoutFromPersistedWorkspacePlacement({
|
||||
workspace,
|
||||
// COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15.
|
||||
@@ -5611,7 +5616,7 @@ export class Session {
|
||||
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||
|
||||
let diffStat: { additions: number; deletions: number } | null = null;
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const snapshot = this.workspaceRuntimeStore.get(workspace.cwd);
|
||||
if (snapshot?.git.diffStat) {
|
||||
diffStat = snapshot.git.diffStat;
|
||||
}
|
||||
@@ -5687,7 +5692,7 @@ export class Session {
|
||||
projectRecord?: PersistedProjectRecord | null,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const snapshot = this.workspaceRuntimeStore.get(workspace.cwd);
|
||||
if (!snapshot) {
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
@@ -135,7 +136,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
workspaces: Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
||||
workspaceGitService: WorkspaceGitService & {
|
||||
registerWorkspace: ReturnType<typeof vi.fn>;
|
||||
peekSnapshot: ReturnType<typeof vi.fn>;
|
||||
|
||||
getSnapshot: ReturnType<typeof vi.fn>;
|
||||
refresh: ReturnType<typeof vi.fn>;
|
||||
requestWorkingTreeWatch: ReturnType<typeof vi.fn>;
|
||||
@@ -177,7 +178,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
unsubscribe,
|
||||
};
|
||||
}),
|
||||
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
||||
|
||||
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
||||
refresh: vi.fn(async () => {}),
|
||||
requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({
|
||||
@@ -189,6 +190,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
@@ -79,7 +80,6 @@ function createHarness(input: {
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
},
|
||||
peekSnapshot: () => null,
|
||||
});
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
@@ -93,6 +93,7 @@ function createHarness(input: {
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
createWorkspaceRuntimeStore,
|
||||
type WorkspaceRuntimeStore,
|
||||
} from "./workspace-runtime-store/index.js";
|
||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
@@ -48,7 +52,12 @@ import {
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
} from "../utils/worktree-metadata.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||
import {
|
||||
WorkspaceGitServiceImpl,
|
||||
type WorkspaceGitRuntimeSnapshot,
|
||||
} from "./workspace-git-service.js";
|
||||
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
|
||||
import type pino from "pino";
|
||||
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
@@ -184,10 +193,10 @@ interface SessionTestAccess {
|
||||
killTerminal(id: string): unknown;
|
||||
clearTerminalAttention?(id: string): Promise<boolean>;
|
||||
} | null;
|
||||
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
workspaceGitService: {
|
||||
getCheckout: (cwd: string) => Promise<unknown>;
|
||||
getSnapshot: (cwd: string, options?: unknown) => Promise<WorkspaceGitRuntimeSnapshot>;
|
||||
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
|
||||
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
|
||||
};
|
||||
filesystem: {
|
||||
@@ -559,6 +568,7 @@ function createSessionForWorkspaceTests(
|
||||
appVersion?: string | null;
|
||||
onMessage?: (message: SessionOutboundMessage) => void;
|
||||
onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"];
|
||||
workspaceRuntimeStore?: WorkspaceRuntimeStore;
|
||||
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
|
||||
terminalManager?: TerminalManager | null;
|
||||
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
|
||||
@@ -647,6 +657,7 @@ function createSessionForWorkspaceTests(
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -1010,6 +1021,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -1166,6 +1178,7 @@ test("create_agent_request launches from an exact subdirectory in a created work
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -1306,6 +1319,7 @@ test("create_agent_request does not title an existing workspace from the agent p
|
||||
let generateCalls = 0;
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -1449,7 +1463,6 @@ test("agent_update placement does not refresh git snapshots", async () => {
|
||||
const workspaceGitService = {
|
||||
...createNoopWorkspaceGitService(),
|
||||
getSnapshot,
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
};
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({
|
||||
@@ -1516,7 +1529,6 @@ test("agent_update emits remove when the agent has no workspaceId", async () =>
|
||||
workspaceGitService: {
|
||||
...createNoopWorkspaceGitService(),
|
||||
getSnapshot,
|
||||
peekSnapshot: vi.fn(() => null),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1578,6 +1590,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -2063,6 +2076,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
||||
const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const }));
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -2234,6 +2248,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -2396,6 +2411,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
const killTerminalBestEffort = vi.fn();
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -2606,7 +2622,7 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
(workspace) => workspace.workspaceId === workspaceId,
|
||||
) ?? null;
|
||||
session.projectRegistry.get = async () => project;
|
||||
session.workspaceGitService.peekSnapshot = (cwd: string) =>
|
||||
session.workspaceRuntimeStore.get = (cwd: string) =>
|
||||
cwd === paseoSubdirectory.cwd
|
||||
? createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: { repoRoot: "/tmp/paseo-worktree" },
|
||||
@@ -3654,6 +3670,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -4624,7 +4641,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
});
|
||||
session.workspaceGitService.peekSnapshot = () => peeked.value;
|
||||
session.workspaceRuntimeStore.get = () => peeked.value;
|
||||
session.workspaceGitService.registerWorkspace = (
|
||||
_params,
|
||||
incomingListener: (snapshot: WorkspaceGitRuntimeSnapshot) => void,
|
||||
@@ -6428,7 +6445,7 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.list = async () => [workspace];
|
||||
session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD);
|
||||
session.workspaceRuntimeStore.get = () => createWorkspaceRuntimeSnapshot(REPO_CWD);
|
||||
|
||||
const descriptors = Array.from(
|
||||
(await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(),
|
||||
@@ -6982,9 +6999,10 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
||||
},
|
||||
},
|
||||
});
|
||||
const peekSnapshotRuntimeFetch = vi.fn(() => runtimeSnapshot);
|
||||
const getRuntimeSnapshotForFetch = vi.fn(() => runtimeSnapshot);
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.peekSnapshot = peekSnapshotRuntimeFetch;
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
workspaceRuntimeStore.get = getRuntimeSnapshotForFetch;
|
||||
workspaceGitService.registerWorkspace = vi.fn(() => ({
|
||||
unsubscribe: () => {},
|
||||
}));
|
||||
@@ -6992,6 +7010,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({
|
||||
workspaceGitService,
|
||||
workspaceRuntimeStore,
|
||||
}),
|
||||
);
|
||||
const project = createPersistedProjectRecord({
|
||||
@@ -7041,7 +7060,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git
|
||||
| { type: "fetch_workspaces_response"; payload: Record<string, unknown> }
|
||||
| undefined;
|
||||
|
||||
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(getRuntimeSnapshotForFetch).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(response?.payload.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "ws-runtime-fetch",
|
||||
@@ -7146,13 +7165,15 @@ test("workspace_update includes updated runtime fields", async () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const peekSnapshotRuntimeUpdate = vi.fn(() => runtimeSnapshot);
|
||||
const getRuntimeSnapshotForUpdate = vi.fn(() => runtimeSnapshot);
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.peekSnapshot = peekSnapshotRuntimeUpdate;
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
workspaceRuntimeStore.get = getRuntimeSnapshotForUpdate;
|
||||
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({
|
||||
workspaceGitService,
|
||||
workspaceRuntimeStore,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
}),
|
||||
);
|
||||
@@ -7199,7 +7220,7 @@ test("workspace_update includes updated runtime fields", async () => {
|
||||
|
||||
await session.emitWorkspaceUpdateForCwd(REPO_CWD);
|
||||
|
||||
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(getRuntimeSnapshotForUpdate).toHaveBeenCalledWith(REPO_CWD);
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
@@ -9692,3 +9713,212 @@ test.each(["failed", "blocked"] as const)(
|
||||
expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
// Diagnostic reproduction: PR status on workspace descriptors must survive a
|
||||
// session teardown (client reconnect after the 90s grace, or any routed-session
|
||||
// disconnect) when the daemon already observed it.
|
||||
test("workspace PR status survives session cleanup and reconnect", async () => {
|
||||
const cwd = path.resolve("/tmp/repro-pr-repo");
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-repro",
|
||||
rootPath: cwd,
|
||||
kind: "git",
|
||||
displayName: "repro",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repro",
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "worktree",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
displayName: "repro",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const projectRegistry: SessionOptions["projectRegistry"] = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [project],
|
||||
get: async (id) => (id === project.projectId ? project : null),
|
||||
getOrCreateActiveByRoot: async () => project,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
};
|
||||
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [workspace],
|
||||
get: async (id) => (id === workspace.workspaceId ? workspace : null),
|
||||
update: async (id, updater) => (id === workspace.workspaceId ? updater(workspace) : null),
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
};
|
||||
|
||||
const checkoutStatus: CheckoutStatusGit = {
|
||||
isGit: true,
|
||||
repoRoot: cwd,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "feature",
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
};
|
||||
const facts: CheckoutSnapshotFacts = {
|
||||
isGit: true,
|
||||
worktreeRoot: cwd,
|
||||
currentBranch: "feature",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
absoluteGitDir: path.join(cwd, ".git"),
|
||||
gitCommonDir: path.join(cwd, ".git"),
|
||||
paseoWorktree: { isPaseoOwnedWorktree: false },
|
||||
storedBaseRef: null,
|
||||
resolvedBaseRef: "main",
|
||||
mainRepoRoot: null,
|
||||
comparisonBaseRef: null,
|
||||
branchRemoteName: "origin",
|
||||
branchMergeRef: "refs/heads/feature",
|
||||
pullRequestLookupTarget: { headRef: "feature" },
|
||||
};
|
||||
const githubStub = {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({
|
||||
items: [],
|
||||
featuresEnabled: true,
|
||||
githubFeaturesEnabled: true,
|
||||
}),
|
||||
getPullRequest: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
getPullRequestHeadRef: async () => "feature",
|
||||
getPullRequestCheckoutTarget: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
getCurrentPullRequestStatus: async () => null,
|
||||
createPullRequest: async () => ({ url: "https://github.com/acme/repo/pull/1", number: 1 }),
|
||||
mergePullRequest: async () => ({ success: true }),
|
||||
isAuthenticated: async () => true,
|
||||
authProbeCanThrow: true,
|
||||
invalidate: () => {},
|
||||
} as unknown as ForgeService;
|
||||
const gitService = new WorkspaceGitServiceImpl({
|
||||
logger: createTestLogger() as unknown as pino.Logger,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
deps: {
|
||||
subscribe: async () => ({ updateIgnore: async () => {}, unsubscribe: async () => {} }),
|
||||
getCheckoutSnapshotFacts: async () => facts,
|
||||
getCheckoutRefDerivedState: async (_cwd, f, current) => ({
|
||||
...current,
|
||||
upstreamStatus: f.upstreamStatus,
|
||||
}),
|
||||
getCheckoutStatus: async () => checkoutStatus,
|
||||
getCheckoutShortstat: async () => ({ additions: 1, deletions: 0 }),
|
||||
getCheckoutWorktreeState: async () => ({
|
||||
isDirty: false,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
}),
|
||||
getPullRequestStatus: async () => ({
|
||||
status: {
|
||||
url: "https://github.com/acme/repo/pull/123",
|
||||
title: "Merged feature",
|
||||
state: "merged",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
isMerged: true,
|
||||
checksStatus: "success",
|
||||
},
|
||||
authState: "authenticated",
|
||||
featuresEnabled: true,
|
||||
githubFeaturesEnabled: true,
|
||||
}),
|
||||
forgeOverrides: { github: githubStub },
|
||||
resolveAbsoluteGitDir: async () => path.join(cwd, ".git"),
|
||||
hasOriginRemote: async () => false,
|
||||
runGitFetch: async () => ({ changes: [], error: null }),
|
||||
runGitCommand: async () => ({
|
||||
stdout: `${cwd}\n`,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}),
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 30_000,
|
||||
createWatcherLivenessCanary: () => ({
|
||||
path: "",
|
||||
filterEvents: (events) => events,
|
||||
verify: async () => {},
|
||||
}),
|
||||
now: () => new Date(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
gitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
|
||||
const emitted1: SessionOutboundMessage[] = [];
|
||||
const session1 = createSessionForWorkspaceTests({
|
||||
onMessage: (m) => emitted1.push(m),
|
||||
workspaceGitService: gitService,
|
||||
workspaceRuntimeStore,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
});
|
||||
await session1.handleMessage({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "sub-1",
|
||||
subscribe: { subscriptionId: "sub-1" },
|
||||
});
|
||||
expect(filterByType(emitted1, "rpc_error")).toEqual([]);
|
||||
const prUrlFrom = (m: SessionOutboundMessage) =>
|
||||
m.type === "workspace_update" && m.payload.kind === "upsert"
|
||||
? (m.payload.workspace.githubRuntime?.pullRequest?.url ?? null)
|
||||
: null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(emitted1.map(prUrlFrom)).toContain("https://github.com/acme/repo/pull/123");
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
expect(workspaceRuntimeStore.get(cwd)?.forge.pullRequest?.url).toBe(
|
||||
"https://github.com/acme/repo/pull/123",
|
||||
);
|
||||
|
||||
// Client disconnect past the reconnect grace (or any routed-session drop).
|
||||
await session1.cleanup();
|
||||
|
||||
const emitted2: SessionOutboundMessage[] = [];
|
||||
const session2 = createSessionForWorkspaceTests({
|
||||
onMessage: (m) => emitted2.push(m),
|
||||
workspaceGitService: gitService,
|
||||
workspaceRuntimeStore,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
});
|
||||
await session2.handleMessage({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "sub-2",
|
||||
subscribe: { subscriptionId: "sub-2" },
|
||||
});
|
||||
const response = filterByType(emitted2, "fetch_workspaces_response")[0];
|
||||
const entry = response?.payload.entries.find((e) => e.id === workspace.workspaceId);
|
||||
const reconnectEntryPrUrl = entry?.githubRuntime?.pullRequest?.url;
|
||||
try {
|
||||
// Expected: the daemon already knew this PR; the reconnecting client should get it back
|
||||
// in the directory listing instead of a descriptor with no githubRuntime at all.
|
||||
expect(entry?.githubRuntime).toBeDefined();
|
||||
expect(reconnectEntryPrUrl).toBe("https://github.com/acme/repo/pull/123");
|
||||
} finally {
|
||||
await session2.cleanup();
|
||||
await gitService.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
-5
@@ -53,7 +53,6 @@ let provisioning: WorkspaceProvisioningService;
|
||||
|
||||
function gitService() {
|
||||
return createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getSnapshot: async (cwd: string) => {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
if (!restoredMergedChangeRequestUrl) return snapshot;
|
||||
@@ -242,7 +241,6 @@ test("persists manual worktree ownership separately from its workspace kind", as
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
@@ -330,7 +328,6 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
@@ -886,7 +883,6 @@ test("a failed import keeps an archived worktree's placement when its directory
|
||||
logger,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
const status = await getCheckoutStatus(cwd, { logger });
|
||||
return checkoutLiteFromGitSnapshot(
|
||||
@@ -960,7 +956,6 @@ test("a directory that goes away while the git read is in flight keeps its place
|
||||
isDirectory,
|
||||
logger,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
return {
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
serverId?: string;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot">;
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
logger: Logger;
|
||||
lifecycle?: PluginLifecycle;
|
||||
|
||||
+7
-7
@@ -22,11 +22,11 @@ import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
WorktreeScriptResult,
|
||||
} from "../../worktree-bootstrap.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type { WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js";
|
||||
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
|
||||
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug},
|
||||
// The production module reads only WorkspaceRuntimeStore.get,
|
||||
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
|
||||
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
|
||||
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
|
||||
@@ -51,7 +51,7 @@ function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick<Projec
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitService() {
|
||||
function fakeRuntimeStore() {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo");
|
||||
snapshot.git = {
|
||||
...snapshot.git,
|
||||
@@ -63,7 +63,7 @@ function fakeGitService() {
|
||||
};
|
||||
|
||||
return {
|
||||
peekSnapshot() {
|
||||
get() {
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
@@ -80,7 +80,7 @@ interface BuildOptions {
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
spawnThrows?: string;
|
||||
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
runtimeStore?: Pick<WorkspaceRuntimeStore, "get">;
|
||||
automationError?: Error;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ function buildService(options: BuildOptions = {}) {
|
||||
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
||||
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
||||
projectRegistry: fakeProjectRegistry(options.project ?? null),
|
||||
workspaceGitService: options.gitService ?? fakeGitService(),
|
||||
workspaceRuntimeStore: options.runtimeStore ?? fakeRuntimeStore(),
|
||||
getDaemonTcpPort: () => 6767,
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
@@ -204,7 +204,7 @@ describe("buildSnapshot", () => {
|
||||
workspace,
|
||||
project,
|
||||
serviceProxy,
|
||||
gitService: { peekSnapshot: () => undefined },
|
||||
runtimeStore: { get: () => null },
|
||||
});
|
||||
|
||||
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
ScriptStatusUpdateMessage,
|
||||
@@ -10,7 +11,6 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||
import type { ServiceProxySubsystem } from "../../service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
|
||||
import type { ScriptHealthState } from "../../script-health-monitor.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
@@ -51,15 +51,13 @@ export interface WorkspaceScriptsService {
|
||||
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||
}
|
||||
|
||||
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
|
||||
export function createWorkspaceScriptsService(deps: {
|
||||
serviceProxy: ServiceProxySubsystem | null;
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
terminalManager: TerminalManager | null;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
|
||||
projectRegistry: Pick<ProjectRegistry, "get">;
|
||||
workspaceGitService: WorkspaceScriptsGitSource;
|
||||
workspaceRuntimeStore: Pick<WorkspaceRuntimeStore, "get">;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
serviceProxyPublicBaseUrl: string | null;
|
||||
@@ -78,7 +76,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
terminalManager,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService,
|
||||
workspaceRuntimeStore,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
@@ -95,7 +93,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: { projectId: string; rootPath: string } | null,
|
||||
) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const snapshot = workspaceRuntimeStore.get(workspace.cwd);
|
||||
const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null;
|
||||
if (project) {
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
@@ -104,6 +105,7 @@ describe("snapshot mutation ownership boundary", () => {
|
||||
|
||||
const session = asInternals<SessionInternals>(
|
||||
new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
|
||||
@@ -38,7 +38,7 @@ export function createNoopWorkspaceGitService(
|
||||
onSnapshotUpdated: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { createServer, type Server as HTTPServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
@@ -306,6 +307,7 @@ function createVoiceAssistantWebSocketServer(params: {
|
||||
null,
|
||||
{ allowedOrigins: new Set(["*"]) },
|
||||
createWorkspaceAutoNameStub(),
|
||||
createWorkspaceRuntimeStore(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { SessionDelivery } from "./session/owned-subscriptions/index.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
@@ -117,6 +118,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
createWorkspaceAutoNameStub(),
|
||||
createWorkspaceRuntimeStore(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { SessionDelivery } from "./session/owned-subscriptions/index.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
@@ -285,6 +286,7 @@ function createServer(options?: {
|
||||
null,
|
||||
{ allowedOrigins: new Set(), startPaused: options?.startPaused },
|
||||
createWorkspaceAutoNameStub(),
|
||||
createWorkspaceRuntimeStore(),
|
||||
options?.auth,
|
||||
speechReadiness
|
||||
? {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { SessionDelivery } from "./session/owned-subscriptions/index.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
@@ -148,6 +149,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
createWorkspaceAutoNameStub(),
|
||||
createWorkspaceRuntimeStore(),
|
||||
undefined,
|
||||
undefined,
|
||||
terminalManager,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import {
|
||||
createAgentMessageDelivery,
|
||||
type AgentMessageDelivery,
|
||||
@@ -241,7 +242,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
onSnapshotUpdated: () => ({
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: false,
|
||||
@@ -649,6 +649,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
mcpBaseUrl: string | null,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
workspaceAutoName: WorkspaceAutoName,
|
||||
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore,
|
||||
auth?: DaemonAuthConfig,
|
||||
speech?: SpeechService | null,
|
||||
terminalManager?: TerminalManager | null,
|
||||
@@ -1606,6 +1607,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
checkoutDiffManager: this.checkoutDiffManager,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
workspaceRuntimeStore: this.workspaceRuntimeStore,
|
||||
workspaceAutoName: this.workspaceAutoName,
|
||||
daemonConfigStore: this.daemonConfigStore,
|
||||
pluginRuntime: this.pluginRuntime,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
@@ -214,6 +215,7 @@ function createSessionForWireCompatTest(options?: {
|
||||
];
|
||||
|
||||
const session = new Session({
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
creationService: createTestCreationService(),
|
||||
@@ -262,9 +264,7 @@ function createSessionForWireCompatTest(options?: {
|
||||
async listStashes() {
|
||||
return [];
|
||||
},
|
||||
peekSnapshot() {
|
||||
return null;
|
||||
},
|
||||
|
||||
async validateBranchRef() {
|
||||
return { ok: false, error: "not found" };
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -204,7 +205,10 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
||||
runGitCommand,
|
||||
} as never,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const diffManager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: workspaceRuntimeStore,
|
||||
logger: createLogger(),
|
||||
paseoHome: path.join(tempDir, "paseo-home"),
|
||||
workspaceGitService: service,
|
||||
@@ -229,7 +233,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(activeWatcherCount).toBe(2);
|
||||
expect(service.peekSnapshot(repoDir)).not.toBeNull();
|
||||
expect(workspaceRuntimeStore.get(repoDir)).not.toBeNull();
|
||||
expect(service.getMetrics()).toMatchObject({
|
||||
workspaceObservationSetupInFlightCount: 0,
|
||||
workspaceRefreshInFlightCount: 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type pino from "pino";
|
||||
@@ -379,11 +380,13 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getCheckoutStatus,
|
||||
runGitCommand,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
|
||||
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)).not.toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
@@ -442,7 +445,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getPullRequestStatus,
|
||||
getCheckoutDiff,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const diffManager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: workspaceRuntimeStore,
|
||||
logger: createLogger(),
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
workspaceGitService: service,
|
||||
@@ -509,10 +515,12 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
},
|
||||
);
|
||||
const service = createService(watcher, { getCheckoutDiff });
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)).not.toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const initialUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
|
||||
@@ -963,12 +971,14 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
return { changes: [], nonRemoteRefsChanged: false, error: null };
|
||||
}),
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await fetchSnapshotRead.promise;
|
||||
// Observation setup also reads facts. Wait for the initial snapshot to be
|
||||
// published before injecting an event that must produce a second refresh.
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature");
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)?.git.currentBranch).toBe("feature");
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
@@ -1233,7 +1243,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getCheckoutShortstat,
|
||||
getCheckoutDiff,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const diffManager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: workspaceRuntimeStore,
|
||||
logger: createLogger(),
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
workspaceGitService: service,
|
||||
@@ -1288,7 +1301,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
}));
|
||||
const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState });
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const diffManager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: workspaceRuntimeStore,
|
||||
logger: createLogger(),
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
workspaceGitService: service,
|
||||
@@ -1518,6 +1534,8 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getCheckoutStatus,
|
||||
getPullRequestStatus,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -1529,7 +1547,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
expect(service.peekSnapshot(REPO_CWD)).not.toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(7_000);
|
||||
@@ -1677,10 +1695,12 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 60_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(false);
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(false);
|
||||
expect(service.getMetrics()).toMatchObject({
|
||||
workspaceObservationSetupInFlightCount: 0,
|
||||
workspaceRefreshInFlightCount: 0,
|
||||
@@ -1691,7 +1711,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
isGit = true;
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(true);
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(true);
|
||||
expect(service.getMetrics()).toMatchObject({
|
||||
repositoryTargetCount: 1,
|
||||
repositoryWorkspaceLinkCount: 1,
|
||||
@@ -1728,10 +1748,12 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)).not.toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
@@ -2743,7 +2765,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
getCheckoutShortstat,
|
||||
getCheckoutDiff,
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
const diffManager = new CheckoutDiffManager({
|
||||
workspaceRuntimeStore: workspaceRuntimeStore,
|
||||
logger: createLogger(),
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
workspaceGitService: service,
|
||||
@@ -2757,7 +2782,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.peekSnapshot(REPO_CWD)).not.toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
isDirty = true;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -566,19 +567,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
||||
expect(getCheckoutStatus).not.toHaveBeenCalled();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||
expect(listener.mock.lastCall?.[0] ?? null).toBeNull();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await flushPromises();
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||
expect(listener.mock.lastCall?.[0] ?? null).toBeNull();
|
||||
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD));
|
||||
|
||||
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||
expect(listener.mock.lastCall?.[0] ?? null).toEqual(createSnapshot(REPO_CWD));
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -703,7 +704,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
|
||||
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||
await refresh;
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(
|
||||
expect(await service.getSnapshot(REPO_CWD)).toEqual(
|
||||
createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature",
|
||||
@@ -1385,23 +1386,25 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult("Visible PR")),
|
||||
});
|
||||
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
try {
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
workspaceRuntimeStore.set(await service.getSnapshot(REPO_CWD));
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR");
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR");
|
||||
|
||||
headSha = "2222222222222222222222222222222222222222";
|
||||
nowMs = 3_000;
|
||||
await service.refresh(REPO_CWD);
|
||||
|
||||
expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest).toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest).toBeNull();
|
||||
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushPromises();
|
||||
|
||||
expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed");
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed");
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
service.dispose();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path, { join } from "node:path";
|
||||
@@ -334,6 +335,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
test("registerWorkspace returns a subscription without an initial snapshot contract", async () => {
|
||||
const service = createService();
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
|
||||
const listener = vi.fn();
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
@@ -341,12 +344,46 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
|
||||
expect("initial" in subscription).toBe(false);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(service.peekSnapshot(REPO_CWD)).toBeNull();
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).toBeNull();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("shared watches publish to the runtime store before listeners and retain data after teardown", async () => {
|
||||
const subscribe = vi.fn(async () => createAsyncSubscription());
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const service = createService({ subscribe, getPullRequestStatus });
|
||||
const store = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => store.set(snapshot));
|
||||
const observed: WorkspaceGitRuntimeSnapshot[] = [];
|
||||
const listener = (snapshot: WorkspaceGitRuntimeSnapshot) => {
|
||||
expect(store.get(REPO_CWD)).toBe(snapshot);
|
||||
observed.push(snapshot);
|
||||
};
|
||||
const first = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, (snapshot) =>
|
||||
listener(snapshot),
|
||||
);
|
||||
await service.getSnapshot(REPO_CWD, { force: true, reason: "runtime-store" });
|
||||
await flushPromises();
|
||||
expect(observed.length).toBeGreaterThan(0);
|
||||
expect(service.getMetrics().workspaceTargetCount).toBe(1);
|
||||
const subscriptionCount = subscribe.mock.calls.length;
|
||||
const snapshot = store.get(REPO_CWD);
|
||||
expect(snapshot?.forge.pullRequest).not.toBeNull();
|
||||
first.unsubscribe();
|
||||
expect(service.getMetrics().workspaceListenerCount).toBe(1);
|
||||
second.unsubscribe();
|
||||
expect(service.getMetrics().workspaceTargetCount).toBe(0);
|
||||
const pollCount = getPullRequestStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(360_000);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(pollCount);
|
||||
expect(subscribe).toHaveBeenCalledTimes(subscriptionCount);
|
||||
expect(store.get(REPO_CWD)).toBe(snapshot);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("onSnapshotUpdated emits only for observed workspace snapshots and can unsubscribe", async () => {
|
||||
const service = createService();
|
||||
const snapshotListener = vi.fn();
|
||||
@@ -516,7 +553,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||
|
||||
await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
@@ -834,13 +870,15 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
resolveAbsoluteGitDir,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||
|
||||
const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn());
|
||||
|
||||
await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual(
|
||||
createSnapshot(REPO_CWD),
|
||||
);
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||
expect(workspaceRuntimeStore.get(REPO_CWD)).toEqual(createSnapshot(REPO_CWD));
|
||||
|
||||
nowMs += 3_000;
|
||||
await service.refresh(REPO_CWD);
|
||||
|
||||
@@ -209,7 +209,6 @@ export interface WorkspaceGitService {
|
||||
): WorkspaceGitSubscription;
|
||||
|
||||
onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription;
|
||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
||||
getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload>;
|
||||
getSnapshot(
|
||||
cwd: string,
|
||||
@@ -765,7 +764,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
const cached = this.peekSnapshot(normalizedCwd);
|
||||
const cached = this.getCachedSnapshot(normalizedCwd);
|
||||
if (cached && cached.git.isGit !== status.isGit) {
|
||||
// A fresh checkout read discovered a repository transition. Keep the
|
||||
// shared snapshot current before exposing the new project capability.
|
||||
@@ -795,7 +794,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
});
|
||||
}
|
||||
|
||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||
private getCachedSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||
cwd = resolve(cwd);
|
||||
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
|
||||
}
|
||||
@@ -3518,9 +3517,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (!options?.notify || target.listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const listener of target.listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
for (const listener of this.snapshotUpdatedListeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
@@ -3531,6 +3527,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const listener of target.listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private async runRepoFetch(target: RepoGitTarget): Promise<void> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FileBackedWorkspaceRegistry,
|
||||
resolveWorkspaceDisplayName,
|
||||
resolveWorkspaceName,
|
||||
type WorkspaceMutation,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
describe("resolveWorkspaceName", () => {
|
||||
@@ -430,6 +431,10 @@ describe("workspace registries", () => {
|
||||
|
||||
test("creates, updates, archives, deletes, and lists workspace records", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
const mutations: WorkspaceMutation[] = [];
|
||||
workspaceRegistry.subscribeToMutations((mutation) => {
|
||||
mutations.push(mutation);
|
||||
});
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
@@ -460,6 +465,11 @@ describe("workspace registries", () => {
|
||||
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
|
||||
await workspaceRegistry.remove("/tmp/repo");
|
||||
expect(mutations.at(-1)).toEqual({
|
||||
kind: "remove",
|
||||
workspaceId: "/tmp/repo",
|
||||
workspace: archived,
|
||||
});
|
||||
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -585,7 +585,7 @@ export class FileBackedWorkspaceRegistry
|
||||
override async remove(workspaceId: string): Promise<void> {
|
||||
const workspace = await this.removeIfPresent(workspaceId);
|
||||
if (!workspace) return;
|
||||
await this.notifyMutation({ kind: "remove", workspaceId, workspace: null });
|
||||
await this.notifyMutation({ kind: "remove", workspaceId, workspace });
|
||||
}
|
||||
|
||||
async commitWorkspaceLabelMutation<TResult>(input: {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { resolve } from "node:path";
|
||||
import { createNoGitWorkspaceRuntimeSnapshot } from "../test-utils/workspace-git-service-stub.js";
|
||||
import { createWorkspaceRuntimeStore } from "./index.js";
|
||||
|
||||
test("retains, replaces, and removes snapshots by resolved workspace directory", () => {
|
||||
const store = createWorkspaceRuntimeStore();
|
||||
const cwd = "./workspace";
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
expect(store.get(cwd)).toBeNull();
|
||||
store.set(snapshot);
|
||||
expect(store.get(resolve(cwd))).toBe(snapshot);
|
||||
const replacement = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
store.set(replacement);
|
||||
expect(store.get(cwd)).toBe(replacement);
|
||||
store.remove(resolve(cwd));
|
||||
expect(store.get(cwd)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
|
||||
export interface WorkspaceRuntimeStore {
|
||||
get(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
||||
set(snapshot: WorkspaceGitRuntimeSnapshot): void;
|
||||
remove(cwd: string): void;
|
||||
}
|
||||
|
||||
export function createWorkspaceRuntimeStore(): WorkspaceRuntimeStore {
|
||||
const snapshots = new Map<string, WorkspaceGitRuntimeSnapshot>();
|
||||
return {
|
||||
get(cwd) {
|
||||
return snapshots.get(resolve(cwd)) ?? null;
|
||||
},
|
||||
set(snapshot) {
|
||||
snapshots.set(resolve(snapshot.cwd), snapshot);
|
||||
},
|
||||
remove(cwd) {
|
||||
snapshots.delete(resolve(cwd));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user