feat(member-work-sync): ingest codex turn settled events
This commit is contained in:
parent
adb2b5c351
commit
8e7f7bea43
12 changed files with 424 additions and 1 deletions
|
|
@ -11,6 +11,11 @@ export interface RuntimeTurnSettledEvent {
|
||||||
turnId?: string;
|
turnId?: string;
|
||||||
transcriptPath?: string;
|
transcriptPath?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
teamName?: string;
|
||||||
|
memberName?: string;
|
||||||
|
agentId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
outcome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRuntimeTurnSettledSourceId(input: {
|
export function buildRuntimeTurnSettledSourceId(input: {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,10 @@ export class TeamRuntimeTurnSettledTargetResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
|
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
|
||||||
|
if (event.provider === 'codex') {
|
||||||
|
return this.resolveCodexEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
if (event.provider !== 'claude') {
|
if (event.provider !== 'claude') {
|
||||||
return { ok: false, reason: 'unsupported_provider' };
|
return { ok: false, reason: 'unsupported_provider' };
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +156,35 @@ export class TeamRuntimeTurnSettledTargetResolver
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveCodexEvent(
|
||||||
|
event: RuntimeTurnSettledEvent
|
||||||
|
): Promise<RuntimeTurnSettledTargetResolution> {
|
||||||
|
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(
|
private async resolveActiveMember(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string
|
memberName: string
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTas
|
||||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||||
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||||
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||||
|
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
||||||
|
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
||||||
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
||||||
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +42,7 @@ import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
|
||||||
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
|
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
|
||||||
import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths';
|
import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths';
|
||||||
import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
|
import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
|
||||||
|
import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment';
|
||||||
import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings';
|
import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings';
|
||||||
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
|
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
|
||||||
|
|
||||||
|
|
@ -90,6 +93,9 @@ export interface MemberWorkSyncFeatureFacade {
|
||||||
buildRuntimeTurnSettledHookSettings(input: {
|
buildRuntimeTurnSettledHookSettings(input: {
|
||||||
provider: RuntimeTurnSettledProvider;
|
provider: RuntimeTurnSettledProvider;
|
||||||
}): Promise<Record<string, unknown> | null>;
|
}): Promise<Record<string, unknown> | null>;
|
||||||
|
buildRuntimeTurnSettledEnvironment(input: {
|
||||||
|
provider: RuntimeTurnSettledProvider;
|
||||||
|
}): Promise<Record<string, string> | null>;
|
||||||
drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
|
drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
|
||||||
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
|
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
|
||||||
dispose(): Promise<void>;
|
dispose(): Promise<void>;
|
||||||
|
|
@ -127,7 +133,10 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
|
const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
|
||||||
paths: runtimeTurnSettledSpoolPaths,
|
paths: runtimeTurnSettledSpoolPaths,
|
||||||
});
|
});
|
||||||
const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash);
|
const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([
|
||||||
|
new ClaudeStopHookPayloadNormalizer(hash),
|
||||||
|
new CodexNativeTurnSettledPayloadNormalizer(hash),
|
||||||
|
]);
|
||||||
const runtimeTurnSettledTargetResolver =
|
const runtimeTurnSettledTargetResolver =
|
||||||
deps.runtimeTurnSettledTargetResolver ??
|
deps.runtimeTurnSettledTargetResolver ??
|
||||||
new TeamRuntimeTurnSettledTargetResolver({
|
new TeamRuntimeTurnSettledTargetResolver({
|
||||||
|
|
@ -258,6 +267,16 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
buildRuntimeTurnSettledEnvironment: async ({ provider }) => {
|
||||||
|
if (provider !== 'codex') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const installed = await runtimeTurnSettledHookInstaller.install();
|
||||||
|
return buildRuntimeTurnSettledEnvironment({
|
||||||
|
provider,
|
||||||
|
spoolRoot: installed.spoolRoot,
|
||||||
|
});
|
||||||
|
},
|
||||||
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
|
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
|
||||||
getQueueDiagnostics: () => queue.getDiagnostics(),
|
getQueueDiagnostics: () => queue.getDiagnostics(),
|
||||||
dispose: async () => {
|
dispose: async () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import {
|
||||||
|
buildRuntimeTurnSettledSourceId,
|
||||||
|
type RuntimeTurnSettledProvider,
|
||||||
|
} from '../../core/domain';
|
||||||
|
import type {
|
||||||
|
MemberWorkSyncHashPort,
|
||||||
|
RuntimeTurnSettledPayloadNormalization,
|
||||||
|
RuntimeTurnSettledPayloadNormalizerPort,
|
||||||
|
} from '../../core/application';
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(record: Record<string, unknown>, ...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 } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, string> | null {
|
||||||
|
if (input.provider !== 'codex') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1254,6 +1254,11 @@ async function initializeServices(): Promise<void> {
|
||||||
? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input)
|
? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input)
|
||||||
: Promise.resolve(null)
|
: Promise.resolve(null)
|
||||||
);
|
);
|
||||||
|
teamProvisioningService.setRuntimeTurnSettledEnvironmentProvider((input) =>
|
||||||
|
memberWorkSyncFeature
|
||||||
|
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||||
|
: Promise.resolve(null)
|
||||||
|
);
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.listTeams()
|
.listTeams()
|
||||||
.then(async (teams) => {
|
.then(async (teams) => {
|
||||||
|
|
|
||||||
|
|
@ -4324,6 +4324,9 @@ export class TeamProvisioningService {
|
||||||
private runtimeTurnSettledHookSettingsProvider:
|
private runtimeTurnSettledHookSettingsProvider:
|
||||||
| ((input: { provider: RuntimeTurnSettledProvider }) => Promise<Record<string, unknown> | null>)
|
| ((input: { provider: RuntimeTurnSettledProvider }) => Promise<Record<string, unknown> | null>)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
private runtimeTurnSettledEnvironmentProvider:
|
||||||
|
| ((input: { provider: RuntimeTurnSettledProvider }) => Promise<Record<string, string> | null>)
|
||||||
|
| null = null;
|
||||||
private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map<string, Promise<number>>();
|
private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map<string, Promise<number>>();
|
||||||
private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set<string>();
|
private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set<string>();
|
||||||
private crossTeamSender:
|
private crossTeamSender:
|
||||||
|
|
@ -4395,6 +4398,16 @@ export class TeamProvisioningService {
|
||||||
this.runtimeTurnSettledHookSettingsProvider = provider;
|
this.runtimeTurnSettledHookSettingsProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRuntimeTurnSettledEnvironmentProvider(
|
||||||
|
provider:
|
||||||
|
| ((input: {
|
||||||
|
provider: RuntimeTurnSettledProvider;
|
||||||
|
}) => Promise<Record<string, string> | null>)
|
||||||
|
| null
|
||||||
|
): void {
|
||||||
|
this.runtimeTurnSettledEnvironmentProvider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
private async buildRuntimeTurnSettledHookSettingsArgs(
|
private async buildRuntimeTurnSettledHookSettingsArgs(
|
||||||
providerId: TeamProviderId
|
providerId: TeamProviderId
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
|
|
@ -4415,6 +4428,25 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildRuntimeTurnSettledEnvironment(
|
||||||
|
providerId: TeamProviderId
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
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: {
|
private async readRuntimeProviderLaunchFacts(params: {
|
||||||
claudePath: string;
|
claudePath: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
@ -22816,6 +22848,7 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId];
|
const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId];
|
||||||
const providerEnv = providerEnvResult.env;
|
const providerEnv = providerEnvResult.env;
|
||||||
|
Object.assign(providerEnv, await this.buildRuntimeTurnSettledEnvironment(resolvedProviderId));
|
||||||
|
|
||||||
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
||||||
if (controlApiBaseUrl) {
|
if (controlApiBaseUrl) {
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -74,4 +74,63 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => {
|
||||||
})
|
})
|
||||||
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
|
).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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
createMemberWorkSyncFeature,
|
createMemberWorkSyncFeature,
|
||||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||||
} from '@features/member-work-sync/main';
|
} from '@features/member-work-sync/main';
|
||||||
|
import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment';
|
||||||
|
|
||||||
const tempRoots: string[] = [];
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
|
@ -114,4 +115,31 @@ describe('createMemberWorkSyncFeature composition', () => {
|
||||||
await feature.dispose();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ const REQUIRED_MOCK_AGENT_TEAMS_TOOLS = [
|
||||||
'lead_briefing',
|
'lead_briefing',
|
||||||
'member_briefing',
|
'member_briefing',
|
||||||
'message_send',
|
'message_send',
|
||||||
|
'member_work_sync_report',
|
||||||
|
'member_work_sync_status',
|
||||||
'process_list',
|
'process_list',
|
||||||
'process_register',
|
'process_register',
|
||||||
'process_stop',
|
'process_stop',
|
||||||
|
|
@ -1954,6 +1956,20 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
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 () => {
|
it('allows help-env resolution to continue even when provisioning env warns', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue