Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/desktop/tests/unit/context_store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// FR-001: Local Bus Decoupled Dispatch (context store consumes bus events via ILocalBusPort)
// FR-003: Workspace Isolation (active context is scoped to workspace/lane/session)
// Traces to: ILocalBusPort (event subscription), IWorkspacePort (workspace context)
import { describe, expect, test } from "bun:test";
import {
ActiveContextStore,
INITIAL_ACTIVE_CONTEXT_STATE,
selectActiveContext,
selectRendererSwitchStatus,
} from "../../src/context_store";

describe("ActiveContextStore", () => {
test("keeps a single active context across tab switches", () => {
const store = new ActiveContextStore(INITIAL_ACTIVE_CONTEXT_STATE);

store.dispatch({ type: "workspace.set", workspaceId: "ws_1" });
store.dispatch({ type: "lane.set", laneId: "lane_1" });
store.dispatch({ type: "session.set", sessionId: "session_1" });
store.dispatch({ type: "tab.set", tab: "chat" });
store.dispatch({ type: "tab.set", tab: "project" });

expect(selectActiveContext(store.getState())).toEqual({
workspaceId: "ws_1",
laneId: "lane_1",
sessionId: "session_1",
terminalId: null,
activeTab: "project",
});
});

test("tracks renderer switch rollback status", () => {
const store = new ActiveContextStore(INITIAL_ACTIVE_CONTEXT_STATE);
store.dispatch({
type: "renderer.switch.started",
previousEngine: "ghostty",
targetEngine: "rio",
});
store.dispatch({
type: "renderer.switch.rolled_back",
engine: "ghostty",
message: "renderer rollback to ghostty applied",
});

const status = selectRendererSwitchStatus(store.getState());
expect(status.lastStatus).toBe("rolled_back");
expect(status.targetEngine).toBe("ghostty");
});
});
44 changes: 44 additions & 0 deletions apps/desktop/tests/unit/control_plane.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// FR-001: Local Bus Decoupled Dispatch (control plane boots with ILocalBusPort adapter)
// FR-002: Command Correlation Guarantee (control plane commands must receive correlated responses)
// Traces to: ILocalBusPort (primary port), IWorkspacePort (workspace boot)
import { describe, expect, test } from "bun:test";
import { createRuntime } from "../../../runtime/src";
import { bootDesktop } from "../../src";

describe("EditorlessControlPlane", () => {
test("wires lane/session/terminal actions and keeps context in sync", async () => {
const runtime = createRuntime();
const controlPlane = bootDesktop({ bus: runtime.bus });

const laneResult = await controlPlane.createLane({
workspaceId: "workspace_alpha",
simulateDegrade: true,
});
expect(laneResult.ok).toBe(true);

const laneId = laneResult.laneId as string;
const sessionResult = await controlPlane.ensureSession({
workspaceId: "workspace_alpha",
laneId,
});
expect(sessionResult.ok).toBe(true);

const sessionId = sessionResult.sessionId as string;
const terminalResult = await controlPlane.spawnTerminal({
workspaceId: "workspace_alpha",
laneId,
sessionId,
});
expect(terminalResult.ok).toBe(true);

controlPlane.setActiveTab("chat");
controlPlane.setActiveTab("project");

const tabs = controlPlane.getTabs();
expect(tabs.terminal.context.laneId).toBe(laneId);
expect(tabs.agent.context.sessionId).toBe(sessionId);
expect(tabs.project.context.terminalId).toBe(terminalResult.terminalId);
expect(tabs.chat.diagnostics.resolvedTransport).toBe("cliproxy_harness");
expect(tabs.chat.diagnostics.degradedReason).toBeNull();
});
});
206 changes: 206 additions & 0 deletions apps/desktop/tests/unit/panels/lane_actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// FR-001: Local Bus Decoupled Dispatch (lane actions dispatch commands via ILocalBusPort)
// FR-002: Command Correlation Guarantee (create/close lane commands are correlated)
// Traces to: ILocalBusPort (command dispatch), IWorkspacePort (lane belongs to workspace)
import { LaneActions } from "../../../src/panels/lane_actions";
import type { RuntimeAPI } from "../../../src/panels/lane_actions";

