Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] e9d63185d1 fix(markdown): end bare URL links at table cell breaks
Keep a whitespace boundary before inline line separators so GFM autolinking cannot absorb the next line. Cover URL-to-file and URL-to-URL breaks through the actual parser and link recognizer.
2026-11-06 04:44:50 +00:00
paseo-bot[bot] 86b937a129 fix(markdown): preserve wrapped table breaks and separate file links
Use the HTML-disabled Markdown pass to find tables exposed by wrapper normalization. Treat inline line separators as file-link boundaries so adjacent paths remain independently usable.
2026-11-06 04:44:50 +00:00
paseo-bot[bot] fc4880539e fix(markdown): keep HTML line breaks inside table cells
Normalize table breaks to Unicode line separators so parsing does not split the row. Render those separators as explicit web line breaks, preserving paragraph and literal-code behavior.
2026-11-06 04:44:50 +00:00
Mohamed Boudra 5abfdf7107 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
2026-11-06 10:55:56 +07:00
18 changed files with 429 additions and 38 deletions
+6
View File
@@ -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();
}
},
);
+25 -7
View File
@@ -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> &lt;br&gt; |",
"| A | B |\n|---|---|\n| x | \\<br> &lt;br&gt; |",
],
["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",
+2
View File
@@ -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,
+2
View File
@@ -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,
};
}
+2
View File
@@ -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,
@@ -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[] = [];
@@ -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