Merge remote-tracking branch 'origin/main' into refactor/task-to-agent-tool

This commit is contained in:
tanzhenxin 2026-03-20 16:32:37 +08:00
commit 3a686d5ad1
49 changed files with 3624 additions and 315 deletions

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { Storage } from './storage.js';
@ -40,3 +40,321 @@ describe('Storage additional helpers', () => {
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
});
});
describe('Storage getRuntimeBaseDir / setRuntimeBaseDir', () => {
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
beforeEach(() => {
// Reset state before each test
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
});
afterEach(() => {
// Restore original env
Storage.setRuntimeBaseDir(null);
if (originalEnv !== undefined) {
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
} else {
delete process.env['QWEN_RUNTIME_DIR'];
}
});
it('defaults to getGlobalQwenDir() when nothing is configured', () => {
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
it('uses setRuntimeBaseDir value when set with absolute path', () => {
const runtimeDir = path.resolve('custom', 'runtime');
Storage.setRuntimeBaseDir(runtimeDir);
expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir);
});
it('env var QWEN_RUNTIME_DIR takes priority over setRuntimeBaseDir', () => {
const settingsDir = path.resolve('from-settings');
const envDir = path.resolve('from-env');
Storage.setRuntimeBaseDir(settingsDir);
process.env['QWEN_RUNTIME_DIR'] = envDir;
expect(Storage.getRuntimeBaseDir()).toBe(envDir);
});
it('expands tilde (~) in setRuntimeBaseDir', () => {
Storage.setRuntimeBaseDir('~/custom-runtime');
const expected = path.join(os.homedir(), 'custom-runtime');
expect(Storage.getRuntimeBaseDir()).toBe(expected);
});
it('expands Windows-style tilde paths in setRuntimeBaseDir', () => {
Storage.setRuntimeBaseDir('~\\custom-runtime');
const expected = path.join(os.homedir(), 'custom-runtime');
expect(Storage.getRuntimeBaseDir()).toBe(expected);
});
it('expands tilde (~) in QWEN_RUNTIME_DIR env var', () => {
process.env['QWEN_RUNTIME_DIR'] = '~/env-runtime';
const expected = path.join(os.homedir(), 'env-runtime');
expect(Storage.getRuntimeBaseDir()).toBe(expected);
});
it('resolves relative paths in setRuntimeBaseDir using process.cwd by default', () => {
Storage.setRuntimeBaseDir('relative/path');
const expected = path.resolve('relative/path');
expect(Storage.getRuntimeBaseDir()).toBe(expected);
});
it('resolves relative paths in setRuntimeBaseDir using explicit cwd', () => {
const cwd = path.resolve('workspace', 'projectA');
Storage.setRuntimeBaseDir('.qwen', cwd);
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen'));
});
it('ignores cwd when path is absolute', () => {
const absolutePath = path.resolve('absolute', 'path');
const cwd = path.resolve('workspace', 'projectA');
Storage.setRuntimeBaseDir(absolutePath, cwd);
expect(Storage.getRuntimeBaseDir()).toBe(absolutePath);
});
it('ignores cwd when path starts with tilde', () => {
Storage.setRuntimeBaseDir(
'~/runtime',
path.resolve('workspace', 'projectA'),
);
const expected = path.join(os.homedir(), 'runtime');
expect(Storage.getRuntimeBaseDir()).toBe(expected);
});
it('resolves relative paths in QWEN_RUNTIME_DIR env var', () => {
process.env['QWEN_RUNTIME_DIR'] = 'relative/env-path';
const expected = path.resolve('relative/env-path');
expect(Storage.getRuntimeBaseDir()).toBe(expected);
});
it('resets to default when setRuntimeBaseDir is called with null', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
expect(Storage.getRuntimeBaseDir()).toBe(customDir);
Storage.setRuntimeBaseDir(null);
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
it('resets to default when setRuntimeBaseDir is called with undefined', () => {
Storage.setRuntimeBaseDir(path.resolve('custom'));
Storage.setRuntimeBaseDir(undefined);
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
it('resets to default when setRuntimeBaseDir is called with empty string', () => {
Storage.setRuntimeBaseDir(path.resolve('custom'));
Storage.setRuntimeBaseDir('');
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
it('handles bare tilde (~) as home directory', () => {
Storage.setRuntimeBaseDir('~');
expect(Storage.getRuntimeBaseDir()).toBe(os.homedir());
});
});
describe('Storage runtime path methods use getRuntimeBaseDir', () => {
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
beforeEach(() => {
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
if (originalEnv !== undefined) {
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
} else {
delete process.env['QWEN_RUNTIME_DIR'];
}
});
it('getGlobalTempDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
expect(Storage.getGlobalTempDir()).toBe(path.join(customDir, 'tmp'));
});
it('getGlobalDebugDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
expect(Storage.getGlobalDebugDir()).toBe(path.join(customDir, 'debug'));
});
it('getDebugLogPath uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
expect(Storage.getDebugLogPath('session-123')).toBe(
path.join(customDir, 'debug', 'session-123.txt'),
);
});
it('getGlobalIdeDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
expect(Storage.getGlobalIdeDir()).toBe(path.join(customDir, 'ide'));
});
it('getProjectDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
const storage = new Storage('/tmp/project');
expect(storage.getProjectDir()).toContain(path.join(customDir, 'projects'));
});
it('getHistoryDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
const storage = new Storage('/tmp/project');
expect(storage.getHistoryDir()).toContain(path.join(customDir, 'history'));
});
it('getProjectTempDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
const storage = new Storage('/tmp/project');
expect(storage.getProjectTempDir()).toContain(path.join(customDir, 'tmp'));
});
it('getProjectTempCheckpointsDir uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
const storage = new Storage('/tmp/project');
expect(storage.getProjectTempCheckpointsDir()).toContain(
path.join(customDir, 'tmp'),
);
expect(storage.getProjectTempCheckpointsDir()).toMatch(/checkpoints$/);
});
it('getHistoryFilePath uses custom runtime base dir', () => {
const customDir = path.resolve('custom');
Storage.setRuntimeBaseDir(customDir);
const storage = new Storage('/tmp/project');
expect(storage.getHistoryFilePath()).toContain(path.join(customDir, 'tmp'));
expect(storage.getHistoryFilePath()).toMatch(/shell_history$/);
});
});
describe('Storage config paths remain at ~/.qwen regardless of runtime dir', () => {
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
const globalQwenDir = Storage.getGlobalQwenDir();
beforeEach(() => {
Storage.setRuntimeBaseDir(path.resolve('custom-runtime'));
process.env['QWEN_RUNTIME_DIR'] = path.resolve('env-runtime');
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
if (originalEnv !== undefined) {
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
} else {
delete process.env['QWEN_RUNTIME_DIR'];
}
});
it('getGlobalSettingsPath still uses ~/.qwen', () => {
expect(Storage.getGlobalSettingsPath()).toBe(
path.join(globalQwenDir, 'settings.json'),
);
});
it('getInstallationIdPath still uses ~/.qwen', () => {
expect(Storage.getInstallationIdPath()).toBe(
path.join(globalQwenDir, 'installation_id'),
);
});
it('getGoogleAccountsPath still uses ~/.qwen', () => {
expect(Storage.getGoogleAccountsPath()).toBe(
path.join(globalQwenDir, 'google_accounts.json'),
);
});
it('getMcpOAuthTokensPath still uses ~/.qwen', () => {
expect(Storage.getMcpOAuthTokensPath()).toBe(
path.join(globalQwenDir, 'mcp-oauth-tokens.json'),
);
});
it('getOAuthCredsPath still uses ~/.qwen', () => {
expect(Storage.getOAuthCredsPath()).toBe(
path.join(globalQwenDir, 'oauth_creds.json'),
);
});
it('getUserCommandsDir still uses ~/.qwen', () => {
expect(Storage.getUserCommandsDir()).toBe(
path.join(globalQwenDir, 'commands'),
);
});
it('getGlobalMemoryFilePath still uses ~/.qwen', () => {
expect(Storage.getGlobalMemoryFilePath()).toBe(
path.join(globalQwenDir, 'memory.md'),
);
});
it('getGlobalBinDir still uses ~/.qwen', () => {
expect(Storage.getGlobalBinDir()).toBe(path.join(globalQwenDir, 'bin'));
});
it('getUserSkillsDirs still includes ~/.qwen/skills', () => {
const storage = new Storage('/tmp/project');
const skillsDirs = storage.getUserSkillsDirs();
expect(
skillsDirs.some((dir) => dir === path.join(globalQwenDir, 'skills')),
).toBe(true);
});
});
describe('Storage runtime base dir async context isolation', () => {
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
beforeEach(() => {
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
if (originalEnv !== undefined) {
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
} else {
delete process.env['QWEN_RUNTIME_DIR'];
}
});
it('uses contextual runtime dir inside runWithRuntimeBaseDir', async () => {
Storage.setRuntimeBaseDir(path.resolve('global-runtime'));
const cwd = path.resolve('workspace', 'project-a');
await Storage.runWithRuntimeBaseDir('.qwen', cwd, async () => {
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen'));
});
});
it('keeps concurrent contexts isolated', async () => {
const cwdA = path.resolve('workspace', 'a');
const cwdB = path.resolve('workspace', 'b');
const runA = Storage.runWithRuntimeBaseDir('.qwen-a', cwdA, async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return Storage.getRuntimeBaseDir();
});
const runB = Storage.runWithRuntimeBaseDir('.qwen-b', cwdB, async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
return Storage.getRuntimeBaseDir();
});
const [a, b] = await Promise.all([runA, runB]);
expect(a).toBe(path.join(cwdA, '.qwen-a'));
expect(b).toBe(path.join(cwdB, '.qwen-b'));
});
});

