mirror of
https://github.com/cline/cline.git
synced 2026-10-13 09:09:43 +08:00
fix(core): automatically reload symlinked instruction targets
This commit is contained in:
+13
@@ -347,6 +347,19 @@ standalone adapters do this in the `errorCode` dimension, and use the bounded
|
||||
labels `signal` and `no_exit_code` for `CommandTerminationError`. Only an actual
|
||||
numeric exit is reported as `exitCode`.
|
||||
|
||||
## Symlinked instruction files
|
||||
|
||||
Rules and workflows can be symbolic links to regular files. Source paths and
|
||||
fallback names use the link's name and location, not the target's. Broken,
|
||||
circular, and non-file targets are not loaded.
|
||||
|
||||
When started, `UnifiedConfigFileWatcher` also watches the parent directories of
|
||||
symlink targets and intermediate links. External edits, atomic saves, link
|
||||
retargeting, and target deletion/recreation automatically refresh the affected
|
||||
config types. Broken targets can recover while their parent directories exist
|
||||
and are accessible. Unused target-directory watches are removed on rediscovery;
|
||||
`stop()` closes all watches.
|
||||
|
||||
## Unknown model completion recovery
|
||||
|
||||
The AI SDK adapter maps unified `length` to `max-tokens` and missing or unrecognized reasons (including `other`) to `unknown`. Explicit `stop`, `tool-calls`, `content-filter`, and `error` retain their meanings. The agent also treats a stream without a finish event as unknown.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
@@ -126,6 +128,132 @@ describe("UnifiedConfigFileWatcher", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"automatically reloads symlink chains after edits, atomic saves, deletion, recreation, and retargeting",
|
||||
async () => {
|
||||
const tempRoot = await realpath(
|
||||
await mkdtemp(join(tmpdir(), "core-config-symlink-watch-")),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const profilesDir = join(tempRoot, "profiles");
|
||||
const linksDir = join(tempRoot, "links");
|
||||
const sourceDir = join(tempRoot, "source");
|
||||
const alternateDir = join(tempRoot, "alternate");
|
||||
const linkedDirectory = join(tempRoot, "nested", "linked-directory");
|
||||
for (const dir of [profilesDir, linksDir, sourceDir, alternateDir]) {
|
||||
await mkdir(dir);
|
||||
}
|
||||
await mkdir(join(tempRoot, "nested"));
|
||||
await symlink(linksDir, linkedDirectory, "dir");
|
||||
const sourcePath = join(sourceDir, "source.profile");
|
||||
const alternatePath = join(alternateDir, "source.profile");
|
||||
const intermediatePath = join(linksDir, "alias.profile");
|
||||
const linkedPath = join(profilesDir, "linked.profile");
|
||||
await writeFile(sourcePath, "name: Linked\n\nOriginal content.");
|
||||
await writeFile(alternatePath, "name: Linked\n\nAlternate content.");
|
||||
await symlink(
|
||||
join("..", "source", "source.profile"),
|
||||
intermediatePath,
|
||||
"file",
|
||||
);
|
||||
// The intermediate link's relative target must resolve against its
|
||||
// physical parent, not against this directory alias's lexical path.
|
||||
await symlink(join(linkedDirectory, "alias.profile"), linkedPath, "file");
|
||||
const watcher = new UnifiedConfigFileWatcher(
|
||||
[
|
||||
{
|
||||
type: "profile" as const,
|
||||
directories: [profilesDir],
|
||||
includeFile: (fileName) => fileName.endsWith(".profile"),
|
||||
parseFile: (context) => parseTestProfileConfig(context.content),
|
||||
resolveId: (config) => config.name.toLowerCase(),
|
||||
},
|
||||
],
|
||||
{ debounceMs: 10 },
|
||||
);
|
||||
const events: Array<
|
||||
UnifiedConfigWatcherEvent<"profile", TestProfileConfig>
|
||||
> = [];
|
||||
const unsubscribe = watcher.subscribe((event) => events.push(event));
|
||||
const watchedDirectories = () =>
|
||||
new Set(
|
||||
(
|
||||
Reflect.get(watcher, "watchersByDirectory") as Map<string, unknown>
|
||||
).keys(),
|
||||
);
|
||||
const waitForBody = async (body: string) => {
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.kind === "upsert" && event.record.item.body === body,
|
||||
);
|
||||
expect(watcher.getSnapshot("profile").get("linked")).toMatchObject({
|
||||
filePath: linkedPath,
|
||||
item: { body },
|
||||
});
|
||||
events.length = 0;
|
||||
};
|
||||
try {
|
||||
await watcher.start();
|
||||
expect(watchedDirectories()).toEqual(
|
||||
new Set([profilesDir, linkedDirectory, sourceDir]),
|
||||
);
|
||||
events.length = 0;
|
||||
await writeFile(sourcePath, "name: Linked\n\nEdited content.");
|
||||
await waitForBody("Edited content.");
|
||||
|
||||
const savedPath = join(sourceDir, "saved.profile");
|
||||
await writeFile(savedPath, "name: Linked\n\nAtomically saved content.");
|
||||
await rename(savedPath, sourcePath);
|
||||
await waitForBody("Atomically saved content.");
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
"name: Linked\n\nEdited after atomic save.",
|
||||
);
|
||||
await waitForBody("Edited after atomic save.");
|
||||
|
||||
await unlink(sourcePath);
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.kind === "remove" && event.id === "linked",
|
||||
);
|
||||
expect(watcher.getSnapshot("profile").size).toBe(0);
|
||||
events.length = 0;
|
||||
await writeFile(sourcePath, "name: Linked\n\nRecreated content.");
|
||||
await waitForBody("Recreated content.");
|
||||
|
||||
await unlink(intermediatePath);
|
||||
await symlink(alternatePath, intermediatePath, "file");
|
||||
await waitForBody("Alternate content.");
|
||||
expect(watchedDirectories()).toEqual(
|
||||
new Set([profilesDir, linkedDirectory, alternateDir]),
|
||||
);
|
||||
await writeFile(
|
||||
alternatePath,
|
||||
"name: Linked\n\nEdited alternate content.",
|
||||
);
|
||||
await waitForBody("Edited alternate content.");
|
||||
|
||||
await unlink(linkedPath);
|
||||
await symlink(sourcePath, linkedPath, "file");
|
||||
await waitForBody("Recreated content.");
|
||||
expect(watchedDirectories()).toEqual(new Set([profilesDir, sourceDir]));
|
||||
await writeFile(sourcePath, "name: Linked\n\nEdited direct target.");
|
||||
await waitForBody("Edited direct target.");
|
||||
await unlink(linkedPath);
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.kind === "remove" && event.id === "linked",
|
||||
);
|
||||
expect(watchedDirectories()).toEqual(new Set([profilesDir]));
|
||||
} finally {
|
||||
watcher.stop();
|
||||
unsubscribe();
|
||||
expect(watchedDirectories().size).toBe(0);
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
it("emits upsert and remove events with config type", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-unified-config-watcher-"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { type FSWatcher, watch } from "node:fs";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
export interface UnifiedConfigFileContext<TType extends string = string> {
|
||||
type: TType;
|
||||
@@ -91,6 +91,32 @@ function isInaccessibleDirectoryError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function discoverSymlinkTargetDirectories(
|
||||
filePath: string,
|
||||
): Promise<Set<string>> {
|
||||
const directories = new Set<string>();
|
||||
const visitedPaths = new Set<string>();
|
||||
let currentPath = resolve(filePath);
|
||||
while (!visitedPaths.has(currentPath)) {
|
||||
visitedPaths.add(currentPath);
|
||||
const target = await readlink(currentPath).catch(() => undefined);
|
||||
if (target === undefined) {
|
||||
break;
|
||||
}
|
||||
// Relative link targets resolve from the physical parent directory,
|
||||
// which can differ from dirname(currentPath) through directory aliases.
|
||||
const parentPath = await realpath(dirname(currentPath)).catch(() =>
|
||||
dirname(currentPath),
|
||||
);
|
||||
currentPath = resolve(parentPath, target);
|
||||
// Watch parents rather than file inodes so atomic saves and target
|
||||
// deletion/recreation keep working. Include intermediate links so
|
||||
// retargeting a symlink chain triggers rediscovery too.
|
||||
directories.add(dirname(currentPath));
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
export class UnifiedConfigFileWatcher<
|
||||
TType extends string = string,
|
||||
TItem = unknown,
|
||||
@@ -413,6 +439,22 @@ export class UnifiedConfigFileWatcher<
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const targetDirectories =
|
||||
await discoverSymlinkTargetDirectories(filePath);
|
||||
if (targetDirectories.size > 0) {
|
||||
const targetStat = await stat(filePath).catch(() => undefined);
|
||||
if (targetStat && !targetStat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
for (const targetDirectory of targetDirectories) {
|
||||
discoveredDirectories.add(targetDirectory);
|
||||
}
|
||||
if (!targetStat) {
|
||||
// Keep watching a broken link's target parent so recreating
|
||||
// the file loads it again without a manual refresh.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const context: UnifiedConfigFileContext<TType> = {
|
||||
@@ -478,13 +520,9 @@ export class UnifiedConfigFileWatcher<
|
||||
const candidates: UnifiedConfigFileCandidate[] = [];
|
||||
for (const entry of entries) {
|
||||
const filePath = join(directoryPath, entry.name);
|
||||
const isFile =
|
||||
entry.isFile() ||
|
||||
(entry.isSymbolicLink() &&
|
||||
(await stat(filePath)
|
||||
.then((target) => target.isFile())
|
||||
.catch(() => false)));
|
||||
if (!isFile) {
|
||||
// Retain broken links as candidates so loadDefinition can watch
|
||||
// their targets without trying to parse them.
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
|
||||
@@ -422,6 +422,92 @@ Escalation runbook`,
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["rule", "workflow"] as const)(
|
||||
"automatically reloads symlinked %s files when external targets change",
|
||||
async (type) => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-instruction-symlink-watch-"),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const configDir = join(tempRoot, "configs");
|
||||
const externalDir = join(tempRoot, "external");
|
||||
await mkdir(configDir);
|
||||
await mkdir(externalDir);
|
||||
const sourcePath = join(externalDir, "source.md");
|
||||
const linkedPath = join(configDir, "linked.md");
|
||||
await writeFile(sourcePath, "Original instructions.");
|
||||
await symlink(sourcePath, linkedPath, "file");
|
||||
const watcher = createUserInstructionConfigWatcher({
|
||||
skills: { directories: [] },
|
||||
rules: { directories: type === "rule" ? [configDir] : [] },
|
||||
workflows: { directories: type === "workflow" ? [configDir] : [] },
|
||||
debounceMs: 10,
|
||||
});
|
||||
const events: UserInstructionConfigWatcherEvent[] = [];
|
||||
const unsubscribe = watcher.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await watcher.start();
|
||||
events.length = 0;
|
||||
await writeFile(sourcePath, "Updated instructions.");
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.kind === "upsert" &&
|
||||
event.record.type === type &&
|
||||
event.record.item.instructions === "Updated instructions.",
|
||||
);
|
||||
expect(watcher.getSnapshot(type).get("linked")).toMatchObject({
|
||||
filePath: linkedPath,
|
||||
item: { instructions: "Updated instructions." },
|
||||
});
|
||||
} finally {
|
||||
watcher.stop();
|
||||
unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"loads an initially broken explicit AGENTS.md symlink when its target is created",
|
||||
async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-agents-symlink-watch-"),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const externalDir = join(tempRoot, "external");
|
||||
await mkdir(externalDir);
|
||||
const sourcePath = join(externalDir, "source.md");
|
||||
const agentsPath = join(tempRoot, "AGENTS.md");
|
||||
await symlink(sourcePath, agentsPath, "file");
|
||||
const watcher = createUserInstructionConfigWatcher({
|
||||
skills: { directories: [] },
|
||||
rules: { directories: [agentsPath], workspacePath: tempRoot },
|
||||
workflows: { directories: [] },
|
||||
debounceMs: 10,
|
||||
});
|
||||
const events: UserInstructionConfigWatcherEvent[] = [];
|
||||
const unsubscribe = watcher.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await watcher.start();
|
||||
expect(watcher.getSnapshot("rule").size).toBe(0);
|
||||
await writeFile(sourcePath, "Created instructions.");
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.kind === "upsert" && event.record.type === "rule",
|
||||
);
|
||||
expect(
|
||||
watcher.getSnapshot("rule").get("workspace agents.md"),
|
||||
).toMatchObject({
|
||||
filePath: agentsPath,
|
||||
item: { instructions: "Created instructions." },
|
||||
});
|
||||
} finally {
|
||||
watcher.stop();
|
||||
unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"discovers skill directories through symlinks",
|
||||
async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
@@ -520,8 +520,14 @@ async function discoverRulesLikeFiles(
|
||||
}
|
||||
|
||||
try {
|
||||
const entryStat = await stat(directoryPath);
|
||||
if (entryStat.isFile()) {
|
||||
const entryStat = await lstat(directoryPath);
|
||||
const isFile =
|
||||
entryStat.isFile() ||
|
||||
(entryStat.isSymbolicLink() &&
|
||||
!(await stat(directoryPath)
|
||||
.then((target) => target.isDirectory())
|
||||
.catch(() => false)));
|
||||
if (isFile) {
|
||||
return [
|
||||
{
|
||||
directoryPath: dirname(directoryPath),
|
||||
@@ -540,19 +546,15 @@ async function discoverRulesLikeFiles(
|
||||
const entries = await readdir(directoryPath, { withFileTypes: true });
|
||||
const candidates: UnifiedConfigFileCandidate[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry.name)) {
|
||||
if (
|
||||
!isMarkdownFile(entry.name) ||
|
||||
(!entry.isFile() && !entry.isSymbolicLink())
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directoryPath, entry.name);
|
||||
const isFile =
|
||||
entry.isFile() ||
|
||||
(entry.isSymbolicLink() &&
|
||||
(await stat(filePath)
|
||||
.then((target) => target.isFile())
|
||||
.catch(() => false)));
|
||||
if (!isFile) {
|
||||
continue;
|
||||
}
|
||||
// The watcher validates symlink targets and watches their parents,
|
||||
// including broken links whose targets may be recreated later.
|
||||
candidates.push({
|
||||
directoryPath,
|
||||
fileName: entry.name,
|
||||
|
||||
Reference in New Issue
Block a user