denberek_obsidian-agent-fleet/src/repository/entityStore.ts
Denis Berekchiyan 0b5ea13bd0 release: 0.16.0 — Windows stdin fix, robustness hardening, UX polish, internal restructuring
See CHANGELOG.md for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:58:22 -07:00

598 lines
23 KiB
TypeScript

import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import type { FLEET_SUBFOLDERS } from "../constants";
import { asStringArray } from "./shared";
import {
loadFolderAgent,
loadFolderSkill,
parseAgent,
parseChannelFile,
parseSkill,
parseTask,
sidecarPermissionsPath,
} from "./entityParsers";
import type { ParsedEntity, ParserContext } from "./entityParsers";
import type {
AgentConfig,
ChannelConfig,
ChannelCredentialEntry,
FleetSettings,
FleetSnapshot,
McpServer,
SkillConfig,
TaskConfig,
ValidationIssue,
} from "../types";
/** A path-keyed entity map that notifies on every mutation. The store's
* name-keyed lookup indexes are invalidated through this callback, so no
* mutation site (load, reload, delete, clear) can ever be missed — staleness
* is impossible by construction rather than by auditing call sites. */
class MutationTrackedMap<V> extends Map<string, V> {
constructor(private readonly onMutate: () => void) {
super();
}
override set(key: string, value: V): this {
this.onMutate();
return super.set(key, value);
}
override delete(key: string): boolean {
this.onMutate();
return super.delete(key);
}
override clear(): void {
this.onMutate();
super.clear();
}
}
/**
* The entity cluster extracted from the FleetRepository facade: in-memory
* agent/skill/task/channel/MCP-server maps, the load flow (flat + folder
* entities), the two-tier validation-issue bookkeeping, and cross-entity
* reference validation. The facade delegates here and keeps only wiring and
* cross-store operations. Persisting mutations (create/update/delete entity
* files) lives in EntityMutations, which drives this store's maps through
* loadFile/clearStoredFile/validateReferences.
*/
export class EntityStore {
private agents = new MutationTrackedMap<AgentConfig>(() => {
this.nameIndexesStale = true;
});
private skills = new MutationTrackedMap<SkillConfig>(() => {
this.nameIndexesStale = true;
});
private tasks = new MutationTrackedMap<TaskConfig>(() => {
this.nameIndexesStale = true;
});
private channels = new Map<string, ChannelConfig>();
private mcpServers = new Map<string, McpServer>();
/** Load-time issues (parse failures, corrupt permission/sidecar files),
* keyed by file path. Set while loading individual files and cleared only
* when that file is re-parsed or removed. */
private validationIssues = new Map<string, ValidationIssue[]>();
/** Cross-entity REFERENCE issues (missing skill/agent/credential, duplicate
* names), keyed by file path. Kept separate from {@link validationIssues}
* so {@link validateReferences} can be re-run after any mutation: it clears
* and recomputes only this map, never touching (or duplicating alongside)
* the load-time corrupt-file issues. */
private referenceIssues = new Map<string, ValidationIssue[]>();
/** True while loadAll() streams files in — suppresses the per-mutation
* validateReferences() so a bulk load validates once at the end instead of
* once per file. */
private bulkLoading = false;
/** Lazily rebuilt name-keyed indexes over agents/skills/tasks. Keeps the
* FIRST entity per name (insertion order) so duplicate names resolve the
* same way the previous linear `find()` scans did. */
private nameIndexesStale = true;
private agentsByName = new Map<string, AgentConfig>();
private skillsByName = new Map<string, SkillConfig>();
private tasksById = new Map<string, TaskConfig>();
private channelCredentialGetter?: () => Record<string, ChannelCredentialEntry>;
/** Agents we've already logged a folder-model-conflict warning for. */
private warnedFolderAgentModelConflict = new Set<string>();
private warnedLegacyPerms = new Set<string>();
private readonly vault: Vault;
private readonly settings: FleetSettings;
/** Context handed to the extracted entity parsers — same surface the
* parsers had as private facade methods (vault reads, issue reporting into
* {@link validationIssues}, the one-time-warning sets). */
private readonly parserCtx: ParserContext;
constructor(
app: App,
private readonly deps: {
settings: FleetSettings;
getFleetRoot: () => string;
getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string;
/** MCP registry files are parsed by McpRegistry; the store only owns the
* in-memory server map. Routed through the facade so registry parse
* errors keep landing in this store's validation-issue map. */
parseMcpServerFile: (path: string, content: string) => McpServer | null;
},
) {
this.vault = app.vault;
this.settings = deps.settings;
this.parserCtx = {
vault: this.vault,
settings: this.settings,
reportIssue: (path, message) => this.setIssue(path, message),
clearIssue: (path) => {
this.validationIssues.delete(path);
},
warnedLegacyPerms: this.warnedLegacyPerms,
warnedFolderAgentModelConflict: this.warnedFolderAgentModelConflict,
};
}
/** Inject a live credential getter so validation reads from the credential store
* instead of the (possibly empty post-migration) settings.channelCredentials. */
setChannelCredentialGetter(getter: () => Record<string, ChannelCredentialEntry>): void {
this.channelCredentialGetter = getter;
}
async loadAll(): Promise<FleetSnapshot> {
this.agents.clear();
this.skills.clear();
this.tasks.clear();
this.channels.clear();
this.mcpServers.clear();
this.validationIssues.clear();
this.referenceIssues.clear();
this.bulkLoading = true;
try {
// Load folder-based agents and skills first
await this.loadFolderAgents();
await this.loadFolderSkills();
const files = this.vault
.getMarkdownFiles()
.filter((file) => file.path.startsWith(`${this.deps.getFleetRoot()}/`));
for (const file of files) {
await this.loadFile(file);
}
} finally {
this.bulkLoading = false;
}
this.validateReferences();
return this.getSnapshot();
}
async loadFile(fileOrPath: TFile | string): Promise<void> {
await this.loadFileInternal(fileOrPath);
// Entity references may have changed (e.g. an agent's skills list edited on
// disk, a task retargeted) — recompute reference issues immediately instead
// of waiting for the next full loadAll(). Skipped inside loadAll's own loop
// (validated once at the end). Cheap: validateReferences is a pure in-memory
// pass over the loaded maps.
if (!this.bulkLoading) this.validateReferences();
}
private async loadFileInternal(fileOrPath: TFile | string): Promise<void> {
const file = typeof fileOrPath === "string" ? this.vault.getAbstractFileByPath(fileOrPath) : fileOrPath;
if (!(file instanceof TFile) || file.extension !== "md") {
return;
}
// Files inside agent folders need to reload the whole folder agent
if (this.isInsideAgentFolder(file.path)) {
await this.reloadFolderAgentContaining(file.path);
return;
}
// Files inside skill folders need to reload the whole folder skill
if (this.isInsideSkillFolder(file.path)) {
await this.reloadFolderSkillContaining(file.path);
return;
}
this.clearStoredFile(file.path);
// Channel files live under _fleet/channels/ and are parsed through a dedicated
// path that does NOT flow through parseFile(). This keeps channels out of the
// ParsedEntity union (AgentConfig | SkillConfig | TaskConfig) and lets their
// validation (type discrimination, approval_required block) live beside the
// parser.
const channelsPrefix = `${this.deps.getSubfolder("channels")}/`;
if (file.path.startsWith(channelsPrefix)) {
// Skip nested files (e.g. sessions/<id>.json). Only top-level *.md are channel configs.
const rel = file.path.slice(channelsPrefix.length);
if (!rel.includes("/")) {
const content = await this.vault.cachedRead(file);
const channel = parseChannelFile(this.parserCtx, file.path, content);
if (channel) {
this.channels.set(file.path, channel);
}
}
return;
}
// MCP server registry files live under _fleet/mcp/ and, like channels, are
// parsed through a dedicated path that does NOT flow through parseFile()
// (they're not part of the ParsedEntity union).
const mcpPrefix = `${this.deps.getSubfolder("mcp")}/`;
if (file.path.startsWith(mcpPrefix)) {
const rel = file.path.slice(mcpPrefix.length);
if (!rel.includes("/")) {
const content = await this.vault.cachedRead(file);
const server = this.deps.parseMcpServerFile(file.path, content);
if (server) {
this.mcpServers.set(file.path, server);
}
}
return;
}
const content = await this.vault.cachedRead(file);
const parsed = this.parseFile(file.path, content);
if (parsed) {
if ("taskId" in parsed) {
this.tasks.set(file.path, parsed);
} else if ("model" in parsed) {
// Flat agent: sidecar `<name>.permissions.json` wins over legacy
// frontmatter allowed_tools/blocked_tools when both exist.
if (!parsed.isFolder) {
const sidecarPath = sidecarPermissionsPath(file.path);
// clearStoredFile above only clears issues keyed on the .md path;
// drop any stale sidecar issue before re-evaluating the file.
this.validationIssues.delete(sidecarPath);
const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath);
if (sidecarFile instanceof TFile) {
try {
const sidecarContent = await this.vault.cachedRead(sidecarFile);
const data = JSON.parse(sidecarContent) as Record<string, unknown>;
parsed.permissionRules = {
allow: asStringArray(data.allow),
deny: asStringArray(data.deny),
};
} catch (err) {
// Invalid sidecar JSON — keep whatever permissionRules parseAgent assigned.
console.error(
`Agent Fleet: invalid JSON in ${sidecarPath} — agent "${parsed.name}" falls back to legacy frontmatter permission rules`,
err,
);
this.setIssue(
sidecarPath,
`Invalid JSON in ${sidecarPath} for agent \`${parsed.name}\` — sidecar permission rules are NOT applied.`,
);
}
}
}
this.agents.set(file.path, parsed);
} else {
this.skills.set(file.path, parsed);
}
}
}
private async reloadFolderAgentContaining(filePath: string): Promise<void> {
const agentsDir = `${this.deps.getSubfolder("agents")}/`;
const relative = filePath.slice(agentsDir.length);
const folderName = relative.split("/")[0];
if (!folderName) return;
const folderPath = normalizePath(`${agentsDir}${folderName}`);
const agentMdPath = normalizePath(`${folderPath}/agent.md`);
// Clear any existing entry for this folder agent
this.agents.delete(agentMdPath);
const folder = this.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) return;
const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath);
if (!(agentMdFile instanceof TFile)) return;
const agent = await loadFolderAgent(this.parserCtx, folderPath, agentMdFile);
if (agent) {
this.agents.set(agentMdPath, agent);
}
}
private isInsideAgentFolder(path: string): boolean {
const agentsDir = `${this.deps.getSubfolder("agents")}/`;
if (!path.startsWith(agentsDir)) return false;
const relative = path.slice(agentsDir.length);
return relative.includes("/");
}
private isInsideSkillFolder(path: string): boolean {
const skillsDir = `${this.deps.getSubfolder("skills")}/`;
if (!path.startsWith(skillsDir)) return false;
const relative = path.slice(skillsDir.length);
return relative.includes("/");
}
private async reloadFolderSkillContaining(filePath: string): Promise<void> {
const skillsDir = `${this.deps.getSubfolder("skills")}/`;
const relative = filePath.slice(skillsDir.length);
const folderName = relative.split("/")[0];
if (!folderName) return;
const folderPath = normalizePath(`${skillsDir}${folderName}`);
const skillMdPath = normalizePath(`${folderPath}/skill.md`);
this.skills.delete(skillMdPath);
const folder = this.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) return;
const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath);
if (!(skillMdFile instanceof TFile)) return;
const skill = await loadFolderSkill(this.parserCtx, folderPath, skillMdFile);
if (skill) {
this.skills.set(skillMdPath, skill);
}
}
private async loadFolderSkills(): Promise<void> {
const skillsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("skills"));
if (!(skillsFolder instanceof TFolder)) return;
for (const child of skillsFolder.children) {
if (!(child instanceof TFolder)) continue;
const skillMdPath = normalizePath(`${child.path}/skill.md`);
const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath);
if (!(skillMdFile instanceof TFile)) continue;
const skill = await loadFolderSkill(this.parserCtx, child.path, skillMdFile);
if (skill) {
this.skills.set(skillMdPath, skill);
}
}
}
private async loadFolderAgents(): Promise<void> {
const agentsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("agents"));
if (!(agentsFolder instanceof TFolder)) return;
for (const child of agentsFolder.children) {
if (!(child instanceof TFolder)) continue;
const agentMdPath = normalizePath(`${child.path}/agent.md`);
const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath);
if (!(agentMdFile instanceof TFile)) continue;
const agent = await loadFolderAgent(this.parserCtx, child.path, agentMdFile);
if (agent) {
this.agents.set(agentMdPath, agent);
}
}
}
removeFile(path: string): void {
this.clearStoredFile(path);
// A removed entity can orphan references pointing at it — revalidate now.
if (!this.bulkLoading) this.validateReferences();
}
getSnapshot(): FleetSnapshot {
return {
agents: Array.from(this.agents.values()).sort((a, b) => a.name.localeCompare(b.name)),
skills: Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name)),
tasks: Array.from(this.tasks.values()).sort((a, b) => a.taskId.localeCompare(b.taskId)),
channels: Array.from(this.channels.values()).sort((a, b) => a.name.localeCompare(b.name)),
mcpServers: Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)),
validationIssues: [
...Array.from(this.validationIssues.values()).flat(),
...Array.from(this.referenceIssues.values()).flat(),
],
};
}
private rebuildNameIndexes(): void {
if (!this.nameIndexesStale) return;
this.agentsByName.clear();
this.skillsByName.clear();
this.tasksById.clear();
for (const agent of this.agents.values()) {
if (!this.agentsByName.has(agent.name)) this.agentsByName.set(agent.name, agent);
}
for (const skill of this.skills.values()) {
if (!this.skillsByName.has(skill.name)) this.skillsByName.set(skill.name, skill);
}
for (const task of this.tasks.values()) {
if (!this.tasksById.has(task.taskId)) this.tasksById.set(task.taskId, task);
}
this.nameIndexesStale = false;
}
getAgentByName(name: string): AgentConfig | undefined {
this.rebuildNameIndexes();
return this.agentsByName.get(name);
}
getSkillByName(name: string): SkillConfig | undefined {
this.rebuildNameIndexes();
return this.skillsByName.get(name);
}
getTaskById(taskId: string): TaskConfig | undefined {
this.rebuildNameIndexes();
return this.tasksById.get(taskId);
}
getTasksForAgent(agentName: string): TaskConfig[] {
return Array.from(this.tasks.values()).filter((task) => task.agent === agentName);
}
getChannelByName(name: string): ChannelConfig | undefined {
return Array.from(this.channels.values()).find((channel) => channel.name === name);
}
getChannelsForAgent(agentName: string): ChannelConfig[] {
return Array.from(this.channels.values()).filter((channel) => channel.defaultAgent === agentName);
}
/** All registered MCP servers, sorted by name. */
getMcpServers(): McpServer[] {
return Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name));
}
getMcpServerByName(name: string): McpServer | undefined {
return Array.from(this.mcpServers.values()).find((s) => s.name === name);
}
/** Drop every in-memory entity and issue keyed on `path`. Public within the
* repository layer: EntityMutations drives it on delete paths. */
clearStoredFile(path: string): void {
this.agents.delete(path);
this.skills.delete(path);
this.tasks.delete(path);
this.channels.delete(path);
this.mcpServers.delete(path);
this.validationIssues.delete(path);
this.referenceIssues.delete(path);
}
/** Drop every stored issue keyed under `folderPath/` — folder agents/skills
* carry issues keyed on member files (e.g. permissions.json), which
* clearStoredFile(<entity>.md) alone would leave behind after a delete. */
clearIssuesUnder(folderPath: string): void {
const prefix = `${folderPath}/`;
for (const key of [...this.validationIssues.keys()]) {
if (key.startsWith(prefix)) this.validationIssues.delete(key);
}
for (const key of [...this.referenceIssues.keys()]) {
if (key.startsWith(prefix)) this.referenceIssues.delete(key);
}
}
/** Drop both load-time and reference issues keyed on exactly `path` — used
* when deleting a flat agent to clear its permissions-sidecar issues. */
clearIssuesFor(path: string): void {
this.validationIssues.delete(path);
this.referenceIssues.delete(path);
}
/** Record a LOAD-TIME issue (parse failure, corrupt permission/sidecar
* file). Public within the repository layer: McpRegistry reports its parse
* errors here (wired through the facade) so they land in the same map as
* every other parser's. */
setIssue(path: string, message: string): void {
const issues = this.validationIssues.get(path) ?? [];
issues.push({ path, message });
this.validationIssues.set(path, issues);
}
/** Record a cross-entity reference issue. Lives in {@link referenceIssues}
* (recomputed wholesale by validateReferences), NOT in the load-time
* {@link validationIssues} map. */
private setReferenceIssue(path: string, message: string): void {
const issues = this.referenceIssues.get(path) ?? [];
issues.push({ path, message });
this.referenceIssues.set(path, issues);
}
private parseFile(path: string, content: string): ParsedEntity | null {
if (path.startsWith(`${this.deps.getSubfolder("agents")}/`)) {
return parseAgent(this.parserCtx, path, content);
}
if (path.startsWith(`${this.deps.getSubfolder("skills")}/`)) {
return parseSkill(this.parserCtx, path, content);
}
if (path.startsWith(`${this.deps.getSubfolder("tasks")}/`)) {
return parseTask(this.parserCtx, path, content);
}
return null;
}
/**
* Recompute every cross-entity reference issue from the in-memory maps.
* Pure in-memory pass (no vault I/O — only the loaded maps plus the injected
* credential getter), so it is cheap enough to re-run after every mutation.
* Idempotent: {@link referenceIssues} is cleared and rebuilt wholesale each
* run; load-time corrupt-file issues live in the separate
* {@link validationIssues} map and are never touched here.
*
* Public within the repository layer: EntityMutations re-runs it after each
* delete once all map mutations are done.
*/
validateReferences(): void {
this.referenceIssues.clear();
const skillNames = new Set<string>();
for (const skill of this.skills.values()) {
if (skillNames.has(skill.name)) {
this.setReferenceIssue(skill.filePath, `Duplicate skill name \`${skill.name}\`.`);
}
skillNames.add(skill.name);
}
const agentNames = new Set<string>();
for (const agent of this.agents.values()) {
if (agentNames.has(agent.name)) {
this.setReferenceIssue(agent.filePath, `Duplicate agent name \`${agent.name}\`.`);
}
agentNames.add(agent.name);
for (const skillName of agent.skills) {
if (!skillNames.has(skillName)) {
this.setReferenceIssue(agent.filePath, `Agent references missing skill \`${skillName}\`.`);
}
}
}
for (const task of this.tasks.values()) {
if (!agentNames.has(task.agent)) {
this.setReferenceIssue(task.filePath, `Task references missing agent \`${task.agent}\`.`);
}
}
const channelNames = new Set<string>();
const agentsByName = new Map<string, AgentConfig>();
for (const agent of this.agents.values()) {
agentsByName.set(agent.name, agent);
}
const credentials = this.channelCredentialGetter?.() ?? this.settings.channelCredentials ?? {};
for (const channel of this.channels.values()) {
if (channelNames.has(channel.name)) {
this.setReferenceIssue(channel.filePath, `Duplicate channel name \`${channel.name}\`.`);
}
channelNames.add(channel.name);
const boundAgent = agentsByName.get(channel.defaultAgent);
if (!boundAgent) {
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` references missing agent \`${channel.defaultAgent}\`.`,
);
} else if (boundAgent.approvalRequired.length > 0) {
// Channels have no approval UI; a channel turn needing approval would deadlock.
// Block the binding at load time — the channel loads but is treated as disabled.
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` cannot bind to agent \`${boundAgent.name}\` because the agent has \`approval_required\` set (${boundAgent.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`,
);
}
const credential = credentials[channel.credentialRef];
if (!credential) {
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` references missing credential \`${channel.credentialRef}\`. Add it under Settings → Channel Credentials.`,
);
} else if (credential.type !== channel.type) {
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` is type \`${channel.type}\` but credential \`${channel.credentialRef}\` is type \`${credential.type}\`.`,
);
}
}
const mcpNames = new Set<string>();
for (const server of this.mcpServers.values()) {
if (mcpNames.has(server.name)) {
this.setReferenceIssue(server.filePath ?? server.name, `Duplicate MCP server name \`${server.name}\`.`);
}
mcpNames.add(server.name);
}
}
}