mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix: improve Orbit and packaged data-dir startup errors (#1067)
This commit is contained in:
parent
671b9006f6
commit
223d35f073
6 changed files with 211 additions and 5 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
48
apps/packaged/tests/launch.test.ts
Normal file
48
apps/packaged/tests/launch.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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']>>) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue