mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d63185d1 | ||
|
|
86b937a129 | ||
|
|
fc4880539e | ||
|
|
5abfdf7107 |
@@ -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.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { within, waitFor } from "@testing-library/dom";
|
||||
import { EnrichedMarkdownText } from "react-native-enriched-markdown";
|
||||
import { expect, it } from "vitest";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
|
||||
const FLAGS = { hardSoftBreaks: true, latexMath: false };
|
||||
|
||||
it.each([false, true])(
|
||||
"renders table-cell breaks on separate lines (details: %s)",
|
||||
async (details) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const table =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const source = details ? `<details><summary>Results</summary>\n${table}\n</details>` : table;
|
||||
try {
|
||||
act(() =>
|
||||
root.render(
|
||||
<EnrichedMarkdownText markdown={normalizeMarkdownHtml(source)} md4cFlags={FLAGS} />,
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4));
|
||||
for (const row of within(container).getAllByRole("row").slice(1)) {
|
||||
const cell = within(row).getAllByRole("cell")[1];
|
||||
expect(cell.innerText).toBe("first\nsecond");
|
||||
const first = document.createRange();
|
||||
first.selectNodeContents(cell.firstChild!);
|
||||
const second = document.createRange();
|
||||
second.selectNodeContents(cell.lastChild!);
|
||||
expect(second.getBoundingClientRect().top).toBeGreaterThan(
|
||||
first.getBoundingClientRect().top,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import type { Nodes } from "mdast";
|
||||
|
||||
@@ -34,25 +36,35 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
|
||||
export function normalizeMarkdownHtml(source: string): string {
|
||||
if (!source.includes("<")) return source;
|
||||
const htmlRanges: { start: number; end: number }[] = [];
|
||||
const tableRanges: { start: number; end: number }[] = [];
|
||||
function findHtml(node: Nodes) {
|
||||
if (node.type === "html" && node.position) {
|
||||
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
} else if ("children" in node) node.children.forEach(findHtml);
|
||||
}
|
||||
findHtml(fromMarkdown(source));
|
||||
findHtml(
|
||||
fromMarkdown(source, {
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}),
|
||||
);
|
||||
const codeRanges: { start: number; end: number }[] = [];
|
||||
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
|
||||
// Disable HTML parsing so code and tables inside <details> remain visible.
|
||||
const tree = fromMarkdown(source, {
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }, gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
});
|
||||
function protectCode(node: Nodes) {
|
||||
function findMarkdownRanges(node: Nodes) {
|
||||
if (node.type === "table" && node.position) {
|
||||
tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
}
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
|
||||
} else if ("children" in node) node.children.forEach(protectCode);
|
||||
} else if ("children" in node) node.children.forEach(findMarkdownRanges);
|
||||
}
|
||||
protectCode(tree);
|
||||
findMarkdownRanges(tree);
|
||||
const links: (string | undefined)[] = [];
|
||||
let previousTag: { name: string; end: number } | null = null;
|
||||
const convert = (raw: string, offset: number): string => {
|
||||
@@ -84,6 +96,13 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
: /^\s*$/.test(source.slice(0, offset));
|
||||
previousTag = { name, end: offset + raw.length };
|
||||
if (EMPHASIS[name]) return EMPHASIS[name];
|
||||
if (name === "br") {
|
||||
if (closing) return "";
|
||||
// A Unicode line separator keeps the row intact; its space ends GFM autolinks.
|
||||
return tableRanges.some(({ start, end }) => offset >= start && offset < end)
|
||||
? " \u2028"
|
||||
: " \n";
|
||||
}
|
||||
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
|
||||
if (boundary !== null) return boundary;
|
||||
if (name === "a") return markdownAnchor(raw, closing, links);
|
||||
@@ -168,7 +187,6 @@ function layoutBoundary(
|
||||
closing: boolean,
|
||||
startsAfterBoundary: boolean,
|
||||
): string | null {
|
||||
if (name === "br") return closing ? "" : " \n";
|
||||
if (name === "summary") return closing ? "**\n\n" : "**";
|
||||
if (name === "details")
|
||||
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
|
||||
|
||||
@@ -39,6 +39,88 @@ it("resolves authored root-relative links only when the target is a known comman
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps HTML line breaks inside their Markdown table cells", async () => {
|
||||
const source =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const rows: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "TableRow") rows.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(rows).toHaveLength(4);
|
||||
const text = (node: ASTNode): string =>
|
||||
(node.content ?? "") + (node.children?.map(text).join("") ?? "");
|
||||
expect(rows.map((row) => row.children?.map(text))).toEqual([
|
||||
["A", "B"],
|
||||
["1", "first \u2028second"],
|
||||
["2", "first \u2028second"],
|
||||
["3", "first \u2028second"],
|
||||
]);
|
||||
});
|
||||
it("keeps table breaks inside a details wrapper without a blank line", async () => {
|
||||
const source =
|
||||
"<details><summary>Results</summary>\n| A | B |\n|---|---|\n| 1 | first<br>second |\n</details>";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const tables: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Table") tables.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].attributes?.bodyRowCount).toBe("1");
|
||||
expect(tables[0].children?.[1]?.children?.[0]?.children?.[1]?.children).toEqual([
|
||||
{ type: "Text", content: "first \u2028second" },
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
["src/first.ts", "src/second.ts"],
|
||||
["https://example.com/first", "src/second.ts"],
|
||||
["https://example.com/first", "https://example.com/second"],
|
||||
])("keeps %s and %s separate across a table break", async (first, second) => {
|
||||
const prepared = prepareMarkdownSource(
|
||||
`| A | B |\n|---|---|\n| 1 | ${first}<br>${second} |`,
|
||||
"/repo",
|
||||
);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { hardSoftBreaks: true, latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const links: string[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(links).toEqual([first, second]);
|
||||
});
|
||||
it.each([
|
||||
["| A<br>B | C |\n|---|---|\n| x | y |", "| A \u2028B | C |\n|---|---|\n| x | y |"],
|
||||
[
|
||||
"> | A | B |\n> |---|---|\n> | x | first<br>second |",
|
||||
"> | A | B |\n> |---|---|\n> | x | first \u2028second |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first<br><BR />second** |",
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first \u2028 \u2028second** |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
],
|
||||
["Not a table | first<br>second", "Not a table | first \nsecond"],
|
||||
])("normalizes table breaks without changing literal tags: %s", (source, expected) => {
|
||||
expect(normalizeMarkdownHtml(source)).toBe(expected);
|
||||
});
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
|
||||
@@ -10,9 +10,10 @@ import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
|
||||
|
||||
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
||||
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
||||
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`;
|
||||
// U+2028 is an inline line break, including between table-cell file links.
|
||||
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%+-]`;
|
||||
const PATH_LETTER = String.raw`[a-zA-Z]`;
|
||||
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`;
|
||||
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+-]`;
|
||||
const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`;
|
||||
const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS]
|
||||
.map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`))
|
||||
@@ -29,7 +30,7 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
|
||||
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
return new RegExp(
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
"g",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user