describe("LaneActions", () => {
let actions: LaneActions;
let mockAPI: RuntimeAPI;

const createMockAPI = (): RuntimeAPI => ({
createLane: vi.fn().mockResolvedValue({ id: "lane-new", name: "New Lane" }),
attachLane: vi.fn().mockResolvedValue(undefined),
detachLane: vi.fn().mockResolvedValue(undefined),
cleanupLane: vi.fn().mockResolvedValue(undefined),
});

beforeEach(() => {
mockAPI = createMockAPI();
});

afterEach(() => {
if (actions) {
actions.destroy();
}
});

it("should create lane successfully", async () => {
const onLaneCreated = vi.fn();
actions = new LaneActions({
runtimeAPI: mockAPI,
onLaneCreated,
});

await actions.createLane("ws-1");

expect(mockAPI.createLane).toHaveBeenCalledWith("ws-1");
expect(onLaneCreated).toHaveBeenCalledWith("lane-new");
});

it("should call optimistic callback on create", async () => {
actions = new LaneActions({
runtimeAPI: mockAPI,
});

const optimisticCallback = vi.fn();

await actions.createLane("ws-1", optimisticCallback);

expect(optimisticCallback).toHaveBeenCalled();
});

it("should handle create lane error", async () => {
const error = new Error("API error");
mockAPI.createLane = vi.fn().mockRejectedValue(error);
const onError = vi.fn();

actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
});

await actions.createLane("ws-1");

expect(onError).toHaveBeenCalled();
const errorArg = (onError as any).mock.calls[0][0];
expect(errorArg.code).toBe("CREATE_FAILED");
});

it("should attach lane successfully", async () => {
const onLaneAttached = vi.fn();
actions = new LaneActions({
runtimeAPI: mockAPI,
onLaneAttached,
});

await actions.attachLane("lane-1");

expect(mockAPI.attachLane).toHaveBeenCalledWith("lane-1");
expect(onLaneAttached).toHaveBeenCalledWith("lane-1");
});

it("should handle attach lane error", async () => {
mockAPI.attachLane = vi.fn().mockRejectedValue(new Error("Attach failed"));
const onError = vi.fn();

actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
});

await actions.attachLane("lane-1");

expect(onError).toHaveBeenCalled();
const errorArg = (onError as any).mock.calls[0][0];
expect(errorArg.code).toBe("ATTACH_FAILED");
});

it("should detach lane successfully", async () => {
const onLaneDetached = vi.fn();
actions = new LaneActions({
runtimeAPI: mockAPI,
onLaneDetached,
});

await actions.detachLane("lane-1");

expect(mockAPI.detachLane).toHaveBeenCalledWith("lane-1");
expect(onLaneDetached).toHaveBeenCalledWith("lane-1");
});

it("should handle detach lane error", async () => {
mockAPI.detachLane = vi.fn().mockRejectedValue(new Error("Detach failed"));
const onError = vi.fn();

actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
});

await actions.detachLane("lane-1");

expect(onError).toHaveBeenCalled();
});

it("should cleanup lane successfully", async () => {
const onLaneCleaned = vi.fn();
actions = new LaneActions({
runtimeAPI: mockAPI,
onLaneCleaned,
});

const result = await actions.cleanupLane("lane-1", false);

expect(mockAPI.cleanupLane).toHaveBeenCalledWith("lane-1");
expect(onLaneCleaned).toHaveBeenCalledWith("lane-1");
expect(result).toBe(true);
});

it("should handle cleanup lane error", async () => {
mockAPI.cleanupLane = vi.fn().mockRejectedValue(new Error("Cleanup failed"));
const onError = vi.fn();

actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
});

const result = await actions.cleanupLane("lane-1", false);

expect(onError).toHaveBeenCalled();
expect(result).toBe(false);
});

it("should dismiss error by code", async () => {
const onError = vi.fn();
actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
errorDismissTimeout: 10000,
});

