feat(member-work-sync): ingest opencode turn-settled events
This commit is contained in:
parent
62a1b0e865
commit
0af9994c9a
14 changed files with 3131 additions and 41 deletions
2699
docs/team-management/member-work-sync-opencode-turn-settled-plan.md
Normal file
2699
docs/team-management/member-work-sync-opencode-turn-settled-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
export type RuntimeTurnSettledProvider = 'claude' | 'codex';
|
||||
export type RuntimeTurnSettledProvider = 'claude' | 'codex' | 'opencode';
|
||||
|
||||
export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider {
|
||||
return value === 'claude' || value === 'codex';
|
||||
return value === 'claude' || value === 'codex' || value === 'opencode';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,11 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
|
|||
|
||||
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
|
||||
if (event.provider === 'codex') {
|
||||
return this.resolveCodexEvent(event);
|
||||
return this.resolveProviderOwnedEvent(event, 'codex');
|
||||
}
|
||||
|
||||
if (event.provider === 'opencode') {
|
||||
return this.resolveProviderOwnedEvent(event, 'opencode');
|
||||
}
|
||||
|
||||
if (event.provider !== 'claude') {
|
||||
|
|
@ -154,8 +158,9 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
|
|||
};
|
||||
}
|
||||
|
||||
private async resolveCodexEvent(
|
||||
event: RuntimeTurnSettledEvent
|
||||
private async resolveProviderOwnedEvent(
|
||||
event: RuntimeTurnSettledEvent,
|
||||
expectedProviderId: 'codex' | 'opencode'
|
||||
): Promise<RuntimeTurnSettledTargetResolution> {
|
||||
const teamName = event.teamName?.trim();
|
||||
const memberName = event.memberName?.trim();
|
||||
|
|
@ -172,7 +177,7 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
|
|||
}
|
||||
|
||||
const providerId = providerForMember(member);
|
||||
if (providerId && providerId !== 'codex') {
|
||||
if (providerId && providerId !== expectedProviderId) {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,11 +31,9 @@ import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWo
|
|||
import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths';
|
||||
import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal';
|
||||
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
|
||||
import { OpenCodeTurnSettledPayloadNormalizer } from '../infrastructure/OpenCodeTurnSettledPayloadNormalizer';
|
||||
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
|
||||
import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment';
|
||||
import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings';
|
||||
import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths';
|
||||
import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
|
||||
import { RuntimeTurnSettledSpoolInitializer } from '../infrastructure/RuntimeTurnSettledSpoolInitializer';
|
||||
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
|
||||
|
||||
import type {
|
||||
|
|
@ -82,6 +80,15 @@ export function resolveMemberWorkSyncNudgeSideEffectsEnabled(
|
|||
return false;
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
|
||||
teamsBasePath: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, string> | null> {
|
||||
return new RuntimeTurnSettledSpoolInitializer(input.teamsBasePath).buildEnvironment({
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncFeatureFacade {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
|
|
@ -126,16 +133,14 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
});
|
||||
const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath);
|
||||
const store = new JsonMemberWorkSyncStore(storePaths);
|
||||
const runtimeTurnSettledSpoolPaths = new RuntimeTurnSettledSpoolPaths(deps.teamsBasePath);
|
||||
const runtimeTurnSettledHookInstaller = new ShellRuntimeTurnSettledHookScriptInstaller(
|
||||
runtimeTurnSettledSpoolPaths
|
||||
);
|
||||
const runtimeTurnSettledSpool = new RuntimeTurnSettledSpoolInitializer(deps.teamsBasePath);
|
||||
const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
|
||||
paths: runtimeTurnSettledSpoolPaths,
|
||||
paths: runtimeTurnSettledSpool.getPaths(),
|
||||
});
|
||||
const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([
|
||||
new ClaudeStopHookPayloadNormalizer(hash),
|
||||
new CodexNativeTurnSettledPayloadNormalizer(hash),
|
||||
new OpenCodeTurnSettledPayloadNormalizer(hash),
|
||||
]);
|
||||
const runtimeTurnSettledTargetResolver =
|
||||
deps.runtimeTurnSettledTargetResolver ??
|
||||
|
|
@ -257,27 +262,10 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
})
|
||||
: Promise.resolve(emptyNudgeDispatchSummary()),
|
||||
buildRuntimeTurnSettledHookSettings: async ({ provider }) => {
|
||||
if (provider !== 'claude') {
|
||||
return null;
|
||||
}
|
||||
const installed = await runtimeTurnSettledHookInstaller.install();
|
||||
return buildRuntimeTurnSettledHookSettings({
|
||||
scriptPath: installed.scriptPath,
|
||||
spoolRoot: installed.spoolRoot,
|
||||
provider,
|
||||
});
|
||||
},
|
||||
buildRuntimeTurnSettledEnvironment: async ({ provider }) => {
|
||||
if (provider !== 'codex') {
|
||||
return null;
|
||||
}
|
||||
const installed = await runtimeTurnSettledHookInstaller.install();
|
||||
return buildRuntimeTurnSettledEnvironment({
|
||||
provider,
|
||||
spoolRoot: installed.spoolRoot,
|
||||
});
|
||||
},
|
||||
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
|
||||
runtimeTurnSettledSpool.buildHookSettings({ provider }),
|
||||
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
|
||||
runtimeTurnSettledSpool.buildEnvironment({ provider }),
|
||||
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
|
||||
getQueueDiagnostics: () => queue.getDiagnostics(),
|
||||
dispose: async () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export {
|
|||
} from './adapters/input/registerMemberWorkSyncIpc';
|
||||
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';
|
||||
export {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis
|
|||
import path from 'path';
|
||||
|
||||
import { isRuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
import type {
|
||||
RuntimeTurnSettledClaimedPayload,
|
||||
RuntimeTurnSettledEventStorePort,
|
||||
|
|
@ -25,7 +25,7 @@ export interface FileRuntimeTurnSettledEventStoreDeps {
|
|||
now?: () => Date;
|
||||
}
|
||||
|
||||
function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null {
|
||||
function parseProviderFromFileName(fileName: string): RuntimeTurnSettledProvider | null {
|
||||
const parts = fileName.split('.');
|
||||
const provider = parts.length >= 3 ? parts[parts.length - 2] : null;
|
||||
return isRuntimeTurnSettledProvider(provider) ? provider : null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
buildRuntimeTurnSettledSourceId,
|
||||
type RuntimeTurnSettledProvider,
|
||||
} from '../../core/domain';
|
||||
import type {
|
||||
MemberWorkSyncHashPort,
|
||||
RuntimeTurnSettledPayloadNormalization,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
} from '../../core/application';
|
||||
|
||||
const SUPPORTED_SOURCE = 'agent-teams-orchestrator-opencode';
|
||||
|
||||
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 OpenCodeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
|
||||
constructor(private readonly hash: MemberWorkSyncHashPort) {}
|
||||
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization {
|
||||
if (input.provider !== 'opencode') {
|
||||
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 !== 'opencode') {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
|
||||
const source = getString(payload, 'source');
|
||||
if (source !== SUPPORTED_SOURCE) {
|
||||
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', 'opencodeSessionId');
|
||||
const teamName = getString(payload, 'teamName', 'team_name', 'teamId', 'team_id');
|
||||
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 promptMessageId = getString(
|
||||
payload,
|
||||
'runtimePromptMessageId',
|
||||
'runtime_prompt_message_id',
|
||||
'promptMessageId',
|
||||
'prompt_message_id'
|
||||
);
|
||||
const turnId = getString(payload, 'turnId', 'turn_id') ?? promptMessageId;
|
||||
const cwd = getString(payload, 'cwd', 'projectPath', 'project_path');
|
||||
const agentId = getString(payload, 'agentId', 'agent_id', 'laneId', 'lane_id');
|
||||
const outcome = getString(payload, 'outcome');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
schemaVersion: 1,
|
||||
provider: 'opencode',
|
||||
hookEventName: 'Stop',
|
||||
payloadHash,
|
||||
recordedAt:
|
||||
getString(payload, 'recordedAt', 'recorded_at', 'observedAt', 'observed_at') ??
|
||||
input.recordedAt,
|
||||
sourceId: buildRuntimeTurnSettledSourceId({
|
||||
provider: 'opencode',
|
||||
sessionId,
|
||||
turnId,
|
||||
payloadHash,
|
||||
}),
|
||||
sessionId,
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
teamName,
|
||||
memberName,
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(promptMessageId ? { threadId: promptMessageId } : {}),
|
||||
...(outcome ? { outcome } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
|
||||
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
|
||||
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
|
||||
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export class RuntimeTurnSettledSpoolInitializer {
|
||||
private readonly paths: RuntimeTurnSettledSpoolPaths;
|
||||
private readonly installer: ShellRuntimeTurnSettledHookScriptInstaller;
|
||||
|
||||
constructor(teamsBasePath: string) {
|
||||
this.paths = new RuntimeTurnSettledSpoolPaths(teamsBasePath);
|
||||
this.installer = new ShellRuntimeTurnSettledHookScriptInstaller(this.paths);
|
||||
}
|
||||
|
||||
getPaths(): RuntimeTurnSettledSpoolPaths {
|
||||
return this.paths;
|
||||
}
|
||||
|
||||
async buildHookSettings(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
if (input.provider !== 'claude') {
|
||||
return null;
|
||||
}
|
||||
const installed = await this.installer.install();
|
||||
return buildRuntimeTurnSettledHookSettings({
|
||||
scriptPath: installed.scriptPath,
|
||||
spoolRoot: installed.spoolRoot,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
|
||||
async buildEnvironment(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, string> | null> {
|
||||
if (input.provider !== 'codex' && input.provider !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
const installed = await this.installer.install();
|
||||
return buildRuntimeTurnSettledEnvironment({
|
||||
provider: input.provider,
|
||||
spoolRoot: installed.spoolRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV =
|
||||
'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT';
|
||||
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') {
|
||||
if (input.provider !== 'codex' && input.provider !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
createCodexModelCatalogFeature,
|
||||
} from '@features/codex-model-catalog/main';
|
||||
import {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
type MemberWorkSyncFeatureFacade,
|
||||
registerMemberWorkSyncIpc,
|
||||
|
|
@ -246,6 +247,21 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
|||
|
||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||
try {
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
provider: 'opencode',
|
||||
});
|
||||
if (turnSettledEnv) {
|
||||
Object.assign(bridgeEnv, turnSettledEnv);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge turn-settled spool unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const mcpEntry = mcpLaunchSpec.args[0];
|
||||
|
|
|
|||
|
|
@ -113,6 +113,29 @@ describe('FileRuntimeTurnSettledEventStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('claims OpenCode runtime turn-settled payloads', async () => {
|
||||
const paths = await makePaths();
|
||||
await fs.mkdir(paths.getIncomingDir(), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(paths.getIncomingDir(), '20260429-1.opencode.json'),
|
||||
'{"eventName":"runtime_turn_settled"}',
|
||||
'utf8'
|
||||
);
|
||||
const store = new FileRuntimeTurnSettledEventStore({
|
||||
paths,
|
||||
now: () => new Date('2026-04-29T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
const claimed = await store.claimPending(10);
|
||||
|
||||
expect(claimed).toHaveLength(1);
|
||||
expect(claimed[0]).toMatchObject({
|
||||
fileName: '20260429-1.opencode.json',
|
||||
provider: 'opencode',
|
||||
raw: '{"eventName":"runtime_turn_settled"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reclaim fresh processing payloads from an active drain', async () => {
|
||||
const paths = await makePaths();
|
||||
await fs.mkdir(paths.getProcessingDir(), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer';
|
||||
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
||||
|
||||
describe('OpenCodeTurnSettledPayloadNormalizer', () => {
|
||||
it('normalizes orchestrator-native OpenCode turn-settled payloads', () => {
|
||||
const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter());
|
||||
|
||||
const result = normalizer.normalize({
|
||||
provider: 'opencode',
|
||||
raw: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
provider: 'opencode',
|
||||
source: 'agent-teams-orchestrator-opencode',
|
||||
eventName: 'runtime_turn_settled',
|
||||
hookEventName: 'Stop',
|
||||
sessionId: 'ses-opencode-1',
|
||||
runtimePromptMessageId: 'msg_123',
|
||||
laneId: 'lane-jack',
|
||||
memberName: 'jack',
|
||||
teamName: 'team-a',
|
||||
projectPath: '/tmp/project',
|
||||
outcome: 'success',
|
||||
observedAt: '2026-04-29T12:00:00.000Z',
|
||||
}),
|
||||
recordedAt: '2026-04-29T12:00:01.000Z',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
event: expect.objectContaining({
|
||||
provider: 'opencode',
|
||||
hookEventName: 'Stop',
|
||||
sessionId: 'ses-opencode-1',
|
||||
turnId: 'msg_123',
|
||||
threadId: 'msg_123',
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
agentId: 'lane-jack',
|
||||
cwd: '/tmp/project',
|
||||
outcome: 'success',
|
||||
recordedAt: '2026-04-29T12:00:00.000Z',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects OpenCode payloads without durable team/member identity', () => {
|
||||
const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter());
|
||||
|
||||
expect(
|
||||
normalizer.normalize({
|
||||
provider: 'opencode',
|
||||
raw: JSON.stringify({
|
||||
provider: 'opencode',
|
||||
source: 'agent-teams-orchestrator-opencode',
|
||||
eventName: 'runtime_turn_settled',
|
||||
sessionId: 'ses-opencode-1',
|
||||
}),
|
||||
recordedAt: '2026-04-29T12:00:01.000Z',
|
||||
})
|
||||
).toEqual({ ok: false, reason: 'missing_team_member_identity' });
|
||||
});
|
||||
|
||||
it('rejects payloads from non-agent-teams OpenCode sources', () => {
|
||||
const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter());
|
||||
|
||||
expect(
|
||||
normalizer.normalize({
|
||||
provider: 'opencode',
|
||||
raw: JSON.stringify({
|
||||
provider: 'opencode',
|
||||
source: 'some-other-source',
|
||||
eventName: 'runtime_turn_settled',
|
||||
sessionId: 'ses-opencode-1',
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
}),
|
||||
recordedAt: '2026-04-29T12:00:01.000Z',
|
||||
})
|
||||
).toEqual({ ok: false, reason: 'source_mismatch' });
|
||||
});
|
||||
});
|
||||
|
|
@ -133,4 +133,63 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => {
|
|||
})
|
||||
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
|
||||
});
|
||||
|
||||
it('resolves OpenCode turn-settled payloads from durable team/member identity', async () => {
|
||||
const resolver = new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: {
|
||||
listTeams: vi.fn(async () => {
|
||||
throw new Error('opencode path should not scan attributed files');
|
||||
}),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'team-a',
|
||||
members: [{ name: 'Jack', providerId: 'opencode' }],
|
||||
}) satisfies TeamConfig),
|
||||
},
|
||||
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
||||
memberLogsFinder: {
|
||||
listAttributedMemberFiles: vi.fn(async () => []),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolve({
|
||||
schemaVersion: 1,
|
||||
provider: 'opencode',
|
||||
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 OpenCode events for non-OpenCode teammates', async () => {
|
||||
const resolver = new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: {
|
||||
listTeams: vi.fn(async () => []),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'team-a',
|
||||
members: [{ name: 'Jack', providerId: 'codex' }],
|
||||
}) satisfies TeamConfig),
|
||||
},
|
||||
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolve({
|
||||
schemaVersion: 1,
|
||||
provider: 'opencode',
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
} from '@features/member-work-sync/main';
|
||||
|
|
@ -142,4 +143,50 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('builds OpenCode 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: 'opencode' });
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
it('builds OpenCode bridge environment before feature facade initialization', async () => {
|
||||
const root = makeTempRoot();
|
||||
|
||||
const env = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
teamsBasePath: root,
|
||||
provider: 'opencode',
|
||||
});
|
||||
|
||||
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) });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue