mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47bfbd66a | ||
|
|
efa76b37d3 | ||
|
|
6bacbd964d | ||
|
|
a3d5b0555f |
@@ -1,20 +1,9 @@
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
|
||||
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
function fsPath(filePath: string): string {
|
||||
if (process.platform !== 'win32') return filePath;
|
||||
if (!filePath) return filePath;
|
||||
if (filePath.startsWith('\\\\?\\')) return filePath;
|
||||
const windowsPath = filePath.replace(/\//g, '\\');
|
||||
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
|
||||
if (windowsPath.startsWith('\\\\')) {
|
||||
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
|
||||
}
|
||||
return `\\\\?\\${windowsPath}`;
|
||||
}
|
||||
import { linkDirSafe, normalizeFsPath as fsPath } from './fs-link';
|
||||
import { getAllSettings } from '../utils/store';
|
||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
@@ -245,7 +234,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
|
||||
if (existsSync(dest)) continue;
|
||||
try {
|
||||
mkdirSync(join(topNM, pkg.name), { recursive: true });
|
||||
symlinkSync(join(scopeDir, sub.name), dest);
|
||||
linkDirSafe(join(scopeDir, sub.name), dest);
|
||||
linkedCount++;
|
||||
} catch { /* skip on error — non-fatal */ }
|
||||
}
|
||||
@@ -254,7 +243,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
|
||||
if (existsSync(dest)) continue;
|
||||
try {
|
||||
mkdirSync(topNM, { recursive: true });
|
||||
symlinkSync(join(extNM, pkg.name), dest);
|
||||
linkDirSafe(join(extNM, pkg.name), dest);
|
||||
linkedCount++;
|
||||
} catch { /* skip on error — non-fatal */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { symlinkSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Normalize a filesystem path for the current platform. On Windows, convert
|
||||
* forward slashes to backslashes and apply the `\\?\` extended-length prefix
|
||||
* for absolute paths so long paths are handled correctly. On POSIX, return
|
||||
* the path unchanged.
|
||||
*/
|
||||
export function normalizeFsPath(filePath: string): string {
|
||||
if (process.platform !== 'win32') return filePath;
|
||||
if (!filePath) return filePath;
|
||||
if (filePath.startsWith('\\\\?\\')) return filePath;
|
||||
const windowsPath = filePath.replace(/\//g, '\\');
|
||||
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
|
||||
if (windowsPath.startsWith('\\\\')) {
|
||||
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
|
||||
}
|
||||
return `\\\\?\\${windowsPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory link from `src` to `dest`.
|
||||
*
|
||||
* On POSIX uses a regular symlink. On Windows prefers a junction (which does
|
||||
* not require Developer Mode or administrator privileges) and falls back to
|
||||
* a regular symlink only if the junction attempt fails.
|
||||
*
|
||||
* Throws on POSIX when symlink creation fails. On Windows, both attempts
|
||||
* failing will throw the symlink error — callers guard with try/catch when
|
||||
* link creation is non-fatal (e.g. optional extension dependency linking).
|
||||
*/
|
||||
export function linkDirSafe(src: string, dest: string): void {
|
||||
const isWin = process.platform === 'win32';
|
||||
const srcP = normalizeFsPath(src);
|
||||
const destP = normalizeFsPath(dest);
|
||||
if (!isWin) {
|
||||
symlinkSync(srcP, destP, 'dir');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
symlinkSync(srcP, destP, 'junction');
|
||||
} catch {
|
||||
// Junction failed (e.g. cross-volume). Try a symlink as a last resort.
|
||||
symlinkSync(srcP, destP, 'dir');
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,12 @@ export class GatewayManager extends EventEmitter {
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
|
||||
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
||||
private static readonly GATEWAY_READY_FALLBACK_MS = 30_000;
|
||||
// Fallback for the server-side gateway.ready event: if the event doesn't
|
||||
// arrive within this window after the WS handshake completes, we assume the
|
||||
// gateway is effectively ready so downstream consumers don't block forever.
|
||||
// Kept short (5s) because handshake completion already implies a working
|
||||
// RPC channel — this is only a safety net, not the primary signal.
|
||||
private static readonly GATEWAY_READY_FALLBACK_MS = 5_000;
|
||||
private lastRestartAt = 0;
|
||||
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
|
||||
private isAutoReconnectStart = false;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawx",
|
||||
"version": "0.3.11",
|
||||
"version": "0.3.12-alpha.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
|
||||
@@ -98,7 +98,11 @@ function maybeLoadSessions(
|
||||
force = false,
|
||||
): void {
|
||||
const { status } = useGatewayStore.getState();
|
||||
if (status.gatewayReady === false) return;
|
||||
// Gate on the RPC channel being live (handshake complete), not the later
|
||||
// gateway.ready event. The ready event can lag up to GATEWAY_READY_FALLBACK_MS
|
||||
// behind handshake completion while plugins finish booting, and sessions.list
|
||||
// does not require plugins to be up.
|
||||
if (status.state !== 'running') return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastLoadSessionsAt < LOAD_SESSIONS_MIN_INTERVAL_MS) return;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { symlinkSyncMock } = vi.hoisted(() => ({ symlinkSyncMock: vi.fn() }));
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
const mocked = {
|
||||
...actual,
|
||||
symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args),
|
||||
};
|
||||
return { ...mocked, default: mocked };
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
const mocked = {
|
||||
...actual,
|
||||
symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args),
|
||||
};
|
||||
return { ...mocked, default: mocked };
|
||||
});
|
||||
|
||||
describe('fs-link', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
beforeEach(() => {
|
||||
symlinkSyncMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
function setPlatform(p: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: p, configurable: true });
|
||||
}
|
||||
|
||||
describe('linkDirSafe', () => {
|
||||
it('creates a plain dir symlink on POSIX and passes paths through unchanged', async () => {
|
||||
setPlatform('darwin');
|
||||
vi.resetModules();
|
||||
const { linkDirSafe } = await import('@electron/gateway/fs-link');
|
||||
|
||||
linkDirSafe('/src/a', '/dest/a');
|
||||
|
||||
expect(symlinkSyncMock).toHaveBeenCalledTimes(1);
|
||||
expect(symlinkSyncMock).toHaveBeenCalledWith('/src/a', '/dest/a', 'dir');
|
||||
});
|
||||
|
||||
it('prefers junction on Windows and normalizes paths with the extended prefix', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { linkDirSafe } = await import('@electron/gateway/fs-link');
|
||||
|
||||
linkDirSafe('C:/foo/bar', 'C:/baz/qux');
|
||||
|
||||
expect(symlinkSyncMock).toHaveBeenCalledTimes(1);
|
||||
expect(symlinkSyncMock).toHaveBeenCalledWith(
|
||||
'\\\\?\\C:\\foo\\bar',
|
||||
'\\\\?\\C:\\baz\\qux',
|
||||
'junction',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to symlink when junction creation throws on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { linkDirSafe } = await import('@electron/gateway/fs-link');
|
||||
|
||||
symlinkSyncMock.mockImplementationOnce(() => {
|
||||
throw new Error('EXDEV: cross-volume junction not supported');
|
||||
});
|
||||
|
||||
linkDirSafe('C:/foo', 'D:/bar');
|
||||
|
||||
expect(symlinkSyncMock).toHaveBeenCalledTimes(2);
|
||||
const [firstCall, secondCall] = symlinkSyncMock.mock.calls;
|
||||
expect(firstCall[2]).toBe('junction');
|
||||
expect(secondCall[2]).toBe('dir');
|
||||
});
|
||||
|
||||
it('rethrows when both junction and symlink fail on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { linkDirSafe } = await import('@electron/gateway/fs-link');
|
||||
|
||||
symlinkSyncMock.mockImplementation(() => {
|
||||
throw new Error('EPERM');
|
||||
});
|
||||
|
||||
expect(() => linkDirSafe('C:/foo', 'C:/bar')).toThrow('EPERM');
|
||||
expect(symlinkSyncMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFsPath', () => {
|
||||
it('passes POSIX paths through unchanged', async () => {
|
||||
setPlatform('linux');
|
||||
vi.resetModules();
|
||||
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
|
||||
|
||||
expect(normalizeFsPath('/a/b/c')).toBe('/a/b/c');
|
||||
expect(normalizeFsPath('')).toBe('');
|
||||
});
|
||||
|
||||
it('adds the \\\\?\\ prefix on Windows for absolute drive paths', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
|
||||
|
||||
expect(normalizeFsPath('C:/a/b')).toBe('\\\\?\\C:\\a\\b');
|
||||
});
|
||||
|
||||
it('adds the UNC extended prefix for UNC paths on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
|
||||
|
||||
expect(normalizeFsPath('//server/share/file')).toBe('\\\\?\\UNC\\server\\share\\file');
|
||||
});
|
||||
|
||||
it('does not double-prefix already-normalized Windows paths', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
|
||||
|
||||
const already = '\\\\?\\C:\\x\\y';
|
||||
expect(normalizeFsPath(already)).toBe(already);
|
||||
});
|
||||
|
||||
it('leaves relative Windows paths un-prefixed', async () => {
|
||||
setPlatform('win32');
|
||||
vi.resetModules();
|
||||
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
|
||||
|
||||
expect(normalizeFsPath('a/b')).toBe('a\\b');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,10 +90,10 @@ describe('GatewayManager gatewayReady fallback', () => {
|
||||
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
|
||||
|
||||
// Before timeout, no gatewayReady update
|
||||
vi.advanceTimersByTime(29_000);
|
||||
vi.advanceTimersByTime(4_000);
|
||||
expect(statusUpdates.find((u) => u.gatewayReady === true)).toBeUndefined();
|
||||
|
||||
// After 30s fallback timeout
|
||||
// After fallback timeout (5s)
|
||||
vi.advanceTimersByTime(2_000);
|
||||
const readyUpdate = statusUpdates.find((u) => u.gatewayReady === true);
|
||||
expect(readyUpdate).toBeDefined();
|
||||
@@ -115,13 +115,13 @@ describe('GatewayManager gatewayReady fallback', () => {
|
||||
// Schedule fallback
|
||||
(manager as unknown as { scheduleGatewayReadyFallback: () => void }).scheduleGatewayReadyFallback();
|
||||
|
||||
// gateway:ready event arrives at 5s
|
||||
vi.advanceTimersByTime(5_000);
|
||||
// gateway:ready event arrives before fallback (at 1s, well under 5s)
|
||||
vi.advanceTimersByTime(1_000);
|
||||
manager.emit('gateway:ready', {});
|
||||
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
|
||||
|
||||
// After 30s, no duplicate gatewayReady=true
|
||||
vi.advanceTimersByTime(30_000);
|
||||
// Well past the fallback window, no duplicate gatewayReady=true
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(statusUpdates.filter((u) => u.gatewayReady === true)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user