UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

465 lines (464 loc) 19.1 kB
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { c as resolveUserPath } from "./home-dir-BPhrG-aM.js"; import { r as resolveRealpathOrAbsolute } from "./boundary-path-DMNeww4q.js"; import { r as isPathInside } from "./path-guards-Cp-mGr3-.js"; import { t as CONFIG_DIR } from "./utils-P__uGsPB.js"; import { o as isDefaultStateDir } from "./paths-D2sRr1a_.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { n as resolveAllowedSkillSymlinkTargetRealPaths, r as tryRealpath } from "./symlink-targets-JaagYXQi.js"; import { a as resetSkillsRefreshStateForTest, n as clearSkillsSnapshotVersionForWorkspace, o as setSkillsChangeListenerErrorHandler, t as bumpSkillsSnapshotVersion } from "./refresh-state-DHnXO3IV.js"; import { n as resolvePluginSkillRootsFromMetadata, t as resolvePluginSkillRoots } from "./plugin-skills-ZuwJn5ic.js"; import fs from "node:fs"; import path from "node:path"; import { AsyncLocalStorage } from "node:async_hooks"; import os from "node:os"; import chokidar from "chokidar"; //#region src/skills/runtime/refresh-watch-path.ts function toWatchRoot(raw) { const normalized = raw.replaceAll("\\", "/"); const root = path.parse(normalized).root; const trimmed = normalized.replace(/\/+$/, ""); return trimmed.length < root.length ? root : trimmed; } function resolveSkillsWatchPath(raw) { if (process.platform !== "win32") return raw; const absolute = path.resolve(raw); const root = path.parse(absolute).root; const parts = absolute.slice(root.length).split(path.sep); let cursor = root; let index = 0; for (const part of parts) { const next = path.join(cursor, part); try { if (fs.lstatSync(next).isSymbolicLink()) break; } catch { break; } cursor = next; index += 1; } try { return path.join(fs.realpathSync.native(cursor), ...parts.slice(index)); } catch { return raw; } } //#endregion //#region src/skills/runtime/refresh.ts const log = createSubsystemLogger("gateway/skills"); const runInSkillsWatcherContext = AsyncLocalStorage.snapshot(); const GROUPED_SKILLS_WATCH_DEPTH = 6; const CONFIGURED_ROOT_WATCH_DEPTH = 2; const MAX_SYMLINK_WATCH_TARGETS_PER_ROOT = 100; const MAX_SYMLINK_WATCH_DIRECTORY_SCANS_PER_ROOT = 200; const MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT = 2e3; const RAW_SKILL_FILE_POLL_INTERVAL_MS = 100; const SKILLS_WATCH_DEBOUNCE_MS = 250; const pathWatchers = /* @__PURE__ */ new Map(); const workspaceWatchTargets = /* @__PURE__ */ new Map(); const workspaceWatchOwnerDirs = /* @__PURE__ */ new Map(); const workspaceWatchTargetCache = /* @__PURE__ */ new Map(); const workspaceWatchLastEnsuredAt = /* @__PURE__ */ new Map(); const SKILLS_WORKSPACE_WATCH_IDLE_TTL_MS = 36e5; setSkillsChangeListenerErrorHandler((err) => { log.warn(`skills change listener failed: ${String(err)}`); }); const DEFAULT_SKILLS_WATCH_IGNORED = [ /(^|[\\/])\.git([\\/]|$)/, /(^|[\\/])node_modules([\\/]|$)/, /(^|[\\/])dist([\\/]|$)/, /(^|[\\/])\.venv([\\/]|$)/, /(^|[\\/])venv([\\/]|$)/, /(^|[\\/])__pycache__([\\/]|$)/, /(^|[\\/])\.mypy_cache([\\/]|$)/, /(^|[\\/])\.pytest_cache([\\/]|$)/, /(^|[\\/])build([\\/]|$)/, /(^|[\\/])\.cache([\\/]|$)/ ]; function resolveWatchTargets(workspaceDir, config, executionSkillsDir, watcherKey, pluginMetadataSnapshot) { const baseRoots = []; if (workspaceDir.trim()) { baseRoots.push({ path: path.join(workspaceDir, "skills"), source: "openclaw-workspace" }); baseRoots.push({ path: path.join(workspaceDir, ".agents", "skills"), source: "agents-skills-project" }); } if (executionSkillsDir) baseRoots.push({ path: executionSkillsDir, source: "openclaw-workspace" }); baseRoots.push({ path: path.join(CONFIG_DIR, "skills"), source: "openclaw-managed" }); if (isDefaultStateDir()) baseRoots.push({ path: path.join(os.homedir(), ".agents", "skills"), source: "agents-skills-personal" }); const extraDirs = (config?.skills?.load?.extraDirs ?? []).map((d) => normalizeOptionalString(d) ?? "").filter(Boolean).map((dir) => resolveUserPath(dir)); const pluginSkillDirs = (pluginMetadataSnapshot ? resolvePluginSkillRootsFromMetadata({ workspaceDir, config, metadataSnapshot: pluginMetadataSnapshot }) : resolvePluginSkillRoots({ workspaceDir, config })).map((root) => root.dir); const allowedSymlinkTargetRealPaths = resolveAllowedSkillSymlinkTargetRealPaths(config); const signature = JSON.stringify({ basePaths: baseRoots.map((root) => toWatchRoot(root.path)), extraDirs: extraDirs.map(toWatchRoot), pluginSkillDirs: pluginSkillDirs.map(toWatchRoot), allowSymlinkTargets: allowedSymlinkTargetRealPaths }); const cached = workspaceWatchTargetCache.get(watcherKey); if (cached?.signature === signature) return cached.targets; const targets = /* @__PURE__ */ new Map(); for (const root of baseRoots) addSkillSourceWatchTargets(targets, root.path, root.source, allowedSymlinkTargetRealPaths, GROUPED_SKILLS_WATCH_DEPTH); for (const resolved of extraDirs) addSkillSourceWatchTargets(targets, resolved, "openclaw-extra", allowedSymlinkTargetRealPaths); for (const dir of pluginSkillDirs) addSkillSourceWatchTargets(targets, dir, "openclaw-plugin", allowedSymlinkTargetRealPaths); const sortedTargets = Array.from(targets.values()).toSorted((a, b) => a.path.localeCompare(b.path)); workspaceWatchTargetCache.set(watcherKey, { signature, targets: sortedTargets }); return sortedTargets; } function makeWatchTarget(raw, depth) { const watchPath = toWatchRoot(resolveSkillsWatchPath(raw)); let watchRoot = watchPath; while (!fs.existsSync(watchRoot)) { const parent = path.dirname(watchRoot); if (parent === watchRoot) break; watchRoot = parent; } return { path: watchPath, watchRoot: toWatchRoot(watchRoot), depth }; } function addWatchTarget(targets, raw, depth) { const target = makeWatchTarget(raw, depth); target.depth = Math.max(target.depth, targets.get(target.path)?.depth ?? 0); targets.set(target.path, target); } function addSkillRootWatchTargets(targets, root, rootDepth) { addWatchTarget(targets, root, rootDepth); const companionSkillsRoot = path.join(root, "skills"); addWatchTarget(targets, companionSkillsRoot, GROUPED_SKILLS_WATCH_DEPTH); return companionSkillsRoot; } function addSkillSourceWatchTargets(targets, root, source, allowedSymlinkTargetRealPaths, rootDepth = path.basename(root) === "skills" ? GROUPED_SKILLS_WATCH_DEPTH : CONFIGURED_ROOT_WATCH_DEPTH) { const companionSkillsRoot = addSkillRootWatchTargets(targets, root, rootDepth); const rootRealPath = resolveRealpathOrAbsolute(root); addTrustedSymlinkSkillWatchTargets(targets, root, source, allowedSymlinkTargetRealPaths, rootDepth, rootRealPath, rootRealPath); addTrustedSymlinkSkillWatchTargets(targets, companionSkillsRoot, source, allowedSymlinkTargetRealPaths, GROUPED_SKILLS_WATCH_DEPTH, rootRealPath, resolveRealpathOrAbsolute(companionSkillsRoot)); } function addTrustedSymlinkSkillWatchTargets(targets, root, source, allowedSymlinkTargetRealPaths, maxDepth, containmentRootRealPath, rootRealPath) { try { if (fs.lstatSync(root).isSymbolicLink() && isTrustedSymlinkSkillTarget(source, containmentRootRealPath, rootRealPath, allowedSymlinkTargetRealPaths)) addSkillRootWatchTargets(targets, rootRealPath, maxDepth); } catch { return; } const queue = [{ dir: root, depth: 0 }]; let watched = 0; let directoryScans = 0; let rawEntries = 0; for (const queued of queue) { if (watched >= MAX_SYMLINK_WATCH_TARGETS_PER_ROOT || directoryScans >= MAX_SYMLINK_WATCH_DIRECTORY_SCANS_PER_ROOT || rawEntries >= MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT) break; const current = queued; if (!current) continue; const scan = readBudgetedDirEntries(current.dir, MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT - rawEntries); directoryScans += 1; rawEntries += scan.scannedEntryCount; if (!scan.ok) continue; for (const entry of scan.entries.toSorted((a, b) => a.name.localeCompare(b.name))) { if (watched >= MAX_SYMLINK_WATCH_TARGETS_PER_ROOT) break; if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const childPath = path.join(current.dir, entry.name); if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(childPath))) continue; if (entry.isSymbolicLink()) { const targetRealPath = tryRealpath(childPath); if (targetRealPath && isTrustedSymlinkSkillTarget(source, containmentRootRealPath, targetRealPath, allowedSymlinkTargetRealPaths)) { addSkillRootWatchTargets(targets, targetRealPath, GROUPED_SKILLS_WATCH_DEPTH); watched += 1; } continue; } if (entry.isDirectory() && current.depth < maxDepth) queue.push({ dir: childPath, depth: current.depth + 1 }); } } } function readBudgetedDirEntries(dir, maxEntries) { const entries = []; const limit = Math.max(0, maxEntries); let handle; try { handle = fs.opendirSync(dir); for (let scanned = 0; scanned < limit; scanned += 1) { const entry = handle.readSync(); if (!entry) return { ok: true, entries, scannedEntryCount: scanned }; entries.push(entry); } return { ok: true, entries, scannedEntryCount: limit }; } catch { return { ok: false, scannedEntryCount: 0 }; } finally { handle?.closeSync(); } } function isTrustedSymlinkSkillTarget(source, rootRealPath, targetRealPath, allowedSymlinkTargetRealPaths) { if (source === "openclaw-managed" || source === "agents-skills-personal") return true; return isPathInside(rootRealPath, targetRealPath) || allowedSymlinkTargetRealPaths.some((root) => isPathInside(root, targetRealPath)); } function shouldIgnoreSkillsWatchPath(watchPath, stats, options = {}) { if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) return true; if (stats?.isDirectory?.() || stats?.isSymbolicLink?.()) return false; if (!stats) return false; if (options.usePolling && isSkillFileWatchPath(watchPath)) return false; return true; } function isSkillFileWatchPath(watchPath) { if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) return false; const normalized = watchPath.replaceAll("\\", "/"); return path.posix.basename(normalized) === "SKILL.md"; } function getRawWatchedPath(details) { return typeof details === "object" && details !== null && typeof details.watchedPath === "string" ? details.watchedPath : void 0; } function rawPathToString(rawPath) { if (typeof rawPath === "string") return rawPath || void 0; if (Buffer.isBuffer(rawPath)) return rawPath.toString() || void 0; } function resolveRawSkillsWatchPath(rawPath, details) { if (path.isAbsolute(rawPath)) return rawPath; const watchedPath = getRawWatchedPath(details); return watchedPath ? path.join(watchedPath, rawPath) : void 0; } function readFileStabilitySnapshot(filePath) { try { const stat = fs.statSync(filePath); return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs } : void 0; } catch { return; } } async function waitForStableSkillFile(filePath, stabilityMs, watcher) { if (watcher.closed || stabilityMs <= 0) return; let previous = readFileStabilitySnapshot(filePath); if (!previous) return; let stableForMs = 0; while (stableForMs < stabilityMs) { const delayMs = Math.min(RAW_SKILL_FILE_POLL_INTERVAL_MS, stabilityMs - stableForMs); await new Promise((resolve) => { setTimeout(resolve, delayMs); }); const next = watcher.closed ? void 0 : readFileStabilitySnapshot(filePath); if (!next) return; if (next.size === previous.size && next.mtimeMs === previous.mtimeMs) { stableForMs += delayMs; continue; } previous = next; stableForMs = 0; } } function resolveSkillsWatcherUsePolling() { const envPolling = process.env.CHOKIDAR_USEPOLLING; if (envPolling === void 0) return process.platform === "os400"; const normalized = envPolling.toLowerCase(); if (normalized === "false" || normalized === "0") return false; if (normalized === "true" || normalized === "1") return true; return Boolean(normalized); } function sameWatchTargets(a, b) { return a.length === b.length && a.every((target, index) => target.path === b[index]?.path && target.watchRoot === b[index]?.watchRoot && target.depth === b[index]?.depth); } function createSkillsPathWatcher(target) { const usePolling = resolveSkillsWatcherUsePolling(); const watcher = runInSkillsWatcherContext(() => chokidar.watch(target.watchRoot, { ignoreInitial: true, followSymlinks: false, usePolling, depth: target.depth + path.relative(target.watchRoot, target.path).split(path.sep).filter(Boolean).length, awaitWriteFinish: { stabilityThreshold: SKILLS_WATCH_DEBOUNCE_MS, pollInterval: 100 }, ignored: (watchPath, stats) => !isPathInside(target.path, watchPath) && !isPathInside(watchPath, target.path) || shouldIgnoreSkillsWatchPath(watchPath, stats, { usePolling }) })); const state = { watcher, watchRoot: target.watchRoot, depth: target.depth, subscribers: /* @__PURE__ */ new Set() }; const schedule = (changedPath) => { if (watcher.closed) return; state.pendingPath = changedPath ?? state.pendingPath; if (state.timer) clearTimeout(state.timer); state.timer = setTimeout(() => { const pendingPath = state.pendingPath; state.pendingPath = void 0; state.timer = void 0; for (const watcherKey of state.subscribers) { workspaceWatchTargetCache.delete(watcherKey); bumpSkillsSnapshotVersion({ workspaceDir: workspaceWatchOwnerDirs.get(watcherKey) ?? watcherKey, reason: "watch", changedPath: pendingPath }); } }, SKILLS_WATCH_DEBOUNCE_MS); }; const scheduleRawSkillFile = (changedPath) => { waitForStableSkillFile(changedPath, SKILLS_WATCH_DEBOUNCE_MS, watcher).catch((err) => { log.warn(`skills watcher stability check failed (${changedPath}): ${String(err)}`); }).then(() => schedule(changedPath)); }; watcher.on("all", (_event, changedPath) => { if (isPathInside(target.path, changedPath) || isPathInside(changedPath, target.path)) schedule(changedPath); }); watcher.on("raw", (_eventName, rawPath, details) => { const rawPathText = rawPathToString(rawPath); if (!rawPathText) { const watchedPath = getRawWatchedPath(details); if (watchedPath && isPathInside(target.path, watchedPath)) schedule(watchedPath); return; } const changedPath = resolveRawSkillsWatchPath(rawPathText, details); if (changedPath && isPathInside(target.path, changedPath) && isSkillFileWatchPath(changedPath)) { if (usePolling) return; scheduleRawSkillFile(changedPath); } }); watcher.on("error", (err) => { log.warn(`skills watcher error (${target.path}): ${String(err)}`); }); return state; } async function teardownSkillsPathWatcher(state) { if (state.timer) clearTimeout(state.timer); try { await state.watcher.close(); } catch {} } function subscribeWorkspaceToPath(workspaceDir, watchTarget) { const existing = pathWatchers.get(watchTarget.path); if (existing && existing.watchRoot === watchTarget.watchRoot && existing.depth >= watchTarget.depth) { existing.subscribers.add(workspaceDir); return; } if (existing) { const next = createSkillsPathWatcher({ ...watchTarget, depth: Math.max(existing.depth, watchTarget.depth) }); for (const subscriber of existing.subscribers) next.subscribers.add(subscriber); next.subscribers.add(workspaceDir); teardownSkillsPathWatcher(existing); pathWatchers.set(watchTarget.path, next); return; } const state = createSkillsPathWatcher(watchTarget); state.subscribers.add(workspaceDir); pathWatchers.set(watchTarget.path, state); } function unsubscribeWorkspaceFromPath(workspaceDir, watchTarget) { const state = pathWatchers.get(watchTarget.path); if (!state) return; state.subscribers.delete(workspaceDir); if (state.subscribers.size === 0) { teardownSkillsPathWatcher(state); pathWatchers.delete(watchTarget.path); } } function disposeWorkspaceWatchState(watcherKey, watchTargets = workspaceWatchTargets.get(watcherKey) ?? []) { const workspaceDir = workspaceWatchOwnerDirs.get(watcherKey) ?? watcherKey; const hadWatchTargets = watchTargets.length > 0; for (const watchTarget of watchTargets) unsubscribeWorkspaceFromPath(watcherKey, watchTarget); workspaceWatchTargets.delete(watcherKey); workspaceWatchOwnerDirs.delete(watcherKey); workspaceWatchTargetCache.delete(watcherKey); workspaceWatchLastEnsuredAt.delete(watcherKey); if (hadWatchTargets) bumpSkillsSnapshotVersion({ workspaceDir, reason: "watch-targets" }); clearSkillsSnapshotVersionForWorkspace(workspaceDir); } function evictIdleWorkspaceWatchStates(now) { const cutoff = now - SKILLS_WORKSPACE_WATCH_IDLE_TTL_MS; for (const [workspaceDir, lastEnsuredAt] of workspaceWatchLastEnsuredAt) if (lastEnsuredAt < cutoff) disposeWorkspaceWatchState(workspaceDir); } function ensureSkillsWatcher(params) { const workspaceDir = params.workspaceDir.trim(); if (!workspaceDir) return; const watcherKey = params.executionSkillsDir ? JSON.stringify([workspaceDir, params.executionSkillsDir]) : workspaceDir; workspaceWatchOwnerDirs.set(watcherKey, workspaceDir); const now = Date.now(); const watchEnabled = params.config?.skills?.load?.watch !== false; const previousTargets = workspaceWatchTargets.get(watcherKey) ?? []; if (!watchEnabled) { disposeWorkspaceWatchState(watcherKey, previousTargets); evictIdleWorkspaceWatchStates(now); return; } workspaceWatchLastEnsuredAt.set(watcherKey, now); const watchTargets = resolveWatchTargets(workspaceDir, params.config, params.executionSkillsDir, watcherKey, params.pluginMetadataSnapshot); const targetsUnchanged = sameWatchTargets(previousTargets, watchTargets); const watcherDepthsCoverTargets = watchTargets.every((watchTarget) => (pathWatchers.get(watchTarget.path)?.depth ?? -1) >= watchTarget.depth); if (targetsUnchanged && watcherDepthsCoverTargets) { evictIdleWorkspaceWatchStates(now); return; } const watchTargetsChanged = previousTargets.length > 0 && !targetsUnchanged; const nextTargetKeys = new Set(watchTargets.map((target) => target.path)); for (const watchTarget of previousTargets) if (!nextTargetKeys.has(watchTarget.path)) unsubscribeWorkspaceFromPath(watcherKey, watchTarget); for (const watchTarget of watchTargets) subscribeWorkspaceToPath(watcherKey, watchTarget); workspaceWatchTargets.set(watcherKey, watchTargets); if (watchTargetsChanged) bumpSkillsSnapshotVersion({ workspaceDir, reason: "watch-targets", changedPath: watchTargets.map((target) => target.path).join("|") }); evictIdleWorkspaceWatchStates(now); } async function closeSkillsWatchers(resetState = false) { if (resetState) resetSkillsRefreshStateForTest(); const active = Array.from(pathWatchers.values()); pathWatchers.clear(); workspaceWatchTargets.clear(); workspaceWatchOwnerDirs.clear(); workspaceWatchTargetCache.clear(); workspaceWatchLastEnsuredAt.clear(); await Promise.all(active.map(teardownSkillsPathWatcher)); } if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.skillsRefreshTestApi")] = { resetSkillsRefreshForTest: () => closeSkillsWatchers(true) }; //#endregion export { ensureSkillsWatcher as n, closeSkillsWatchers as t };