mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
Record workspace creation parentage (#8890)
* feat(workspaces): record workspace creation parentage Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle. * fix(app): preserve workspace parentage in replica cache
This commit is contained in:
@@ -136,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
|
||||
This is informational ancestry for future workspace grouping. It does not change placement,
|
||||
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
|
||||
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
|
||||
never inferred from directories, projects, or Git branches.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
|
||||
@@ -321,6 +321,26 @@ describe("ReplicaCache", () => {
|
||||
expect(restored?.range).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves workspace parentage and its checkpoint across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
...workspacePayload(),
|
||||
parentWorkspaceId: "parent",
|
||||
});
|
||||
baseline.workspaces.set(workspace.id, workspace);
|
||||
commitDirectory(writer, SERVER_ID, baseline);
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
const restored = await reader.readDirectory(SERVER_ID);
|
||||
expect(restored.checkpoint).toEqual(baseline.checkpoint);
|
||||
expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent");
|
||||
expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe(
|
||||
"parent",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -299,6 +299,7 @@ const WorkspaceGitRuntimeSchema = z
|
||||
.optional();
|
||||
|
||||
const StoredWorkspaceSchema = z.strictObject({
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
// Require both keys: a cursor without these values would preserve the wrong sort on reconnect.
|
||||
createdAt: IsoDateSchema.nullable(),
|
||||
lastUserMessageAt: IsoDateSchema.nullable(),
|
||||
@@ -707,6 +708,7 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor {
|
||||
|
||||
function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
||||
return {
|
||||
parentWorkspaceId: workspace.parentWorkspaceId,
|
||||
createdAt: workspace.createdAt?.toISOString() ?? null,
|
||||
lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null,
|
||||
id: workspace.id,
|
||||
|
||||
@@ -557,6 +557,33 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
expect(blank.workspaceDirectory).toBe("");
|
||||
});
|
||||
|
||||
it("preserves informational workspace parentage", () => {
|
||||
const payload = {
|
||||
id: "child",
|
||||
parentWorkspaceId: "parent",
|
||||
projectId: "1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
} as WorkspaceDescriptorPayload;
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload);
|
||||
|
||||
expect(workspace.parentWorkspaceId).toBe("parent");
|
||||
expect(
|
||||
normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults missing scripts to an empty array", () => {
|
||||
const payload = {
|
||||
id: "1",
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface Agent {
|
||||
}
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
parentWorkspaceId?: string;
|
||||
createdAt: Date | null;
|
||||
lastUserMessageAt: Date | null;
|
||||
id: string;
|
||||
@@ -162,6 +163,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
background: payload.background ?? false,
|
||||
parentWorkspaceId: payload.parentWorkspaceId,
|
||||
name: payload.name,
|
||||
title: payload.title ?? null,
|
||||
pinnedAt: payload.pinnedAt ?? null,
|
||||
|
||||
@@ -4189,6 +4189,8 @@ export const WorkspaceGitHubRuntimePayloadSchema = z
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z
|
||||
.object({
|
||||
// Informational creation ancestry; independent of placement and lifecycle.
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
|
||||
@@ -634,6 +634,26 @@ describe("workspace message schemas", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves informational workspace parentage in directory updates", () => {
|
||||
const workspace = {
|
||||
id: "child",
|
||||
parentWorkspaceId: "parent",
|
||||
projectId: "project",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "child",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
};
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent");
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined })
|
||||
.parentWorkspaceId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses legacy workspace descriptors without workspaceDirectory", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
|
||||
@@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
|
||||
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
|
||||
@@ -1197,12 +1197,43 @@ describe("Suite E: Workspace Tools", () => {
|
||||
});
|
||||
ownedWorkspaces.push(str(inherited.workspaceId));
|
||||
expect(inherited.background).toBe(true);
|
||||
expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const visible = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "local",
|
||||
background: false,
|
||||
});
|
||||
ownedWorkspaces.push(str(visible.workspaceId));
|
||||
expect(visible.background).toBe(false);
|
||||
expect(visible.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const worktree = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "worktree",
|
||||
branchName: "parentage-child",
|
||||
baseBranch: "main",
|
||||
background: false,
|
||||
});
|
||||
ownedWorkspaces.push(str(worktree.workspaceId));
|
||||
expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: worktreeRepoCwd },
|
||||
callerAgentId: str(parent.agentId),
|
||||
background: false,
|
||||
});
|
||||
const workspaceId = str(created.workspace?.id);
|
||||
ownedWorkspaces.push(workspaceId);
|
||||
expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const listed = await client.fetchWorkspaces();
|
||||
expect(listed.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: workspaceId,
|
||||
parentWorkspaceId: parentWorkspaceId,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
await scoped?.close();
|
||||
for (const workspaceId of ownedWorkspaces.toReversed())
|
||||
@@ -1210,7 +1241,7 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("a background agent's children and workspaces are background unless it overrides", async () => {
|
||||
const visibleAgentId = await createTopLevelAgent();
|
||||
|
||||
@@ -188,6 +188,7 @@ interface ProviderSummary {
|
||||
}
|
||||
|
||||
const WorkspaceAutomationSummarySchema = z.object({
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -206,6 +207,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
|
||||
kind: workspace.kind,
|
||||
title: workspace.title,
|
||||
background: workspace.background,
|
||||
parentWorkspaceId: workspace.parentWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5900,6 +5900,7 @@ export class Session {
|
||||
pinnedAt: workspace.pinnedAt,
|
||||
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
|
||||
background: workspace.background,
|
||||
parentWorkspaceId: workspace.parentWorkspaceId,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
@@ -5988,6 +5989,7 @@ export class Session {
|
||||
projectKind: projectRecord?.kind ?? "git",
|
||||
workspaceKind: result.workspace.kind,
|
||||
background: result.workspace.background,
|
||||
parentWorkspaceId: result.workspace.parentWorkspaceId,
|
||||
name: resolveWorkspaceName({
|
||||
title: result.workspace.title,
|
||||
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
|
||||
|
||||
+76
@@ -205,6 +205,82 @@ test("fresh non-git directory creates a directory workspace at the exact path",
|
||||
expect(workspace.cwd).toBe(dir);
|
||||
});
|
||||
|
||||
test("workspace parentage survives reload and parent archive without changing placement", async () => {
|
||||
const cwd = path.join(tmpDir, "plain");
|
||||
const parent = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
const child = await provisioning.createWorkspaceForDirectory(cwd, null, undefined, {
|
||||
callerWorkspaceId: parent.workspaceId,
|
||||
background: false,
|
||||
});
|
||||
const grandchild = await provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "other"),
|
||||
null,
|
||||
undefined,
|
||||
{
|
||||
callerWorkspaceId: child.workspaceId,
|
||||
},
|
||||
);
|
||||
expect(parent.parentWorkspaceId).toBeUndefined();
|
||||
expect(child).toMatchObject({ parentWorkspaceId: parent.workspaceId, cwd });
|
||||
expect(grandchild).toMatchObject({ parentWorkspaceId: child.workspaceId });
|
||||
await workspaceRegistry.archive(parent.workspaceId, ARCHIVED_AT);
|
||||
const reloaded = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
await reloaded.initialize();
|
||||
expect(await reloaded.get(child.workspaceId)).toMatchObject({
|
||||
parentWorkspaceId: parent.workspaceId,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(await reloaded.get(grandchild.workspaceId)).toMatchObject({
|
||||
parentWorkspaceId: child.workspaceId,
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("worktree workspace creation records its caller independently of its source project", async () => {
|
||||
const sourceCwd = path.join(tmpDir, "repo");
|
||||
gitRoots.add(sourceCwd);
|
||||
const parent = await provisioning.createWorkspaceForDirectory(path.join(tmpDir, "other"));
|
||||
const child = await provisioning.createWorkspaceForWorktree({
|
||||
sourceCwd,
|
||||
repoRoot: sourceCwd,
|
||||
cwd: path.join(tmpDir, "worktree"),
|
||||
worktreeRoot: path.join(tmpDir, "worktree"),
|
||||
branch: "child",
|
||||
baseBranch: "main",
|
||||
title: null,
|
||||
callerWorkspaceId: parent.workspaceId,
|
||||
});
|
||||
expect(child.parentWorkspaceId).toBe(parent.workspaceId);
|
||||
expect(child.projectId).not.toBe(parent.projectId);
|
||||
expect(await workspaceRegistry.get(child.workspaceId)).toMatchObject({
|
||||
parentWorkspaceId: parent.workspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace creation rejects an unknown caller even with explicit visibility", async () => {
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "plain"), null, undefined, {
|
||||
callerWorkspaceId: "missing",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow("Caller workspace missing not found");
|
||||
});
|
||||
|
||||
test("workspace creation cannot overwrite its caller with a self relationship", async () => {
|
||||
const cwd = path.join(tmpDir, "plain");
|
||||
const parent = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(cwd, null, undefined, {
|
||||
callerWorkspaceId: parent.workspaceId,
|
||||
workspaceId: parent.workspaceId,
|
||||
}),
|
||||
).rejects.toThrow("A workspace cannot be its own parent");
|
||||
expect(await workspaceRegistry.get(parent.workspaceId)).toEqual(parent);
|
||||
});
|
||||
|
||||
test("background workspace creation preserves hooks, inheritance and durable visibility", async () => {
|
||||
const dir = path.join(tmpDir, "plain");
|
||||
const events: string[] = [];
|
||||
|
||||
+13
-7
@@ -227,15 +227,21 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
return project;
|
||||
}
|
||||
|
||||
async function resolveBackground(input?: {
|
||||
async function resolveCreationMetadata(input?: {
|
||||
workspaceId?: string;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}): Promise<boolean> {
|
||||
if (input?.background !== undefined) return input.background;
|
||||
if (!input?.callerWorkspaceId) return false;
|
||||
}): Promise<{ background: boolean; parentWorkspaceId?: string }> {
|
||||
if (!input?.callerWorkspaceId) return { background: input?.background ?? false };
|
||||
if (input.workspaceId === input.callerWorkspaceId) {
|
||||
throw new Error("A workspace cannot be its own parent");
|
||||
}
|
||||
const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId);
|
||||
if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`);
|
||||
return callerWorkspace.background;
|
||||
return {
|
||||
background: input.background ?? callerWorkspace.background,
|
||||
parentWorkspaceId: callerWorkspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
@@ -269,7 +275,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
background: await resolveBackground(context),
|
||||
...(await resolveCreationMetadata(context)),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: context?.expectsInitialAgent,
|
||||
@@ -310,7 +316,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
|
||||
background: await resolveBackground(input),
|
||||
...(await resolveCreationMetadata(input)),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: input.expectsInitialAgent,
|
||||
|
||||
@@ -49,6 +49,7 @@ const PersistedProjectRecordSchema = z.object({
|
||||
});
|
||||
|
||||
const PersistedWorkspaceRecordSchema = z.object({
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -679,6 +680,7 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin
|
||||
}
|
||||
|
||||
export function createPersistedWorkspaceRecord(input: {
|
||||
parentWorkspaceId?: string;
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
cwd: string;
|
||||
|
||||
Reference in New Issue
Block a user