mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
The rename is adapter-specific, so it lives where the provider id resolves to its adapter: AgentManager converts an agent's own options on create, resume, import, reload, apply and draft controls, following derivedFromProviderId, so a custom provider extending codex keeps working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
586 lines
20 KiB
TypeScript
586 lines
20 KiB
TypeScript
import { promises as fs, type Dirent } from "node:fs";
|
|
import path from "node:path";
|
|
import { z } from "zod";
|
|
import type { Logger } from "pino";
|
|
|
|
import { writeJsonFileAtomic } from "../atomic-file.js";
|
|
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
|
import type { ManagedAgent } from "./agent-manager.js";
|
|
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
|
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
|
import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js";
|
|
|
|
const SERIALIZABLE_CONFIG_SCHEMA = z
|
|
.object({
|
|
modeId: z.string().nullable().optional(),
|
|
model: z.string().nullable().optional(),
|
|
thinkingOptionId: z.string().nullable().optional(),
|
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
|
|
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
toolPolicy: z
|
|
.object({
|
|
preapproved: z.array(
|
|
z.object({ kind: z.literal("mcp"), server: z.string(), tool: z.string() }).strict(),
|
|
),
|
|
})
|
|
.strict()
|
|
.nullable()
|
|
.optional(),
|
|
systemPrompt: z.string().nullable().optional(),
|
|
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
|
|
})
|
|
.nullable()
|
|
.optional();
|
|
|
|
const PERSISTENCE_HANDLE_SCHEMA = z
|
|
.object({
|
|
provider: z.string(),
|
|
sessionId: z.string(),
|
|
nativeHandle: z.any().optional(),
|
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
})
|
|
.nullable()
|
|
.optional();
|
|
|
|
const RAW_STORED_AGENT_SCHEMA = z.object({
|
|
id: z.string(),
|
|
provider: z.string(),
|
|
cwd: z.string(),
|
|
workspaceId: z.string().optional(),
|
|
createdAt: z.string(),
|
|
updatedAt: z.string(),
|
|
lastActivityAt: z.string().optional(),
|
|
lastUserMessageAt: z.string().nullable().optional(),
|
|
title: z.string().nullable().optional(),
|
|
labels: z.record(z.string(), z.string()).default({}),
|
|
lastStatus: AgentStatusSchema.default("closed"),
|
|
resumeOnStartup: z.boolean().optional(),
|
|
lastModeId: z.string().nullable().optional(),
|
|
config: SERIALIZABLE_CONFIG_SCHEMA,
|
|
launchEnv: z.record(z.string(), z.string()).optional(),
|
|
runtimeInfo: z
|
|
.object({
|
|
provider: z.string(),
|
|
sessionId: z.string().nullable(),
|
|
model: z.string().nullable().optional(),
|
|
thinkingOptionId: z.string().nullable().optional(),
|
|
modeId: z.string().nullable().optional(),
|
|
extra: z.record(z.string(), z.unknown()).optional(),
|
|
})
|
|
.optional(),
|
|
controls: z.array(AgentOptionControlSchema).optional(),
|
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
|
|
features: z.array(AgentFeatureSchema).optional(),
|
|
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
|
lastError: z.string().nullable().optional(),
|
|
requiresAttention: z.boolean().optional(),
|
|
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
|
attentionTimestamp: z.string().nullable().optional(),
|
|
internal: z.boolean().optional(),
|
|
background: z.boolean().optional(),
|
|
archivedAt: z.string().nullable().optional(),
|
|
owner: AgentOwnerSchema.optional(),
|
|
});
|
|
|
|
export type SerializableAgentConfig = Pick<
|
|
AgentSessionConfig,
|
|
| "modeId"
|
|
| "model"
|
|
| "thinkingOptionId"
|
|
| "providerOptions"
|
|
| "toolPolicy"
|
|
| "systemPrompt"
|
|
| "mcpServers"
|
|
>;
|
|
|
|
type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>;
|
|
type RawStoredAgentConfig = NonNullable<RawStoredAgentRecord["config"]>;
|
|
|
|
/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */
|
|
export type StoredAgentRecord = Omit<RawStoredAgentRecord, "config" | "features"> & {
|
|
config?: Omit<RawStoredAgentConfig, "featureValues"> | null;
|
|
};
|
|
|
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord {
|
|
const { features, config, controls, ...rest } = record;
|
|
return {
|
|
...rest,
|
|
...(config ? { config: foldFeatureValues(config) } : { config }),
|
|
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
|
|
};
|
|
}
|
|
|
|
const STORED_AGENT_SCHEMA = RAW_STORED_AGENT_SCHEMA.transform(foldLegacyRecordFields);
|
|
|
|
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
|
return STORED_AGENT_SCHEMA.parse(value);
|
|
}
|
|
|
|
export class AgentStorage {
|
|
private cache: Map<string, StoredAgentRecord> = new Map();
|
|
private pathById: Map<string, string> = new Map();
|
|
private pathsById: Map<string, Set<string>> = new Map();
|
|
private pendingWrites: Map<string, Promise<void>> = new Map();
|
|
private deleting: Set<string> = new Set();
|
|
private daemonAgentIdsByExecution: Map<string, string> = new Map();
|
|
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
|
|
private readonly idsByWorkspace = new Map<string, Set<string>>();
|
|
private readonly workspaceById = new Map<string, string>();
|
|
private readonly activeIds = new Set<string>();
|
|
private readonly promptTimeByAgent = new Map<string, string>();
|
|
private readonly promptTimes = new Map<string, string>();
|
|
private loaded = false;
|
|
private baseDir: string;
|
|
private loadPromise: Promise<void> | null = null;
|
|
private logger: Logger;
|
|
|
|
constructor(baseDir: string, logger: Logger) {
|
|
this.baseDir = baseDir;
|
|
this.logger = logger.child({ module: "agent", component: "agent-storage" });
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
await this.load();
|
|
}
|
|
|
|
async listAll(): Promise<StoredAgentRecord[]> {
|
|
await this.load();
|
|
return Array.from(this.cache.values());
|
|
}
|
|
|
|
async listActive(options?: { includeArchived?: boolean }): Promise<StoredAgentRecord[]> {
|
|
await this.load();
|
|
return Array.from(
|
|
options?.includeArchived ? this.cache.keys() : this.activeIds,
|
|
(id) => this.cache.get(id)!,
|
|
);
|
|
}
|
|
|
|
async listByWorkspaces(
|
|
workspaceIds: Iterable<string>,
|
|
options?: { includeArchived?: boolean },
|
|
): Promise<StoredAgentRecord[]> {
|
|
await this.load();
|
|
const records: StoredAgentRecord[] = [];
|
|
for (const workspaceId of new Set(workspaceIds)) {
|
|
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
|
|
const record = this.cache.get(id)!;
|
|
if (options?.includeArchived || !record.archivedAt) records.push(record);
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
async lastUserMessageAtByWorkspace(
|
|
workspaceIds?: Iterable<string>,
|
|
options?: { excludeAgentIds?: Iterable<string> },
|
|
): Promise<ReadonlyMap<string, string>> {
|
|
await this.load();
|
|
const times = new Map<string, string>();
|
|
const excluded = new Set(options?.excludeAgentIds);
|
|
const affected = new Set(Array.from(excluded, (id) => this.workspaceById.get(id)));
|
|
for (const id of workspaceIds ?? this.promptTimes.keys()) {
|
|
let at = this.promptTimes.get(id);
|
|
if (affected.has(id)) {
|
|
at = undefined;
|
|
for (const agentId of this.idsByWorkspace.get(id) ?? []) {
|
|
const record = this.cache.get(agentId)!;
|
|
if (
|
|
!excluded.has(agentId) &&
|
|
!record.internal &&
|
|
!record.background &&
|
|
record.lastUserMessageAt &&
|
|
record.lastUserMessageAt > (at ?? "")
|
|
) {
|
|
at = record.lastUserMessageAt;
|
|
}
|
|
}
|
|
}
|
|
if (at) times.set(id, at);
|
|
}
|
|
return times;
|
|
}
|
|
|
|
private indexWorkspace(record: StoredAgentRecord): void {
|
|
const previous = this.workspaceById.get(record.id);
|
|
if (previous) this.idsByWorkspace.get(previous)?.delete(record.id);
|
|
this.workspaceById.delete(record.id);
|
|
this.activeIds.delete(record.id);
|
|
if (!record.archivedAt) this.activeIds.add(record.id);
|
|
if (record.workspaceId) {
|
|
const ids = this.idsByWorkspace.get(record.workspaceId) ?? new Set<string>();
|
|
ids.add(record.id);
|
|
this.idsByWorkspace.set(record.workspaceId, ids);
|
|
this.workspaceById.set(record.id, record.workspaceId);
|
|
}
|
|
this.indexPromptTime(record, previous);
|
|
}
|
|
|
|
private indexPromptTime(record: StoredAgentRecord, previous: string | undefined): void {
|
|
const previousTime = this.promptTimeByAgent.get(record.id);
|
|
const nextTime =
|
|
!record.internal && !record.background ? (record.lastUserMessageAt ?? undefined) : undefined;
|
|
if (nextTime) this.promptTimeByAgent.set(record.id, nextTime);
|
|
else this.promptTimeByAgent.delete(record.id);
|
|
if (previous === record.workspaceId && previousTime === nextTime) return;
|
|
if (
|
|
previous &&
|
|
previousTime &&
|
|
this.promptTimes.get(previous) === previousTime &&
|
|
(previous !== record.workspaceId || !nextTime || nextTime < previousTime)
|
|
) {
|
|
this.refreshPromptTime(previous);
|
|
}
|
|
if (
|
|
record.workspaceId &&
|
|
nextTime &&
|
|
nextTime > (this.promptTimes.get(record.workspaceId) ?? "")
|
|
) {
|
|
this.promptTimes.set(record.workspaceId, nextTime);
|
|
}
|
|
}
|
|
|
|
private refreshPromptTime(workspaceId: string): void {
|
|
this.promptTimes.delete(workspaceId);
|
|
let latest = "";
|
|
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
|
|
const record = this.cache.get(id)!;
|
|
if (
|
|
!record.internal &&
|
|
!record.background &&
|
|
record.lastUserMessageAt &&
|
|
record.lastUserMessageAt > latest
|
|
)
|
|
latest = record.lastUserMessageAt;
|
|
}
|
|
if (latest) this.promptTimes.set(workspaceId, latest);
|
|
}
|
|
|
|
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
|
await this.load();
|
|
return this.cache.get(agentId) ?? null;
|
|
}
|
|
|
|
async listByProviderSession(
|
|
provider: string,
|
|
providerHandleId: string,
|
|
): Promise<StoredAgentRecord[]> {
|
|
await this.load();
|
|
return Array.from(this.cache.values()).filter(
|
|
(record) =>
|
|
record.persistence?.provider === provider &&
|
|
(record.persistence.sessionId === providerHandleId ||
|
|
record.persistence.nativeHandle === providerHandleId),
|
|
);
|
|
}
|
|
|
|
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
|
|
return this.listByWorkspaces([workspaceId], { includeArchived: true });
|
|
}
|
|
|
|
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
|
|
await this.load();
|
|
const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner));
|
|
return agentId ? (this.cache.get(agentId) ?? null) : null;
|
|
}
|
|
|
|
async upsert(record: StoredAgentRecord): Promise<void> {
|
|
await this.load();
|
|
await this.queueRecordWrite(record);
|
|
}
|
|
|
|
private queueRecordWrite(record: StoredAgentRecord): Promise<void> {
|
|
return this.queueRecordMutation(record.id, () => record);
|
|
}
|
|
|
|
private queueRecordMutation(
|
|
agentId: string,
|
|
mutate: (existing: StoredAgentRecord | null) => StoredAgentRecord,
|
|
): Promise<void> {
|
|
const prev = this.pendingWrites.get(agentId) ?? Promise.resolve();
|
|
const next = prev.then(async () => {
|
|
if (this.deleting.has(agentId)) {
|
|
return undefined;
|
|
}
|
|
|
|
const record = mutate(this.cache.get(agentId) ?? null);
|
|
await this.writeRecord(record);
|
|
return undefined;
|
|
});
|
|
|
|
const tracked = next.finally(() => {
|
|
if (this.pendingWrites.get(agentId) === tracked) {
|
|
this.pendingWrites.delete(agentId);
|
|
}
|
|
});
|
|
|
|
this.pendingWrites.set(agentId, tracked);
|
|
return tracked;
|
|
}
|
|
|
|
private async writeRecord(record: StoredAgentRecord): Promise<void> {
|
|
const agentId = record.id;
|
|
const nextPath = this.buildRecordPath(record);
|
|
const previousPath = this.pathById.get(agentId);
|
|
|
|
await writeJsonFileAtomic(nextPath, record);
|
|
this.addIndexedPath(agentId, nextPath);
|
|
|
|
if (previousPath && previousPath !== nextPath) {
|
|
try {
|
|
await fs.unlink(previousPath);
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
this.removeIndexedPath(agentId, previousPath);
|
|
}
|
|
|
|
this.cache.set(agentId, record);
|
|
this.indexOwner(record);
|
|
this.pathById.set(agentId, nextPath);
|
|
}
|
|
|
|
beginDelete(agentId: string): void {
|
|
this.deleting.add(agentId);
|
|
}
|
|
|
|
async remove(agentId: string): Promise<void> {
|
|
await this.load();
|
|
this.beginDelete(agentId);
|
|
await (this.pendingWrites.get(agentId) ?? Promise.resolve());
|
|
const paths = Array.from(this.pathsById.get(agentId) ?? []);
|
|
await Promise.all(
|
|
paths.map(async (filePath) => {
|
|
try {
|
|
await fs.unlink(filePath);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code && code !== "ENOENT") {
|
|
this.logger.warn(
|
|
{ err: error, agentId, filePath },
|
|
"Failed to remove agent record file",
|
|
);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
const workspaceId = this.workspaceById.get(agentId);
|
|
this.workspaceById.delete(agentId);
|
|
this.promptTimeByAgent.delete(agentId);
|
|
this.activeIds.delete(agentId);
|
|
if (workspaceId) {
|
|
this.idsByWorkspace.get(workspaceId)?.delete(agentId);
|
|
this.refreshPromptTime(workspaceId);
|
|
}
|
|
this.cache.delete(agentId);
|
|
this.removeOwnerIndex(agentId);
|
|
this.pathById.delete(agentId);
|
|
this.pathsById.delete(agentId);
|
|
}
|
|
|
|
async applySnapshot(
|
|
agent: ManagedAgent,
|
|
options?: { title?: string | null; internal?: boolean },
|
|
): Promise<void> {
|
|
await this.load();
|
|
const hasTitleOverride =
|
|
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
|
|
const hasInternalOverride =
|
|
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
|
|
await this.queueRecordMutation(agent.id, (existing) => {
|
|
const record = toStoredAgentRecord(agent, {
|
|
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
|
|
createdAt: existing?.createdAt,
|
|
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
|
|
});
|
|
|
|
// Preserve soft-delete/archive status across snapshot flushes. The
|
|
// projection runs inside the per-agent write queue so it cannot commit a
|
|
// stale pre-archive record after the archive mutation.
|
|
if (existing && existing.archivedAt !== undefined) {
|
|
record.archivedAt = existing.archivedAt;
|
|
}
|
|
// Background is fixed at creation; a runtime opened without it cannot clear it.
|
|
if (existing?.background) record.background = true;
|
|
record.resumeOnStartup =
|
|
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
|
return record;
|
|
});
|
|
}
|
|
|
|
async setTitle(agentId: string, title: string): Promise<void> {
|
|
await this.load();
|
|
await this.waitForPendingWrite(agentId);
|
|
const record = await this.get(agentId);
|
|
if (!record) {
|
|
throw new Error(`Agent ${agentId} not found`);
|
|
}
|
|
await this.upsert({ ...record, title });
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
await this.load().catch(() => undefined);
|
|
const writes = Array.from(this.pendingWrites.values());
|
|
await Promise.allSettled(writes);
|
|
}
|
|
|
|
private async load(): Promise<void> {
|
|
if (this.loaded) {
|
|
return;
|
|
}
|
|
|
|
if (!this.loadPromise) {
|
|
this.loadPromise = this.doLoad();
|
|
}
|
|
|
|
return this.loadPromise;
|
|
}
|
|
|
|
private async doLoad(): Promise<void> {
|
|
this.cache.clear();
|
|
this.pathById.clear();
|
|
this.pathsById.clear();
|
|
this.daemonAgentIdsByExecution.clear();
|
|
this.daemonExecutionKeysByAgentId.clear();
|
|
|
|
try {
|
|
await this.scanDisk();
|
|
this.loaded = true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
this.loaded = true;
|
|
return;
|
|
}
|
|
this.logger.error({ err: error }, "Failed to load agents");
|
|
this.loaded = true;
|
|
}
|
|
}
|
|
|
|
private async scanDisk(): Promise<void> {
|
|
let entries: Dirent[] = [];
|
|
try {
|
|
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const rootRecordPaths = entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => path.join(this.baseDir, entry.name));
|
|
|
|
const projectDirs = entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join(this.baseDir, entry.name));
|
|
|
|
const projectFileLists = await Promise.all(
|
|
projectDirs.map(async (projectDir) => {
|
|
try {
|
|
const files = await fs.readdir(projectDir, { withFileTypes: true });
|
|
return files
|
|
.filter((file) => file.isFile() && file.name.endsWith(".json"))
|
|
.map((file) => path.join(projectDir, file.name));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}),
|
|
);
|
|
|
|
const allFilePaths = [...rootRecordPaths, ...projectFileLists.flat()];
|
|
const loaded = await Promise.all(
|
|
allFilePaths.map(async (filePath) => {
|
|
const record = await this.readRecordFile(filePath);
|
|
return record ? { record, filePath } : null;
|
|
}),
|
|
);
|
|
|
|
for (const item of loaded) {
|
|
if (!item) continue;
|
|
const { record, filePath } = item;
|
|
this.cache.set(record.id, record);
|
|
this.indexOwner(record);
|
|
this.pathById.set(record.id, filePath);
|
|
this.addIndexedPath(record.id, filePath);
|
|
}
|
|
}
|
|
|
|
private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
|
|
try {
|
|
const content = await fs.readFile(filePath, "utf8");
|
|
const parsed = JSON.parse(content);
|
|
return parseStoredAgentRecord(parsed);
|
|
} catch (error) {
|
|
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private buildRecordPath(record: StoredAgentRecord): string {
|
|
const projectDir = projectDirNameFromCwd(record.cwd);
|
|
return path.join(this.baseDir, projectDir, `${record.id}.json`);
|
|
}
|
|
|
|
private addIndexedPath(agentId: string, filePath: string): void {
|
|
const paths = this.pathsById.get(agentId) ?? new Set<string>();
|
|
paths.add(filePath);
|
|
this.pathsById.set(agentId, paths);
|
|
}
|
|
|
|
private removeIndexedPath(agentId: string, filePath: string): void {
|
|
const paths = this.pathsById.get(agentId);
|
|
if (!paths) {
|
|
return;
|
|
}
|
|
paths.delete(filePath);
|
|
if (paths.size === 0) {
|
|
this.pathsById.delete(agentId);
|
|
}
|
|
}
|
|
|
|
private indexOwner(record: StoredAgentRecord): void {
|
|
this.indexWorkspace(record);
|
|
this.removeOwnerIndex(record.id);
|
|
if (record.owner?.kind === "daemon") {
|
|
const key = daemonExecutionKey(record.owner);
|
|
const previousAgentId = this.daemonAgentIdsByExecution.get(key);
|
|
if (previousAgentId && previousAgentId !== record.id) {
|
|
this.daemonExecutionKeysByAgentId.delete(previousAgentId);
|
|
}
|
|
this.daemonAgentIdsByExecution.set(key, record.id);
|
|
this.daemonExecutionKeysByAgentId.set(record.id, key);
|
|
}
|
|
}
|
|
|
|
private removeOwnerIndex(agentId: string): void {
|
|
const key = this.daemonExecutionKeysByAgentId.get(agentId);
|
|
if (!key) return;
|
|
if (this.daemonAgentIdsByExecution.get(key) === agentId) {
|
|
this.daemonAgentIdsByExecution.delete(key);
|
|
}
|
|
this.daemonExecutionKeysByAgentId.delete(agentId);
|
|
}
|
|
|
|
private async waitForPendingWrite(agentId: string): Promise<void> {
|
|
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
function projectDirNameFromCwd(cwd: string): string {
|
|
// path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
|
|
const { root } = path.win32.parse(cwd);
|
|
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
|
|
// Sanitize root: strip colons and separators, keep letters (e.g. "C:\" → "C", "\\server\share\" → "server-share")
|
|
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
|
|
const prefix = sanitizedRoot ? sanitizedRoot + "-" : "";
|
|
if (!withoutRoot) {
|
|
return sanitizedRoot || "root";
|
|
}
|
|
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
|
|
}
|