fix: improve Orbit and packaged data-dir startup errors (#1067)

This commit is contained in:
Marc Chan 2026-05-09 16:47:01 +08:00 committed by GitHub
parent 671b9006f6
commit 223d35f073
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 211 additions and 5 deletions

View file

@ -774,8 +774,22 @@ export function resolveDataDir(raw, projectRoot) {
fs.accessSync(resolved, fs.constants.W_OK);
} catch (err) {
const e = err;
const currentUser = (() => {
try {
return os.userInfo().username;
} catch {
return process.env.USER ?? process.env.LOGNAME ?? 'unknown';
}
})();
const parentDir = path.dirname(resolved);
throw new Error(
`OD_DATA_DIR "${resolved}" is not writable: ${e.message}`,
[
`OD_DATA_DIR "${resolved}" is not writable: ${e.message}`,
`Current user: ${currentUser}`,
`Check whether the folder or one of its parents is owned by another user, is a symlink to a protected location, or was previously created with sudo.`,
`Try: ls -ld "${parentDir}" "${resolved}"`,
`If the folder should belong to you, fix ownership/permissions, for example: sudo chown -R "${currentUser}":staff "${parentDir}" && chmod -R u+rwX "${parentDir}"`,
].join(' '),
);
}
return resolved;

View file

@ -11,11 +11,12 @@ import {
resolveAppIpcPath,
} from "@open-design/sidecar";
import { readProcessStamp } from "@open-design/platform";
import { app } from "electron";
import { app, dialog } from "electron";
import { readPackagedConfig } from "./config.js";
import { writePackagedDesktopIdentity } from "./identity.js";
import {
PackagedPathAccessError,
applyPackagedElectronPathOverrides,
ensurePackagedNamespacePaths,
} from "./launch.js";
@ -105,6 +106,13 @@ async function main(): Promise<void> {
}
void main().catch((error: unknown) => {
if (error instanceof PackagedPathAccessError) {
try {
dialog.showErrorBox(error.title, error.message);
} catch {
// Fall through to console logging + process exit.
}
}
packagedLogger?.error("packaged runtime failed", { error });
console.error("packaged runtime failed", error);
process.exit(1);

View file

@ -1,12 +1,102 @@
import { mkdir } from "node:fs/promises";
import { access, mkdir, stat } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { dirname } from "node:path";
import { userInfo } from "node:os";
import { app } from "electron";
import type { PackagedNamespacePaths } from "./paths.js";
export class PackagedPathAccessError extends Error {
readonly title: string;
constructor(message: string, options?: { cause?: unknown; title?: string }) {
super(message, options);
this.name = "PackagedPathAccessError";
this.title = options?.title ?? "Open Design cannot access its data folder";
}
}
type PathDiagnostic = {
exists: boolean;
mode?: number;
path: string;
};
function formatMode(mode: number | undefined): string {
if (mode == null) return "unknown";
return `0${(mode & 0o777).toString(8)}`;
}
async function inspectPath(path: string): Promise<PathDiagnostic> {
try {
const stats = await stat(path);
return { exists: true, mode: stats.mode, path };
} catch {
return { exists: false, path };
}
}
function formatWritablePathError(options: {
attemptedPath: string;
currentUser: string;
diagnostic: PathDiagnostic;
error: unknown;
parentDiagnostic: PathDiagnostic;
}): string {
const { attemptedPath, currentUser, diagnostic, error, parentDiagnostic } = options;
const message = error instanceof Error ? error.message : String(error);
const parentPath = dirname(attemptedPath);
const diagLines = [
`Open Design could not create or write to:`,
attemptedPath,
"",
`Current user: ${currentUser}`,
`Node error: ${message}`,
`Target exists: ${diagnostic.exists ? "yes" : "no"}`,
`Target mode: ${formatMode(diagnostic.mode)}`,
`Parent exists: ${parentDiagnostic.exists ? "yes" : "no"}`,
`Parent mode: ${formatMode(parentDiagnostic.mode)}`,
"",
`Common causes:`,
`• the folder was created by another user (for example with sudo)`,
`• the parent folder is not writable`,
`• the folder is a symlink to a protected location`,
"",
`Try in Terminal:`,
`ls -ld \"${parentPath}\" \"${attemptedPath}\"`,
`sudo chown -R \"${currentUser}\":staff \"${parentPath}\"`,
`chmod -R u+rwX \"${parentPath}\"`,
];
return diagLines.join("\n");
}
export async function verifyPackagedDataRootWritable(paths: Pick<PackagedNamespacePaths, "dataRoot">): Promise<void> {
try {
await mkdir(paths.dataRoot, { recursive: true });
await access(paths.dataRoot, fsConstants.W_OK);
} catch (error) {
const [diagnostic, parentDiagnostic] = await Promise.all([
inspectPath(paths.dataRoot),
inspectPath(dirname(paths.dataRoot)),
]);
throw new PackagedPathAccessError(
formatWritablePathError({
attemptedPath: paths.dataRoot,
currentUser: userInfo().username,
diagnostic,
error,
parentDiagnostic,
}),
{ cause: error },
);
}
}
export async function ensurePackagedNamespacePaths(
paths: PackagedNamespacePaths,
): Promise<void> {
await verifyPackagedDataRootWritable(paths);
await Promise.all([
mkdir(paths.namespaceRoot, { recursive: true }),
mkdir(paths.cacheRoot, { recursive: true }),

View file

@ -0,0 +1,48 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {},
}));
import { PackagedPathAccessError, verifyPackagedDataRootWritable } from "../src/launch.js";
describe("verifyPackagedDataRootWritable", () => {
it("accepts a writable dataRoot", async () => {
const root = mkdtempSync(join(tmpdir(), "od-packaged-launch-"));
try {
const dataRoot = join(root, "namespaces", "release-beta", "data");
await expect(verifyPackagedDataRootWritable({ dataRoot })).resolves.toBeUndefined();
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("wraps low-level mkdir/access failures with a user-actionable error", async () => {
const root = mkdtempSync(join(tmpdir(), "od-packaged-launch-"));
try {
const blocker = join(root, "namespaces", "release-beta");
mkdirSync(blocker, { recursive: true });
writeFileSync(join(blocker, "data"), "not a directory");
let captured: unknown;
try {
await verifyPackagedDataRootWritable({ dataRoot: join(blocker, "data") });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PackagedPathAccessError);
expect((captured as Error).message).toContain("Open Design could not create or write to:");
expect((captured as Error).message).toContain(join(blocker, "data"));
expect((captured as Error).message).toContain("Current user:");
expect((captured as Error).message).toContain("Try in Terminal:");
expect((captured as Error).message).toContain("sudo chown -R");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});

View file

@ -2545,8 +2545,15 @@ function OrbitSection({
// Once the user clicks Generate we close Settings and navigate away. The ref
// lets late-arriving handlers no-op without React warnings.
const isMountedRef = useRef(true);
useEffect(() => () => {
isMountedRef.current = false;
useEffect(() => {
// React Strict Mode replays mount effects in development. Reset the ref on
// each setup so the synthetic cleanup from the first pass does not leave
// async Orbit status / connector refreshes permanently thinking the panel
// has unmounted.
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const updateOrbit = (patch: Partial<NonNullable<AppConfig['orbit']>>) => {

View file

@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { StrictMode } from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConnectorDetail } from '@open-design/contracts';
@ -134,6 +135,44 @@ describe('SettingsDialog Orbit connector gate refresh', () => {
});
});
it('enables Run it now after connector load in StrictMode', async () => {
vi.mocked(fetchConnectors).mockResolvedValue([connectedConnector]);
vi.mocked(fetchSkills).mockResolvedValue([]);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/orbit/status') {
return new Response(JSON.stringify({
running: false,
nextRunAt: null,
lastRun: null,
lastRunsByTemplate: {},
}), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
render(
<StrictMode>
<SettingsDialog
initial={baseConfig}
agents={[]}
daemonLive
appVersionInfo={null}
initialSection="orbit"
onPersist={vi.fn()}
onPersistComposioKey={vi.fn()}
onClose={vi.fn()}
onRefreshAgents={vi.fn()}
/>
</StrictMode>,
);
await waitFor(() => {
expect(screen.queryByTestId('orbit-config-gate')).toBeNull();
expect(screen.getByRole('button', { name: 'Run it now' }).hasAttribute('disabled')).toBe(false);
});
});
it('updates the Last run panel when the selected Orbit template changes', async () => {
vi.mocked(fetchConnectors).mockResolvedValue([connectedConnector]);
vi.mocked(fetchSkills).mockResolvedValue(orbitTemplates);