Expose la gestion des appareils appairés introduite en B1-B4, sur une surface unique partagée par le web et le desktop. - Écran Appareils : liste, renommage, révocation unitaire ou globale, activité formatée, panneau de code éphémère. - Le parcours d'appairage demande un nom d'appareil, pour qu'une révocation porte sur quelque chose d'identifiable par l'utilisateur. - Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port restant le seul contrat connu de la feature. Les erreurs sont mappées localement et le message du serveur n'est jamais affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour qui teste des codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2859 lines
88 KiB
TypeScript
2859 lines
88 KiB
TypeScript
/**
|
|
* 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,
|
|
DiagnosticWarning,
|
|
DomainEvent,
|
|
EmbedderEngines,
|
|
EmbedderProfile,
|
|
EmbeddedServerStatus,
|
|
FirstRunState,
|
|
GatewayError,
|
|
GitBranches,
|
|
GitCommit,
|
|
GitFileStatus,
|
|
GraphCommit,
|
|
HealthReport,
|
|
LayoutInfo,
|
|
LayoutKind,
|
|
LayoutList,
|
|
LayoutOperation,
|
|
LayoutTree,
|
|
LocalModelServerConfig,
|
|
ModelServerCommandPreview,
|
|
Memory,
|
|
MemoryIndexEntry,
|
|
MemoryLink,
|
|
MemoryType,
|
|
EffectivePermissions,
|
|
PairedDevice,
|
|
PairingCode,
|
|
PermissionSet,
|
|
Project,
|
|
ProjectPermissions,
|
|
ProjectWorkState,
|
|
ProfileAvailability,
|
|
ResumableAgent,
|
|
ServerExposurePreview,
|
|
ServerExposureSettings,
|
|
Skill,
|
|
ReplyChunk,
|
|
SkillScope,
|
|
Sprint,
|
|
Template,
|
|
TerminalSession,
|
|
Ticket,
|
|
TicketCarnet,
|
|
TicketChat,
|
|
TicketLink,
|
|
TicketLinkKind,
|
|
TicketList,
|
|
TicketPriority,
|
|
TicketStatus,
|
|
TurnPage,
|
|
TurnView,
|
|
Unsubscribe,
|
|
} from "@/domain";
|
|
import type {
|
|
AgentGateway,
|
|
ConversationGateway,
|
|
ConversationPageRequest,
|
|
ConversationDetails,
|
|
CloneOpenCodeProfileFromSeedInput,
|
|
CreateAgentInput,
|
|
CreateMemoryInput,
|
|
CreateSkillInput,
|
|
DesktopServerGateway,
|
|
DeviceGateway,
|
|
EmbedderGateway,
|
|
CreateTemplateInput,
|
|
Gateways,
|
|
InputGateway,
|
|
LiveAgent,
|
|
MemoryGateway,
|
|
GitGateway,
|
|
LayoutGateway,
|
|
ModelServerGateway,
|
|
OpenTerminalOptions,
|
|
ProfileGateway,
|
|
ProjectGateway,
|
|
PermissionGateway,
|
|
ReattachResult,
|
|
RemoteGateway,
|
|
SkillGateway,
|
|
StoppedLiveAgent,
|
|
SystemGateway,
|
|
TemplateGateway,
|
|
TerminalGateway,
|
|
TerminalHandle,
|
|
TicketGateway,
|
|
TicketListQuery,
|
|
CreateTicketInput,
|
|
UpdateTicketInput,
|
|
UiPreferencesGateway,
|
|
ViewWindowClosed,
|
|
ViewWindowSnapshot,
|
|
WindowGateway,
|
|
FocusedProject,
|
|
FocusedProjectGateway,
|
|
WorkStateGateway,
|
|
} from "@/ports";
|
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
|
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
|
|
|
const TICKET_PRIORITY_RANK: Record<TicketPriority, number> = {
|
|
low: 0,
|
|
medium: 1,
|
|
high: 2,
|
|
critical: 3,
|
|
};
|
|
|
|
const TICKET_STATUS_RANK: Record<TicketStatus, number> = {
|
|
open: 0,
|
|
inProgress: 1,
|
|
QA: 2,
|
|
closed: 3,
|
|
};
|
|
|
|
function sortTickets(
|
|
rows: Ticket[],
|
|
sort: NonNullable<TicketListQuery["sort"]>,
|
|
): Ticket[] {
|
|
return rows.sort((a, b) => {
|
|
let fieldOrder = 0;
|
|
if (sort.field === "number") {
|
|
fieldOrder = a.number - b.number;
|
|
} else if (sort.field === "priority") {
|
|
fieldOrder =
|
|
TICKET_PRIORITY_RANK[a.priority] - TICKET_PRIORITY_RANK[b.priority];
|
|
} else if (sort.field === "status") {
|
|
fieldOrder = TICKET_STATUS_RANK[a.status] - TICKET_STATUS_RANK[b.status];
|
|
} else {
|
|
fieldOrder =
|
|
a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) ||
|
|
a.title.localeCompare(b.title);
|
|
}
|
|
|
|
const directed = sort.direction === "desc" ? -fieldOrder : fieldOrder;
|
|
return directed || a.number - b.number;
|
|
});
|
|
}
|
|
|
|
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, "");
|
|
}
|
|
|
|
/**
|
|
* A live in-memory mock PTY session: it retains a scrollback (everything written
|
|
* to `onData`) and tracks the *current* output sink so a view can detach (sink
|
|
* cleared, session stays alive) and later re-attach (new sink, scrollback
|
|
* replayed). Only `close` ends the session — mirroring the backend's decoupling
|
|
* of PTY lifecycle from view lifecycle.
|
|
*/
|
|
class MockPtySession {
|
|
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
|
|
private scrollback: number[] = [];
|
|
/** Current view sink; `null` while detached. */
|
|
private sink: ((bytes: Uint8Array) => void) | null = null;
|
|
/** Whether the session was explicitly closed (PTY killed). */
|
|
closed = false;
|
|
|
|
constructor(
|
|
readonly sessionId: string,
|
|
sink: (bytes: Uint8Array) => void,
|
|
) {
|
|
this.sink = sink;
|
|
}
|
|
|
|
/** Records output into the scrollback and forwards it to the current sink. */
|
|
emit(bytes: Uint8Array): void {
|
|
if (this.closed) return;
|
|
for (const b of bytes) this.scrollback.push(b);
|
|
this.sink?.(bytes);
|
|
}
|
|
|
|
/** Detaches the current view: stop delivering, keep the session alive. */
|
|
detach(): void {
|
|
this.sink = null;
|
|
}
|
|
|
|
/** Re-attaches a new view, returning the retained scrollback to repaint. */
|
|
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
|
|
this.sink = sink;
|
|
return Uint8Array.from(this.scrollback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** Live agent PTY sessions, kept across detach so reattach can find them. */
|
|
private sessions = new Map<string, MockPtySession>();
|
|
/**
|
|
* The cell each live agent currently runs in (`agentId → nodeId`). Mirrors the
|
|
* backend "one live session per agent" invariant: launching an agent already
|
|
* present here in a *different* node is refused; closing its session clears it.
|
|
* Only populated when the caller supplies a `nodeId`.
|
|
*/
|
|
private liveByAgent = new Map<string, string>();
|
|
/** Live PTY session id per agent (`agentId → sessionId`). */
|
|
private liveSessionByAgent = new Map<string, string>();
|
|
|
|
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 listLiveAgents(projectId: string): Promise<LiveAgent[]> {
|
|
// Only report agents that belong to this project (ids are disjoint across
|
|
// projects in the mock, mirroring the backend's per-project agent ids).
|
|
const known = new Set(this.getAgents(projectId).map((a) => a.id));
|
|
return [...this.liveByAgent.entries()]
|
|
.filter(([agentId]) => known.has(agentId))
|
|
.map(([agentId, nodeId]) => ({
|
|
agentId,
|
|
nodeId,
|
|
sessionId: this.liveSessionByAgent.get(agentId)!,
|
|
kind: "pty" as const,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Resumable agents per project, seeded by tests via
|
|
* {@link _setResumableAgents}. Empty by default (no panel on open).
|
|
*/
|
|
private resumable = new Map<string, ResumableAgent[]>();
|
|
|
|
/** Seeds the resumable inventory returned for a project (deterministic tests). */
|
|
_setResumableAgents(projectId: string, list: ResumableAgent[]): void {
|
|
this.resumable.set(projectId, list);
|
|
}
|
|
|
|
async listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
|
|
return structuredClone(this.resumable.get(projectId) ?? []);
|
|
}
|
|
|
|
async attachLiveAgent(
|
|
projectId: string,
|
|
agentId: string,
|
|
nodeId: string,
|
|
): Promise<LiveAgent> {
|
|
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;
|
|
}
|
|
const sessionId = this.liveSessionByAgent.get(agentId);
|
|
if (!sessionId || !this.sessions.has(sessionId)) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent ${agentId} has no live session`,
|
|
};
|
|
throw err;
|
|
}
|
|
this.liveByAgent.set(agentId, nodeId);
|
|
return { agentId, nodeId, sessionId, kind: "pty" };
|
|
}
|
|
|
|
async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
|
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;
|
|
}
|
|
const sessionId = this.liveSessionByAgent.get(agentId);
|
|
if (!sessionId) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent ${agentId} has no live session`,
|
|
};
|
|
throw err;
|
|
}
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) session.closed = true;
|
|
this.sessions.delete(sessionId);
|
|
this.liveSessionByAgent.delete(agentId);
|
|
this.liveByAgent.delete(agentId);
|
|
return { agentId, sessionId, kind: "pty" };
|
|
}
|
|
|
|
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,
|
|
skills: [],
|
|
};
|
|
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));
|
|
}
|
|
|
|
async changeAgentProfile(
|
|
projectId: string,
|
|
agentId: string,
|
|
profileId: string,
|
|
rows: number,
|
|
cols: number,
|
|
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
|
|
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;
|
|
}
|
|
// Mutate the agent's runtime profile in place (the rest of the record —
|
|
// origin, synchronized, skills — is untouched).
|
|
list[idx] = { ...list[idx], profileId };
|
|
const agent = structuredClone(list[idx]);
|
|
|
|
// Hot-swap path: when the agent owns a live session, the engine restarts —
|
|
// the old PTY is killed (history abandoned, per the product decision) and a
|
|
// fresh session is minted in the same cell, surfaced for the caller to
|
|
// rebind. No live session ⇒ nothing to relaunch.
|
|
const nodeId = this.liveByAgent.get(agentId);
|
|
const oldSessionId = this.liveSessionByAgent.get(agentId);
|
|
if (!nodeId || !oldSessionId) {
|
|
return { agent };
|
|
}
|
|
// Kill the old session.
|
|
const old = this.sessions.get(oldSessionId);
|
|
if (old) old.closed = true;
|
|
this.sessions.delete(oldSessionId);
|
|
|
|
// Mint a fresh, detached session (no view sink yet; the cell reattaches).
|
|
this.sessionSeq += 1;
|
|
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
|
const session = new MockPtySession(sessionId, () => {});
|
|
session.detach();
|
|
this.sessions.set(sessionId, session);
|
|
this.liveSessionByAgent.set(agentId, sessionId);
|
|
this.liveByAgent.set(agentId, nodeId);
|
|
|
|
return {
|
|
agent,
|
|
relaunchedSession: {
|
|
sessionId,
|
|
cwd: `/home/user/mock-project`,
|
|
rows,
|
|
cols,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
/**
|
|
* Replaces an agent's assigned skills in-place.
|
|
* Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
|
|
* share the same in-memory store.
|
|
*/
|
|
_setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
|
|
const list = this.getAgents(projectId);
|
|
const idx = list.findIndex((a) => a.id === agentId);
|
|
if (idx === -1) return;
|
|
list[idx] = { ...list[idx], skills };
|
|
}
|
|
|
|
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;
|
|
}
|
|
// Singleton invariant: refuse a launch when the agent is already live in a
|
|
// *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same
|
|
// node is allowed (idempotent re-launch of the very same cell).
|
|
const liveNode = this.liveByAgent.get(agentId);
|
|
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
|
|
const err: GatewayError = {
|
|
code: "AGENT_ALREADY_RUNNING",
|
|
message: `agent ${agentId} is already running in cell ${liveNode}`,
|
|
};
|
|
throw err;
|
|
}
|
|
this.sessionSeq += 1;
|
|
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
|
const cwd = options.cwd;
|
|
|
|
// Record liveness for `listLiveAgents` + the guard above (only when the
|
|
// caller pins a node).
|
|
if (options.nodeId) {
|
|
this.liveByAgent.set(agentId, options.nodeId);
|
|
this.liveSessionByAgent.set(agentId, sessionId);
|
|
}
|
|
const clearLive = () => {
|
|
if (this.liveByAgent.get(agentId) === options.nodeId) {
|
|
this.liveByAgent.delete(agentId);
|
|
this.liveSessionByAgent.delete(agentId);
|
|
}
|
|
};
|
|
|
|
// Every agent cell is a raw PTY (Option 1 — Terminal + MCP). The former
|
|
// structured "chat" cell routing is retired.
|
|
const enc = new TextEncoder();
|
|
const session = new MockPtySession(sessionId, onData);
|
|
this.sessions.set(sessionId, session);
|
|
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
|
queueMicrotask(() =>
|
|
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
|
);
|
|
const handle: TerminalHandle = makeMockHandle(session, () => {
|
|
this.sessions.delete(sessionId);
|
|
clearLive();
|
|
});
|
|
|
|
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
|
|
// newly-minted id surfaced on the handle so the caller persists it; a cell
|
|
// that already carries an id resumes it and nothing new is assigned.
|
|
if (!options.conversationId) {
|
|
return {
|
|
...handle,
|
|
assignedConversationId: `mock-conversation-${sessionId}`,
|
|
};
|
|
}
|
|
return handle;
|
|
}
|
|
|
|
async reattach(
|
|
sessionId: string,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<ReattachResult> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.closed) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent session ${sessionId} is not alive`,
|
|
};
|
|
throw err;
|
|
}
|
|
const scrollback = session.reattach(onData);
|
|
return {
|
|
handle: makeMockHandle(session, () => {
|
|
this.sessions.delete(sessionId);
|
|
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
|
if (liveSessionId === sessionId) {
|
|
this.liveSessionByAgent.delete(agentId);
|
|
this.liveByAgent.delete(agentId);
|
|
}
|
|
}
|
|
}),
|
|
scrollback,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Best-effort conversation details, keyed by conversation id (T7). Empty by
|
|
* default (degraded mode); a test seeds enriched details via
|
|
* {@link _setConversationDetails}.
|
|
*/
|
|
private conversationDetails = new Map<string, ConversationDetails>();
|
|
|
|
/** Seeds the (best-effort) details returned for a given conversation id. */
|
|
_setConversationDetails(
|
|
conversationId: string,
|
|
details: ConversationDetails,
|
|
): void {
|
|
this.conversationDetails.set(conversationId, details);
|
|
}
|
|
|
|
async inspectConversation(
|
|
_projectId: string,
|
|
_agentId: string,
|
|
conversationId: string,
|
|
): Promise<ConversationDetails> {
|
|
// Best-effort: an unknown conversation yields empty details (degraded mode),
|
|
// never an error — mirroring the backend contract.
|
|
return structuredClone(this.conversationDetails.get(conversationId) ?? {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
|
|
* (cooked-terminal CRLF translation) through the session so the scrollback
|
|
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
|
|
*/
|
|
function makeMockHandle(
|
|
session: MockPtySession,
|
|
unregister: () => void,
|
|
): TerminalHandle {
|
|
return {
|
|
sessionId: session.sessionId,
|
|
async write(data: Uint8Array): Promise<void> {
|
|
if (session.closed) return;
|
|
const out: number[] = [];
|
|
for (const b of data) {
|
|
if (b === 0x0d) out.push(0x0d, 0x0a);
|
|
else out.push(b);
|
|
}
|
|
session.emit(Uint8Array.from(out));
|
|
},
|
|
async resize(): Promise<void> {},
|
|
detach(): void {
|
|
session.detach();
|
|
},
|
|
async close(): Promise<void> {
|
|
session.closed = true;
|
|
unregister();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** Live sessions kept across detach so reattach can find them. */
|
|
private sessions = new Map<string, MockPtySession>();
|
|
|
|
async openTerminal(
|
|
options: OpenTerminalOptions,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<TerminalHandle> {
|
|
this.seq += 1;
|
|
const sessionId = `mock-session-${this.seq}`;
|
|
const enc = new TextEncoder();
|
|
const session = new MockPtySession(sessionId, onData);
|
|
this.sessions.set(sessionId, session);
|
|
// Greet so something is visible immediately.
|
|
queueMicrotask(() =>
|
|
session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
|
|
);
|
|
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
|
}
|
|
|
|
async reattach(
|
|
sessionId: string,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<ReattachResult> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.closed) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `terminal session ${sessionId} is not alive`,
|
|
};
|
|
throw err;
|
|
}
|
|
const scrollback = session.reattach(onData);
|
|
return {
|
|
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
|
scrollback,
|
|
};
|
|
}
|
|
|
|
async closeTerminal(sessionId: string): Promise<void> {
|
|
// Best-effort / idempotent: kill the PTY by id (mirrors the handle's close)
|
|
// so a later reattach to it fails. No-op if the session is already gone.
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.closed = true;
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class MockProjectGateway implements ProjectGateway {
|
|
private projects: Project[] = [];
|
|
private contexts = new Map<string, string>();
|
|
|
|
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> {}
|
|
|
|
async readProjectContext(projectId: string): Promise<string> {
|
|
if (!this.projects.some((p) => p.id === projectId)) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `project ${projectId} not found`,
|
|
};
|
|
throw err;
|
|
}
|
|
return this.contexts.get(projectId) ?? "";
|
|
}
|
|
|
|
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
|
if (!this.projects.some((p) => p.id === projectId)) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `project ${projectId} not found`,
|
|
};
|
|
throw err;
|
|
}
|
|
this.contexts.set(projectId, content);
|
|
}
|
|
}
|
|
|
|
/** 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<{ activeId: string }> {
|
|
const ps = this.getProjectLayouts(projectId);
|
|
// Self-heal (I4): only adopt the requested id when it exists; otherwise keep
|
|
// the current active id. Either way the returned id is authoritative.
|
|
if (ps.layouts.some((l) => l.id === layoutId)) {
|
|
ps.activeId = layoutId;
|
|
}
|
|
return { activeId: ps.activeId };
|
|
}
|
|
}
|
|
|
|
/** 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> {}
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks
|
|
* which views are "detached" in a set keyed by `panel` alone (a panel-only
|
|
* window follows the focused project, so no project id is part of the key), and
|
|
* lets tests simulate the OS closing a window via {@link simulateOsClose}.
|
|
* `closeViewWindow` also emits the close event, mirroring the backend (a
|
|
* programmatic close still notifies).
|
|
*/
|
|
export class MockWindowGateway implements WindowGateway {
|
|
/** Currently-open detached windows, keyed by `panel`. */
|
|
readonly open = new Set<string>();
|
|
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
|
|
|
|
async openViewWindow(panel: string): Promise<void> {
|
|
this.open.add(panel);
|
|
}
|
|
|
|
async closeViewWindow(panel: string): Promise<void> {
|
|
if (this.open.delete(panel)) {
|
|
this.emit({ panel });
|
|
}
|
|
}
|
|
|
|
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
|
// Mirror the backend enumeration: every currently-open detached window is
|
|
// reported visible, with a stable label derived from its panel id.
|
|
return [...this.open].map((panel) => ({
|
|
panel,
|
|
label: panel,
|
|
visible: true,
|
|
}));
|
|
}
|
|
|
|
async onViewWindowClosed(
|
|
handler: (event: ViewWindowClosed) => void,
|
|
): Promise<Unsubscribe> {
|
|
this.listeners.add(handler);
|
|
return () => {
|
|
this.listeners.delete(handler);
|
|
};
|
|
}
|
|
|
|
/** Test hook: simulate the user closing the OS window (fires the event). */
|
|
simulateOsClose(panel: string): void {
|
|
this.open.delete(panel);
|
|
this.emit({ panel });
|
|
}
|
|
|
|
private emit(event: ViewWindowClosed): void {
|
|
for (const l of this.listeners) l(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current
|
|
* focus in memory and fans changes out to subscribers — the mock counterpart to
|
|
* the backend's shared focused-project state + `focused-project://changed`
|
|
* event. `setFocusedProject` always notifies (mirrors the backend emit), so a
|
|
* detached-window test can drive a panel window's focus transitions.
|
|
*/
|
|
export class MockFocusedProjectGateway implements FocusedProjectGateway {
|
|
private focused: FocusedProject | null = null;
|
|
private readonly listeners = new Set<
|
|
(project: FocusedProject | null) => void
|
|
>();
|
|
|
|
async setFocusedProject(project: FocusedProject | null): Promise<void> {
|
|
this.focused = project;
|
|
for (const l of this.listeners) l(project);
|
|
}
|
|
|
|
async getFocusedProject(): Promise<FocusedProject | null> {
|
|
return this.focused;
|
|
}
|
|
|
|
async onFocusedProjectChanged(
|
|
handler: (project: FocusedProject | null) => void,
|
|
): Promise<Unsubscribe> {
|
|
this.listeners.add(handler);
|
|
return () => {
|
|
this.listeners.delete(handler);
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The pre-filled reference catalogue the mock serves — mirror of the backend's
|
|
* **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
|
|
* (Claude + Codex) are offered to selection/creation. Gemini/Aider stay in the
|
|
* backend's raw catalogue data but are filtered out of the selection path, so the
|
|
* mock must not serve them either.
|
|
*/
|
|
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-opencode",
|
|
name: "OpenCode + llama.cpp",
|
|
command: "opencode",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
|
detect: "opencode --version",
|
|
cwdTemplate: "{projectRoot}",
|
|
structuredAdapter: "openCode",
|
|
opencode: {
|
|
baseURL: "http://localhost:8080/v1",
|
|
apiKey: "sk-no-key",
|
|
model: "qwen3-coder-30b",
|
|
},
|
|
},
|
|
];
|
|
|
|
/**
|
|
* 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;
|
|
/** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */
|
|
private cloneCounter = 0;
|
|
|
|
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);
|
|
}
|
|
|
|
async cloneOpenCodeProfileFromSeed(
|
|
input: CloneOpenCodeProfileFromSeedInput = {},
|
|
): Promise<AgentProfile> {
|
|
const seed = MOCK_REFERENCE_PROFILES.find(
|
|
(p) => p.structuredAdapter === "openCode",
|
|
);
|
|
if (!seed) throw new Error("no OpenCode seed profile");
|
|
this.cloneCounter += 1;
|
|
return structuredClone({
|
|
...seed,
|
|
id: `mock-opencode-clone-${this.cloneCounter}`,
|
|
name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`,
|
|
opencode: input.opencode ?? seed.opencode,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory local model server registry (F35). Tracks declared servers and can
|
|
* be told which ids are still referenced by a profile, so `deleteModelServer`
|
|
* reproduces the backend `model_server_in_use` rejection offline.
|
|
*/
|
|
export class MockModelServerGateway implements ModelServerGateway {
|
|
private servers: LocalModelServerConfig[] = [];
|
|
private readonly inUse = new Set<string>();
|
|
|
|
async listModelServers(): Promise<LocalModelServerConfig[]> {
|
|
return structuredClone(this.servers);
|
|
}
|
|
|
|
async saveModelServer(
|
|
config: LocalModelServerConfig,
|
|
): Promise<LocalModelServerConfig> {
|
|
const i = this.servers.findIndex((s) => s.id === config.id);
|
|
if (i >= 0) this.servers[i] = structuredClone(config);
|
|
else this.servers.push(structuredClone(config));
|
|
return structuredClone(config);
|
|
}
|
|
|
|
async deleteModelServer(serverId: string): Promise<void> {
|
|
if (this.inUse.has(serverId)) {
|
|
const err: GatewayError = {
|
|
code: "model_server_in_use",
|
|
message: "A profile still references this server.",
|
|
};
|
|
throw err;
|
|
}
|
|
this.servers = this.servers.filter((s) => s.id !== serverId);
|
|
}
|
|
|
|
async previewModelServerCommand(
|
|
config: LocalModelServerConfig,
|
|
): Promise<ModelServerCommandPreview> {
|
|
// Mirror of the backend `LlamaCppRuntime::build_argv` (order matters), kept
|
|
// deliberately simple: it exists so the UI preview flow is testable, never
|
|
// as the source of truth (production uses the real backend command).
|
|
if (!config.modelSource) {
|
|
const err: GatewayError = {
|
|
code: "INVALID",
|
|
message: "model.source missing",
|
|
};
|
|
throw err;
|
|
}
|
|
const command =
|
|
config.binaryPath && config.binaryPath.trim().length > 0
|
|
? config.binaryPath
|
|
: "llama-server";
|
|
const args: string[] = [];
|
|
if (config.modelSource.type === "localPath") {
|
|
args.push("--model", config.modelSource.path);
|
|
} else {
|
|
args.push("-hf", config.modelSource.repo);
|
|
}
|
|
args.push("--port", String(config.port), "--host", config.host);
|
|
if (config.gpuLayers !== undefined) {
|
|
args.push("-ngl", String(config.gpuLayers));
|
|
}
|
|
if (config.contextSize !== undefined) {
|
|
args.push("-c", String(config.contextSize));
|
|
}
|
|
if (config.jinja) args.push("--jinja");
|
|
args.push(...config.args);
|
|
const display = [command, ...args]
|
|
.map((part) => (/\s/.test(part) ? `'${part}'` : part))
|
|
.join(" ");
|
|
return { command, args, display };
|
|
}
|
|
|
|
/** Test hook: mark a server id as still referenced (delete then rejects). */
|
|
markInUse(serverId: string): void {
|
|
this.inUse.add(serverId);
|
|
}
|
|
}
|
|
|
|
/** Mirror of the backend `default_settings()` (ticket #68). */
|
|
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
|
|
mode: "localOnly",
|
|
port: 17373,
|
|
trustedProxies: [],
|
|
};
|
|
|
|
/** Fixed LAN candidates so the offline UI has something to pick from. */
|
|
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];
|
|
|
|
function invalid(message: string): never {
|
|
const err: GatewayError = { code: "INVALID", message };
|
|
throw err;
|
|
}
|
|
|
|
/**
|
|
* In-memory embedded-server gateway (ticket #68).
|
|
*
|
|
* Mirrors the backend `validate_settings` / `preview_settings` closely enough
|
|
* that the Deployment UI — including its rejection paths — is testable offline.
|
|
* It is never the source of truth: production runs the real Tauri commands.
|
|
* `candidateLanAddresses` is fixed data here precisely because the UI must take
|
|
* addresses from the gateway rather than deriving them.
|
|
*/
|
|
export class MockDesktopServerGateway implements DesktopServerGateway {
|
|
private settings: ServerExposureSettings = structuredClone(
|
|
DEFAULT_EXPOSURE_SETTINGS,
|
|
);
|
|
private current: EmbeddedServerStatus = { state: "stopped" };
|
|
private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();
|
|
|
|
async getExposureSettings(): Promise<ServerExposureSettings> {
|
|
return structuredClone(this.settings);
|
|
}
|
|
|
|
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
|
|
this.validate(settings);
|
|
this.settings = structuredClone(settings);
|
|
}
|
|
|
|
async previewExposure(
|
|
settings: ServerExposureSettings,
|
|
): Promise<ServerExposurePreview> {
|
|
// The real `preview_server_exposure_settings` validates *before* previewing,
|
|
// so an incomplete draft is rejected rather than previewed. Mirrored here —
|
|
// the UI relies on it as its validation authority.
|
|
this.validate(settings);
|
|
const warnings: DiagnosticWarning[] = [];
|
|
if (
|
|
settings.mode === "remoteProxyOtherMachine" &&
|
|
settings.trustedProxies.length === 0
|
|
) {
|
|
// Mirrors the backend's `missingTrustedProxy` warning — which the backend
|
|
// itself cannot currently reach, since `validate_settings` rejects an
|
|
// empty `trustedProxies` for this mode first. Kept aligned on purpose: if
|
|
// the backend reorders, the mock already matches.
|
|
warnings.push({
|
|
code: "missingTrustedProxy",
|
|
message:
|
|
"Add the IP address or CIDR of the reverse proxy that connects to this server.",
|
|
});
|
|
}
|
|
const bindAddress =
|
|
settings.mode === "remoteProxyOtherMachine"
|
|
? settings.lanBindAddress
|
|
: "127.0.0.1";
|
|
const upstreamUrl =
|
|
settings.mode !== "localOnly" && bindAddress
|
|
? `http://${bindAddress}:${settings.port}`
|
|
: undefined;
|
|
return {
|
|
candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
|
|
upstreamUrl,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
async status(): Promise<EmbeddedServerStatus> {
|
|
return structuredClone(this.current);
|
|
}
|
|
|
|
async start(): Promise<EmbeddedServerStatus> {
|
|
// The backend re-validates on start; a bad config fails there, not silently.
|
|
this.validate(this.settings);
|
|
const preview = await this.previewExposure(this.settings);
|
|
this.emit({
|
|
state: "running",
|
|
localUrl: `http://127.0.0.1:${this.settings.port}`,
|
|
publicUrl:
|
|
this.settings.mode === "localOnly"
|
|
? undefined
|
|
: this.settings.publicOrigin,
|
|
upstreamUrl: preview.upstreamUrl,
|
|
});
|
|
return structuredClone(this.current);
|
|
}
|
|
|
|
async stop(): Promise<EmbeddedServerStatus> {
|
|
this.emit({ state: "stopped" });
|
|
return structuredClone(this.current);
|
|
}
|
|
|
|
async onStatusChanged(
|
|
handler: (status: EmbeddedServerStatus) => void,
|
|
): Promise<Unsubscribe> {
|
|
this.handlers.add(handler);
|
|
return () => this.handlers.delete(handler);
|
|
}
|
|
|
|
/** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
|
|
setStatus(status: EmbeddedServerStatus): void {
|
|
this.emit(status);
|
|
}
|
|
|
|
private emit(status: EmbeddedServerStatus): void {
|
|
this.current = status;
|
|
for (const handler of this.handlers) handler(structuredClone(status));
|
|
}
|
|
|
|
private validate(settings: ServerExposureSettings): void {
|
|
if (settings.mode !== "localOnly") {
|
|
const origin = settings.publicOrigin;
|
|
if (!origin) invalid("remote exposure requires publicOrigin");
|
|
if (!origin.startsWith("https://")) {
|
|
invalid("remote exposure requires publicOrigin to start with https://");
|
|
}
|
|
if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
|
|
if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
|
|
}
|
|
if (settings.mode === "remoteProxyOtherMachine") {
|
|
if (!settings.lanBindAddress) {
|
|
invalid("remoteProxyOtherMachine requires lanBindAddress");
|
|
}
|
|
if (settings.trustedProxies.length === 0) {
|
|
invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
skills: [],
|
|
};
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stateful in-memory skill gateway (L12).
|
|
*
|
|
* Skills are keyed by `${scope}` (project scope is further partitioned by
|
|
* `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
|
|
* agent record held by the injected {@link MockAgentGateway}, so both gateways
|
|
* share one store — exactly like {@link MockTemplateGateway}.
|
|
*/
|
|
export class MockSkillGateway implements SkillGateway {
|
|
// Global skills are project-independent; project skills live under their id.
|
|
private global: Skill[] = [];
|
|
private byProject = new Map<string, Skill[]>();
|
|
private seq = 0;
|
|
|
|
constructor(private readonly agentGateway: MockAgentGateway) {}
|
|
|
|
private bucket(projectId: string, scope: SkillScope): Skill[] {
|
|
if (scope === "global") return this.global;
|
|
let list = this.byProject.get(projectId);
|
|
if (!list) {
|
|
list = [];
|
|
this.byProject.set(projectId, list);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
|
return structuredClone(this.bucket(projectId, scope));
|
|
}
|
|
|
|
async createSkill(input: CreateSkillInput): Promise<Skill> {
|
|
this.seq += 1;
|
|
const skill: Skill = {
|
|
id: `mock-skill-${this.seq}`,
|
|
name: input.name,
|
|
contentMd: input.content,
|
|
scope: input.scope,
|
|
};
|
|
this.bucket(input.projectId, input.scope).push(skill);
|
|
return structuredClone(skill);
|
|
}
|
|
|
|
async updateSkill(
|
|
projectId: string,
|
|
scope: SkillScope,
|
|
skillId: string,
|
|
content: string,
|
|
): Promise<Skill> {
|
|
const list = this.bucket(projectId, scope);
|
|
const idx = list.findIndex((s) => s.id === skillId);
|
|
if (idx === -1) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `skill ${skillId} not found`,
|
|
};
|
|
throw err;
|
|
}
|
|
list[idx] = { ...list[idx], contentMd: content };
|
|
return structuredClone(list[idx]);
|
|
}
|
|
|
|
async deleteSkill(
|
|
projectId: string,
|
|
scope: SkillScope,
|
|
skillId: string,
|
|
): Promise<void> {
|
|
const list = this.bucket(projectId, scope);
|
|
const idx = list.findIndex((s) => s.id === skillId);
|
|
if (idx === -1) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `skill ${skillId} not found`,
|
|
};
|
|
throw err;
|
|
}
|
|
list.splice(idx, 1);
|
|
}
|
|
|
|
async assignSkill(
|
|
projectId: string,
|
|
agentId: string,
|
|
skillId: string,
|
|
scope: SkillScope,
|
|
): Promise<void> {
|
|
const agent = this.agentGateway
|
|
._rawAgents(projectId)
|
|
.find((a) => a.id === agentId);
|
|
if (!agent) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent ${agentId} not found in project ${projectId}`,
|
|
};
|
|
throw err;
|
|
}
|
|
if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
|
|
this.agentGateway._setSkills(projectId, agentId, [
|
|
...agent.skills,
|
|
{ skillId, scope },
|
|
]);
|
|
}
|
|
|
|
async unassignSkill(
|
|
projectId: string,
|
|
agentId: string,
|
|
skillId: string,
|
|
): Promise<void> {
|
|
const agent = this.agentGateway
|
|
._rawAgents(projectId)
|
|
.find((a) => a.id === agentId);
|
|
if (!agent) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent ${agentId} not found in project ${projectId}`,
|
|
};
|
|
throw err;
|
|
}
|
|
this.agentGateway._setSkills(
|
|
projectId,
|
|
agentId,
|
|
agent.skills.filter((s) => s.skillId !== skillId),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stateful in-memory memory gateway (L14).
|
|
*
|
|
* Notes are partitioned by `projectId` and keyed by their immutable **slug**
|
|
* (derived from the name on create). `readIndex` projects the notes into the
|
|
* recall index; `resolveLinks` scans `[[slug]]` wikilinks in a note's content
|
|
* and keeps only those targeting an existing note; `recall` returns a simple
|
|
* truncation of the index (no embeddings in the mock).
|
|
*/
|
|
export class MockMemoryGateway implements MemoryGateway {
|
|
private byProject = new Map<string, Map<string, Memory>>();
|
|
|
|
private bucket(projectId: string): Map<string, Memory> {
|
|
let store = this.byProject.get(projectId);
|
|
if (!store) {
|
|
store = new Map();
|
|
this.byProject.set(projectId, store);
|
|
}
|
|
return store;
|
|
}
|
|
|
|
private notFound(slug: string, projectId: string): GatewayError {
|
|
return {
|
|
code: "NOT_FOUND",
|
|
message: `memory ${slug} not found in project ${projectId}`,
|
|
};
|
|
}
|
|
|
|
async listMemories(projectId: string): Promise<Memory[]> {
|
|
return structuredClone([...this.bucket(projectId).values()]);
|
|
}
|
|
|
|
async getMemory(projectId: string, slug: string): Promise<Memory> {
|
|
const note = this.bucket(projectId).get(slug);
|
|
if (!note) throw this.notFound(slug, projectId);
|
|
return structuredClone(note);
|
|
}
|
|
|
|
async createMemory(input: CreateMemoryInput): Promise<Memory> {
|
|
const note: Memory = {
|
|
name: input.name,
|
|
description: input.description,
|
|
type: input.type,
|
|
content: input.content,
|
|
};
|
|
this.bucket(input.projectId).set(slugify(input.name), note);
|
|
return structuredClone(note);
|
|
}
|
|
|
|
async updateMemory(
|
|
projectId: string,
|
|
slug: string,
|
|
description: string,
|
|
type: MemoryType,
|
|
content: string,
|
|
): Promise<Memory> {
|
|
const store = this.bucket(projectId);
|
|
const note = store.get(slug);
|
|
if (!note) throw this.notFound(slug, projectId);
|
|
const updated: Memory = { ...note, description, type, content };
|
|
store.set(slug, updated);
|
|
return structuredClone(updated);
|
|
}
|
|
|
|
async deleteMemory(projectId: string, slug: string): Promise<void> {
|
|
const store = this.bucket(projectId);
|
|
if (!store.delete(slug)) throw this.notFound(slug, projectId);
|
|
}
|
|
|
|
async readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
|
|
return [...this.bucket(projectId).entries()].map(([slug, note]) => ({
|
|
slug,
|
|
title: note.name,
|
|
hook: note.description,
|
|
type: note.type,
|
|
}));
|
|
}
|
|
|
|
async resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
|
|
const store = this.bucket(projectId);
|
|
const note = store.get(slug);
|
|
if (!note) throw this.notFound(slug, projectId);
|
|
const targets = new Set<MemoryLink>();
|
|
for (const match of note.content.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
const target = match[1].trim();
|
|
if (target !== slug && store.has(target)) targets.add(target);
|
|
}
|
|
return [...targets];
|
|
}
|
|
|
|
async recall(
|
|
projectId: string,
|
|
_text: string,
|
|
tokenBudget: number,
|
|
): Promise<MemoryIndexEntry[]> {
|
|
const index = await this.readIndex(projectId);
|
|
const limit = Math.max(0, Math.floor(tokenBudget / 16));
|
|
return index.slice(0, limit);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of
|
|
* recommended ONNX engines (e5-small recommended) with both feature flags on by
|
|
* default; tests can pass overrides to simulate a build where a strategy is not
|
|
* compiled in.
|
|
*/
|
|
export class MockEmbedderGateway implements EmbedderGateway {
|
|
private profiles = new Map<string, EmbedderProfile>();
|
|
private engines: EmbedderEngines;
|
|
|
|
constructor(
|
|
engines?: Partial<EmbedderEngines>,
|
|
seedProfiles: EmbedderProfile[] = [],
|
|
) {
|
|
this.engines = {
|
|
recommendedOnnx: [
|
|
{
|
|
id: "e5-small",
|
|
displayName: "E5 Small (multilingual)",
|
|
dimension: 384,
|
|
approxSizeMb: 120,
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "bge-small",
|
|
displayName: "BGE Small",
|
|
dimension: 384,
|
|
approxSizeMb: 130,
|
|
recommended: false,
|
|
},
|
|
],
|
|
ollamaDetected: false,
|
|
onnxCachedModels: [],
|
|
vectorHttpEnabled: true,
|
|
vectorOnnxEnabled: true,
|
|
...engines,
|
|
};
|
|
for (const p of seedProfiles) this.profiles.set(p.id, p);
|
|
}
|
|
|
|
async listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
|
return structuredClone([...this.profiles.values()]);
|
|
}
|
|
|
|
async saveEmbedderProfile(
|
|
profile: EmbedderProfile,
|
|
): Promise<EmbedderProfile> {
|
|
if (!profile.id.trim() || !profile.name.trim()) {
|
|
const err: GatewayError = {
|
|
code: "INVALID",
|
|
message: "embedder profile id/name must not be empty",
|
|
};
|
|
throw err;
|
|
}
|
|
if (profile.dimension <= 0) {
|
|
const err: GatewayError = {
|
|
code: "INVALID",
|
|
message: "embedder profile dimension must be > 0",
|
|
};
|
|
throw err;
|
|
}
|
|
this.profiles.set(profile.id, structuredClone(profile));
|
|
return structuredClone(profile);
|
|
}
|
|
|
|
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
|
this.profiles.delete(embedderId);
|
|
}
|
|
|
|
async describeEmbedderEngines(): Promise<EmbedderEngines> {
|
|
return structuredClone(this.engines);
|
|
}
|
|
}
|
|
|
|
/** One recorded agent-input control call (for test assertions). */
|
|
export interface MockInputCall {
|
|
projectId: string;
|
|
agentId: string;
|
|
/** Present for `delegationDelivered`, absent for `interrupt`. */
|
|
ticket?: string;
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link InputGateway} (ARCHITECTURE §20.3). Records every
|
|
* `interrupt`/`delegationDelivered` so tests can assert the component routed
|
|
* through the port with the right args — no backend.
|
|
*
|
|
* Exported so tests can instantiate it directly (same pattern as the other mocks).
|
|
*/
|
|
export class MockInputGateway implements InputGateway {
|
|
/** All recorded interrupt calls, in order. */
|
|
readonly interrupts: MockInputCall[] = [];
|
|
/** All recorded delegation-delivered acks, in order. */
|
|
readonly delivered: MockInputCall[] = [];
|
|
/** All recorded front-attached reports, in order. */
|
|
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
|
|
/** All recorded cancel-resume calls, in order. */
|
|
readonly cancelledResumes: string[] = [];
|
|
/** All recorded set-resume-at armings (human net, level 3), in order. */
|
|
readonly resumeArmings: { agentId: string; resetsAtMs: number }[] = [];
|
|
/**
|
|
* What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a
|
|
* pending resume was disarmed). Tests can flip it to exercise the "already
|
|
* fired / none armed" path.
|
|
*/
|
|
cancelResumeResult = true;
|
|
|
|
async interrupt(projectId: string, agentId: string): Promise<void> {
|
|
this.interrupts.push({ projectId, agentId });
|
|
}
|
|
|
|
async delegationDelivered(
|
|
projectId: string,
|
|
agentId: string,
|
|
ticket: string,
|
|
): Promise<void> {
|
|
this.delivered.push({ projectId, agentId, ticket });
|
|
}
|
|
|
|
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
|
this.frontAttached.push({ agentId, attached });
|
|
}
|
|
|
|
async cancelResume(agentId: string): Promise<boolean> {
|
|
this.cancelledResumes.push(agentId);
|
|
return this.cancelResumeResult;
|
|
}
|
|
|
|
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
|
this.resumeArmings.push({ agentId, resetsAtMs });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while
|
|
* the backend lands, and drives the `VITE_USE_MOCK` dev surface.
|
|
*
|
|
* Models the parts of the frozen contract the UI can actually observe: a device
|
|
* list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL
|
|
* where generating invalidates the previous one, and revocation. It does **not**
|
|
* model the wire (no HTTP, no cookie): that is the HTTP gateway's job.
|
|
*/
|
|
export class MockDeviceGateway implements DeviceGateway {
|
|
private devices: PairedDevice[] = [];
|
|
private issued = 0;
|
|
|
|
/** Seeds the device list (tests/dev). */
|
|
_setDevices(devices: PairedDevice[]): void {
|
|
this.devices = structuredClone(devices);
|
|
}
|
|
|
|
/** Every code handed out so far, newest last — the previous ones are dead. */
|
|
_issuedCodes: string[] = [];
|
|
|
|
async listDevices(): Promise<PairedDevice[]> {
|
|
return structuredClone(this.devices);
|
|
}
|
|
|
|
async createPairingCode(): Promise<PairingCode> {
|
|
// Deterministic 8-char uppercase hex, same shape as the server's.
|
|
const code = (0x10000000 + this.issued++)
|
|
.toString(16)
|
|
.toUpperCase()
|
|
.slice(0, 8);
|
|
this._issuedCodes.push(code);
|
|
const ttlSeconds = 600;
|
|
return {
|
|
code,
|
|
// Epoch ms, exactly as the server sends it — a mock that invents a
|
|
// friendlier encoding is how the ISO/epoch mismatch stayed green (#77).
|
|
expiresAtMs: Date.now() + ttlSeconds * 1000,
|
|
ttlSeconds,
|
|
};
|
|
}
|
|
|
|
async renameDevice(deviceId: string, name: string): Promise<void> {
|
|
const device = this.devices.find((d) => d.deviceId === deviceId);
|
|
if (!device) {
|
|
const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` };
|
|
throw err;
|
|
}
|
|
device.name = name;
|
|
}
|
|
|
|
async revokeDevice(deviceId: string): Promise<void> {
|
|
this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
|
|
}
|
|
|
|
async revokeAllDevices(): Promise<void> {
|
|
this.devices = [];
|
|
}
|
|
}
|
|
|
|
/** In-memory permissions gateway. */
|
|
export class MockPermissionGateway implements PermissionGateway {
|
|
private docs = new Map<string, ProjectPermissions>();
|
|
|
|
private doc(projectId: string): ProjectPermissions {
|
|
if (!this.docs.has(projectId)) {
|
|
this.docs.set(projectId, { version: 1, agents: [] });
|
|
}
|
|
return this.docs.get(projectId)!;
|
|
}
|
|
|
|
async getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
|
|
return structuredClone(this.doc(projectId));
|
|
}
|
|
|
|
async updateProjectPermissions(
|
|
projectId: string,
|
|
permissions: PermissionSet | null,
|
|
): Promise<ProjectPermissions> {
|
|
const doc = this.doc(projectId);
|
|
if (permissions) doc.projectDefaults = structuredClone(permissions);
|
|
else delete doc.projectDefaults;
|
|
return structuredClone(doc);
|
|
}
|
|
|
|
async updateAgentPermissions(
|
|
projectId: string,
|
|
agentId: string,
|
|
permissions: PermissionSet | null,
|
|
): Promise<ProjectPermissions> {
|
|
const doc = this.doc(projectId);
|
|
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
|
|
if (permissions) agents.push({ agentId, permissions: structuredClone(permissions) });
|
|
doc.agents = agents;
|
|
return structuredClone(doc);
|
|
}
|
|
|
|
async resolveAgentPermissions(
|
|
projectId: string,
|
|
agentId: string,
|
|
): Promise<EffectivePermissions | null> {
|
|
const doc = this.doc(projectId);
|
|
const project = doc.projectDefaults;
|
|
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
|
|
if (!project && !agent) return null;
|
|
return {
|
|
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
|
|
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
|
};
|
|
}
|
|
}
|
|
|
|
export class MockWorkStateGateway implements WorkStateGateway {
|
|
private states = new Map<string, ProjectWorkState>();
|
|
|
|
/** Seeds the read-model returned for a project (deterministic tests/dev). */
|
|
_setProjectWorkState(projectId: string, state: unknown): void {
|
|
this.states.set(projectId, normalizeProjectWorkState(state));
|
|
}
|
|
|
|
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
|
return structuredClone(
|
|
this.states.get(projectId) ?? { agents: [], conversations: [] },
|
|
);
|
|
}
|
|
|
|
async cancelBackgroundTask(_taskId: string): Promise<void> {
|
|
// No-op in the mock; real refresh is driven by domain events.
|
|
}
|
|
|
|
async retryBackgroundTask(_taskId: string): Promise<void> {
|
|
// No-op in the mock; real refresh is driven by domain events.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link ConversationGateway} with **real** pagination, mirroring the
|
|
* LS6 backend slicing (`crates/infrastructure/.../conversation_log/mod.rs::page`
|
|
* + `domain::clamp_page_limit`): turns stored oldest→newest; anchor pagination
|
|
* is **strict** (turns strictly before/after the anchor); `limit` defaults to 50
|
|
* and is clamped to `[1, 200]`; `hasMore` is true when more turns exist in the
|
|
* travel direction; `nextAnchor` is the **last** (most recent) turn of the page.
|
|
* Full turn text is preserved (never truncated).
|
|
*/
|
|
export class MockConversationGateway implements ConversationGateway {
|
|
private threads = new Map<string, TurnView[]>();
|
|
|
|
private key(projectId: string, conversationId: string): string {
|
|
return `${projectId}::${conversationId}`;
|
|
}
|
|
|
|
/** Seeds the turns of a conversation (oldest→newest) for deterministic tests. */
|
|
_setTurns(
|
|
projectId: string,
|
|
conversationId: string,
|
|
turns: TurnView[],
|
|
): void {
|
|
this.threads.set(
|
|
this.key(projectId, conversationId),
|
|
turns.map((t) => structuredClone(t)),
|
|
);
|
|
}
|
|
|
|
async readPage(
|
|
projectId: string,
|
|
conversationId: string,
|
|
request?: ConversationPageRequest,
|
|
): Promise<TurnPage> {
|
|
const all = this.threads.get(this.key(projectId, conversationId)) ?? [];
|
|
const len = all.length;
|
|
const raw = request?.limit;
|
|
const limit = Math.min(Math.max(raw && raw > 0 ? raw : 50, 1), 200);
|
|
const direction = request?.direction ?? "backward";
|
|
const anchor = request?.anchor;
|
|
|
|
let start: number;
|
|
let end: number;
|
|
let hasMore: boolean;
|
|
if (anchor === undefined) {
|
|
if (direction === "forward") {
|
|
start = 0;
|
|
end = Math.min(limit, len);
|
|
hasMore = end < len;
|
|
} else {
|
|
start = Math.max(len - limit, 0);
|
|
end = len;
|
|
hasMore = start > 0;
|
|
}
|
|
} else {
|
|
const pos = all.findIndex((t) => t.id === anchor);
|
|
if (pos === -1) {
|
|
start = 0;
|
|
end = 0;
|
|
hasMore = false;
|
|
} else if (direction === "forward") {
|
|
start = pos + 1;
|
|
end = Math.min(start + limit, len);
|
|
hasMore = end < len;
|
|
} else {
|
|
end = pos;
|
|
start = Math.max(end - limit, 0);
|
|
hasMore = start > 0;
|
|
}
|
|
}
|
|
|
|
const turns = all.slice(start, end).map((t) => structuredClone(t));
|
|
const last = turns.at(-1);
|
|
return {
|
|
turns,
|
|
hasMore,
|
|
...(last ? { nextAnchor: last.id } : {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link TicketGateway} with real optimistic-concurrency semantics:
|
|
* every mutation checks `expectedVersion` against the stored version and rejects
|
|
* a stale write with an `INVALID` {@link GatewayError} whose message contains
|
|
* `"version conflict"` (mirroring the backend `IssueStoreError::VersionConflict`
|
|
* → `AppError::Invalid` mapping). Mutations emit the matching `Issue*` domain
|
|
* event through the injected {@link MockSystemGateway} so event-driven refresh
|
|
* can be exercised offline.
|
|
*/
|
|
export class MockTicketGateway implements TicketGateway {
|
|
private tickets = new Map<string, Map<string, Ticket>>();
|
|
private carnets = new Map<string, Map<string, string>>();
|
|
private counters = new Map<string, number>();
|
|
private sprints = new Map<string, Map<string, Sprint>>();
|
|
private sprintCounters = new Map<string, number>();
|
|
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
|
private chatSessions = new Map<string, string>();
|
|
private chatCounter = 0;
|
|
|
|
constructor(private readonly system?: MockSystemGateway) {}
|
|
|
|
/** Seeds a ticket for deterministic tests; returns the stored clone. */
|
|
_seedTicket(projectId: string, ticket: Ticket): Ticket {
|
|
const stored = structuredClone(ticket);
|
|
if (stored.sprintId === undefined) stored.sprintId = null;
|
|
this.projectTickets(projectId).set(stored.ref, stored);
|
|
this.counters.set(
|
|
projectId,
|
|
Math.max(this.counters.get(projectId) ?? 0, stored.number),
|
|
);
|
|
return structuredClone(stored);
|
|
}
|
|
|
|
/** Seeds a sprint for deterministic tests; returns the stored clone. */
|
|
_seedSprint(
|
|
projectId: string,
|
|
sprint: Pick<Sprint, "id" | "order" | "name"> &
|
|
Partial<Pick<Sprint, "status" | "ticketCount" | "version">>,
|
|
): Sprint {
|
|
const stored: Sprint = {
|
|
status: "planned",
|
|
ticketCount: 0,
|
|
version: 1,
|
|
...sprint,
|
|
};
|
|
this.projectSprints(projectId).set(stored.id, stored);
|
|
return structuredClone(stored);
|
|
}
|
|
|
|
private projectSprints(projectId: string): Map<string, Sprint> {
|
|
let map = this.sprints.get(projectId);
|
|
if (!map) {
|
|
map = new Map();
|
|
this.sprints.set(projectId, map);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** Recomputes `ticketCount` for every sprint from the current tickets. */
|
|
private recountSprints(projectId: string): void {
|
|
const sprints = this.projectSprints(projectId);
|
|
const counts = new Map<string, number>();
|
|
for (const t of this.projectTickets(projectId).values()) {
|
|
if (t.sprintId) counts.set(t.sprintId, (counts.get(t.sprintId) ?? 0) + 1);
|
|
}
|
|
for (const s of sprints.values()) s.ticketCount = counts.get(s.id) ?? 0;
|
|
}
|
|
|
|
private projectTickets(projectId: string): Map<string, Ticket> {
|
|
let map = this.tickets.get(projectId);
|
|
if (!map) {
|
|
map = new Map();
|
|
this.tickets.set(projectId, map);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
private projectCarnets(projectId: string): Map<string, string> {
|
|
let map = this.carnets.get(projectId);
|
|
if (!map) {
|
|
map = new Map();
|
|
this.carnets.set(projectId, map);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
private require(projectId: string, ref: string): Ticket {
|
|
const ticket = this.projectTickets(projectId).get(ref);
|
|
if (!ticket) {
|
|
throw { code: "NOT_FOUND", message: `ticket ${ref} not found` } as GatewayError;
|
|
}
|
|
return ticket;
|
|
}
|
|
|
|
private guard(ticket: Ticket, expectedVersion: number): void {
|
|
if (ticket.version !== expectedVersion) {
|
|
throw {
|
|
code: "INVALID",
|
|
message: `issue version conflict: expected ${expectedVersion}, actual ${ticket.version}`,
|
|
} as GatewayError;
|
|
}
|
|
}
|
|
|
|
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
|
|
const number = (this.counters.get(projectId) ?? 0) + 1;
|
|
this.counters.set(projectId, number);
|
|
const now = Date.now();
|
|
const ticket: Ticket = {
|
|
id: `mock-ticket-${projectId}-${number}`,
|
|
ref: `#${number}`,
|
|
number,
|
|
title: input.title,
|
|
description: input.description ?? "",
|
|
status: input.status ?? "open",
|
|
priority: input.priority ?? "medium",
|
|
sprintId: null,
|
|
links: [],
|
|
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
|
|
createdBy: { kind: "user" },
|
|
updatedBy: { kind: "user" },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
version: 1,
|
|
};
|
|
this.projectTickets(projectId).set(ticket.ref, ticket);
|
|
this.system?.emit({
|
|
type: "issueCreated",
|
|
issueId: ticket.id,
|
|
issueRef: ticket.ref,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async read(
|
|
projectId: string,
|
|
ref: string,
|
|
includeCarnet = false,
|
|
): Promise<Ticket> {
|
|
const ticket = structuredClone(this.require(projectId, ref));
|
|
if (includeCarnet) {
|
|
ticket.carnet = this.projectCarnets(projectId).get(ref) ?? "";
|
|
}
|
|
return ticket;
|
|
}
|
|
|
|
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
|
const q = query ?? {};
|
|
const text = q.text?.trim().toLowerCase();
|
|
// Multi-select facets (ticket #12): OR within a facet, AND across facets; an
|
|
// empty/absent set means "no constraint" (all values pass).
|
|
const statuses = q.statuses ?? [];
|
|
const priorities = q.priorities ?? [];
|
|
let rows = [...this.projectTickets(projectId).values()]
|
|
.filter((t) => statuses.length === 0 || statuses.includes(t.status))
|
|
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
|
|
.filter(
|
|
(t) =>
|
|
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
|
|
)
|
|
.filter(
|
|
(t) =>
|
|
!text ||
|
|
t.title.toLowerCase().includes(text) ||
|
|
t.description.toLowerCase().includes(text),
|
|
);
|
|
rows = q.sort
|
|
? sortTickets(rows, q.sort)
|
|
: rows.sort((a, b) => b.number - a.number);
|
|
const start = Number.parseInt(q.cursor ?? "0", 10) || 0;
|
|
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
|
|
const end = Math.min(rows.length, start + limit);
|
|
return {
|
|
items: rows.slice(start, end).map((t) => ({
|
|
ref: t.ref,
|
|
path: `.ideai/tickets/${t.number}.md`,
|
|
title: t.title,
|
|
status: t.status,
|
|
priority: t.priority,
|
|
sprintId: t.sprintId ?? null,
|
|
assignedAgentIds: [...t.assignedAgentIds],
|
|
updatedAt: t.updatedAt,
|
|
})),
|
|
...(end < rows.length ? { nextCursor: String(end) } : {}),
|
|
};
|
|
}
|
|
|
|
private bump(ticket: Ticket): void {
|
|
ticket.version += 1;
|
|
ticket.updatedAt = Date.now();
|
|
ticket.updatedBy = { kind: "user" };
|
|
}
|
|
|
|
async update(
|
|
projectId: string,
|
|
ref: string,
|
|
input: UpdateTicketInput,
|
|
): Promise<Ticket> {
|
|
const ticket = this.require(projectId, ref);
|
|
this.guard(ticket, input.expectedVersion);
|
|
if (input.title !== undefined) ticket.title = input.title;
|
|
if (input.description !== undefined) ticket.description = input.description;
|
|
if (input.status !== undefined) ticket.status = input.status;
|
|
if (input.priority !== undefined) ticket.priority = input.priority;
|
|
if (input.assignedAgentIds !== undefined) {
|
|
ticket.assignedAgentIds = [...input.assignedAgentIds];
|
|
}
|
|
this.bump(ticket);
|
|
this.system?.emit({
|
|
type: "issueUpdated",
|
|
issueRef: ref,
|
|
version: ticket.version,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async delete(projectId: string, ref: string): Promise<void> {
|
|
// NotFound if absent (mirrors the backend `AppError::NotFound`).
|
|
const ticket = this.require(projectId, ref);
|
|
const freedSprint = ticket.sprintId ?? null;
|
|
this.projectTickets(projectId).delete(ref);
|
|
this.projectCarnets(projectId).delete(ref);
|
|
// Releasing the ticket updates its former sprint's count.
|
|
if (freedSprint) this.recountSprints(projectId);
|
|
this.system?.emit({
|
|
type: "issueDeleted",
|
|
projectId,
|
|
issueRef: ref,
|
|
freedSprint,
|
|
});
|
|
}
|
|
|
|
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
|
const ticket = this.require(projectId, ref);
|
|
return {
|
|
ref,
|
|
carnet: this.projectCarnets(projectId).get(ref) ?? "",
|
|
version: ticket.version,
|
|
};
|
|
}
|
|
|
|
async updateCarnet(
|
|
projectId: string,
|
|
ref: string,
|
|
carnet: string,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
const ticket = this.require(projectId, ref);
|
|
this.guard(ticket, expectedVersion);
|
|
this.projectCarnets(projectId).set(ref, carnet);
|
|
this.bump(ticket);
|
|
this.system?.emit({
|
|
type: "issueCarnetUpdated",
|
|
issueRef: ref,
|
|
version: ticket.version,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async link(
|
|
projectId: string,
|
|
ref: string,
|
|
targetRef: string,
|
|
kind: TicketLinkKind,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
const ticket = this.require(projectId, ref);
|
|
this.guard(ticket, expectedVersion);
|
|
const exists = ticket.links.some(
|
|
(l: TicketLink) => l.targetRef === targetRef && l.kind === kind,
|
|
);
|
|
if (!exists) ticket.links.push({ targetRef, kind });
|
|
this.bump(ticket);
|
|
this.system?.emit({
|
|
type: "issueLinked",
|
|
issueRef: ref,
|
|
target: targetRef,
|
|
kind,
|
|
version: ticket.version,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async unlink(
|
|
projectId: string,
|
|
ref: string,
|
|
targetRef: string,
|
|
expectedVersion: number,
|
|
kind?: TicketLinkKind,
|
|
): Promise<Ticket> {
|
|
const ticket = this.require(projectId, ref);
|
|
this.guard(ticket, expectedVersion);
|
|
ticket.links = ticket.links.filter(
|
|
(l: TicketLink) =>
|
|
l.targetRef !== targetRef || (kind !== undefined && l.kind !== kind),
|
|
);
|
|
this.bump(ticket);
|
|
this.system?.emit({
|
|
type: "issueUnlinked",
|
|
issueRef: ref,
|
|
target: targetRef,
|
|
kind: kind ?? "relatesTo",
|
|
version: ticket.version,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async assign(
|
|
projectId: string,
|
|
ref: string,
|
|
agentId: string,
|
|
assigned: boolean,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
const ticket = this.require(projectId, ref);
|
|
this.guard(ticket, expectedVersion);
|
|
const has = ticket.assignedAgentIds.includes(agentId);
|
|
if (assigned && !has) ticket.assignedAgentIds.push(agentId);
|
|
if (!assigned && has) {
|
|
ticket.assignedAgentIds = ticket.assignedAgentIds.filter(
|
|
(id) => id !== agentId,
|
|
);
|
|
}
|
|
this.bump(ticket);
|
|
this.system?.emit({
|
|
type: assigned ? "issueAgentAssigned" : "issueAgentUnassigned",
|
|
issueRef: ref,
|
|
agentId,
|
|
version: ticket.version,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async listSprints(projectId: string): Promise<Sprint[]> {
|
|
this.recountSprints(projectId);
|
|
return [...this.projectSprints(projectId).values()]
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((s) => structuredClone(s));
|
|
}
|
|
|
|
async setTicketSprint(
|
|
projectId: string,
|
|
ref: string,
|
|
sprintId: string | null,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
const ticket = this.require(projectId, ref);
|
|
this.guard(ticket, expectedVersion);
|
|
if (sprintId !== null && !this.projectSprints(projectId).has(sprintId)) {
|
|
throw {
|
|
code: "NOT_FOUND",
|
|
message: `sprint ${sprintId} not found`,
|
|
} as GatewayError;
|
|
}
|
|
const from = ticket.sprintId ?? null;
|
|
ticket.sprintId = sprintId;
|
|
this.bump(ticket);
|
|
this.recountSprints(projectId);
|
|
this.system?.emit({
|
|
type: "issueSprintChanged",
|
|
issueRef: ref,
|
|
from,
|
|
to: sprintId,
|
|
version: ticket.version,
|
|
});
|
|
return structuredClone(ticket);
|
|
}
|
|
|
|
async createSprint(projectId: string, name: string): Promise<Sprint> {
|
|
const sprints = this.projectSprints(projectId);
|
|
const seq = (this.sprintCounters.get(projectId) ?? 0) + 1;
|
|
this.sprintCounters.set(projectId, seq);
|
|
const order =
|
|
sprints.size === 0
|
|
? 1
|
|
: Math.max(...[...sprints.values()].map((s) => s.order)) + 1;
|
|
const sprint: Sprint = {
|
|
id: `mock-sprint-${projectId}-${seq}`,
|
|
order,
|
|
name,
|
|
status: "planned",
|
|
ticketCount: 0,
|
|
version: 1,
|
|
};
|
|
sprints.set(sprint.id, sprint);
|
|
this.system?.emit({
|
|
type: "sprintCreated",
|
|
sprintId: sprint.id,
|
|
order: sprint.order,
|
|
});
|
|
return structuredClone(sprint);
|
|
}
|
|
|
|
async renameSprint(
|
|
projectId: string,
|
|
sprintId: string,
|
|
name: string,
|
|
expectedVersion: number,
|
|
): Promise<Sprint> {
|
|
const sprint = this.requireSprint(projectId, sprintId);
|
|
this.guardSprint(sprint, expectedVersion);
|
|
sprint.name = name;
|
|
sprint.version += 1;
|
|
this.system?.emit({
|
|
type: "sprintRenamed",
|
|
sprintId,
|
|
name,
|
|
version: sprint.version,
|
|
});
|
|
return structuredClone(sprint);
|
|
}
|
|
|
|
async reorderSprints(
|
|
projectId: string,
|
|
orderedIds: string[],
|
|
): Promise<Sprint[]> {
|
|
const sprints = this.projectSprints(projectId);
|
|
for (const id of orderedIds) this.requireSprint(projectId, id);
|
|
// Assign 1-based order following the provided id sequence; ids omitted from
|
|
// `orderedIds` keep their relative order after the listed ones.
|
|
const rest = [...sprints.values()]
|
|
.filter((s) => !orderedIds.includes(s.id))
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((s) => s.id);
|
|
[...orderedIds, ...rest].forEach((id, index) => {
|
|
const sprint = sprints.get(id);
|
|
if (sprint) {
|
|
sprint.order = index + 1;
|
|
this.system?.emit({
|
|
type: "sprintReordered",
|
|
sprintId: id,
|
|
order: sprint.order,
|
|
version: sprint.version,
|
|
});
|
|
}
|
|
});
|
|
return this.listSprints(projectId);
|
|
}
|
|
|
|
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
|
this.requireSprint(projectId, sprintId);
|
|
// Delete unassigns the sprint's tickets (it does NOT delete them): they fall
|
|
// back to the "no sprint" bucket.
|
|
for (const ticket of this.projectTickets(projectId).values()) {
|
|
if (ticket.sprintId === sprintId) {
|
|
ticket.sprintId = null;
|
|
this.bump(ticket);
|
|
this.system?.emit({
|
|
type: "issueSprintChanged",
|
|
issueRef: ticket.ref,
|
|
from: sprintId,
|
|
to: null,
|
|
version: ticket.version,
|
|
});
|
|
}
|
|
}
|
|
this.projectSprints(projectId).delete(sprintId);
|
|
this.system?.emit({ type: "sprintDeleted", sprintId });
|
|
}
|
|
|
|
async openTicketChat(
|
|
projectId: string,
|
|
issueRef: string,
|
|
profileId: string,
|
|
): Promise<TicketChat> {
|
|
// Ensure the ticket exists (mirrors the backend resolving the ref).
|
|
this.require(projectId, issueRef);
|
|
const sessionId = `mock-ticket-chat-${++this.chatCounter}`;
|
|
this.chatSessions.set(issueRef, sessionId);
|
|
this.system?.emit({ type: "ticketAssistantOpened", issueRef, profileId });
|
|
return { sessionId, requester: "ticket-assistant", issueRef };
|
|
}
|
|
|
|
async closeTicketChat(_projectId: string, issueRef: string): Promise<void> {
|
|
if (this.chatSessions.delete(issueRef)) {
|
|
this.system?.emit({ type: "ticketAssistantClosed", issueRef });
|
|
}
|
|
}
|
|
|
|
async sendTicketChat(
|
|
sessionId: string,
|
|
message: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
): Promise<void> {
|
|
if (![...this.chatSessions.values()].includes(sessionId)) {
|
|
throw {
|
|
code: "NOT_FOUND",
|
|
message: `chat session ${sessionId} not found`,
|
|
} as GatewayError;
|
|
}
|
|
// Simulate a streamed assistant turn: a couple of text deltas, then the
|
|
// deterministic `final` chunk. Delivered across microtasks (like a real
|
|
// Channel), so a consumer that awaits sees the whole turn.
|
|
const content = `Assistant: reçu « ${message} ».`;
|
|
const chunks: ReplyChunk[] = [
|
|
{ kind: "textDelta", text: "Assistant: reçu " },
|
|
{ kind: "textDelta", text: `« ${message} ».` },
|
|
{ kind: "final", content },
|
|
];
|
|
for (const chunk of chunks) {
|
|
await Promise.resolve();
|
|
onChunk(chunk);
|
|
}
|
|
}
|
|
|
|
private requireSprint(projectId: string, sprintId: string): Sprint {
|
|
const sprint = this.projectSprints(projectId).get(sprintId);
|
|
if (!sprint) {
|
|
throw {
|
|
code: "NOT_FOUND",
|
|
message: `sprint ${sprintId} not found`,
|
|
} as GatewayError;
|
|
}
|
|
return sprint;
|
|
}
|
|
|
|
private guardSprint(sprint: Sprint, expectedVersion: number): void {
|
|
if (sprint.version !== expectedVersion) {
|
|
throw {
|
|
code: "INVALID",
|
|
message: `sprint version conflict: expected ${expectedVersion}, actual ${sprint.version}`,
|
|
} as GatewayError;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory {@link UiPreferencesGateway} for tests/dev (ticket #29). Mirrors the
|
|
* localStorage adapter's best-effort JSON round-trip without touching the DOM,
|
|
* so persisted-filter behaviour can be exercised offline.
|
|
*/
|
|
export class MockUiPreferencesGateway implements UiPreferencesGateway {
|
|
private readonly store = new Map<string, string>();
|
|
|
|
read(key: string): unknown {
|
|
const raw = this.store.get(key);
|
|
if (raw === undefined) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
this.store.delete(key);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
write(key: string, value: unknown): void {
|
|
try {
|
|
this.store.set(key, JSON.stringify(value));
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
remove(key: string): void {
|
|
this.store.delete(key);
|
|
}
|
|
}
|
|
|
|
function mostRestrictive(
|
|
project?: PermissionSet["fallback"],
|
|
agent?: PermissionSet["fallback"],
|
|
): PermissionSet["fallback"] {
|
|
const rank = { allow: 0, ask: 1, deny: 2 } as const;
|
|
const fallback = project ?? agent ?? "ask";
|
|
if (!agent) return fallback;
|
|
return rank[agent] >= rank[fallback] ? agent : fallback;
|
|
}
|
|
|
|
/** Builds the full set of mock gateways. */
|
|
export function createMockGateways(): Gateways {
|
|
const agentGateway = new MockAgentGateway();
|
|
const systemGateway = new MockSystemGateway();
|
|
return {
|
|
system: systemGateway,
|
|
agent: agentGateway,
|
|
input: new MockInputGateway(),
|
|
terminal: new MockTerminalGateway(),
|
|
project: new MockProjectGateway(),
|
|
layout: new MockLayoutGateway(),
|
|
git: new MockGitGateway(),
|
|
remote: new MockRemoteGateway(),
|
|
profile: new MockProfileGateway(),
|
|
modelServer: new MockModelServerGateway(),
|
|
desktopServer: new MockDesktopServerGateway(),
|
|
template: new MockTemplateGateway(agentGateway),
|
|
skill: new MockSkillGateway(agentGateway),
|
|
memory: new MockMemoryGateway(),
|
|
embedder: new MockEmbedderGateway(),
|
|
device: new MockDeviceGateway(),
|
|
permission: new MockPermissionGateway(),
|
|
workState: new MockWorkStateGateway(),
|
|
conversation: new MockConversationGateway(),
|
|
ticket: new MockTicketGateway(systemGateway),
|
|
window: new MockWindowGateway(),
|
|
focusedProject: new MockFocusedProjectGateway(),
|
|
uiPreferences: new MockUiPreferencesGateway(),
|
|
};
|
|
}
|
|
|
|
// Re-export GatewayError so tests can reference the thrown shape.
|
|
export type { GatewayError };
|