[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/coder/xum/main/src/node/worktree/codeWorkspaceSync.ts [Back]  [Original]

import * as fsPromises from "fs/promises";
import * as path from "path";
import * as jsonc from "jsonc-parser";
import writeFileAtomic from "write-file-atomic";
import { isWorkspaceArchived } from "@/common/utils/archive";
import { getErrorMessage } from "@/common/utils/errors";
import { isMultiProject } from "@/common/utils/multiProject";
import { isWorktreeRuntime, type RuntimeConfig } from "@/common/types/runtime";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { Config } from "@/node/config";
import { expandTilde } from "@/node/runtime/tildeExpansion";
import { log } from "@/node/services/log";
import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout";
import { isPathInsideDir, stripTrailingSlashes } from "@/node/utils/pathUtils";
import { getProjectName } from "@/node/utils/runtime/helpers";

/**
 * Opt-in sync of a VS Code `.code-workspace` file's `folders` list with a
 * project's active worktree workspaces (issue #3722), so users browsing in
 * VS Code / code-server see every xum worktree in one multi-root window.
 *
 * Managed-entry invariant: xum only ever adds or removes folder entries whose
 * resolved path lives under the project's managed worktree root
 * (`//`). Everything else in the file (user-added
 * folders, the project-root entry seeded on creation, `settings`,
 * `extensions`, comments) is never touched, reordered, or rewritten.
 */

export const CODE_WORKSPACE_EXTENSION = ".code-workspace";

// The target file can be repository- or user-controlled, and jsonc.parse is
// synchronous (no timeout can preempt it), so cap the bytes we are willing to
// read and parse. Real .code-workspace files are a few KiB.
export const MAX_CODE_WORKSPACE_FILE_BYTES = 1024 * 1024;
export const MAX_CODE_WORKSPACE_FOLDERS = 2_000;

// Bound each sync: a configured file on an unavailable network mount can hang
// fs calls indefinitely, and workspace lifecycle operations await syncs.
const SYNC_TIMEOUT_MS = 10_000;

// VS Code generates .code-workspace files with tab indentation; match it.
const MODIFY_OPTIONS: jsonc.ModificationOptions = {
  formattingOptions: { insertSpaces: false, tabSize: 4, eol: "\n" },
};

// Serialize read-modify-write per canonical file path: two projects may share
// one .code-workspace file, and concurrent lifecycle syncs would otherwise
// lose updates (the later write wins over a stale read).
const fileWriteQueues = new Map();

async function withFileWriteLock(key: string, fn: () => Promise): Promise {
  const prev = fileWriteQueues.get(key) ?? Promise.resolve();
  const run = prev.then(fn);
  const tail: Promise = run
    .then(
      () => undefined,
      () => undefined
    )
    .then(() => {
      if (fileWriteQueues.get(key) === tail) {
        fileWriteQueues.delete(key);
      }
    });
  fileWriteQueues.set(key, tail);
  return run;
}

// Canonicalization of another project's configured path must never stall the
// current sync (its file may sit on a dead network mount), so realpath calls
// used for grouping are individually bounded.
const REALPATH_TIMEOUT_MS = 1_000;

async function boundedRealPath(filePath: string): Promise {
  const outcome = await raceWithAbortAndTimeout(resolveRealPath(filePath), {
    timeoutMs: REALPATH_TIMEOUT_MS,
  });
  return outcome.kind === "ok" ? outcome.value : null;
}

// write-file-atomic renames a temp file over the target, which would replace a
// symlink rather than write through it; resolve the real target first so user
// symlinks to shared editor config survive syncs.
async function resolveRealPath(filePath: string): Promise {
  return resolveRealPathFollowingDanglingLinks(filePath, 10);
}

async function resolveRealPathFollowingDanglingLinks(
  filePath: string,
  hopsLeft: number
): Promise {
  try {
    return await fsPromises.realpath(filePath);
  } catch {
    // realpath fails on a DANGLING symlink too; follow it manually so the
    // creation path writes the link's intended target instead of atomically
    // renaming over (and destroying) the link itself. Hops are bounded so
    // cyclic links cannot loop forever.
    if (hopsLeft > 0) {
      try {
        const linkTarget = await fsPromises.readlink(filePath);
        return await resolveRealPathFollowingDanglingLinks(
          path.resolve(path.dirname(filePath), linkTarget),
          hopsLeft - 1
        );
      } catch {
        // Not a symlink: a plain missing file, resolved via its parent below.
      }
    }
    // File may not exist yet; resolve the parent so directory symlinks are
    // still honored, keeping the configured basename.
    try {
      const realDir = await fsPromises.realpath(path.dirname(filePath));
      return path.join(realDir, path.basename(filePath));
    } catch {
      return filePath;
    }
  }
}

// VS Code resolves relative folder paths against the .code-workspace file's
// directory; it does not expand `~` or variables, so neither do we. Such
// entries never match the managed root and stay untouched.
function getEntryPath(entry: unknown, workspaceFileDir: string): string | null {
  if (typeof entry !== "object" || entry === null) {
    return null;
  }
  const folderPath = (entry as { path?: unknown }).path;
  if (typeof folderPath !== "string" || folderPath.trim() === "") {
    return null;
  }
  return path.resolve(workspaceFileDir, folderPath);
}

export interface CodeWorkspaceFileUpdate {
  /** Absolute path of the .code-workspace file. */
  codeWorkspacePath: string;
  /** Absolute directories; only folder entries under one of them are managed by xum. */
  managedRootDirs: string[];
  /** Absolute worktree paths that should be present as folder entries. */
  desiredPaths: string[];
  /** Folder paths written when the file does not exist yet. */
  seedFolders: string[];
}

function isUnderAnyRoot(managedRootDirs: string[], candidate: string): boolean {
  return managedRootDirs.some((rootDir) => isPathInsideDir(rootDir, candidate));
}

/**
 * Reconcile the file's `folders` array with `desiredPaths` under the
 * managed-entry invariant. Creates the file (with `seedFolders`) when missing.
 * Uses jsonc-parser edits so user comments and unknown keys survive.
 */
export async function updateCodeWorkspaceFile(
  update: CodeWorkspaceFileUpdate
): Promise {
  const targetPath = await resolveRealPath(update.codeWorkspacePath);
  return withFileWriteLock(targetPath, () => updateCodeWorkspaceFileLocked(targetPath, update));
}

async function updateCodeWorkspaceFileLocked(
  targetPath: string,
  update: CodeWorkspaceFileUpdate
): Promise {
  const { codeWorkspacePath, managedRootDirs, desiredPaths } = update;
  // SECURITY: the configured path was extension-validated, but a symlink at
  // that path can live inside the checkout (repo-controlled) and point
  // anywhere. Re-validate the RESOLVED target so a planted link cannot
  // redirect the write into an arbitrary JSON file (or create one).
  if (!targetPath.endsWith(CODE_WORKSPACE_EXTENSION)) {
    log.warn("Skipping .code-workspace sync: resolved target is not a .code-workspace file", {
      codeWorkspacePath,
    });
    return { ok: false, error: "Workspace file symlink does not target a .code-workspace file" };
  }
  // Relative folder entries resolve against the configured file location,
  // matching how VS Code resolves them for the file the user opens.
  const fileDir = path.dirname(codeWorkspacePath);

  let original: string | null;
  try {
    // Reject non-regular targets before opening: a symlink can point at e.g.
    // /dev/zero, where the reported size is 0 but reads never reach EOF.
    const stats = await fsPromises.stat(targetPath);
    if (!stats.isFile()) {
      log.warn("Skipping .code-workspace sync: target is not a regular file", {
        codeWorkspacePath,
      });
      return { ok: false, error: "Workspace file is not a regular file" };
    }
    if (stats.size > MAX_CODE_WORKSPACE_FILE_BYTES) {
      log.warn("Skipping .code-workspace sync: file exceeds size limit", {
        codeWorkspacePath,
        sizeBytes: stats.size,
      });
      return { ok: false, error: "Workspace file exceeds the 1 MiB sync limit" };
    }
    // Byte-limited read through one descriptor: never trust the stat size.
    const handle = await fsPromises.open(targetPath, "r");
    try {
      const handleStats = await handle.stat();
      if (!handleStats.isFile()) {
        log.warn("Skipping .code-workspace sync: target is not a regular file", {
          codeWorkspacePath,
        });
        return { ok: false, error: "Workspace file is not a regular file" };
      }
      const buffer = Buffer.alloc(MAX_CODE_WORKSPACE_FILE_BYTES + 1);
      // read() may legally return short counts before EOF (notably on network
      // filesystems), so loop until EOF or the cap is exceeded; a single read
      // could silently truncate a valid file and rewrite it without its tail.
      let totalRead = 0;
      while (totalRead < buffer.length) {
        const { bytesRead } = await handle.read(
          buffer,
          totalRead,
          buffer.length - totalRead,
          totalRead
        );
        if (bytesRead === 0) {
          break;
        }
        totalRead += bytesRead;
      }
      if (totalRead > MAX_CODE_WORKSPACE_FILE_BYTES) {
        log.warn("Skipping .code-workspace sync: file exceeds size limit", {
          codeWorkspacePath,
        });
        return { ok: false, error: "Workspace file exceeds the 1 MiB sync limit" };
      }
      original = buffer.subarray(0, totalRead).toString("utf-8");
    } finally {
      await handle.close();
    }
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
      throw error;
    }
    original = null;
  }

  if (original === null) {
    if (update.seedFolders.length > MAX_CODE_WORKSPACE_FOLDERS) {
      log.warn("Skipping .code-workspace sync: too many folder entries", {
        codeWorkspacePath,
        folderCount: update.seedFolders.length,
      });
      return { ok: false, error: "Workspace file has too many folder entries to sync" };
    }
    const fresh = { folders: update.seedFolders.map((folderPath) => ({ path: folderPath })) };
    await fsPromises.mkdir(path.dirname(targetPath), { recursive: true });
    await writeFileAtomic(targetPath, JSON.stringify(fresh, null, "\t") + "\n");
    return { ok: true };
  }

  // Never clobber a file we cannot faithfully edit (self-healing over failing).
  const parseErrors: jsonc.ParseError[] = [];
  const parsed = jsonc.parse(original, parseErrors, { allowTrailingComma: true }) as unknown;
  if (
    parseErrors.length > 0 ||
    typeof parsed !== "object" ||
    parsed === null ||
    Array.isArray(parsed)
  ) {
    log.warn("Skipping .code-workspace sync: file is not valid JSONC", { codeWorkspacePath });
    return { ok: false, error: "Workspace file is not valid JSONC" };
  }

  const existingFolders = (parsed as { folders?: unknown }).folders;
  if (existingFolders !== undefined && !Array.isArray(existingFolders)) {
    log.warn("Skipping .code-workspace sync: 'folders' is not an array", { codeWorkspacePath });
    return { ok: false, error: "Workspace file 'folders' is not an array" };
  }
  // jsonc.parse reads the LAST duplicate property while jsonc.modify edits the
  // FIRST, so a hand-edited file with duplicate `folders` keys would silently
  // stop syncing while reporting success; reject it instead.
  const rootNode = jsonc.parseTree(original, undefined, { allowTrailingComma: true });
  const foldersPropCount =
    rootNode?.children?.filter(
      (prop) => prop.type === "property" && prop.children?.[0]?.value === "folders"
    ).length ?? 0;
  if (foldersPropCount > 1) {
    log.warn("Skipping .code-workspace sync: duplicate 'folders' properties", {
      codeWorkspacePath,
    });
    return { ok: false, error: "Workspace file has duplicate 'folders' properties" };
  }

  const desired = new Set(desiredPaths.map((desiredPath) => path.resolve(desiredPath)));

  // One-pass reconcile: a repository-controlled file can hold thousands of
  // entries and per-entry jsonc edits reparse the whole document each time
  // (quadratic, synchronous, and untouchable by timeouts). Build the new
  // folders value once and apply a single edit. Comments inside the folders
  // array are not preserved when a change is needed; everything outside it is.
  const currentFolders: unknown[] = existingFolders ?? [];
  const presentPaths = new Set();
  const kept = currentFolders.filter((entry) => {
    const entryPath = getEntryPath(entry, fileDir);
    if (entryPath === null) {
      return true;
    }
    if (isUnderAnyRoot(managedRootDirs, entryPath) && !desired.has(entryPath)) {
      return false;
    }
    presentPaths.add(entryPath);
    return true;
  });
  // Append missing desired entries after the existing ones (user order is preserved).
  const additions = [...desired].filter((desiredPath) => !presentPaths.has(desiredPath)).sort();

  if (kept.length === currentFolders.length && additions.length === 0) {
    return { ok: true };
  }
  const newFolders = [...kept, ...additions.map((folderPath) => ({ path: folderPath }))];
  // SECURITY: jsonc.modify serialization is synchronous and superlinear in
  // entry count, so a file within the byte cap can still hold tens of
  // thousands of entries and freeze the main thread for >10s. Checked on the
  // FINAL count (not the input) so a write can never push a file over the cap
  // and brick later syncs. Real multi-root workspaces stay far below it.
  if (newFolders.length > MAX_CODE_WORKSPACE_FOLDERS) {
    log.warn("Skipping .code-workspace sync: too many folder entries", {
      codeWorkspacePath,
      folderCount: newFolders.length,
    });
    return { ok: false, error: "Workspace file has too many folder entries to sync" };
  }
  const text = jsonc.applyEdits(
    original,
    jsonc.modify(original, ["folders"], newFolders, MODIFY_OPTIONS)
  );
  await writeFileAtomic(targetPath, text);
  return { ok: true };
}

