Merge 76f53095d2401418bc475cc9dbd067c622644d53 into 6b4c24c2e55b5b4013277bd799525086f6a0c40f
This commit is contained in:
commit
ae43aec073
@ -28,6 +28,7 @@ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[num
|
||||
type ResolvedAgentConfig = {
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
multipleWorkspaces?: string[];
|
||||
agentDir?: string;
|
||||
model?: AgentEntry["model"];
|
||||
skills?: AgentEntry["skills"];
|
||||
@ -127,6 +128,9 @@ export function resolveAgentConfig(
|
||||
return {
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
multipleWorkspaces: Array.isArray(entry.multipleWorkspaces)
|
||||
? entry.multipleWorkspaces
|
||||
: undefined,
|
||||
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
|
||||
model:
|
||||
typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
|
||||
@ -253,8 +257,32 @@ export function resolveEffectiveModelFallbacks(params: {
|
||||
return agentFallbacksOverride ?? defaultFallbacks;
|
||||
}
|
||||
|
||||
export function resolveAgentMultipleWorkspaces(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): string[] | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const agentConfig = resolveAgentConfig(cfg, id);
|
||||
const mw = agentConfig?.multipleWorkspaces;
|
||||
if (!Array.isArray(mw) || mw.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = mw
|
||||
.filter((p) => typeof p === "string" && p.trim().length > 0)
|
||||
.map((p) => stripNullBytes(resolveUserPath(p)));
|
||||
return resolved.length > 0 ? resolved : undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
|
||||
// multipleWorkspaces takes precedence: return a composite workspace dir.
|
||||
const multiWs = resolveAgentMultipleWorkspaces(cfg, id);
|
||||
if (multiWs) {
|
||||
const stateDir = resolveStateDir(process.env);
|
||||
return stripNullBytes(path.join(stateDir, `workspace-composite-${id}`));
|
||||
}
|
||||
|
||||
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
|
||||
if (configured) {
|
||||
return stripNullBytes(resolveUserPath(configured));
|
||||
@ -303,10 +331,26 @@ export function resolveAgentIdsByWorkspacePath(
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
const id = ids[index];
|
||||
const workspaceDir = normalizePathForComparison(resolveAgentWorkspaceDir(cfg, id));
|
||||
if (!isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) {
|
||||
if (isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) {
|
||||
matches.push({ id, workspaceDir, order: index });
|
||||
continue;
|
||||
}
|
||||
matches.push({ id, workspaceDir, order: index });
|
||||
// Also match individual multipleWorkspaces entries — pick the most specific (longest path).
|
||||
const multiWs = resolveAgentMultipleWorkspaces(cfg, id);
|
||||
if (multiWs) {
|
||||
let bestMatch: string | null = null;
|
||||
for (const ws of multiWs) {
|
||||
const normalizedWs = normalizePathForComparison(ws);
|
||||
if (isPathWithinRoot(normalizedWorkspacePath, normalizedWs)) {
|
||||
if (!bestMatch || normalizedWs.length > bestMatch.length) {
|
||||
bestMatch = normalizedWs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
matches.push({ id, workspaceDir: bestMatch, order: index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((left, right) => {
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import {
|
||||
resolveAgentMultipleWorkspaces,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "./agent-scope.js";
|
||||
|
||||
export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] {
|
||||
const dirs = new Set<string>();
|
||||
@ -8,6 +12,13 @@ export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] {
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === "object" && typeof entry.id === "string") {
|
||||
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
|
||||
// Also include individual multipleWorkspaces entries.
|
||||
const multiWs = resolveAgentMultipleWorkspaces(cfg, entry.id);
|
||||
if (multiWs) {
|
||||
for (const ws of multiWs) {
|
||||
dirs.add(ws);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { openBoundaryFile } from "../infra/boundary-file-read.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@ -327,6 +328,7 @@ async function ensureGitRepo(dir: string, isBrandNewWorkspace: boolean) {
|
||||
export async function ensureAgentWorkspace(params?: {
|
||||
dir?: string;
|
||||
ensureBootstrapFiles?: boolean;
|
||||
multipleWorkspaces?: string[];
|
||||
}): Promise<{
|
||||
dir: string;
|
||||
agentsPath?: string;
|
||||
@ -341,6 +343,16 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const dir = resolveUserPath(rawDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
// If multipleWorkspaces is set, populate the composite directory with symlinks
|
||||
// before any bootstrap file creation. This ensures all execution paths (reply,
|
||||
// CLI, heartbeat, etc.) see the same composite workspace.
|
||||
if (params?.multipleWorkspaces && params.multipleWorkspaces.length > 0) {
|
||||
await ensureCompositeWorkspace({
|
||||
compositeDir: dir,
|
||||
workspacePaths: params.multipleWorkspaces,
|
||||
});
|
||||
}
|
||||
|
||||
if (!params?.ensureBootstrapFiles) {
|
||||
return { dir };
|
||||
}
|
||||
@ -645,3 +657,152 @@ export async function loadExtraBootstrapFilesWithDiagnostics(
|
||||
}
|
||||
return { files, diagnostics };
|
||||
}
|
||||
|
||||
const compositeLog = createSubsystemLogger("composite-workspace");
|
||||
|
||||
/**
|
||||
* Ensure a composite workspace directory exists with symlinks to each workspace path.
|
||||
* Stale symlinks (pointing to paths not in the current set) are removed.
|
||||
* Basename collisions are resolved by prepending the parent directory name.
|
||||
*/
|
||||
export async function ensureCompositeWorkspace(params: {
|
||||
compositeDir: string;
|
||||
workspacePaths: string[];
|
||||
}): Promise<void> {
|
||||
const { compositeDir, workspacePaths } = params;
|
||||
await fs.mkdir(compositeDir, { recursive: true });
|
||||
|
||||
// On case-insensitive filesystems (macOS, Windows) names like "Repo" and "repo"
|
||||
// resolve to the same on-disk entry, so we normalize all collision checks to lowercase.
|
||||
const isCaseInsensitive = process.platform === "darwin" || process.platform === "win32";
|
||||
const normalizeKey = (name: string) => (isCaseInsensitive ? name.toLowerCase() : name);
|
||||
|
||||
// Build unique link names from basenames, deduping collisions.
|
||||
const linkEntries: Array<{ linkName: string; target: string }> = [];
|
||||
const nameCount = new Map<string, number>();
|
||||
for (const ws of workspacePaths) {
|
||||
const key = normalizeKey(path.basename(ws));
|
||||
nameCount.set(key, (nameCount.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Internal workspace names (bootstrap files, memory dir) that must never be used as symlink names.
|
||||
const internalNamesRaw = [
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_IDENTITY_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
DEFAULT_MEMORY_FILENAME,
|
||||
DEFAULT_MEMORY_ALT_FILENAME,
|
||||
"memory",
|
||||
WORKSPACE_STATE_DIRNAME,
|
||||
];
|
||||
const internalNames = new Set<string>(internalNamesRaw.map(normalizeKey));
|
||||
// Track all taken names so collision resolution avoids them. Seed with internal names
|
||||
// and non-collision basenames (which keep their short names).
|
||||
const takenNames = new Set<string>(internalNames);
|
||||
for (const ws of workspacePaths) {
|
||||
const key = normalizeKey(path.basename(ws));
|
||||
if ((nameCount.get(key) ?? 0) === 1 && !internalNames.has(key)) {
|
||||
takenNames.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const nameUsed = new Map<string, number>();
|
||||
for (const ws of workspacePaths) {
|
||||
const base = path.basename(ws);
|
||||
const baseKey = normalizeKey(base);
|
||||
let linkName: string;
|
||||
// Dedup when basename collides with another workspace or with an internal name.
|
||||
const needsDedup = (nameCount.get(baseKey) ?? 0) > 1 || internalNames.has(baseKey);
|
||||
if (needsDedup) {
|
||||
const parentName = path.basename(path.dirname(ws));
|
||||
const candidate = `${parentName}-${base}`;
|
||||
const candidateKey = normalizeKey(candidate);
|
||||
let suffix = nameUsed.get(candidateKey) ?? 0;
|
||||
linkName = suffix > 0 ? `${candidate}-${suffix}` : candidate;
|
||||
// Ensure the resolved name doesn't collide with any taken name.
|
||||
while (takenNames.has(normalizeKey(linkName))) {
|
||||
suffix += 1;
|
||||
linkName = `${candidate}-${suffix}`;
|
||||
}
|
||||
nameUsed.set(candidateKey, suffix + 1);
|
||||
} else {
|
||||
linkName = base;
|
||||
}
|
||||
takenNames.add(normalizeKey(linkName));
|
||||
linkEntries.push({ linkName, target: ws });
|
||||
}
|
||||
|
||||
// Remove stale symlinks in composite dir that are not in the current set.
|
||||
const desiredNames = new Set(
|
||||
linkEntries.map((e) => (isCaseInsensitive ? e.linkName.toLowerCase() : e.linkName)),
|
||||
);
|
||||
try {
|
||||
const existing = await fs.readdir(compositeDir, { withFileTypes: true });
|
||||
for (const entry of existing) {
|
||||
if (!entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (!desiredNames.has(isCaseInsensitive ? entry.name.toLowerCase() : entry.name)) {
|
||||
const stalePath = path.join(compositeDir, entry.name);
|
||||
compositeLog.info(`Removing stale symlink: ${stalePath}`);
|
||||
await fs.unlink(stalePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory may not exist yet or be unreadable.
|
||||
}
|
||||
|
||||
// Create or update symlinks.
|
||||
for (const { linkName, target } of linkEntries) {
|
||||
const linkPath = path.join(compositeDir, linkName);
|
||||
const targetExists = await fs
|
||||
.access(target)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const symlinkType = process.platform === "win32" ? "dir" : undefined;
|
||||
|
||||
// Warn on missing targets but still create the symlink.
|
||||
if (!targetExists) {
|
||||
compositeLog.warn(`Workspace target does not exist: ${target}`);
|
||||
}
|
||||
|
||||
// Check if symlink already points to the correct target.
|
||||
try {
|
||||
const existingTarget = await fs.readlink(linkPath);
|
||||
if (path.resolve(existingTarget) === path.resolve(target)) {
|
||||
// On Windows, a missing target created without an explicit "dir" type can leave
|
||||
// behind a file-typed symlink that resolves to the right target string but is not
|
||||
// usable as a directory once the target appears. When the target exists, verify the
|
||||
// resolved link is traversable as a directory before keeping it.
|
||||
if (!(process.platform === "win32" && targetExists)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(linkPath);
|
||||
if (stats.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Recreate the symlink with the explicit directory type below.
|
||||
}
|
||||
}
|
||||
// Target changed or existing Windows link is unusable — remove old symlink.
|
||||
await fs.unlink(linkPath);
|
||||
} catch {
|
||||
// Symlink doesn't exist or isn't a symlink — try to remove whatever is there.
|
||||
try {
|
||||
await fs.rm(linkPath, { force: true, recursive: false });
|
||||
} catch (rmErr) {
|
||||
compositeLog.warn(`Cannot replace non-symlink at ${linkPath}: ${String(rmErr)}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.symlink(target, linkPath, symlinkType);
|
||||
compositeLog.info(`Symlinked ${linkName} -> ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentMultipleWorkspaces,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveSessionAgentId,
|
||||
resolveAgentSkillsFilter,
|
||||
@ -106,6 +107,7 @@ export async function getReplyFromConfig(
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
|
||||
multipleWorkspaces: resolveAgentMultipleWorkspaces(cfg, agentId),
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
|
||||
@ -63,6 +63,7 @@ export type AgentConfig = {
|
||||
default?: boolean;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
multipleWorkspaces?: string[];
|
||||
agentDir?: string;
|
||||
model?: AgentModelConfig;
|
||||
/** Optional allowlist of skills for this agent (omit = all skills; empty = none). */
|
||||
|
||||
@ -765,6 +765,7 @@ export const AgentEntrySchema = z
|
||||
default: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
multipleWorkspaces: z.array(z.string()).min(1).optional(),
|
||||
agentDir: z.string().optional(),
|
||||
model: AgentModelSchema.optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user