View file

@ -7,6 +7,7 @@
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { AsyncLocalStorage } from 'node:async_hooks';
import { getProjectHash, sanitizeCwd } from '../utils/paths.js';
export const QWEN_DIR = '.qwen';
@ -23,10 +24,99 @@ const ARENA_DIR_NAME = 'arena';
export class Storage {
private readonly targetDir: string;
/**
* Custom runtime output base directory set via settings.
* When null, falls back to getGlobalQwenDir().
*/
private static runtimeBaseDir: string | null = null;
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<
string | null
>();
constructor(targetDir: string) {
this.targetDir = targetDir;
}
private static resolveRuntimeBaseDir(
dir: string | null | undefined,
cwd?: string,
): string | null {
if (!dir) {
return null;
}
let resolved = dir;
if (
resolved === '~' ||
resolved.startsWith('~/') ||
resolved.startsWith('~\\')
) {
const relativeSegments =
resolved === '~'
? []
: resolved
.slice(2)
.split(/[/\\]+/)
.filter(Boolean);
resolved = path.join(os.homedir(), ...relativeSegments);
}
if (!path.isAbsolute(resolved)) {
resolved = cwd ? path.resolve(cwd, resolved) : path.resolve(resolved);
}
return resolved;
}
/**
* Sets the custom runtime output base directory.
* Handles tilde (~) expansion and resolves relative paths to absolute.
* Pass null/undefined/empty string to reset to default (getGlobalQwenDir()).
* @param dir - The directory path, or null/undefined to reset
* @param cwd - Base directory for resolving relative paths (defaults to process.cwd()).
* Pass the project root so that relative values like ".qwen" resolve
* per-project, enabling a single global config to work across all projects.
*/
static setRuntimeBaseDir(dir: string | null | undefined, cwd?: string): void {
Storage.runtimeBaseDir = Storage.resolveRuntimeBaseDir(dir, cwd);
}
/**
* Runs function execution in an async context with a specific runtime output dir.
* This is used to isolate runtime output paths between concurrent sessions.
*/
static runWithRuntimeBaseDir<T>(
dir: string | null | undefined,
cwd: string | undefined,
fn: () => T,
): T {
const resolved = Storage.resolveRuntimeBaseDir(dir, cwd);
return Storage.runtimeBaseDirContext.run(resolved, fn);
}
/**
* Returns the base directory for all runtime output (temp files, debug logs,
* session data, todos, insights, etc.).
*
* Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir()
* @returns Absolute path to the runtime output base directory
*/
static getRuntimeBaseDir(): string {
const envDir = process.env['QWEN_RUNTIME_DIR'];
if (envDir) {
return (
Storage.resolveRuntimeBaseDir(envDir) ?? Storage.getGlobalQwenDir()
);
}
const contextualDir = Storage.runtimeBaseDirContext.getStore();
if (contextualDir !== undefined) {
return contextualDir ?? Storage.getGlobalQwenDir();
}
if (Storage.runtimeBaseDir) {
return Storage.runtimeBaseDir;
}
return Storage.getGlobalQwenDir();
}
static getGlobalQwenDir(): string {
const homeDir = os.homedir();
if (!homeDir) {
@ -60,11 +150,11 @@ export class Storage {
}
static getGlobalTempDir(): string {
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
return path.join(Storage.getRuntimeBaseDir(), TMP_DIR_NAME);
}
static getGlobalDebugDir(): string {
return path.join(Storage.getGlobalQwenDir(), DEBUG_DIR_NAME);
return path.join(Storage.getRuntimeBaseDir(), DEBUG_DIR_NAME);
}
static getDebugLogPath(sessionId: string): string {
@ -72,7 +162,7 @@ export class Storage {
}
static getGlobalIdeDir(): string {
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME);
}
static getGlobalBinDir(): string {
@ -89,7 +179,10 @@ export class Storage {
getProjectDir(): string {
const projectId = sanitizeCwd(this.getProjectRoot());
const projectsDir = path.join(Storage.getGlobalQwenDir(), PROJECT_DIR_NAME);
const projectsDir = path.join(
Storage.getRuntimeBaseDir(),
PROJECT_DIR_NAME,
);
return path.join(projectsDir, projectId);
}
@ -114,7 +207,7 @@ export class Storage {
getHistoryDir(): string {
const hash = getProjectHash(this.getProjectRoot());
const historyDir = path.join(Storage.getGlobalQwenDir(), 'history');
const historyDir = path.join(Storage.getRuntimeBaseDir(), 'history');
const targetDir = path.join(historyDir, hash);
return targetDir;
}