Advertisement

← vibesdk

sdk/src/session.ts

11.4 KB Download file
import type {
	AgentConnection,
	AgentConnectionOptions,
	AgentWsServerMessage,
	BehaviorType,
	BuildStartEvent,
	Credentials,
	FileTreeNode,
	ImageAttachment,
	PhaseEventType,
	PhaseInfo,
	PhaseTimelineEvent,
	ProjectType,
	SessionDeployable,
	SessionFiles,
	WaitForPhaseOptions,
	WaitOptions,
	WsMessageOf,
} from './types';
import { SessionStateStore } from './state';
import { createAgentConnection } from './ws';
import { WorkspaceStore } from './workspace';
import type { HttpClient } from './http';

export type WaitUntilReadyOptions = WaitOptions;

export type BuildSessionConnectOptions = Omit<AgentConnectionOptions, 'credentials'> & {
	/** If true (default), send `get_conversation_state` on socket open. */
	autoRequestConversationState?: boolean;
	/** Credentials to send via session_init after connection. */
	credentials?: Credentials;
};

type BuildSessionInit = {
	httpClient: HttpClient;
	defaultCredentials?: Credentials;
};

function buildFileTree(paths: string[]): FileTreeNode[] {
	type Dir = {
		name: string;
		path: string;
		dirs: Map<string, Dir>;
		files: FileTreeNode[];
	};

	const root: Dir = { name: '', path: '', dirs: new Map(), files: [] };

	for (const p of paths) {
		const parts = p.split('/').filter(Boolean);
		let curr = root;
		for (let i = 0; i < parts.length; i += 1) {
			const part = parts[i]!;
			const isLast = i === parts.length - 1;
			if (isLast) {
				curr.files.push({ type: 'file', name: part, path: p });
				continue;
			}

			const nextPath = curr.path ? `${curr.path}/${part}` : part;
			let next = curr.dirs.get(part);
			if (!next) {
				next = { name: part, path: nextPath, dirs: new Map(), files: [] };
				curr.dirs.set(part, next);
			}
			curr = next;
		}
	}

	function toNodes(dir: Dir): FileTreeNode[] {
		const dirs = Array.from(dir.dirs.values())
			.sort((a, b) => a.name.localeCompare(b.name))
			.map(
				(d) =>
					({
						type: 'dir',
						name: d.name,
						path: d.path,
						children: toNodes(d),
					}) as FileTreeNode,
			);
		const files = dir.files.sort((a, b) => a.name.localeCompare(b.name));
		return [...dirs, ...files];
	}

	return toNodes(root);
}

export class BuildSession {
	readonly agentId: string;
	readonly websocketUrl: string;
	readonly behaviorType: BehaviorType | undefined;
	readonly projectType: ProjectType | string | undefined;

	private connection: AgentConnection | null = null;
	readonly workspace = new WorkspaceStore();
	readonly state = new SessionStateStore();

	readonly files: SessionFiles = {
		listPaths: () => this.workspace.paths(),
		read: (path) => this.workspace.read(path),
		snapshot: () => this.workspace.snapshot(),
		tree: () => buildFileTree(this.workspace.paths()),
	};

	/**
	 * High-level API for accessing the phase timeline.
	 * Phases are seeded from agent_connected and updated on phase events.
	 */
	readonly phases = {
		/** Get all phases in the timeline. */
		list: (): PhaseInfo[] => this.state.get().phases,

		/** Get the currently active phase (first non-completed phase), or undefined. */
		current: (): PhaseInfo | undefined =>
			this.state.get().phases.find((p) => p.status !== 'completed' && p.status !== 'cancelled'),

		/** Get all completed phases. */
		completed: (): PhaseInfo[] =>
			this.state.get().phases.filter((p) => p.status === 'completed'),

		/** Get a phase by its id (e.g., "phase-0"). */
		get: (id: string): PhaseInfo | undefined =>
			this.state.get().phases.find((p) => p.id === id),

		/** Get the total count of phases. */
		count: (): number => this.state.get().phases.length,

		/** Check if all phases are completed. */
		allCompleted: (): boolean =>
			this.state.get().phases.length > 0 &&
			this.state.get().phases.every((p) => p.status === 'completed'),

		/**
		 * Subscribe to phase timeline changes.
		 * Fires when a phase is added or when a phase's status/files change.
		 * @returns Unsubscribe function.
		 */
		onChange: (cb: (event: PhaseTimelineEvent) => void): (() => void) =>
			this.state.onPhaseChange(cb),
	};

