diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 2aa62e0..fd0624b 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -10,7 +10,7 @@ import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -import { EJCLAW_ENV } from 'ejclaw-runners-shared'; +import { EJCLAW_ENV, normalizePairedRoomRole } from 'ejclaw-runners-shared'; import { buildCiWatchPrompt, DEFAULT_WATCH_CI_CONTEXT_MODE, @@ -51,11 +51,7 @@ function currentAgentType(): 'claude-code' | 'codex' { } function currentRoomRole(): 'owner' | 'reviewer' | 'arbiter' | undefined { - return roomRole === 'owner' || - roomRole === 'reviewer' || - roomRole === 'arbiter' - ? roomRole - : undefined; + return normalizePairedRoomRole(roomRole); } function writeIpcFile(dir: string, data: object): string { diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index 937138f..523795f 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -3,6 +3,13 @@ export { type RoomRoleContext, } from './room-role-context.js'; export { EJCLAW_ENV, type EjclawEnvName } from './ejclaw-env.js'; +export { + isPairedRoomRole, + normalizePairedRoomRole, + normalizePairedRoomRoleOrNull, + PAIRED_ROOM_ROLES, + type PairedRoomRole, +} from './paired-room-role.js'; export { extractMarkdownImageAttachments, extractMediaAttachments, diff --git a/runners/shared/src/paired-room-role.ts b/runners/shared/src/paired-room-role.ts new file mode 100644 index 0000000..4133129 --- /dev/null +++ b/runners/shared/src/paired-room-role.ts @@ -0,0 +1,19 @@ +export const PAIRED_ROOM_ROLES = ['owner', 'reviewer', 'arbiter'] as const; + +export type PairedRoomRole = (typeof PAIRED_ROOM_ROLES)[number]; + +export function isPairedRoomRole(value: unknown): value is PairedRoomRole { + return value === 'owner' || value === 'reviewer' || value === 'arbiter'; +} + +export function normalizePairedRoomRole( + value: unknown, +): PairedRoomRole | undefined { + return isPairedRoomRole(value) ? value : undefined; +} + +export function normalizePairedRoomRoleOrNull( + value: unknown, +): PairedRoomRole | null { + return normalizePairedRoomRole(value) ?? null; +} diff --git a/runners/shared/src/room-role-context.ts b/runners/shared/src/room-role-context.ts index eceabf3..e32fa33 100644 --- a/runners/shared/src/room-role-context.ts +++ b/runners/shared/src/room-role-context.ts @@ -1,8 +1,9 @@ +import type { PairedRoomRole } from './paired-room-role.js'; import type { RunnerAgentType } from './reviewer-runtime-policy.js'; export interface RoomRoleContext { serviceId: string; - role: 'owner' | 'reviewer' | 'arbiter'; + role: PairedRoomRole; ownerServiceId: string; reviewerServiceId: string; ownerAgentType?: RunnerAgentType; diff --git a/runners/shared/test/paired-room-role.test.ts b/runners/shared/test/paired-room-role.test.ts new file mode 100644 index 0000000..dbebbf2 --- /dev/null +++ b/runners/shared/test/paired-room-role.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { + PAIRED_ROOM_ROLES, + isPairedRoomRole, + normalizePairedRoomRole, + normalizePairedRoomRoleOrNull, +} from '../src/paired-room-role.js'; + +describe('paired room role helpers', () => { + it('defines the supported paired roles', () => { + expect(PAIRED_ROOM_ROLES).toEqual(['owner', 'reviewer', 'arbiter']); + }); + + it('recognizes valid paired room roles', () => { + expect(isPairedRoomRole('owner')).toBe(true); + expect(isPairedRoomRole('reviewer')).toBe(true); + expect(isPairedRoomRole('arbiter')).toBe(true); + }); + + it('normalizes unknown values consistently', () => { + expect(normalizePairedRoomRole('main')).toBeUndefined(); + expect(normalizePairedRoomRole(null)).toBeUndefined(); + expect(normalizePairedRoomRoleOrNull('main')).toBeNull(); + }); +}); diff --git a/src/db/canonical-role-metadata.ts b/src/db/canonical-role-metadata.ts index 06f6122..739927c 100644 --- a/src/db/canonical-role-metadata.ts +++ b/src/db/canonical-role-metadata.ts @@ -9,7 +9,11 @@ import { inferRoleFromServiceShadow, resolveRoleServiceShadow, } from '../role-service-shadow.js'; -import type { AgentType, PairedRoomRole } from '../types.js'; +import { + normalizePairedRoomRoleOrNull, + type AgentType, + type PairedRoomRole, +} from '../types.js'; import { normalizeStoredAgentType } from './room-registration.js'; interface RequiredRoleMetadataInput { @@ -336,14 +340,6 @@ export function readCanonicalChannelOwnerLeaseMetadata(input: { }; } -function normalizeHandoffRole( - role: string | null | undefined, -): PairedRoomRole | null { - return role === 'owner' || role === 'reviewer' || role === 'arbiter' - ? role - : null; -} - function assertStoredRoleMatchesServiceShadow(args: { context: string; storedRole: PairedRoomRole | null; @@ -389,11 +385,11 @@ export function fillCanonicalServiceHandoffMetadata(input: { }): CanonicalServiceHandoffMetadata { const context = `service_handoffs(${input.id})`; const sourceRole = - normalizeHandoffRole(input.source_role) ?? - normalizeHandoffRole(input.intended_role); + normalizePairedRoomRoleOrNull(input.source_role) ?? + normalizePairedRoomRoleOrNull(input.intended_role); const targetRole = - normalizeHandoffRole(input.target_role) ?? - normalizeHandoffRole(input.intended_role); + normalizePairedRoomRoleOrNull(input.target_role) ?? + normalizePairedRoomRoleOrNull(input.intended_role); const storedSourceAgentType = normalizeStoredAgentType( input.source_agent_type, ); @@ -541,11 +537,11 @@ export function readCanonicalServiceHandoffMetadata(input: { }): CanonicalServiceHandoffMetadata { const context = `service_handoffs(${input.id})`; const sourceRole = - normalizeHandoffRole(input.source_role) ?? - normalizeHandoffRole(input.intended_role); + normalizePairedRoomRoleOrNull(input.source_role) ?? + normalizePairedRoomRoleOrNull(input.intended_role); const targetRole = - normalizeHandoffRole(input.target_role) ?? - normalizeHandoffRole(input.intended_role); + normalizePairedRoomRoleOrNull(input.target_role) ?? + normalizePairedRoomRoleOrNull(input.intended_role); const sourceServiceId = readStoredHandoffServiceId({ context, field: 'source_service_id', diff --git a/src/ipc-outbound-delivery.ts b/src/ipc-outbound-delivery.ts index abb75fe..a191752 100644 --- a/src/ipc-outbound-delivery.ts +++ b/src/ipc-outbound-delivery.ts @@ -4,6 +4,7 @@ import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js'; import { deliverOpenWorkItem } from './message-runtime-delivery.js'; import { parseVisibleVerdict } from './paired-verdict.js'; import { resolveChannelForDeliveryRole } from './router.js'; +import { normalizePairedRoomRole } from './types.js'; import type { Channel, OutboundAttachment, @@ -66,19 +67,6 @@ export type IpcOutboundDeliveryResult = | 'queued_retry' | 'skipped_recorded_terminal'; -function normalizeDeliveryRole( - senderRole: string | undefined, -): PairedRoomRole | undefined { - if ( - senderRole === 'owner' || - senderRole === 'reviewer' || - senderRole === 'arbiter' - ) { - return senderRole; - } - return undefined; -} - function isTerminalStatusMessage(text: string): boolean { return parseVisibleVerdict(text) !== 'continue'; } @@ -143,7 +131,7 @@ export async function deliverIpcOutboundMessage( deps: IpcOutboundDeliveryDeps, ): Promise { const log = deps.log ?? logger; - const deliveryRole = normalizeDeliveryRole(args.senderRole); + const deliveryRole = normalizePairedRoomRole(args.senderRole); if ( args.runId && (deliveryRole === 'reviewer' || deliveryRole === 'arbiter') && diff --git a/src/ipc-task-processor/schedule.ts b/src/ipc-task-processor/schedule.ts index 12ec9ec..d8074ee 100644 --- a/src/ipc-task-processor/schedule.ts +++ b/src/ipc-task-processor/schedule.ts @@ -9,7 +9,7 @@ import { isWatchCiTask, } from '../task-watch-status.js'; import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js'; -import type { PairedRoomRole, RegisteredGroup } from '../types.js'; +import { normalizePairedRoomRole, type RegisteredGroup } from '../types.js'; type RoomBindings = ReturnType; type ScheduleType = 'cron' | 'interval' | 'once'; @@ -20,14 +20,6 @@ interface ResolvedScheduleTarget { room: RegisteredGroup; } -function normalizePairedRoomRole( - role: string | null | undefined, -): PairedRoomRole | undefined { - return role === 'owner' || role === 'reviewer' || role === 'arbiter' - ? role - : undefined; -} - export function handleScheduleTask( data: TaskIpcPayload, sourceGroup: string, diff --git a/src/types.test.ts b/src/types.test.ts index f8afc97..9c23fc0 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { normalizeAgentOutputPhase, toVisiblePhase } from './types.js'; +import { + normalizeAgentOutputPhase, + normalizePairedRoomRole, + normalizePairedRoomRoleOrNull, + toVisiblePhase, +} from './types.js'; describe('phase helpers', () => { it('maps agent output phases to visible phases', () => { @@ -15,3 +20,17 @@ describe('phase helpers', () => { expect(normalizeAgentOutputPhase('progress')).toBe('progress'); }); }); + +describe('paired room role helpers', () => { + it('normalizes valid paired room roles', () => { + expect(normalizePairedRoomRole('owner')).toBe('owner'); + expect(normalizePairedRoomRole('reviewer')).toBe('reviewer'); + expect(normalizePairedRoomRole('arbiter')).toBe('arbiter'); + }); + + it('rejects invalid paired room roles', () => { + expect(normalizePairedRoomRole('single')).toBeUndefined(); + expect(normalizePairedRoomRole(undefined)).toBeUndefined(); + expect(normalizePairedRoomRoleOrNull('codex')).toBeNull(); + }); +}); diff --git a/src/types.ts b/src/types.ts index b54cc39..422f1b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,20 @@ +import { + PAIRED_ROOM_ROLES, + isPairedRoomRole, + normalizePairedRoomRole, + normalizePairedRoomRoleOrNull, + type PairedRoomRole, +} from 'ejclaw-runners-shared'; import type { VisibleVerdict } from './paired-verdict.js'; +export { + PAIRED_ROOM_ROLES, + isPairedRoomRole, + normalizePairedRoomRole, + normalizePairedRoomRoleOrNull, + type PairedRoomRole, +}; + export interface AgentConfig { timeout?: number; // Default: 300000 (5 minutes) // Per-group model/effort overrides (take precedence over global env vars) @@ -55,8 +70,6 @@ export interface DeleteRecentMessagesByContentOptions { limit?: number; } -export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; - export type PairedTaskStatus = | 'active' | 'review_ready'