Skip to content
Draft
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
35 changes: 35 additions & 0 deletions main/src/core/eventSink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from './eventSink';

describe('PaneEventSink', () => {
it('noopPaneEventSink ignores sends', () => {
expect(() => noopPaneEventSink.send('session:created', { id: 'session-1' })).not.toThrow();
});

it('fans out channel payloads to every sink', () => {
const sinkA = { send: vi.fn() } satisfies PaneEventSink;
const sinkB = { send: vi.fn() } satisfies PaneEventSink;

const sink = createFanoutEventSink([sinkA, sinkB]);
const payload = { id: 'panel-1' };

sink.send('panel:created', payload, 'extra');

expect(sinkA.send).toHaveBeenCalledWith('panel:created', payload, 'extra');
expect(sinkB.send).toHaveBeenCalledWith('panel:created', payload, 'extra');
});

it('continues delivering after one sink throws', () => {
const failingSink = {
send: vi.fn(() => {
throw new Error('sink failed');
}),
} satisfies PaneEventSink;
const healthySink = { send: vi.fn() } satisfies PaneEventSink;

const sink = createFanoutEventSink([failingSink, healthySink]);

expect(() => sink.send('terminal:output', { panelId: 'panel-1' })).toThrow('sink failed');
expect(healthySink.send).toHaveBeenCalledWith('terminal:output', { panelId: 'panel-1' });
});
});
43 changes: 43 additions & 0 deletions main/src/core/eventSink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Daemon-owned event delivery contract.
*
* The current Electron app will back this with `webContents.send(...)`, but the
* contract is intentionally transport-agnostic so a future local socket,
* WebSocket, or relay-backed client can subscribe to the same runtime events.
* Auth, pairing, relay policy, and hosted VM lifecycle stay above this seam.
*/
export interface PaneEventSink {
send(channel: string, ...args: unknown[]): void;
}

/**
* Safe default sink for tests and boot phases that should not emit renderer
* events yet.
*/
export const noopPaneEventSink: PaneEventSink = {
send: () => undefined,
};

/**
* Fan events out to multiple clients. One client error should not prevent
* delivery to the rest of the connected sinks.
*/
export function createFanoutEventSink(sinks: readonly PaneEventSink[]): PaneEventSink {
return {
send(channel: string, ...args: unknown[]) {
let firstError: unknown;

for (const sink of sinks) {
try {
sink.send(channel, ...args);
} catch (error) {
firstError ??= error;
}
}

if (firstError) {
throw firstError;
}
},
};
}
47 changes: 47 additions & 0 deletions main/src/core/importBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';

const MAIN_SRC_ROOT = path.resolve(process.cwd(), 'src');

function readMainSrcFile(relativePath: string): string {
return fs.readFileSync(path.join(MAIN_SRC_ROOT, relativePath), 'utf8');
}

describe('daemon/client import boundary', () => {
it('keeps targeted services off bootstrap globals', () => {
const serviceFiles = [
'events.ts',
'services/panelManager.ts',
'services/terminalPanelManager.ts',
'services/terminalSessionManager.ts',
'services/runCommandManager.ts',
'services/sessionManager.ts',
'services/scriptExecutionTracker.ts',
'services/taskQueue.ts',
'services/panels/cli/AbstractCliManager.ts',
'services/panels/logPanel/logsManager.ts',
];

for (const relativePath of serviceFiles) {
const source = readMainSrcFile(relativePath);
expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/);
expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/);
}
});

it('routes targeted renderer sends through the event sink adapter', () => {
const eventFiles = [
'events.ts',
'services/panelManager.ts',
'services/terminalPanelManager.ts',
'services/panels/logPanel/logsManager.ts',
];

for (const relativePath of eventFiles) {
const source = readMainSrcFile(relativePath);
expect(source, relativePath).not.toContain('webContents.send(');
expect(source, relativePath).not.toContain('mainWindow');
}
});
});
45 changes: 45 additions & 0 deletions main/src/core/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { ConfigManager } from '../services/configManager';
import {
getPaneEventSink,
getPaneRuntime,
getPaneWebviewContextMap,
getPtyHostRuntime,
getRuntimeConfigManager,
resetPaneRuntimeForTests,
setPaneRuntime,
type PaneRuntime,
} from './runtime';

describe('pane runtime', () => {
afterEach(() => {
resetPaneRuntimeForTests();
});

it('throws until runtime has been initialized', () => {
expect(() => getPaneRuntime()).toThrow('Pane runtime has not been initialized');
expect(() => getRuntimeConfigManager()).toThrow('Pane runtime has not been initialized');
expect(() => getPaneWebviewContextMap()).toThrow('Pane runtime has not been initialized');
});

it('returns the installed runtime and helper accessors', () => {
const configManager = { source: 'test' } as unknown as ConfigManager;
const webviewContextMap = new Map([[1, { panelId: 'panel-1', sessionId: 'session-1' }]]);
const runtime: PaneRuntime = {
eventSink: {
send: () => undefined,
},
getConfigManager: () => configManager,
getPtyHostRuntime: () => null,
getWebviewContextMap: () => webviewContextMap,
};

setPaneRuntime(runtime);

expect(getPaneRuntime()).toBe(runtime);
expect(getPaneEventSink()).toBe(runtime.eventSink);
expect(getRuntimeConfigManager()).toBe(configManager);
expect(getPtyHostRuntime()).toBeNull();
expect(getPaneWebviewContextMap()).toBe(webviewContextMap);
});
});
86 changes: 86 additions & 0 deletions main/src/core/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ConfigManager } from '../services/configManager';
import type { PtyHostSpawnOpts } from '../ptyHost/types';
import { noopPaneEventSink, type PaneEventSink } from './eventSink';