	readonly wait = {
		generationStarted: (options: WaitOptions = {}) => this.waitForGenerationStarted(options),
		generationComplete: (options: WaitOptions = {}) => this.waitForGenerationComplete(options),
		phase: (options: WaitForPhaseOptions) => this.waitForPhase(options),
		deployable: (options: WaitOptions = {}) => this.waitForDeployable(options),
		previewDeployed: (options: WaitOptions = {}) => this.waitForPreviewDeployed(options),
		cloudflareDeployed: (options: WaitOptions = {}) => this.waitForCloudflareDeployed(options),
	};

	constructor(
		start: BuildStartEvent,
		private init: BuildSessionInit
	) {
		this.agentId = start.agentId;
		this.websocketUrl = start.websocketUrl;
		this.behaviorType = start.behaviorType;
		this.projectType = start.projectType;
	}

	isConnected(): boolean {
		return this.connection !== null;
	}

	/**
	 * Connect to the agent via WebSocket using ticket-based authentication.
	 * Fetches a fresh ticket on initial connect and on each reconnect.
	 */
	async connect(options: BuildSessionConnectOptions = {}): Promise<AgentConnection> {
		if (this.connection) return this.connection;

		const { autoRequestConversationState, credentials, ...connectionOptions } = options;

		// URL provider fetches fresh ticket on each connect/reconnect
		const getUrl = async (): Promise<string> => {
			const { ticket } = await this.init.httpClient.getWsTicket(this.agentId);
			const base = this.websocketUrl;
			const sep = base.includes('?') ? '&' : '?';
			return `${base}${sep}ticket=${encodeURIComponent(ticket)}`;
		};

		this.state.setConnection('connecting');
		this.connection = createAgentConnection(getUrl, connectionOptions);

		// Handle state updates from messages
		this.connection.on('ws:message', (m) => {
			this.workspace.applyWsMessage(m);
			this.state.applyWsMessage(m);
		});

		this.connection.on('ws:open', () => {
			this.state.setConnection('connected');
		});

		this.connection.on('ws:close', () => {
			this.state.setConnection('disconnected');
		});

		// Send credentials and request state on open
		const sessionCredentials = credentials ?? this.init.defaultCredentials;
		const shouldRequestConversationState = autoRequestConversationState ?? true;

		this.connection.on('ws:open', () => {
			if (sessionCredentials) {
				this.connection?.send({
					type: 'session_init',
					credentials: sessionCredentials,
				});
			}
			if (shouldRequestConversationState) {
				this.connection?.send({ type: 'get_conversation_state' });
			}
		});

		return this.connection;
	}

	startGeneration(): void {
		this.assertConnected();
		this.connection!.send({ type: 'generate_all' });
	}

	stop(): void {
		this.assertConnected();
		this.connection!.send({ type: 'stop_generation' });
	}

	followUp(message: string, options?: { images?: ImageAttachment[] }): void {
		this.assertConnected();
		this.connection!.send({
			type: 'user_suggestion',
			message,
			images: options?.images,
		});
	}

	requestConversationState(): void {
		this.assertConnected();
		this.connection!.send({ type: 'get_conversation_state' });
	}

	deployPreview(): void {
		this.assertConnected();
		this.connection!.send({ type: 'preview' });
	}

	deployCloudflare(): void {
		this.assertConnected();
		this.connection!.send({ type: 'deploy' });
	}

	resume(): void {
		this.assertConnected();
		this.connection!.send({ type: 'resume_generation' });
	}

	clearConversation(): void {
		this.assertConnected();
		this.connection!.send({ type: 'clear_conversation' });
	}

	private getDefaultTimeoutMs(): number {
		return 10 * 60_000;
	}

	private async waitForWsMessage(
		predicate: (msg: AgentWsServerMessage) => boolean,
		timeoutMs: number
	): Promise<AgentWsServerMessage> {
		this.assertConnected();
		return await this.connection!.waitFor('ws:message', predicate, timeoutMs);
	}

