feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,925 @@
/**
* Mock gateways implementing every UI port in-memory, with no backend. They let
* the frontend run and be tested fully offline (ARCHITECTURE §1.3, §11) and are
* selected by the DI provider when `VITE_USE_MOCK` is set.
*/
import type {
Agent,
AgentDrift,
AgentProfile,
DomainEvent,
FirstRunState,
GatewayError,
GitBranches,
GitCommit,
GitFileStatus,
GraphCommit,
HealthReport,
LayoutInfo,
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
Project,
ProfileAvailability,
Template,
Unsubscribe,
} from "@/domain";
import type {
AgentGateway,
CreateAgentInput,
CreateTemplateInput,
Gateways,
GitGateway,
LayoutGateway,
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
RemoteGateway,
SystemGateway,
TemplateGateway,
TerminalGateway,
TerminalHandle,
} from "@/ports";
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>();
async health(note?: string): Promise<HealthReport> {
const report: HealthReport = {
version: "0.1.0-mock",
alive: true,
timeMillis: Date.now(),
correlationId: `mock-${Math.random().toString(36).slice(2, 10)}`,
note: note ?? null,
};
// Emit a smoke event so subscribers can be exercised offline too.
this.emit({ type: "projectCreated", projectId: report.correlationId });
return report;
}
async onDomainEvent(
handler: (event: DomainEvent) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
/** Test/dev helper to push an event to all subscribers. */
emit(event: DomainEvent): void {
for (const l of this.listeners) l(event);
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickFolder(): Promise<string | null> {
return "/home/user/mock-project";
}
}
/**
* Slugifies a display name into a safe file stem (`[a-z0-9-]`), collapsing
* runs of non-alphanumeric characters into a single dash, mirroring the
* backend `slugify` logic.
*/
function slugify(name: string): string {
let out = "";
let prevDash = false;
for (const ch of name.trim()) {
if (/[a-zA-Z0-9]/.test(ch)) {
out += ch.toLowerCase();
prevDash = false;
} else if (!prevDash) {
out += "-";
prevDash = true;
}
}
return out.replace(/^-+|-+$/g, "");
}
/**
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
* `LaunchAgent` use cases, keyed per `projectId` (ARCHITECTURE §11).
*
* Exported so tests can instantiate it directly (same pattern as
* {@link MockProjectGateway}).
*/
export class MockAgentGateway implements AgentGateway {
/** Agents indexed by projectId. */
private agents = new Map<string, Agent[]>();
/** Context content indexed by `${projectId}::${agentId}`. */
private contexts = new Map<string, string>();
/** Monotonic session counter for deterministic session ids in tests. */
private sessionSeq = 0;
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
return this.agents.get(projectId)!;
}
private contextKey(projectId: string, agentId: string): string {
return `${projectId}::${agentId}`;
}
async listAgents(projectId: string): Promise<Agent[]> {
return structuredClone(this.getAgents(projectId));
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
const list = this.getAgents(projectId);
const slug = slugify(input.name) || "agent";
// Derive a unique agents/<slug>.md path, disambiguating with -2, -3, …
const existingPaths = new Set(list.map((a) => a.contextPath));
let candidate = `agents/${slug}.md`;
let n = 2;
while (existingPaths.has(candidate)) {
candidate = `agents/${slug}-${n}.md`;
n += 1;
}
const agent: Agent = {
id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
name: input.name,
contextPath: candidate,
profileId: input.profileId,
origin: { type: "scratch" },
synchronized: false,
};
list.push(agent);
this.contexts.set(
this.contextKey(projectId, agent.id),
input.initialContent ?? "",
);
return structuredClone(agent);
}
async readContext(projectId: string, agentId: string): Promise<string> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
return this.contexts.get(this.contextKey(projectId, agentId)) ?? "";
}
async updateContext(
projectId: string,
agentId: string,
content: string,
): Promise<void> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
this.contexts.set(this.contextKey(projectId, agentId), content);
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
list.splice(idx, 1);
this.contexts.delete(this.contextKey(projectId, agentId));
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
* Inserts a pre-built agent record into the registry.
* Used by `MockTemplateGateway.createAgentFromTemplate` so both gateways
* share the same in-memory store.
*/
_insertAgent(projectId: string, agent: Agent, context: string): void {
const list = this.getAgents(projectId);
list.push(agent);
this.contexts.set(this.contextKey(projectId, agent.id), context);
}
/**
* Updates an agent record in-place (origin + synchronized flag).
* Used by `MockTemplateGateway.syncAgent`.
*/
_updateAgent(
projectId: string,
agentId: string,
patch: Partial<Pick<Agent, "origin" | "synchronized">>,
newContext?: string,
): void {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) return;
list[idx] = { ...list[idx], ...patch };
if (newContext !== undefined) {
this.contexts.set(this.contextKey(projectId, agentId), newContext);
}
}
/**
* Returns a **live** (not cloned) reference to the agent list so
* `MockTemplateGateway` can read agent origins without triggering async overhead.
*/
_rawAgents(projectId: string): Agent[] {
return this.getAgents(projectId);
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd;
const enc = new TextEncoder();
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
let closed = false;
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
onData(Uint8Array.from(out));
},
async resize(): Promise<void> {},
async close(): Promise<void> {
closed = true;
},
};
}
}
/**
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
* back to `onData` (so the xterm wrapper renders typed input) and greets on
* open. Lets the terminal feature run and be tested fully offline.
*/
export class MockTerminalGateway implements TerminalGateway {
private seq = 0;
async openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
this.seq += 1;
const sessionId = `mock-session-${this.seq}`;
const enc = new TextEncoder();
// Greet so something is visible immediately.
queueMicrotask(() =>
onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
);
let closed = false;
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
onData(Uint8Array.from(out));
},
async resize(): Promise<void> {},
async close(): Promise<void> {
closed = true;
},
};
}
}
export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = [];
async listProjects(): Promise<Project[]> {
return [...this.projects];
}
async createProject(name: string, root: string): Promise<Project> {
if (this.projects.some((p) => p.root === root)) {
const err: GatewayError = {
code: "INVALID",
message: `a project already exists at ${root} for this remote`,
};
throw err;
}
const project: Project = {
id: `mock-project-${Math.random().toString(36).slice(2, 10)}`,
name,
root,
remote: { kind: "local" },
createdAt: Date.now(),
};
this.projects.push(project);
return project;
}
async openProject(projectId: string): Promise<Project> {
const project = this.projects.find((p) => p.id === projectId);
if (!project) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
return project;
}
async closeProject(): Promise<void> {}
}
/** Internal per-project layout store entry. */
interface MockLayoutEntry {
id: string;
name: string;
kind: LayoutKind;
tree: LayoutTree;
}
/** Internal per-project layout state: list of named layouts + active id. */
interface MockProjectLayouts {
activeId: string;
layouts: MockLayoutEntry[];
}
/**
* In-memory layout store: keeps multiple named {@link LayoutTree}s per project,
* applying operations with the same pure logic as the backend (`applyOperation`).
* Lets the grid feature run and be tested fully offline.
*
* Each project starts with a single "Default" layout. The old `loadLayout` /
* `mutateLayout` signatures (no `layoutId`) default to the active layout,
* preserving backward-compat with existing tests.
*/
export class MockLayoutGateway implements LayoutGateway {
private store = new Map<string, MockProjectLayouts>();
private getProjectLayouts(projectId: string): MockProjectLayouts {
if (!this.store.has(projectId)) {
const defaultId = `layout-default-${Math.random().toString(36).slice(2, 8)}`;
this.store.set(projectId, {
activeId: defaultId,
layouts: [{ id: defaultId, name: "Default", kind: "terminal", tree: singleLeafTree() }],
});
}
return this.store.get(projectId)!;
}
private getActiveTree(projectId: string): LayoutTree {
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === ps.activeId);
return entry ? entry.tree : singleLeafTree();
}
private getTree(projectId: string, layoutId?: string): LayoutTree {
if (!layoutId) return this.getActiveTree(projectId);
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === layoutId);
return entry ? entry.tree : singleLeafTree();
}
private setTree(projectId: string, tree: LayoutTree, layoutId?: string): void {
const ps = this.getProjectLayouts(projectId);
const id = layoutId ?? ps.activeId;
const entry = ps.layouts.find((l) => l.id === id);
if (entry) entry.tree = tree;
}
async loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree> {
const tree = this.getTree(projectId, layoutId);
return structuredClone(tree);
}
async mutateLayout(
projectId: string,
operation: LayoutOperation,
layoutId?: string,
): Promise<LayoutTree> {
const current = this.getTree(projectId, layoutId);
const next = applyOperation(current, operation);
this.setTree(projectId, next, layoutId);
return structuredClone(next);
}
async listLayouts(projectId: string): Promise<LayoutList> {
const ps = this.getProjectLayouts(projectId);
const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind }));
return { layouts, activeId: ps.activeId };
}
async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> {
const ps = this.getProjectLayouts(projectId);
const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() });
return { layoutId };
}
async renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === layoutId);
if (entry) entry.name = name;
}
async deleteLayout(
projectId: string,
layoutId: string,
): Promise<{ activeId: string }> {
const ps = this.getProjectLayouts(projectId);
const idx = ps.layouts.findIndex((l) => l.id === layoutId);
if (idx !== -1) ps.layouts.splice(idx, 1);
// If the deleted layout was active, switch to the first remaining layout.
if (ps.activeId === layoutId && ps.layouts.length > 0) {
ps.activeId = ps.layouts[0].id;
}
return { activeId: ps.activeId };
}
async setActiveLayout(projectId: string, layoutId: string): Promise<void> {
const ps = this.getProjectLayouts(projectId);
if (ps.layouts.some((l) => l.id === layoutId)) {
ps.activeId = layoutId;
}
}
}
/** Per-project git state kept in the mock. */
interface MockGitProjectState {
files: Map<string, boolean>; // path → staged
branches: string[];
current: string;
log: GitCommit[];
commitSeq: number;
}
/**
* A small demo DAG that exercises branches, a merge commit, and a tag.
*
* Topology (newest first, as git log returns):
*
* e (main, HEAD) — merge commit from feature
* ├─ d (feature)
* │ └─ c
* └─ b
* └─ a (tag: v1.0)
*
* In list form (parents reference earlier hashes):
* e parents=[b,d]
* d parents=[c]
* c parents=[a] (feature branch diverges from a)
* b parents=[a]
* a parents=[] (initial commit, tag v1.0)
*/
const DEMO_GRAPH_COMMITS: GraphCommit[] = [
{
hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
summary: "Merge feature into main",
parents: [
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"dddddddddddddddddddddddddddddddddddddddd",
],
refs: ["main", "HEAD"],
author: "Alice",
timestamp: 1_717_200_000,
},
{
hash: "dddddddddddddddddddddddddddddddddddddddd",
summary: "Implement feature (step 2)",
parents: ["cccccccccccccccccccccccccccccccccccccccc"],
refs: ["feature"],
author: "Bob",
timestamp: 1_717_100_000,
},
{
hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
summary: "Hotfix on main",
parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
refs: [],
author: "Alice",
timestamp: 1_717_090_000,
},
{
hash: "cccccccccccccccccccccccccccccccccccccccc",
summary: "Implement feature (step 1)",
parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
refs: [],
author: "Bob",
timestamp: 1_717_080_000,
},
{
hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
summary: "Initial commit",
parents: [],
refs: ["tag: v1.0"],
author: "Alice",
timestamp: 1_717_000_000,
},
];
/**
* Stateful in-memory git gateway — simulates a git repository per project
* (keyed by projectId). Seeded with demo files so the panel renders something
* on first render.
*
* Exported so tests can instantiate it directly.
*/
export class MockGitGateway implements GitGateway {
private projects = new Map<string, MockGitProjectState>();
private getState(projectId: string): MockGitProjectState {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, this._seedState());
}
return this.projects.get(projectId)!;
}
private _seedState(): MockGitProjectState {
const files = new Map<string, boolean>();
files.set("src/main.rs", false);
files.set("README.md", false);
return {
files,
branches: ["main"],
current: "main",
log: [],
commitSeq: 0,
};
}
async init(projectId: string): Promise<void> {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, this._seedState());
}
}
async status(projectId: string): Promise<GitFileStatus[]> {
const state = this.getState(projectId);
return Array.from(state.files.entries()).map(([path, staged]) => ({
path,
staged,
}));
}
async stage(projectId: string, path: string): Promise<void> {
const state = this.getState(projectId);
if (state.files.has(path)) {
state.files.set(path, true);
}
}
async unstage(projectId: string, path: string): Promise<void> {
const state = this.getState(projectId);
if (state.files.has(path)) {
state.files.set(path, false);
}
}
async commit(projectId: string, message: string): Promise<GitCommit> {
const state = this.getState(projectId);
state.commitSeq += 1;
const gitCommit: GitCommit = {
hash: `mock-${state.commitSeq}`,
summary: message.split("\n")[0],
};
// Remove staged files from the working tree status.
for (const [path, staged] of Array.from(state.files.entries())) {
if (staged) state.files.delete(path);
}
// Push commit to the top of the log.
state.log.unshift(gitCommit);
return gitCommit;
}
async branches(projectId: string): Promise<GitBranches> {
const state = this.getState(projectId);
return { branches: [...state.branches], current: state.current };
}
async checkout(projectId: string, branch: string): Promise<void> {
const state = this.getState(projectId);
if (!state.branches.includes(branch)) {
state.branches.push(branch);
}
state.current = branch;
}
async log(projectId: string, limit: number): Promise<GitCommit[]> {
const state = this.getState(projectId);
return state.log.slice(0, limit);
}
async graph(_projectId: string, limit: number): Promise<GraphCommit[]> {
return DEMO_GRAPH_COMMITS.slice(0, limit);
}
}
class MockRemoteGateway implements RemoteGateway {
async connect(): Promise<void> {}
}
/** The pre-filled reference catalogue the mock serves (mirror of the backend). */
export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
{
id: "mock-claude",
name: "Claude Code",
command: "claude",
args: [],
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: "claude --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-codex",
name: "OpenAI Codex CLI",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "codex --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-gemini",
name: "Gemini CLI",
command: "gemini",
args: [],
contextInjection: { strategy: "conventionFile", target: "GEMINI.md" },
detect: "gemini --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-aider",
name: "Aider",
command: "aider",
args: [],
contextInjection: { strategy: "flag", flag: "--message-file {path}" },
detect: "aider --version",
cwdTemplate: "{projectRoot}",
},
];
/**
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
* the wizard can be driven and tested fully offline. By default it reports the
* first run as *not done* until {@link configureProfiles} is called. Detection
* marks a fixed subset (claude) as installed so ✓/✗ rendering is exercised.
*/
export class MockProfileGateway implements ProfileGateway {
private profiles: AgentProfile[] = [];
private configured = false;
async firstRunState(): Promise<FirstRunState> {
return {
isFirstRun: !this.configured,
referenceProfiles: structuredClone(MOCK_REFERENCE_PROFILES),
};
}
async referenceProfiles(): Promise<AgentProfile[]> {
return structuredClone(MOCK_REFERENCE_PROFILES);
}
async detectProfiles(
candidates: AgentProfile[],
): Promise<ProfileAvailability[]> {
// Pretend only `claude` is installed, so the wizard shows a mix of ✓/✗.
return candidates.map((profile) => ({
profile,
available: profile.command === "claude",
}));
}
async listProfiles(): Promise<AgentProfile[]> {
return structuredClone(this.profiles);
}
async saveProfile(profile: AgentProfile): Promise<AgentProfile> {
const i = this.profiles.findIndex((p) => p.id === profile.id);
if (i >= 0) this.profiles[i] = profile;
else this.profiles.push(profile);
this.configured = true;
return structuredClone(profile);
}
async deleteProfile(profileId: string): Promise<void> {
this.profiles = this.profiles.filter((p) => p.id !== profileId);
}
async configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
this.profiles = structuredClone(profiles);
this.configured = true;
return structuredClone(profiles);
}
}
/**
* Stateful in-memory template gateway.
*
* Shares the `MockAgentGateway` instance passed at construction time so that
* `createAgentFromTemplate` / `detectDrift` / `syncAgent` operate on the same
* agent registry as the rest of the UI (ARCHITECTURE §11).
*
* Exported so tests can instantiate it directly and inject a shared
* `MockAgentGateway`.
*/
export class MockTemplateGateway implements TemplateGateway {
private templates: Template[] = [];
private seq = 0;
constructor(private readonly agentGateway: MockAgentGateway) {}
async listTemplates(): Promise<Template[]> {
return structuredClone(this.templates);
}
async createTemplate(input: CreateTemplateInput): Promise<Template> {
this.seq += 1;
const template: Template = {
id: `mock-template-${this.seq}`,
name: input.name,
contentMd: input.content,
version: 1,
defaultProfileId: input.defaultProfileId,
};
this.templates.push(template);
return structuredClone(template);
}
async updateTemplate(templateId: string, content: string): Promise<Template> {
const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
this.templates[idx] = {
...this.templates[idx],
contentMd: content,
version: this.templates[idx].version + 1,
};
return structuredClone(this.templates[idx]);
}
async deleteTemplate(templateId: string): Promise<void> {
const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
this.templates.splice(idx, 1);
}
async createAgentFromTemplate(
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
): Promise<Agent> {
const template = this.templates.find((t) => t.id === templateId);
if (!template) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
const synchronized = opts?.synchronized ?? true;
const name = opts?.name ?? template.name;
const slug = slugify(name) || "agent";
// Derive unique contextPath (same logic as MockAgentGateway)
const existingPaths = new Set(
this.agentGateway._rawAgents(projectId).map((a) => a.contextPath),
);
let candidate = `agents/${slug}.md`;
let n = 2;
while (existingPaths.has(candidate)) {
candidate = `agents/${slug}-${n}.md`;
n += 1;
}
const agent: Agent = {
id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
name,
contextPath: candidate,
profileId: template.defaultProfileId,
origin: {
type: "fromTemplate",
templateId,
syncedTemplateVersion: template.version,
},
synchronized,
};
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
return structuredClone(agent);
}
async detectDrift(projectId: string): Promise<AgentDrift[]> {
const agents = this.agentGateway._rawAgents(projectId);
const drifts: AgentDrift[] = [];
for (const agent of agents) {
if (!agent.synchronized) continue;
if (agent.origin.type !== "fromTemplate") continue;
const { templateId, syncedTemplateVersion } = agent.origin;
const template = this.templates.find((t) => t.id === templateId);
if (!template) continue;
if (template.version > syncedTemplateVersion) {
drifts.push({
agentId: agent.id,
from: syncedTemplateVersion,
to: template.version,
});
}
}
return drifts;
}
async syncAgent(
projectId: string,
agentId: string,
): Promise<{ synced: boolean; version: number | null }> {
const agents = this.agentGateway._rawAgents(projectId);
const agent = agents.find((a) => a.id === agentId);
if (!agent || !agent.synchronized || agent.origin.type !== "fromTemplate") {
return { synced: false, version: null };
}
const { templateId } = agent.origin;
const template = this.templates.find((t) => t.id === templateId);
if (!template) {
return { synced: false, version: null };
}
this.agentGateway._updateAgent(
projectId,
agentId,
{
origin: {
type: "fromTemplate",
templateId,
syncedTemplateVersion: template.version,
},
},
template.contentMd,
);
return { synced: true, version: template.version };
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
return {
system: new MockSystemGateway(),
agent: agentGateway,
terminal: new MockTerminalGateway(),
project: new MockProjectGateway(),
layout: new MockLayoutGateway(),
git: new MockGitGateway(),
remote: new MockRemoteGateway(),
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
};
}
// Re-export GatewayError so tests can reference the thrown shape.
export type { GatewayError };