export interface PaneWebviewContext {
panelId: string;
sessionId: string;
}

export interface PtyHandleLike {
readonly id: string;
readonly pid: number;
onData(listener: (data: string) => void): { dispose(): void };
onExit(listener: (exitCode: number | null, signal: number | null) => void): { dispose(): void };
write(data: string): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
kill(signal?: NodeJS.Signals): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
}

/**
* Narrow PTY host contract consumed by daemon-owned services.
*
* This intentionally omits Electron window attachment and other client-facing
* concerns. Those stay in the Electron adapter around the runtime.
*/
export interface PtyHostRuntime {
spawn(opts: PtyHostSpawnOpts): Promise<{ ptyId: string; pid: number }>;
write(ptyId: string, data: string): Promise<void>;
resize(ptyId: string, cols: number, rows: number): Promise<void>;
kill(ptyId: string, signal?: NodeJS.Signals): Promise<void>;
ack(ptyId: string, bytes: number): Promise<void>;
pause(ptyId: string): Promise<void>;
resume(ptyId: string): Promise<void>;
getHandle(ptyId: string): PtyHandleLike | undefined;
postDataToRenderers(ptyId: string, data: string): void;
}

/**
* Daemon runtime dependencies installed by the Electron bootstrap today and by
* a future headless daemon bootstrap later.
*
* This layer is intentionally local-only in Phase 1. Network listeners,
* authentication, pairing, relays, and hosted VM orchestration attach later.
*/
export interface PaneRuntime {
eventSink: PaneEventSink;
getConfigManager(): ConfigManager;
getPtyHostRuntime(): PtyHostRuntime | null;
getWebviewContextMap(): Map<number, PaneWebviewContext>;
}

let paneRuntime: PaneRuntime | null = null;

export function setPaneRuntime(runtime: PaneRuntime): void {
paneRuntime = runtime;
}

export function getPaneRuntime(): PaneRuntime {
if (!paneRuntime) {
throw new Error('Pane runtime has not been initialized');
}

return paneRuntime;
}

export function getPaneEventSink(): PaneEventSink {
return paneRuntime?.eventSink ?? noopPaneEventSink;
}

export function getRuntimeConfigManager(): ConfigManager {
return getPaneRuntime().getConfigManager();
}

export function getPtyHostRuntime(): PtyHostRuntime | null {
return getPaneRuntime().getPtyHostRuntime();
}

export function getPaneWebviewContextMap(): Map<number, PaneWebviewContext> {
return getPaneRuntime().getWebviewContextMap();
}

export function resetPaneRuntimeForTests(): void {
paneRuntime = null;
}
35 changes: 35 additions & 0 deletions main/src/core/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { DatabaseService } from '../database/database';
import type { ConfigManager } from '../services/configManager';
import type { SessionManager } from '../services/sessionManager';
import type { WorktreeManager } from '../services/worktreeManager';
import type { CliManagerFactory } from '../services/cliManagerFactory';
import type { AbstractCliManager } from '../services/panels/cli/AbstractCliManager';
import type { GitDiffManager } from '../services/gitDiffManager';
import type { GitStatusManager } from '../services/gitStatusManager';
import type { ExecutionTracker } from '../services/executionTracker';
import type { WorktreeNameGenerator } from '../services/worktreeNameGenerator';
import type { RunCommandManager } from '../services/runCommandManager';
import type { VersionChecker } from '../services/versionChecker';
import type { Logger } from '../utils/logger';
import type { ArchiveProgressManager } from '../services/archiveProgressManager';

/**
* Daemon-neutral service graph. Electron-only dependencies are intentionally
* excluded so the same runtime can later be hosted by a headless daemon.
*/
export interface CoreServices {
configManager: ConfigManager;
databaseService: DatabaseService;
sessionManager: SessionManager;
worktreeManager: WorktreeManager;
cliManagerFactory: CliManagerFactory;
claudeCodeManager: AbstractCliManager;
gitDiffManager: GitDiffManager;
gitStatusManager: GitStatusManager;
executionTracker: ExecutionTracker;
worktreeNameGenerator: WorktreeNameGenerator;
runCommandManager: RunCommandManager;
versionChecker: VersionChecker;
logger?: Logger;
archiveProgressManager?: ArchiveProgressManager;
}
Loading