	async waitForGenerationStarted(options: WaitOptions = {}): Promise<WsMessageOf<'generation_started'>> {
		return await this.waitForMessageType('generation_started', options.timeoutMs ?? this.getDefaultTimeoutMs());
	}

	async waitForGenerationComplete(options: WaitOptions = {}): Promise<WsMessageOf<'generation_complete'>> {
		return await this.waitForMessageType('generation_complete', options.timeoutMs ?? this.getDefaultTimeoutMs());
	}

	async waitForPhase(options: WaitForPhaseOptions): Promise<WsMessageOf<PhaseEventType>> {
		return await this.waitForMessageType(
			options.type,
			options.timeoutMs ?? this.getDefaultTimeoutMs(),
		);
	}

	async waitForDeployable(options: WaitOptions = {}): Promise<SessionDeployable> {
		const timeoutMs = options.timeoutMs ?? this.getDefaultTimeoutMs();
		if (this.behaviorType === 'phasic') {
			await this.waitForPhase({ type: 'phase_validated', timeoutMs });
			return {
				files: this.workspace.paths().length,
				reason: 'phase_validated',
				previewUrl: this.state.get().previewUrl,
			};
		}

		await this.waitForGenerationComplete({ timeoutMs });
		return {
			files: this.workspace.paths().length,
			reason: 'generation_complete',
			previewUrl: this.state.get().previewUrl,
		};
	}

	async waitForPreviewDeployed(options: WaitOptions = {}): Promise<WsMessageOf<'deployment_completed'>> {
		const timeoutMs = options.timeoutMs ?? this.getDefaultTimeoutMs();
		const msg = await this.waitForWsMessage(
			(m) => m.type === 'deployment_completed' || m.type === 'deployment_failed',
			timeoutMs,
		);
		if (msg.type === 'deployment_failed') {
			throw new Error((msg as WsMessageOf<'deployment_failed'>).error);
		}
		return msg as WsMessageOf<'deployment_completed'>;
	}

	async waitForCloudflareDeployed(
		options: WaitOptions = {},
	): Promise<WsMessageOf<'cloudflare_deployment_completed'>> {
		const timeoutMs = options.timeoutMs ?? this.getDefaultTimeoutMs();
		const msg = await this.waitForWsMessage(
			(m) =>
				m.type === 'cloudflare_deployment_completed' || m.type === 'cloudflare_deployment_error',
			timeoutMs,
		);
		if (msg.type === 'cloudflare_deployment_error') {
			throw new Error((msg as WsMessageOf<'cloudflare_deployment_error'>).error);
		}
		return msg as WsMessageOf<'cloudflare_deployment_completed'>;
	}

	/**
	 * Legacy alias. Prefer `session.wait.generationStarted()`.
	 */
	async waitUntilReady(options: WaitUntilReadyOptions = {}): Promise<void> {
		await this.waitForGenerationStarted(options);
	}

	on: AgentConnection['on'] = (event, cb) => {
		this.assertConnected();
		return this.connection!.on(event, cb);
	};

	onAny: AgentConnection['onAny'] = (cb) => {
		this.assertConnected();
		return this.connection!.onAny(cb);
	};

	onMessageType<TType extends AgentWsServerMessage['type']>(
		type: TType,
		cb: (message: WsMessageOf<TType>) => void
	): () => void {
		this.assertConnected();
		return this.connection!.on('ws:message', (msg) => {
			if (msg.type === type) cb(msg as WsMessageOf<TType>);
		});
	}

	async waitForMessageType<TType extends AgentWsServerMessage['type']>(
		type: TType,
		timeoutMs?: number
	): Promise<WsMessageOf<TType>> {
		this.assertConnected();
		return (await this.connection!.waitFor(
			'ws:message',
			(msg) => msg.type === type,
			timeoutMs ?? this.getDefaultTimeoutMs(),
		)) as WsMessageOf<TType>;
	}

	close(): void {
		this.connection?.close();
		this.connection = null;
		this.workspace.clear();
		this.state.clear();
	}

	private assertConnected(): void {
		if (!this.connection) {
			throw new Error('BuildSession is not connected. Call session.connect() first.');
		}
	}
}