// DevcontainerRuntime also creates a normal host git worktree via
// WorktreeManager under the default srcDir, so its workspaces belong in the
// file too (rooted at the default managed root).
function hasManagedHostWorktree(runtimeConfig: RuntimeConfig | undefined): boolean {
  return isWorktreeRuntime(runtimeConfig) || runtimeConfig?.type === "devcontainer";
}

function belongsToProject(metadata: FrontendWorkspaceMetadata, projectPath: string): boolean {
  return (
    stripTrailingSlashes(metadata.projectPath) === projectPath ||
    // Workspaces assigned to a registered sub-project live in the parent's
    // bucket; the sub-project's own file must still list them.
    (metadata.subProjectPath != null &&
      stripTrailingSlashes(metadata.subProjectPath) === projectPath) ||
    (metadata.projects?.some((ref) => stripTrailingSlashes(ref.projectPath) === projectPath) ??
      false)
  );
}

// The directory that holds this workspace's checkout for the given
// participant project. Sub-project workspaces share the parent repo's
// worktree directory; multi-project participants each have a directory named
// after themselves.
function participantCheckoutDirName(
  metadata: FrontendWorkspaceMetadata,
  participantPath: string
): string {
  const isPrimary = stripTrailingSlashes(metadata.projectPath) === participantPath;
  const isRef =
    metadata.projects?.some((ref) => stripTrailingSlashes(ref.projectPath) === participantPath) ??
    false;
  if (isPrimary || isRef) {
    return getProjectName(participantPath);
  }
  return getProjectName(stripTrailingSlashes(metadata.projectPath));
}