mockAPI.createLane = vi.fn().mockRejectedValue(new Error("Create failed"));
await actions.createLane("ws-1");

expect(onError).toHaveBeenCalled();

actions.dismissError("CREATE_FAILED");
// Error should be dismissed (test passes if no exception thrown)
});

it("should clear all errors", async () => {
const onError = vi.fn();
actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
errorDismissTimeout: 10000,
});

mockAPI.createLane = vi.fn().mockRejectedValue(new Error("Create failed"));
await actions.createLane("ws-1");

mockAPI.attachLane = vi.fn().mockRejectedValue(new Error("Attach failed"));
await actions.attachLane("lane-1");

expect(onError).toHaveBeenCalledTimes(2);

actions.clearAllErrors();
// All errors should be cleared
});

it("should revert optimistic update on error", async () => {
mockAPI.attachLane = vi.fn().mockRejectedValue(new Error("Attach failed"));
const onError = vi.fn();
const revertCallback = vi.fn();

actions = new LaneActions({
runtimeAPI: mockAPI,
onError,
});

await actions.attachLane("lane-1", revertCallback);

expect(revertCallback).toHaveBeenCalled();
});
});
39 changes: 39 additions & 0 deletions apps/runtime/src/ports/IAuditPort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Secondary port: Append-only audit log
*
* Defines the hexagonal-architecture secondary port for the audit
* ledger. Driven-side adapters (SQLiteAuditStore, in-memory, …)
* implement this interface.
*
* FR-005: The runtime MUST persist every bus event to a durable,
* append-only audit store accessible for replay and export.
*/

import type { RuntimeAuditRecord, RuntimeAuditBundle } from "../runtime/types.js";

export interface AuditQuery {
readonly type?: RuntimeAuditRecord["type"];
readonly method?: string;
readonly topic?: string;
readonly since?: string; // ISO-8601 lower bound
readonly limit?: number;
}

/**
* IAuditPort — secondary port for audit event persistence.
*
* @see apps/runtime/src/audit/ledger.ts — default adapter
*/
export interface IAuditPort {
/** Append a single audit record. Must never throw on storage errors — log + swallow. */
append(record: RuntimeAuditRecord): Promise<void>;

/** Query records matching the supplied filter. */
query(filter: AuditQuery): Promise<readonly RuntimeAuditRecord[]>;

/** Export a time-bounded bundle for compliance/replay. */
export(since: string, until: string): Promise<RuntimeAuditBundle>;

/** Apply retention policy — delete records older than the given ISO date. */
purge(before: string): Promise<number>;
}
41 changes: 41 additions & 0 deletions apps/runtime/src/ports/ILocalBusPort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Primary port: Local Message Bus
*
* Defines the hexagonal-architecture primary port for the LocalBus
* event-driven message dispatch layer. Concrete adapters (in-process
* LocalBus, test-double NoopBus, etc.) implement this interface.
*
* FR-001: The runtime MUST route all inter-component messages through
* a single bus port so components remain decoupled.
* FR-002: The bus port MUST support command dispatch with correlated
* response delivery.
*/

import type { CommandEnvelope, ResponseEnvelope, EventEnvelope } from "../protocol/types.js";

/** Handler registered for a method name on the bus. */
export type CommandHandler = (
command: CommandEnvelope,
) => Promise<ResponseEnvelope>;

/** Subscriber notified of bus-wide events. */
export type EventSubscriber = (event: EventEnvelope) => void | Promise<void>;

/**
* ILocalBusPort — primary port for message bus interactions.
*
* @see apps/runtime/src/protocol/bus.ts — default adapter
*/
export interface ILocalBusPort {
/** Register a handler for a given method name. */
register(method: string, handler: CommandHandler): void;

/** Dispatch a command envelope; returns the correlated response. */
dispatch(command: CommandEnvelope): Promise<ResponseEnvelope>;

/** Publish an event to all subscribed listeners. */
publish(event: EventEnvelope): Promise<void>;

/** Subscribe to a topic pattern; returns an unsubscribe function. */
subscribe(topic: string, subscriber: EventSubscriber): () => void;
}
Loading
Loading