feat(FN-3033): align agent asset directory naming and heartbeat path compat
The merge delivers three major features: a droid CLI path reconciliation extension that resolves workspace path mismatches for spawned agents, session-first quick chat with improved heartbeat prompts and a dramatically simplified QuickChatFAB component, and canonical agent asset directory naming wit Fusion-Task-Id: FN-3033
This commit is contained in:
@@ -3,6 +3,11 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, access } from "node:fs/promise
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
import {
|
||||
getCanonicalAgentInstructionsBundleDirName,
|
||||
getLegacyAgentInstructionsBundleDirName,
|
||||
getSafeAgentAssetIdSegment,
|
||||
} from "../types.js";
|
||||
|
||||
describe("AgentStore — instructions bundle", () => {
|
||||
let testDir: string;
|
||||
@@ -62,7 +67,9 @@ describe("AgentStore — instructions bundle", () => {
|
||||
it("getInstructionsDir returns the managed bundle directory path", async () => {
|
||||
const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
|
||||
createdAgentIds.push(agent.id);
|
||||
expect(store.getInstructionsDir(agent.id)).toBe(join(testDir, "agents", `${agent.id}-instructions`));
|
||||
expect(store.getInstructionsDir(agent.id)).toBe(
|
||||
join(testDir, "agents", getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id)),
|
||||
);
|
||||
});
|
||||
|
||||
it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => {
|
||||
@@ -273,4 +280,27 @@ describe("AgentStore — instructions bundle", () => {
|
||||
expect(migrated.instructionsText).toBeUndefined();
|
||||
expect(migrated.instructionsPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses existing legacy id-only instructions directory when present", async () => {
|
||||
const agent = await store.createAgent({ name: "Legacy Bundle", role: "executor" });
|
||||
createdAgentIds.push(agent.id);
|
||||
|
||||
const legacyDir = join(testDir, "agents", getLegacyAgentInstructionsBundleDirName(agent.id));
|
||||
await mkdir(legacyDir, { recursive: true });
|
||||
await writeFile(join(legacyDir, "AGENTS.md"), "legacy content", "utf-8");
|
||||
|
||||
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("legacy content");
|
||||
});
|
||||
|
||||
it("uses previously-created display-name instructions directory for same id", async () => {
|
||||
const agent = await store.createAgent({ name: "Current Name", role: "executor" });
|
||||
createdAgentIds.push(agent.id);
|
||||
|
||||
const priorDirName = `previous-name-${getSafeAgentAssetIdSegment(agent.id)}-instructions`;
|
||||
const priorDir = join(testDir, "agents", priorDirName);
|
||||
await mkdir(priorDir, { recursive: true });
|
||||
await writeFile(join(priorDir, "AGENTS.md"), "existing display path", "utf-8");
|
||||
|
||||
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("existing display path");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,12 @@ import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { CheckoutConflictError, type AgentCapability, type AgentState } from "../types.js";
|
||||
import {
|
||||
CheckoutConflictError,
|
||||
getCanonicalAgentAssetDirectoryName,
|
||||
type AgentCapability,
|
||||
type AgentState,
|
||||
} from "../types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
|
||||
@@ -150,6 +155,27 @@ describe("AgentStore", () => {
|
||||
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("defaults heartbeat procedure path to canonical display-name directory", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "CEO",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
|
||||
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
|
||||
});
|
||||
|
||||
it("falls back to id-based segment when display-name slug is empty", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "!!!",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
|
||||
expect(expectedDir).toContain("agent-");
|
||||
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
|
||||
});
|
||||
|
||||
it("preserves custom metadata", async () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "With Meta",
|
||||
|
||||
@@ -38,7 +38,19 @@ import type {
|
||||
Task,
|
||||
AgentLogEntry,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js";
|
||||
import {
|
||||
AGENT_VALID_TRANSITIONS,
|
||||
agentToConfigSnapshot,
|
||||
diffConfigSnapshots,
|
||||
isEphemeralAgent,
|
||||
CheckoutConflictError,
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
getDefaultHeartbeatProcedurePath,
|
||||
getCanonicalAgentInstructionsBundleDirName,
|
||||
getLegacyAgentAssetDirectoryName,
|
||||
getLegacyAgentInstructionsBundleDirName,
|
||||
getSafeAgentAssetIdSegment,
|
||||
} from "./types.js";
|
||||
import type { RunMutationContext } from "./types.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
@@ -262,7 +274,7 @@ export class AgentStore extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id);
|
||||
const newRelPath = await this.resolveCompatibleHeartbeatProcedurePath(agent);
|
||||
const newAbsPath = join(this.rootDir, "..", newRelPath);
|
||||
|
||||
// Best-effort copy of operator edits to the new per-agent location.
|
||||
@@ -468,7 +480,7 @@ export class AgentStore extends EventEmitter {
|
||||
// don't need persistent procedure files.
|
||||
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
|
||||
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
|
||||
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId));
|
||||
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name));
|
||||
|
||||
const agent: Agent = {
|
||||
id: agentId,
|
||||
@@ -742,7 +754,9 @@ export class AgentStore extends EventEmitter {
|
||||
* Does not create the directory.
|
||||
*/
|
||||
getInstructionsDir(agentId: string): string {
|
||||
return this.getBundleDir(agentId);
|
||||
const agent = this.readAgent(agentId);
|
||||
const agentName = agent?.name ?? "";
|
||||
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agentName, agentId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -750,7 +764,7 @@ export class AgentStore extends EventEmitter {
|
||||
* Returns [] when the bundle directory does not exist.
|
||||
*/
|
||||
async listBundleFiles(agentId: string): Promise<string[]> {
|
||||
const bundleDir = this.getBundleDir(agentId);
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||
|
||||
try {
|
||||
const entries = await readdir(bundleDir, { withFileTypes: true });
|
||||
@@ -771,7 +785,8 @@ export class AgentStore extends EventEmitter {
|
||||
*/
|
||||
async readBundleFile(agentId: string, filePath: string): Promise<string> {
|
||||
this.validateBundleFilePath(filePath);
|
||||
const resolvedPath = join(this.getBundleDir(agentId), filePath);
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||
const resolvedPath = join(bundleDir, filePath);
|
||||
return readFile(resolvedPath, "utf-8");
|
||||
}
|
||||
|
||||
@@ -782,7 +797,7 @@ export class AgentStore extends EventEmitter {
|
||||
return this.withLock(agentId, async () => {
|
||||
this.validateBundleFilePath(filePath);
|
||||
|
||||
const bundleDir = this.getBundleDir(agentId);
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agentId, true);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const existingFiles = await this.listBundleFiles(agentId);
|
||||
@@ -804,7 +819,8 @@ export class AgentStore extends EventEmitter {
|
||||
async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
|
||||
return this.withLock(agentId, async () => {
|
||||
this.validateBundleFilePath(filePath);
|
||||
await unlink(join(this.getBundleDir(agentId), filePath));
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||
await unlink(join(bundleDir, filePath));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -831,7 +847,7 @@ export class AgentStore extends EventEmitter {
|
||||
const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig });
|
||||
|
||||
if (normalizedConfig.mode === "managed") {
|
||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
||||
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
|
||||
}
|
||||
|
||||
return updated;
|
||||
@@ -860,7 +876,7 @@ export class AgentStore extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
||||
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
@@ -2057,8 +2073,108 @@ export class AgentStore extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getBundleDir(agentId: string): string {
|
||||
return join(this.agentsDir, `${agentId}-instructions`);
|
||||
private getCanonicalBundleDir(agent: Agent): string {
|
||||
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id));
|
||||
}
|
||||
|
||||
private getLegacyBundleDir(agentId: string): string {
|
||||
return join(this.agentsDir, getLegacyAgentInstructionsBundleDirName(agentId));
|
||||
}
|
||||
|
||||
private async resolveCompatibleBundleDir(agentId: string, createIfMissing: boolean): Promise<string> {
|
||||
const agent = this.readAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const canonicalDir = this.getCanonicalBundleDir(agent);
|
||||
if (await this.pathExists(canonicalDir)) {
|
||||
return canonicalDir;
|
||||
}
|
||||
|
||||
const compatibleDir = await this.findExistingDisplayNameBundleDir(agent);
|
||||
if (compatibleDir) {
|
||||
return compatibleDir;
|
||||
}
|
||||
|
||||
const legacyDir = this.getLegacyBundleDir(agent.id);
|
||||
if (await this.pathExists(legacyDir)) {
|
||||
return legacyDir;
|
||||
}
|
||||
|
||||
return createIfMissing ? canonicalDir : canonicalDir;
|
||||
}
|
||||
|
||||
private async findExistingDisplayNameBundleDir(agent: Agent): Promise<string | null> {
|
||||
const safeId = getSafeAgentAssetIdSegment(agent.id);
|
||||
try {
|
||||
const entries = await readdir(this.agentsDir, { withFileTypes: true });
|
||||
const candidates = entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith("-instructions"))
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => {
|
||||
const base = name.slice(0, -"-instructions".length);
|
||||
return base.endsWith(`-${safeId}`);
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalName = getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id);
|
||||
const selected = candidates.find((candidate) => candidate === canonicalName) ?? candidates[0];
|
||||
return join(this.agentsDir, selected);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCompatibleHeartbeatProcedurePath(agent: Agent): Promise<string> {
|
||||
const canonicalPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
|
||||
const canonicalAbs = join(this.rootDir, "..", canonicalPath);
|
||||
if (await this.pathExists(canonicalAbs)) {
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
const safeId = getSafeAgentAssetIdSegment(agent.id);
|
||||
try {
|
||||
const entries = await readdir(this.agentsDir, { withFileTypes: true });
|
||||
const compatibleDir = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.find((name) => name.endsWith(`-${safeId}`));
|
||||
if (compatibleDir) {
|
||||
const candidatePath = `.fusion/agents/${compatibleDir}/HEARTBEAT.md`;
|
||||
if (await this.pathExists(join(this.rootDir, "..", candidatePath))) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const legacyPath = `.fusion/agents/${getLegacyAgentAssetDirectoryName(agent.id)}/HEARTBEAT.md`;
|
||||
const legacyAbs = join(this.rootDir, "..", legacyPath);
|
||||
if (await this.pathExists(legacyAbs)) {
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
private async pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private validateBundleFilePath(filePath: string): void {
|
||||
|
||||
@@ -3263,20 +3263,71 @@ export interface AgentConfigRevision {
|
||||
*/
|
||||
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
|
||||
|
||||
function slugifyAgentAssetSegment(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function getSafeAgentAssetIdSegment(agentId: string): string {
|
||||
const slug = slugifyAgentAssetSegment(agentId);
|
||||
return slug || "agent";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical per-agent asset directory segment.
|
||||
*
|
||||
* Canonical format: `<slugged-display-name>-<safe-agent-id>`.
|
||||
* Example: `CEO` + `agent2736` => `ceo-agent2736`.
|
||||
*
|
||||
* If the display-name slug is empty (for example name has only symbols), the
|
||||
* id-derived segment is used as the directory prefix so the result is always
|
||||
* filesystem-safe and non-empty.
|
||||
*/
|
||||
export function getCanonicalAgentAssetDirectoryName(agentName: string, agentId: string): string {
|
||||
if (!agentId || typeof agentId !== "string") {
|
||||
throw new Error("getCanonicalAgentAssetDirectoryName requires a non-empty agentId");
|
||||
}
|
||||
const safeId = getSafeAgentAssetIdSegment(agentId);
|
||||
const nameSlug = slugifyAgentAssetSegment(agentName ?? "");
|
||||
const prefix = nameSlug || safeId;
|
||||
return `${prefix}-${safeId}`;
|
||||
}
|
||||
|
||||
/** Legacy per-agent asset directory segment used by older builds. */
|
||||
export function getLegacyAgentAssetDirectoryName(agentId: string): string {
|
||||
if (!agentId || typeof agentId !== "string") {
|
||||
throw new Error("getLegacyAgentAssetDirectoryName requires a non-empty agentId");
|
||||
}
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/** Canonical managed instruction bundle directory name for an agent. */
|
||||
export function getCanonicalAgentInstructionsBundleDirName(agentName: string, agentId: string): string {
|
||||
return `${getCanonicalAgentAssetDirectoryName(agentName, agentId)}-instructions`;
|
||||
}
|
||||
|
||||
/** Legacy managed instruction bundle directory name used by older builds. */
|
||||
export function getLegacyAgentInstructionsBundleDirName(agentId: string): string {
|
||||
return `${getLegacyAgentAssetDirectoryName(agentId)}-instructions`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the project-relative default heartbeat procedure file path for a
|
||||
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
|
||||
* can tune the per-tick procedure without changes leaking across the team.
|
||||
*
|
||||
* The path is laid out under `.fusion/agents/<agentId>/HEARTBEAT.md` so it
|
||||
* lives alongside any other future per-agent assets and survives agent
|
||||
* renames (which do not change the immutable agent id).
|
||||
* The path is laid out under `.fusion/agents/<canonical-agent-dir>/HEARTBEAT.md`.
|
||||
*/
|
||||
export function getDefaultHeartbeatProcedurePath(agentId: string): string {
|
||||
export function getDefaultHeartbeatProcedurePath(agentId: string, agentName?: string): string {
|
||||
if (!agentId || typeof agentId !== "string") {
|
||||
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
|
||||
}
|
||||
return `.fusion/agents/${agentId}/HEARTBEAT.md`;
|
||||
const directory = agentName
|
||||
? getCanonicalAgentAssetDirectoryName(agentName, agentId)
|
||||
: getLegacyAgentAssetDirectoryName(agentId);
|
||||
return `.fusion/agents/${directory}/HEARTBEAT.md`;
|
||||
}
|
||||
|
||||
/** Extract trackable config fields from an Agent into a snapshot */
|
||||
|
||||
Reference in New Issue
Block a user