/**
 * Compute the worktree paths that belong in a project's .code-workspace file
 * (active, top-level worktree workspaces) plus the managed root directories
 * that scope which existing entries xum may remove.
 */
export function computeManagedWorktreePaths(params: {
  allMetadata: FrontendWorkspaceMetadata[];
  /** Normalized (no trailing slash) project path. */
  projectPath: string;
  defaultManagedRootDir: string;
}): { desiredPaths: string[]; managedRootDirs: string[] } {
  const { allMetadata, projectPath, defaultManagedRootDir } = params;

  // Managed roots come from every persisted worktree workspace of the project
  // (any lifecycle state), not just the current global srcDir: worktrees
  // created under a custom or legacy srcBaseDir (e.g. pre-rename ~/.mux/src)
  // must stay managed after upgrades. Callers cleaning up after a deletion
  // pass the removed workspace's roots explicitly (managedRootsByProject).
  const roots = new Set([path.resolve(defaultManagedRootDir)]);
  for (const metadata of allMetadata) {
    if (
      !belongsToProject(metadata, projectPath) ||
      !hasManagedHostWorktree(metadata.runtimeConfig)
    ) {
      continue;
    }
    if (isWorktreeRuntime(metadata.runtimeConfig)) {
      roots.add(
        path.resolve(
          path.join(
            expandTilde(metadata.runtimeConfig.srcBaseDir),
            participantCheckoutDirName(metadata, projectPath)
          )
        )
      );
    } else {
      // Devcontainer host worktrees live under /;
      // for a sub-project participant that differs from defaultManagedRootDir,
      // so derive the root from the checkout itself.
      roots.add(path.dirname(path.resolve(metadata.namedWorkspacePath)));
    }
  }
  const managedRootDirs = [...roots].sort();

  const desired = new Set();
  for (const metadata of allMetadata) {
    const runtimeConfig = metadata.runtimeConfig;
    if (!hasManagedHostWorktree(runtimeConfig)) {
      continue;
    }
    // Sub-agent child workspaces are transient implementation detail; listing
    // them would churn the user's editor window on every spawned task.
    if (metadata.parentWorkspaceId) {
      continue;
    }
    // isolation:"none" tasks share an ancestor checkout, not an own worktree.
    if (metadata.taskIsolation === "none") {
      continue;
    }
    // Transcript-only workspaces (checkout deleted, e.g. archive ->
    // delete-worktree -> unarchive) have no directory to open in the editor.
    if (metadata.transcriptOnly) {
      continue;
    }
    if (isWorkspaceArchived(metadata.archivedAt, metadata.unarchivedAt)) {
      continue;
    }
    if (!belongsToProject(metadata, projectPath)) {
      continue;
    }

    let worktreePath: string;
    if (isMultiProject(metadata) && isWorktreeRuntime(runtimeConfig)) {
      // Multi-project workspaces persist the _workspaces/ symlink
      // container as namedWorkspacePath; the real per-project checkout lives
      // at // for the primary and
      // secondary projects alike (createMultiProject passes
      // directoryName: workspaceName).
      worktreePath = path.join(
        expandTilde(runtimeConfig.srcBaseDir),
        participantCheckoutDirName(metadata, projectPath),
        metadata.name
      );
    } else {
      worktreePath = metadata.namedWorkspacePath;
    }
    const resolved = path.resolve(worktreePath);
    // Only paths under a managed root are synced; anything else is user-owned.
    if (!isUnderAnyRoot(managedRootDirs, resolved)) {
      continue;
    }
    desired.add(resolved);
  }
  return { desiredPaths: [...desired].sort(), managedRootDirs };
}

