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
11 changes: 7 additions & 4 deletions assistant/src/daemon/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { identityHandlers } from './identity.js';
import { dictationHandlers } from './dictation.js';
import { inboxInviteHandlers } from './config-inbox.js';
import { pairingHandlers } from './pairing.js';
import { recordingHandlers } from './recording.js';

// Re-export types and utilities for backwards compatibility
export type {
Expand All @@ -41,6 +42,11 @@ export {
mergeToolResults,
} from './shared.js';

export {
handleRecordingStart,
handleRecordingStop,
} from './recording.js';

// ─── Typed dispatch ──────────────────────────────────────────────────────────

// Inline handlers for messages not owned by any feature group
Expand Down Expand Up @@ -95,10 +101,6 @@ const inlineHandlers = defineHandlers({
ctx.send(socket, { type: 'assistant_inbox_response', success: false, error: 'Not yet implemented' });
},

// Stub: recording lifecycle updates from the client. Server-side handling
// will be wired in a follow-up milestone (M4).
recording_status: () => { /* no-op — standalone recording finalization not yet implemented */ },

});

const handlers = {
Expand All @@ -122,6 +124,7 @@ const handlers = {
...dictationHandlers,
...inboxInviteHandlers,
...pairingHandlers,
...recordingHandlers,
...inlineHandlers,
} satisfies DispatchMap;

Expand Down
145 changes: 145 additions & 0 deletions assistant/src/daemon/handlers/recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import * as net from 'node:net';
import { v4 as uuid } from 'uuid';
import type { RecordingStatus, RecordingOptions } from '../ipc-protocol.js';
import { log, findSocketForSession, defineHandlers, type HandlerContext } from './shared.js';

// ─── Deterministic maps ──────────────────────────────────────────────────────
// These ensure stop resolves the exact active recording for a conversation,
// prevent ambiguous cross-thread stop behavior, and maintain conversation
// linkage for future file attachment (M4).

/** Maps recordingId -> conversationId. */
const standaloneRecordingConversationId = new Map<string, string>();

/** Maps conversationId -> recordingId (active recording). */
const recordingOwnerByConversation = new Map<string, string>();

// ─── Start ───────────────────────────────────────────────────────────────────

/**
* Initiate a standalone recording for a conversation.
* Generates a unique recording ID, stores deterministic mappings, and sends
* a `recording_start` message to the client.
*/
export function handleRecordingStart(
conversationId: string,
options: RecordingOptions | undefined,
socket: net.Socket,
ctx: HandlerContext,
): string {
const recordingId = uuid();

const existingRecordingId = recordingOwnerByConversation.get(conversationId);
if (existingRecordingId) {
log.warn({ conversationId, existingRecordingId }, 'Overwriting active recording for conversation');
standaloneRecordingConversationId.delete(existingRecordingId);
}

standaloneRecordingConversationId.set(recordingId, conversationId);
recordingOwnerByConversation.set(conversationId, recordingId);
Comment thread
Jasonnnz marked this conversation as resolved.
Comment thread
Jasonnnz marked this conversation as resolved.

ctx.send(socket, {
type: 'recording_start',
recordingId,
attachToConversationId: conversationId,
options,
});

log.info({ recordingId, conversationId }, 'Standalone recording started');
return recordingId;
}

// ─── Stop ────────────────────────────────────────────────────────────────────

/**
* Stop the active standalone recording for a conversation.
* Looks up the recording ID from `recordingOwnerByConversation` and sends
* a `recording_stop` message to the client.
*
* Returns the recording ID if a stop was sent, or `undefined` if no active
* recording was found for the conversation.
*/
export function handleRecordingStop(
conversationId: string,
ctx: HandlerContext,
): string | undefined {
const recordingId = recordingOwnerByConversation.get(conversationId);
if (!recordingId) {
log.debug({ conversationId }, 'No active standalone recording to stop for conversation');
return undefined;
}

// Look up the socket currently bound to the conversation so we can send
// the stop command to the correct client connection.
const socket = findSocketForSession(conversationId, ctx);
if (!socket) {
log.warn({ conversationId, recordingId }, 'Cannot send recording_stop: no socket bound to conversation');
standaloneRecordingConversationId.delete(recordingId);
recordingOwnerByConversation.delete(conversationId);
return undefined;
}
Comment thread
Jasonnnz marked this conversation as resolved.

ctx.send(socket, {
type: 'recording_stop',
recordingId,
});

log.info({ recordingId, conversationId }, 'Standalone recording stop sent');
return recordingId;
}

// ─── Status (client → server lifecycle updates) ─────────────────────────────

function handleRecordingStatus(
msg: RecordingStatus,
_socket: net.Socket,
_ctx: HandlerContext,
): void {
const recordingId = msg.sessionId;
const conversationId = standaloneRecordingConversationId.get(recordingId)
?? msg.attachToConversationId;

switch (msg.status) {
case 'started':
log.info({ recordingId, conversationId }, 'Standalone recording confirmed started by client');
break;

case 'stopped': {
log.info(
{ recordingId, conversationId, filePath: msg.filePath, durationMs: msg.durationMs },
'Standalone recording stopped — file ready',
);
// Clean up deterministic maps. Full finalization (attaching the file
// to the conversation as a message) is M4 scope.
standaloneRecordingConversationId.delete(recordingId);
if (conversationId) {
const current = recordingOwnerByConversation.get(conversationId);
if (current === recordingId) {
recordingOwnerByConversation.delete(conversationId);
}
}
break;
}

case 'failed': {
log.warn(
{ recordingId, conversationId, error: msg.error },
'Standalone recording failed',
);
standaloneRecordingConversationId.delete(recordingId);
if (conversationId) {
const current = recordingOwnerByConversation.get(conversationId);
if (current === recordingId) {
recordingOwnerByConversation.delete(conversationId);
}
}
break;
}
}
}

// ─── Export handler group ────────────────────────────────────────────────────

export const recordingHandlers = defineHandlers({
recording_status: handleRecordingStatus,
});