diff --git a/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts b/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts index 93c8747a..7cfd7936 100644 --- a/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts +++ b/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts @@ -11,6 +11,11 @@ export interface RuntimeTurnSettledEvent { turnId?: string; transcriptPath?: string; cwd?: string; + teamName?: string; + memberName?: string; + agentId?: string; + threadId?: string; + outcome?: string; } export function buildRuntimeTurnSettledSourceId(input: { diff --git a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts index 22f3ecfc..1cd69e24 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts @@ -81,6 +81,10 @@ export class TeamRuntimeTurnSettledTargetResolver } async resolve(event: RuntimeTurnSettledEvent): Promise { + if (event.provider === 'codex') { + return this.resolveCodexEvent(event); + } + if (event.provider !== 'claude') { return { ok: false, reason: 'unsupported_provider' }; } @@ -152,6 +156,35 @@ export class TeamRuntimeTurnSettledTargetResolver }; } + private async resolveCodexEvent( + event: RuntimeTurnSettledEvent + ): Promise { + const teamName = event.teamName?.trim(); + const memberName = event.memberName?.trim(); + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const member = await this.resolveActiveMember(teamName, memberName); + if (!member) { + return { ok: false, reason: 'member_not_active' }; + } + if (isReservedMemberName(member.name)) { + return { ok: false, reason: 'reserved_member' }; + } + + const providerId = providerForMember(member); + if (providerId && providerId !== 'codex') { + return { ok: false, reason: 'provider_mismatch' }; + } + + return { + ok: true, + teamName, + memberName: normalizeMemberName(member.name), + }; + } + private async resolveActiveMember( teamName: string, memberName: string diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 324161b2..5fb88636 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -26,6 +26,8 @@ import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTas import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; +import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; +import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { @@ -40,6 +42,7 @@ import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler'; import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths'; import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; +import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment'; import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings'; import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; @@ -90,6 +93,9 @@ export interface MemberWorkSyncFeatureFacade { buildRuntimeTurnSettledHookSettings(input: { provider: RuntimeTurnSettledProvider; }): Promise | null>; + buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null>; drainRuntimeTurnSettledEvents(): Promise; getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics; dispose(): Promise; @@ -127,7 +133,10 @@ export function createMemberWorkSyncFeature(deps: { const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({ paths: runtimeTurnSettledSpoolPaths, }); - const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash); + const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([ + new ClaudeStopHookPayloadNormalizer(hash), + new CodexNativeTurnSettledPayloadNormalizer(hash), + ]); const runtimeTurnSettledTargetResolver = deps.runtimeTurnSettledTargetResolver ?? new TeamRuntimeTurnSettledTargetResolver({ @@ -258,6 +267,16 @@ export function createMemberWorkSyncFeature(deps: { provider, }); }, + buildRuntimeTurnSettledEnvironment: async ({ provider }) => { + if (provider !== 'codex') { + return null; + } + const installed = await runtimeTurnSettledHookInstaller.install(); + return buildRuntimeTurnSettledEnvironment({ + provider, + spoolRoot: installed.spoolRoot, + }); + }, drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(), getQueueDiagnostics: () => queue.getDiagnostics(), dispose: async () => { diff --git a/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts new file mode 100644 index 00000000..89e4c577 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts @@ -0,0 +1,113 @@ +import { + buildRuntimeTurnSettledSourceId, + type RuntimeTurnSettledProvider, +} from '../../core/domain'; +import type { + MemberWorkSyncHashPort, + RuntimeTurnSettledPayloadNormalization, + RuntimeTurnSettledPayloadNormalizerPort, +} from '../../core/application'; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return undefined; +} + +export class CodexNativeTurnSettledPayloadNormalizer + implements RuntimeTurnSettledPayloadNormalizerPort +{ + constructor(private readonly hash: MemberWorkSyncHashPort) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + if (input.provider !== 'codex') { + return { ok: false, reason: 'unsupported_provider' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(input.raw); + } catch { + return { ok: false, reason: 'invalid_json' }; + } + + const payload = asRecord(parsed); + if (!payload) { + return { ok: false, reason: 'payload_not_object' }; + } + + const provider = getString(payload, 'provider'); + if (provider !== 'codex') { + return { ok: false, reason: 'provider_mismatch' }; + } + const source = getString(payload, 'source'); + if (source !== 'agent-teams-orchestrator-codex-native') { + return { ok: false, reason: 'source_mismatch' }; + } + + const eventName = getString(payload, 'eventName', 'event_name'); + const hookEventName = getString(payload, 'hookEventName', 'hook_event_name'); + if (eventName !== 'runtime_turn_settled' && hookEventName !== 'Stop') { + return { ok: false, reason: 'not_turn_settled_event' }; + } + + const sessionId = getString(payload, 'sessionId', 'session_id'); + const teamName = getString(payload, 'teamName', 'team_name'); + const memberName = getString(payload, 'memberName', 'member_name', 'agentName', 'agent_name'); + if (!sessionId) { + return { ok: false, reason: 'missing_session_identity' }; + } + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const payloadHash = this.hash.sha256Hex(input.raw); + const threadId = getString(payload, 'threadId', 'thread_id'); + const turnId = getString(payload, 'turnId', 'turn_id') ?? threadId; + const cwd = getString(payload, 'cwd'); + const agentId = getString(payload, 'agentId', 'agent_id'); + const outcome = getString(payload, 'outcome'); + return { + ok: true, + event: { + schemaVersion: 1, + provider: 'codex', + hookEventName: 'Stop', + payloadHash, + recordedAt: getString(payload, 'recordedAt', 'recorded_at') ?? input.recordedAt, + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'codex', + sessionId, + turnId, + payloadHash, + }), + sessionId, + ...(turnId ? { turnId } : {}), + ...(cwd ? { cwd } : {}), + teamName, + memberName, + ...(agentId ? { agentId } : {}), + ...(threadId ? { threadId } : {}), + ...(outcome ? { outcome } : {}), + }, + }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts new file mode 100644 index 00000000..6ebb6e18 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts @@ -0,0 +1,32 @@ +import type { + RuntimeTurnSettledPayloadNormalization, + RuntimeTurnSettledPayloadNormalizerPort, +} from '../../core/application'; +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +export class CompositeRuntimeTurnSettledPayloadNormalizer + implements RuntimeTurnSettledPayloadNormalizerPort +{ + constructor( + private readonly normalizers: readonly RuntimeTurnSettledPayloadNormalizerPort[] + ) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + let lastUnsupportedReason = 'unsupported_provider'; + for (const normalizer of this.normalizers) { + const result = normalizer.normalize(input); + if (result.ok) { + return result; + } + if (result.reason !== 'unsupported_provider') { + return result; + } + lastUnsupportedReason = result.reason; + } + return { ok: false, reason: lastUnsupportedReason }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts new file mode 100644 index 00000000..13a24693 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts @@ -0,0 +1,16 @@ +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; + +export function buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + spoolRoot: string; +}): Record | null { + if (input.provider !== 'codex') { + return null; + } + return { + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot, + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index ca7e15d1..e28e364f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1254,6 +1254,11 @@ async function initializeServices(): Promise { ? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input) : Promise.resolve(null) ); + teamProvisioningService.setRuntimeTurnSettledEnvironmentProvider((input) => + memberWorkSyncFeature + ? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input) + : Promise.resolve(null) + ); void teamDataService .listTeams() .then(async (teams) => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ca4d17db..096cd4f5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4324,6 +4324,9 @@ export class TeamProvisioningService { private runtimeTurnSettledHookSettingsProvider: | ((input: { provider: RuntimeTurnSettledProvider }) => Promise | null>) | null = null; + private runtimeTurnSettledEnvironmentProvider: + | ((input: { provider: RuntimeTurnSettledProvider }) => Promise | null>) + | null = null; private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map>(); private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set(); private crossTeamSender: @@ -4395,6 +4398,16 @@ export class TeamProvisioningService { this.runtimeTurnSettledHookSettingsProvider = provider; } + setRuntimeTurnSettledEnvironmentProvider( + provider: + | ((input: { + provider: RuntimeTurnSettledProvider; + }) => Promise | null>) + | null + ): void { + this.runtimeTurnSettledEnvironmentProvider = provider; + } + private async buildRuntimeTurnSettledHookSettingsArgs( providerId: TeamProviderId ): Promise { @@ -4415,6 +4428,25 @@ export class TeamProvisioningService { } } + private async buildRuntimeTurnSettledEnvironment( + providerId: TeamProviderId + ): Promise> { + if (providerId !== 'codex' || !this.runtimeTurnSettledEnvironmentProvider) { + return {}; + } + + try { + return (await this.runtimeTurnSettledEnvironmentProvider({ provider: 'codex' })) ?? {}; + } catch (error) { + logger.warn( + `Failed to build member work sync runtime turn-settled environment: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return {}; + } + } + private async readRuntimeProviderLaunchFacts(params: { claudePath: string; cwd: string; @@ -22816,6 +22848,7 @@ export class TeamProvisioningService { }); const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId]; const providerEnv = providerEnvResult.env; + Object.assign(providerEnv, await this.buildRuntimeTurnSettledEnvironment(resolvedProviderId)); const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); if (controlApiBaseUrl) { diff --git a/test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts b/test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts new file mode 100644 index 00000000..e581e683 --- /dev/null +++ b/test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { CodexNativeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer'; +import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; + +describe('CodexNativeTurnSettledPayloadNormalizer', () => { + it('normalizes orchestrator-native Codex turn-settled payloads', () => { + const normalizer = new CodexNativeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + const result = normalizer.normalize({ + provider: 'codex', + raw: JSON.stringify({ + schemaVersion: 1, + provider: 'codex', + source: 'agent-teams-orchestrator-codex-native', + eventName: 'runtime_turn_settled', + hookEventName: 'Stop', + sessionId: 'ses-1', + threadId: 'thread-1', + agentId: 'jack@team-a', + agentName: 'jack', + teamName: 'team-a', + cwd: '/tmp/project', + outcome: 'success', + recordedAt: '2026-04-29T12:00:00.000Z', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }); + + expect(result).toEqual({ + ok: true, + event: expect.objectContaining({ + provider: 'codex', + hookEventName: 'Stop', + sessionId: 'ses-1', + turnId: 'thread-1', + threadId: 'thread-1', + teamName: 'team-a', + memberName: 'jack', + agentId: 'jack@team-a', + cwd: '/tmp/project', + outcome: 'success', + recordedAt: '2026-04-29T12:00:00.000Z', + }), + }); + }); + + it('rejects Codex payloads without durable team/member identity', () => { + const normalizer = new CodexNativeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + expect( + normalizer.normalize({ + provider: 'codex', + raw: JSON.stringify({ + provider: 'codex', + source: 'agent-teams-orchestrator-codex-native', + eventName: 'runtime_turn_settled', + sessionId: 'ses-1', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }) + ).toEqual({ ok: false, reason: 'missing_team_member_identity' }); + }); +}); diff --git a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts index 702bb0fb..81af0c74 100644 --- a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts +++ b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts @@ -74,4 +74,63 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => { }) ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); }); + + it('resolves Codex native turn-settled payloads from durable team/member identity', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => { + throw new Error('codex path should not scan attributed files'); + }), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Jack', providerId: 'codex' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + memberLogsFinder: { + listAttributedMemberFiles: vi.fn(async () => []), + }, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'codex', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'jack' }); + }); + + it('rejects Codex native events for non-Codex teammates', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => []), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Jack', providerId: 'anthropic' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'codex', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); + }); }); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 1605d580..c31cad0d 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -8,6 +8,7 @@ import { createMemberWorkSyncFeature, resolveMemberWorkSyncNudgeSideEffectsEnabled, } from '@features/member-work-sync/main'; +import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment'; const tempRoots: string[] = []; @@ -114,4 +115,31 @@ describe('createMemberWorkSyncFeature composition', () => { await feature.dispose(); } }); + + it('builds Codex turn-settled environment without requiring nudge side effects', async () => { + const root = makeTempRoot(); + const feature = createMemberWorkSyncFeature({ + teamsBasePath: root, + configReader: {} as never, + taskReader: {} as never, + kanbanManager: {} as never, + membersMetaStore: {} as never, + nudgeSideEffectsEnabled: false, + }); + + try { + const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' }); + expect(env).toEqual({ + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( + root, + '.member-work-sync/runtime-hooks' + ), + }); + await expect( + fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + } finally { + await feature.dispose(); + } + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index e19e0b9b..ec69b507 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -149,6 +149,8 @@ const REQUIRED_MOCK_AGENT_TEAMS_TOOLS = [ 'lead_briefing', 'member_briefing', 'message_send', + 'member_work_sync_report', + 'member_work_sync_status', 'process_list', 'process_register', 'process_stop', @@ -1954,6 +1956,20 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.ANTHROPIC_API_KEY).toBe('real-key'); }); + it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => { + const svc = new TeamProvisioningService(); + svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => + provider === 'codex' + ? { AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks' } + : null + ); + + const result = await (svc as any).buildProvisioningEnv('codex'); + + expect(result.authSource).toBe('codex_runtime'); + expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks'); + }); + it('allows help-env resolution to continue even when provisioning env warns', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({