/**
 * Managed roots contributed by one workspace, keyed by each involved project
 * path (normalized). Callers capture this BEFORE deleting a workspace: once
 * its config entry is gone, a custom/legacy srcBaseDir root can no longer be
 * reconstructed and the deleted checkout's folder entry would linger forever.
 * Roots are per-project so one project's file is never granted removal rights
 * under another project's root.
 */
export function managedRootsByProject(metadata: FrontendWorkspaceMetadata): Map {
  const rootsByProject = new Map();
  const runtimeConfig = metadata.runtimeConfig;
  if (!hasManagedHostWorktree(runtimeConfig)) {
    return rootsByProject;
  }
  const involved = new Set([
    metadata.projectPath,
    ...(metadata.subProjectPath != null ? [metadata.subProjectPath] : []),
    ...(metadata.projects ?? []).map((ref) => ref.projectPath),
  ]);
  for (const involvedPath of involved) {
    const normalized = stripTrailingSlashes(involvedPath);
    const root = isWorktreeRuntime(runtimeConfig)
      ? path.resolve(
          path.join(
            expandTilde(runtimeConfig.srcBaseDir),
            participantCheckoutDirName(metadata, normalized)
          )
        )
      : // Devcontainer host worktrees live under the parent project's
        // directory, mirroring computeManagedWorktreePaths.
        path.dirname(path.resolve(metadata.namedWorkspacePath));
    rootsByProject.set(normalized, [root]);
  }
  return rootsByProject;
}

// Resolve a project's configured setting to an absolute target path, or null
// when unset or invalid. Relative settings resolve against the project root;
// `~` is expanded.
function resolveConfiguredCodeWorkspacePath(
  projectPath: string,
  setting: string | undefined
): string | null {
  const raw = setting?.trim();
  if (!raw) {
    return null;
  }
  const codeWorkspacePath = path.resolve(projectPath, expandTilde(raw));
  if (!codeWorkspacePath.endsWith(CODE_WORKSPACE_EXTENSION)) {
    // We read-modify-write this file, so never target arbitrary files.
    log.warn("Skipping .code-workspace sync: path must end with .code-workspace", {
      codeWorkspacePath,
    });
    return null;
  }
  return codeWorkspacePath;
}

export type CodeWorkspaceSyncResult = { ok: true } | { ok: false; error: string };

/**
 * Best-effort sync entry point used by workspace lifecycle operations and
 * startup reconciliation. Fast no-op when the project has no
 * `codeWorkspaceSyncPath` configured. Never throws and is bounded by
 * SYNC_TIMEOUT_MS: sync failures or stalled filesystems must never fail or
 * block a workspace operation. Background callers ignore the returned result;
 * the explicit settings save path surfaces it to the user.
 */
export async function syncProjectCodeWorkspace(
  config: Config,
  projectPath: string,
  options?: { extraManagedRootDirs?: string[] }
): Promise {
  try {
    const normalizedProjectPath = stripTrailingSlashes(projectPath);
    const projects = config.loadConfigOrDefault().projects;
    const targetFile = resolveConfiguredCodeWorkspacePath(
      normalizedProjectPath,
      projects.get(normalizedProjectPath)?.codeWorkspaceSyncPath
    );
    if (!targetFile) {
      return { ok: true };
    }

    // Group by canonical (symlink-resolved) target so aliases of one file
    // reconcile together. The realpath is bounded so a dead mount holding this
    // project's file fails fast instead of hanging until the outer timeout.
    const canonicalTarget = await boundedRealPath(targetFile);
    if (canonicalTarget === null) {
      log.warn("Timed out canonicalizing .code-workspace path", { projectPath });
      return { ok: false, error: "Timed out accessing the workspace file" };
    }

    // The work never rejects (errors become results), so a timeout that
    // orphans it cannot leave an unhandled rejection behind.
    const work: Promise = withFileWriteLock(
      canonicalTarget,
      async (): Promise => {
        try {
          // Desired state is derived INSIDE the per-file critical section:
          // with derivation outside it, an older lifecycle snapshot could be
          // written after a newer one and resurrect stale entries.
          //
          // Reconcile every project targeting this file together. Projects can
          // share a file, and same-basename projects even share a managed
          // root; independent per-project removals would erase each other's
          // entries.
          const currentProjects = config.loadConfigOrDefault().projects;
          const allMetadata = await config.getAllWorkspaceMetadata();
          const desired = new Set();
          const roots = new Set(
            (options?.extraManagedRootDirs ?? []).map((rootDir) => path.resolve(rootDir))
          );
          const candidates: Array = [];
          for (const [participantPath, participantConfig] of currentProjects) {
            const participantFile = resolveConfiguredCodeWorkspacePath(
              participantPath,
              participantConfig.codeWorkspaceSyncPath
            );
            if (participantFile !== null) {
              candidates.push({ participantPath, participantFile });
            }
          }
          // Canonicalize candidates concurrently: several stalled mounts under
          // unrelated projects' paths cost one shared REALPATH_TIMEOUT_MS in
          // total instead of one each, and a stalled path only drops that
          // project from this round; it cannot block the sync.
          const matchedPaths = await Promise.all(
            candidates.map(async ({ participantPath, participantFile }) => {
              if (participantFile === targetFile) {
                return participantPath;
              }
              const participantCanonical = await boundedRealPath(participantFile);
              return participantCanonical === canonicalTarget ? participantPath : null;
            })
          );
          const participantPaths: string[] = [];
          for (const participantPath of matchedPaths) {
            if (participantPath === null) {
              continue;
            }
            const computed = computeManagedWorktreePaths({
              allMetadata,
              projectPath: participantPath,
              defaultManagedRootDir: path.join(
                expandTilde(config.srcDir),
                getProjectName(participantPath)
              ),
            });
            participantPaths.push(participantPath);
            computed.desiredPaths.forEach((desiredPath) => desired.add(desiredPath));
            computed.managedRootDirs.forEach((rootDir) => roots.add(rootDir));
          }
          const desiredPaths = [...desired].sort();
          return await updateCodeWorkspaceFileLocked(canonicalTarget, {
            codeWorkspacePath: targetFile,
            managedRootDirs: [...roots].sort(),
            desiredPaths,
            seedFolders: [...participantPaths, ...desiredPaths],
          });
        } catch (error) {
          log.warn("Failed to sync .code-workspace file", { projectPath, error });
          return { ok: false, error: getErrorMessage(error) };
        }
      }
    );

    const outcome = await raceWithAbortAndTimeout(work, { timeoutMs: SYNC_TIMEOUT_MS });
    if (outcome.kind !== "ok") {
      log.warn("Timed out syncing .code-workspace file; continuing in background", {
        projectPath,
      });
      return { ok: false, error: "Timed out accessing the workspace file" };
    }
    return outcome.value;
  } catch (error) {
    log.warn("Failed to sync .code-workspace file", { projectPath, error });
    return { ok: false, error: getErrorMessage(error) };
  }
}

Web Proxy Viewer  |  New URL  |  Original Page