feat: add member work sync stop hook ingest
This commit is contained in:
parent
b2edfe5d2a
commit
05d9b5a9b1
28 changed files with 3379 additions and 12 deletions
1767
docs/team-management/member-work-sync-runtime-stop-hook-plan.md
Normal file
1767
docs/team-management/member-work-sync-runtime-stop-hook-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,96 @@
|
|||
import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports';
|
||||
import type {
|
||||
RuntimeTurnSettledEventStorePort,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
RuntimeTurnSettledReconcileQueuePort,
|
||||
RuntimeTurnSettledTargetResolverPort,
|
||||
} from './RuntimeTurnSettledPorts';
|
||||
|
||||
export interface RuntimeTurnSettledIngestorDeps {
|
||||
eventStore: RuntimeTurnSettledEventStorePort;
|
||||
normalizer: RuntimeTurnSettledPayloadNormalizerPort;
|
||||
targetResolver: RuntimeTurnSettledTargetResolverPort;
|
||||
reconcileQueue: RuntimeTurnSettledReconcileQueuePort;
|
||||
clock: MemberWorkSyncClockPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledDrainSummary {
|
||||
claimed: number;
|
||||
enqueued: number;
|
||||
unresolved: number;
|
||||
invalid: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export class RuntimeTurnSettledIngestor {
|
||||
constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}
|
||||
|
||||
async drainPending(limit: number = 50): Promise<RuntimeTurnSettledDrainSummary> {
|
||||
const summary: RuntimeTurnSettledDrainSummary = {
|
||||
claimed: 0,
|
||||
enqueued: 0,
|
||||
unresolved: 0,
|
||||
invalid: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
const payloads = await this.deps.eventStore.claimPending(limit);
|
||||
summary.claimed = payloads.length;
|
||||
|
||||
for (const payload of payloads) {
|
||||
const processedAt = this.deps.clock.now().toISOString();
|
||||
try {
|
||||
const normalized = this.deps.normalizer.normalize({
|
||||
provider: payload.provider,
|
||||
raw: payload.raw,
|
||||
recordedAt: payload.claimedAt,
|
||||
});
|
||||
|
||||
if (!normalized.ok) {
|
||||
summary.invalid += 1;
|
||||
await this.deps.eventStore.markInvalid(payload, {
|
||||
reason: normalized.reason,
|
||||
processedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolution = await this.deps.targetResolver.resolve(normalized.event);
|
||||
if (!resolution.ok) {
|
||||
summary.unresolved += 1;
|
||||
await this.deps.eventStore.markProcessed(payload, {
|
||||
event: normalized.event,
|
||||
outcome: 'unresolved',
|
||||
reason: resolution.reason,
|
||||
processedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
this.deps.reconcileQueue.enqueueRuntimeTurnSettled({
|
||||
teamName: resolution.teamName,
|
||||
memberName: resolution.memberName,
|
||||
event: normalized.event,
|
||||
});
|
||||
summary.enqueued += 1;
|
||||
await this.deps.eventStore.markProcessed(payload, {
|
||||
event: normalized.event,
|
||||
teamName: resolution.teamName,
|
||||
memberName: resolution.memberName,
|
||||
outcome: 'enqueued',
|
||||
processedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
summary.failed += 1;
|
||||
this.deps.logger?.warn('runtime turn settled ingest failed', {
|
||||
filePath: payload.filePath,
|
||||
provider: payload.provider,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { RuntimeTurnSettledEvent, RuntimeTurnSettledProvider } from '../domain';
|
||||
|
||||
export interface RuntimeTurnSettledClaimedPayload {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
claimedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledEventStorePort {
|
||||
claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]>;
|
||||
markProcessed(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledProcessedResult
|
||||
): Promise<void>;
|
||||
markInvalid(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledInvalidResult
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledProcessedResult {
|
||||
event: RuntimeTurnSettledEvent;
|
||||
teamName?: string;
|
||||
memberName?: string;
|
||||
outcome: 'enqueued' | 'unresolved' | 'duplicate';
|
||||
reason?: string;
|
||||
processedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledInvalidResult {
|
||||
reason: string;
|
||||
processedAt: string;
|
||||
}
|
||||
|
||||
export type RuntimeTurnSettledPayloadNormalization =
|
||||
| { ok: true; event: RuntimeTurnSettledEvent }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export interface RuntimeTurnSettledPayloadNormalizerPort {
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization;
|
||||
}
|
||||
|
||||
export type RuntimeTurnSettledTargetResolution =
|
||||
| { ok: true; teamName: string; memberName: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export interface RuntimeTurnSettledTargetResolverPort {
|
||||
resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution>;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledReconcileQueuePort {
|
||||
enqueueRuntimeTurnSettled(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
event: RuntimeTurnSettledEvent;
|
||||
}): void;
|
||||
}
|
||||
|
|
@ -6,3 +6,5 @@ export * from './MemberWorkSyncPendingReportIntentReplayer';
|
|||
export * from './MemberWorkSyncReconciler';
|
||||
export * from './MemberWorkSyncReporter';
|
||||
export * from './ports';
|
||||
export * from './RuntimeTurnSettledIngestor';
|
||||
export * from './RuntimeTurnSettledPorts';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import type { RuntimeTurnSettledProvider } from './RuntimeTurnSettledProvider';
|
||||
|
||||
export interface RuntimeTurnSettledEvent {
|
||||
schemaVersion: 1;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
hookEventName: 'Stop';
|
||||
sourceId: string;
|
||||
payloadHash: string;
|
||||
recordedAt: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
transcriptPath?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export function buildRuntimeTurnSettledSourceId(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
transcriptPath?: string;
|
||||
payloadHash: string;
|
||||
}): string {
|
||||
return [
|
||||
'runtime-turn-settled',
|
||||
input.provider,
|
||||
input.sessionId?.trim() || 'no-session',
|
||||
input.turnId?.trim() || 'no-turn',
|
||||
input.transcriptPath?.trim() || 'no-transcript',
|
||||
input.payloadHash,
|
||||
].join(':');
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export type RuntimeTurnSettledProvider = 'claude' | 'codex';
|
||||
|
||||
export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider {
|
||||
return value === 'claude' || value === 'codex';
|
||||
}
|
||||
|
|
@ -5,4 +5,6 @@ export * from './memberName';
|
|||
export * from './MemberWorkSyncPhase2Readiness';
|
||||
export * from './MemberWorkSyncNudge';
|
||||
export * from './MemberWorkSyncReportValidator';
|
||||
export * from './RuntimeTurnSettledEvent';
|
||||
export * from './RuntimeTurnSettledProvider';
|
||||
export * from './SyncDecisionPolicy';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import type {
|
|||
} from '../../infrastructure/MemberWorkSyncEventQueue';
|
||||
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
|
||||
|
||||
interface MemberTurnSettledEventPayload {
|
||||
memberName?: string;
|
||||
sourceId?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface MemberWorkSyncRosterSource {
|
||||
loadActiveMemberNames(teamName: string): Promise<string[]>;
|
||||
}
|
||||
|
|
@ -37,6 +43,18 @@ function parseToolActivity(detail: string | undefined): ToolActivityEventPayload
|
|||
}
|
||||
}
|
||||
|
||||
function parseMemberTurnSettled(detail: string | undefined): MemberTurnSettledEventPayload | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as MemberTurnSettledEventPayload;
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberWorkSyncTeamChangeRouter {
|
||||
constructor(
|
||||
private readonly rosterSource: MemberWorkSyncRosterSource,
|
||||
|
|
@ -82,6 +100,19 @@ export class MemberWorkSyncTeamChangeRouter {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'member-turn-settled') {
|
||||
const payload = parseMemberTurnSettled(event.detail);
|
||||
const memberName = payload?.memberName?.trim();
|
||||
if (memberName) {
|
||||
this.queue.enqueue({
|
||||
teamName: event.teamName,
|
||||
memberName,
|
||||
triggerReason: 'turn_settled',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'inbox' || event.type === 'lead-message') {
|
||||
const recipient = parseInboxRecipient(event.detail);
|
||||
if (recipient) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import path from 'path';
|
||||
|
||||
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
|
||||
import type {
|
||||
RuntimeTurnSettledTargetResolution,
|
||||
RuntimeTurnSettledTargetResolverPort,
|
||||
} from '../../../core/application';
|
||||
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
|
||||
import type { RuntimeTurnSettledEvent } from '../../../core/domain';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||
import type { TeamMember, TeamSummary } from '@shared/types';
|
||||
|
||||
export interface RuntimeTurnSettledTeamSource {
|
||||
listTeams(): Promise<TeamSummary[]>;
|
||||
getConfig(teamName: string): ReturnType<TeamConfigReader['getConfig']>;
|
||||
}
|
||||
|
||||
export interface AttributedMemberFileSource {
|
||||
listAttributedMemberFiles(
|
||||
teamName: string
|
||||
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]>;
|
||||
}
|
||||
|
||||
export interface TeamRuntimeTurnSettledTargetResolverDeps {
|
||||
teamSource: RuntimeTurnSettledTeamSource;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
memberLogsFinder?: AttributedMemberFileSource;
|
||||
maxTeamsToScan?: number;
|
||||
}
|
||||
|
||||
function memberKey(member: Pick<TeamMember, 'name'>): string {
|
||||
return normalizeMemberName(member.name);
|
||||
}
|
||||
|
||||
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
|
||||
const byName = new Map<string, TeamMember>();
|
||||
for (const member of configMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, member);
|
||||
}
|
||||
}
|
||||
for (const member of metaMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, { ...byName.get(key), ...member });
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function providerForMember(member: TeamMember | undefined): string | undefined {
|
||||
return (
|
||||
normalizeOptionalTeamProviderId(member?.providerId) ??
|
||||
inferTeamProviderIdFromModel(member?.model)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePath(value: string | undefined): string | null {
|
||||
if (!value?.trim()) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(value.trim());
|
||||
}
|
||||
|
||||
export class TeamRuntimeTurnSettledTargetResolver
|
||||
implements RuntimeTurnSettledTargetResolverPort
|
||||
{
|
||||
private readonly memberLogsFinder: AttributedMemberFileSource;
|
||||
private readonly maxTeamsToScan: number;
|
||||
|
||||
constructor(private readonly deps: TeamRuntimeTurnSettledTargetResolverDeps) {
|
||||
this.memberLogsFinder = deps.memberLogsFinder ?? new TeamMemberLogsFinder();
|
||||
this.maxTeamsToScan = Math.max(1, deps.maxTeamsToScan ?? 200);
|
||||
}
|
||||
|
||||
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
|
||||
if (event.provider !== 'claude') {
|
||||
return { ok: false, reason: 'unsupported_provider' };
|
||||
}
|
||||
|
||||
const transcriptPath = normalizePath(event.transcriptPath);
|
||||
const sessionId = event.sessionId?.trim() || null;
|
||||
if (!transcriptPath && !sessionId) {
|
||||
return { ok: false, reason: 'missing_session_identity' };
|
||||
}
|
||||
|
||||
const teams = (await this.deps.teamSource.listTeams())
|
||||
.filter((team) => !team.deletedAt)
|
||||
.slice(0, this.maxTeamsToScan);
|
||||
|
||||
const candidates: Array<{
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
exactPath: boolean;
|
||||
mtimeMs: number;
|
||||
}> = [];
|
||||
|
||||
for (const team of teams) {
|
||||
const attributedFiles = await this.memberLogsFinder
|
||||
.listAttributedMemberFiles(team.teamName)
|
||||
.catch(() => []);
|
||||
for (const file of attributedFiles) {
|
||||
const exactPath = transcriptPath ? normalizePath(file.filePath) === transcriptPath : false;
|
||||
const sessionMatch = sessionId ? file.sessionId === sessionId : false;
|
||||
if (!exactPath && !sessionMatch) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
teamName: team.teamName,
|
||||
memberName: file.memberName,
|
||||
exactPath,
|
||||
mtimeMs: file.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = candidates.sort((left, right) => {
|
||||
if (left.exactPath !== right.exactPath) {
|
||||
return left.exactPath ? -1 : 1;
|
||||
}
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
})[0];
|
||||
|
||||
if (!candidate) {
|
||||
return { ok: false, reason: 'no_matching_member_session' };
|
||||
}
|
||||
|
||||
const member = await this.resolveActiveMember(candidate.teamName, candidate.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 !== 'anthropic') {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
teamName: candidate.teamName,
|
||||
memberName: normalizeMemberName(member.name),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveActiveMember(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<TeamMember | null> {
|
||||
const [config, metaMembers] = await Promise.all([
|
||||
this.deps.teamSource.getConfig(teamName),
|
||||
this.deps.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
if (!config || config.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeMemberName(memberName);
|
||||
return (
|
||||
mergeMembers(config.members ?? [], metaMembers).find(
|
||||
(member) => !member.removedAt && memberKey(member) === normalizedTarget
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,11 +16,16 @@ import {
|
|||
MemberWorkSyncReconciler,
|
||||
MemberWorkSyncReporter,
|
||||
type MemberWorkSyncReconcileContext,
|
||||
RuntimeTurnSettledIngestor,
|
||||
type RuntimeTurnSettledDrainSummary,
|
||||
} from '../../core/application';
|
||||
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
|
||||
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
|
||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
||||
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
||||
import {
|
||||
MemberWorkSyncEventQueue,
|
||||
|
|
@ -31,6 +36,10 @@ import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStoreP
|
|||
import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler';
|
||||
import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal';
|
||||
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
|
||||
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
|
||||
import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths';
|
||||
import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
|
||||
import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings';
|
||||
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
|
||||
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
|
|
@ -39,6 +48,35 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt
|
|||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
import type { MemberWorkSyncLoggerPort } from '../../core/application';
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export const MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV =
|
||||
'CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED';
|
||||
|
||||
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
||||
|
||||
function emptyNudgeDispatchSummary(): MemberWorkSyncNudgeDispatchSummary {
|
||||
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||
}
|
||||
|
||||
export function resolveMemberWorkSyncNudgeSideEffectsEnabled(
|
||||
env: Record<string, string | undefined> = process.env
|
||||
): boolean {
|
||||
const rawValue = env[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV];
|
||||
if (rawValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = rawValue.trim().toLowerCase();
|
||||
if (TRUE_ENV_VALUES.has(value)) {
|
||||
return true;
|
||||
}
|
||||
if (FALSE_ENV_VALUES.has(value)) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncFeatureFacade {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
|
|
@ -48,6 +86,10 @@ export interface MemberWorkSyncFeatureFacade {
|
|||
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
||||
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
|
||||
dispatchDueNudges(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
|
||||
buildRuntimeTurnSettledHookSettings(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, unknown> | null>;
|
||||
drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
|
||||
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
|
@ -60,6 +102,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
membersMetaStore: TeamMembersMetaStore;
|
||||
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||
nudgeSideEffectsEnabled?: boolean;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}): MemberWorkSyncFeatureFacade {
|
||||
const clock = new SystemClockAdapter();
|
||||
|
|
@ -74,18 +117,32 @@ 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 runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
|
||||
paths: runtimeTurnSettledSpoolPaths,
|
||||
});
|
||||
const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash);
|
||||
const runtimeTurnSettledTargetResolver = new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: deps.configReader,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
});
|
||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
|
||||
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
|
||||
const busySignal = new MemberWorkSyncToolActivityBusySignal();
|
||||
const nudgeSideEffectsEnabled =
|
||||
deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled();
|
||||
const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null;
|
||||
const useCaseDeps = {
|
||||
clock,
|
||||
hash,
|
||||
agendaSource,
|
||||
statusStore: store,
|
||||
reportStore: store,
|
||||
outboxStore: store,
|
||||
inboxNudge,
|
||||
...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}),
|
||||
...(inboxNudge ? { inboxNudge } : {}),
|
||||
watchdogCooldown,
|
||||
busySignal,
|
||||
reportToken,
|
||||
|
|
@ -101,16 +158,42 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||
await reconciler.execute(request, context);
|
||||
await nudgeDispatcher.dispatchDue({
|
||||
teamNames: [request.teamName],
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
});
|
||||
if (nudgeSideEffectsEnabled) {
|
||||
await nudgeDispatcher.dispatchDue({
|
||||
teamNames: [request.teamName],
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
isTeamActive: deps.isTeamActive ?? (() => true),
|
||||
logger: deps.logger,
|
||||
});
|
||||
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue);
|
||||
const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames
|
||||
const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({
|
||||
eventStore: runtimeTurnSettledStore,
|
||||
normalizer: runtimeTurnSettledNormalizer,
|
||||
targetResolver: runtimeTurnSettledTargetResolver,
|
||||
reconcileQueue: {
|
||||
enqueueRuntimeTurnSettled: ({ teamName, memberName, event }) => {
|
||||
router.noteTeamChange({
|
||||
type: 'member-turn-settled',
|
||||
teamName,
|
||||
detail: JSON.stringify({
|
||||
memberName,
|
||||
sourceId: event.sourceId,
|
||||
provider: event.provider,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
clock,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const runtimeTurnSettledDrainScheduler = new RuntimeTurnSettledDrainScheduler({
|
||||
drain: () => runtimeTurnSettledIngestor.drainPending(),
|
||||
logger: deps.logger,
|
||||
});
|
||||
const nudgeDispatchScheduler = nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames
|
||||
? new MemberWorkSyncNudgeDispatchScheduler({
|
||||
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
|
||||
dispatchDue: (teamNames) =>
|
||||
|
|
@ -121,6 +204,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
logger: deps.logger,
|
||||
})
|
||||
: null;
|
||||
runtimeTurnSettledDrainScheduler.start();
|
||||
nudgeDispatchScheduler?.start();
|
||||
|
||||
return {
|
||||
|
|
@ -151,9 +235,27 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
);
|
||||
},
|
||||
dispatchDueNudges: (teamNames) =>
|
||||
nudgeDispatcher.dispatchDue({ teamNames, claimedBy: `member-work-sync:${process.pid}` }),
|
||||
nudgeSideEffectsEnabled
|
||||
? nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
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,
|
||||
});
|
||||
},
|
||||
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
|
||||
getQueueDiagnostics: () => queue.getDiagnostics(),
|
||||
dispose: async () => {
|
||||
runtimeTurnSettledDrainScheduler.dispose();
|
||||
await Promise.allSettled([queue.stop(), nudgeDispatchScheduler?.dispose()]);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,5 +2,9 @@ export {
|
|||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
} from './adapters/input/registerMemberWorkSyncIpc';
|
||||
export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature';
|
||||
export {
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
createMemberWorkSyncFeature,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
} from './composition/createMemberWorkSyncFeature';
|
||||
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
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 ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
|
||||
constructor(private readonly hash: MemberWorkSyncHashPort) {}
|
||||
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization {
|
||||
if (input.provider !== 'claude') {
|
||||
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 hookEventName = getString(payload, 'hook_event_name', 'hookEventName');
|
||||
if (hookEventName !== 'Stop') {
|
||||
return { ok: false, reason: 'not_stop_hook' };
|
||||
}
|
||||
|
||||
const payloadHash = this.hash.sha256Hex(input.raw);
|
||||
const event = {
|
||||
schemaVersion: 1 as const,
|
||||
provider: 'claude' as const,
|
||||
hookEventName: 'Stop' as const,
|
||||
payloadHash,
|
||||
recordedAt: input.recordedAt,
|
||||
sourceId: buildRuntimeTurnSettledSourceId({
|
||||
provider: 'claude',
|
||||
sessionId: getString(payload, 'session_id', 'sessionId'),
|
||||
turnId: getString(payload, 'turn_id', 'turnId'),
|
||||
transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'),
|
||||
payloadHash,
|
||||
}),
|
||||
sessionId: getString(payload, 'session_id', 'sessionId'),
|
||||
turnId: getString(payload, 'turn_id', 'turnId'),
|
||||
transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'),
|
||||
cwd: getString(payload, 'cwd'),
|
||||
};
|
||||
|
||||
if (!event.sessionId && !event.transcriptPath) {
|
||||
return { ok: false, reason: 'missing_session_identity' };
|
||||
}
|
||||
|
||||
return { ok: true, event };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { isRuntimeTurnSettledProvider } from '../../core/domain';
|
||||
import type {
|
||||
RuntimeTurnSettledClaimedPayload,
|
||||
RuntimeTurnSettledEventStorePort,
|
||||
RuntimeTurnSettledInvalidResult,
|
||||
RuntimeTurnSettledProcessedResult,
|
||||
} from '../../core/application';
|
||||
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||
|
||||
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
|
||||
const DEFAULT_PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_PROCESSED_RETENTION_COUNT = 1000;
|
||||
|
||||
export interface FileRuntimeTurnSettledEventStoreDeps {
|
||||
paths: RuntimeTurnSettledSpoolPaths;
|
||||
maxPayloadBytes?: number;
|
||||
processedRetentionMs?: number;
|
||||
processedRetentionCount?: number;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null {
|
||||
const parts = fileName.split('.');
|
||||
const provider = parts.length >= 3 ? parts[parts.length - 2] : null;
|
||||
return isRuntimeTurnSettledProvider(provider) ? provider : null;
|
||||
}
|
||||
|
||||
function buildMetaFilePath(filePath: string): string {
|
||||
return `${filePath}.meta.json`;
|
||||
}
|
||||
|
||||
async function moveFileBestEffort(sourcePath: string, targetPath: string): Promise<void> {
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
try {
|
||||
await rename(sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort {
|
||||
private readonly maxPayloadBytes: number;
|
||||
private readonly processedRetentionMs: number;
|
||||
private readonly processedRetentionCount: number;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(private readonly deps: FileRuntimeTurnSettledEventStoreDeps) {
|
||||
this.maxPayloadBytes = deps.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
|
||||
this.processedRetentionMs = deps.processedRetentionMs ?? DEFAULT_PROCESSED_RETENTION_MS;
|
||||
this.processedRetentionCount =
|
||||
deps.processedRetentionCount ?? DEFAULT_PROCESSED_RETENTION_COUNT;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]> {
|
||||
await this.ensureDirectories();
|
||||
|
||||
const entries = await readdir(this.deps.paths.getIncomingDir(), { withFileTypes: true }).catch(
|
||||
() => []
|
||||
);
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((fileName) => !fileName.startsWith('.'))
|
||||
.sort()
|
||||
.slice(0, Math.max(0, limit));
|
||||
|
||||
const claimed: RuntimeTurnSettledClaimedPayload[] = [];
|
||||
for (const fileName of files) {
|
||||
const provider = parseProviderFromFileName(fileName);
|
||||
const incomingPath = path.join(this.deps.paths.getIncomingDir(), fileName);
|
||||
if (!provider) {
|
||||
await this.quarantineIncoming(incomingPath, fileName, 'unsupported_provider');
|
||||
continue;
|
||||
}
|
||||
|
||||
const processingPath = path.join(this.deps.paths.getProcessingDir(), fileName);
|
||||
try {
|
||||
await rename(incomingPath, processingPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileStat = await stat(processingPath).catch(() => null);
|
||||
const payloadTooLarge = Boolean(fileStat?.isFile() && fileStat.size > this.maxPayloadBytes);
|
||||
if (!fileStat?.isFile() || payloadTooLarge) {
|
||||
await this.markInvalid(
|
||||
{
|
||||
id: fileName,
|
||||
filePath: processingPath,
|
||||
fileName,
|
||||
provider,
|
||||
raw: '',
|
||||
claimedAt: this.now().toISOString(),
|
||||
},
|
||||
{
|
||||
reason: payloadTooLarge ? 'payload_too_large' : 'payload_missing',
|
||||
processedAt: this.now().toISOString(),
|
||||
}
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = await readFile(processingPath, 'utf8').catch(() => '');
|
||||
claimed.push({
|
||||
id: fileName,
|
||||
filePath: processingPath,
|
||||
fileName,
|
||||
provider,
|
||||
raw,
|
||||
claimedAt: this.now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
async markProcessed(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledProcessedResult
|
||||
): Promise<void> {
|
||||
const processedPath = path.join(this.deps.paths.getProcessedDir(), payload.fileName);
|
||||
await moveFileBestEffort(payload.filePath, processedPath);
|
||||
await writeFile(buildMetaFilePath(processedPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
await this.cleanupDirectory(this.deps.paths.getProcessedDir());
|
||||
}
|
||||
|
||||
async markInvalid(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledInvalidResult
|
||||
): Promise<void> {
|
||||
const invalidPath = path.join(this.deps.paths.getInvalidDir(), payload.fileName);
|
||||
await moveFileBestEffort(payload.filePath, invalidPath);
|
||||
await writeFile(buildMetaFilePath(invalidPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
await this.cleanupDirectory(this.deps.paths.getInvalidDir());
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
await Promise.all([
|
||||
mkdir(this.deps.paths.getIncomingDir(), { recursive: true }),
|
||||
mkdir(this.deps.paths.getProcessingDir(), { recursive: true }),
|
||||
mkdir(this.deps.paths.getProcessedDir(), { recursive: true }),
|
||||
mkdir(this.deps.paths.getInvalidDir(), { recursive: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
private async quarantineIncoming(
|
||||
incomingPath: string,
|
||||
fileName: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const invalidPath = path.join(this.deps.paths.getInvalidDir(), fileName);
|
||||
await moveFileBestEffort(incomingPath, invalidPath);
|
||||
await writeFile(
|
||||
buildMetaFilePath(invalidPath),
|
||||
`${JSON.stringify({ reason, processedAt: this.now().toISOString() }, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
private async cleanupDirectory(directory: string): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
const files = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && !entry.name.endsWith('.meta.json'))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(directory, entry.name);
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
return fileStat?.isFile() ? { filePath, mtimeMs: fileStat.mtimeMs } : null;
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((entry): entry is { filePath: string; mtimeMs: number } => Boolean(entry))
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
|
||||
const cutoff = this.now().getTime() - this.processedRetentionMs;
|
||||
const toRemove = files.filter(
|
||||
(file, index) => index >= this.processedRetentionCount || file.mtimeMs < cutoff
|
||||
);
|
||||
|
||||
await Promise.allSettled(
|
||||
toRemove.flatMap((file) => [rm(file.filePath, { force: true }), rm(buildMetaFilePath(file.filePath), { force: true })])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export type MemberWorkSyncTriggerReason =
|
|||
| 'member_spawned'
|
||||
| 'tool_finished'
|
||||
| 'runtime_activity'
|
||||
| 'turn_settled'
|
||||
| 'manual_refresh';
|
||||
|
||||
export interface MemberWorkSyncQueueDiagnostics {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import type {
|
||||
MemberWorkSyncLoggerPort,
|
||||
RuntimeTurnSettledDrainSummary,
|
||||
} from '../../core/application';
|
||||
|
||||
export interface RuntimeTurnSettledDrainSchedulerDeps {
|
||||
drain(): Promise<RuntimeTurnSettledDrainSummary>;
|
||||
intervalMs?: number;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export class RuntimeTurnSettledDrainScheduler {
|
||||
private readonly intervalMs: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private running = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly deps: RuntimeTurnSettledDrainSchedulerDeps) {
|
||||
this.intervalMs = Math.max(1_000, deps.intervalMs ?? 15_000);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.disposed || this.timer) {
|
||||
return;
|
||||
}
|
||||
this.schedule(100);
|
||||
}
|
||||
|
||||
async drainNow(): Promise<RuntimeTurnSettledDrainSummary | null> {
|
||||
if (this.running || this.disposed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
return await this.deps.drain();
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('runtime turn settled scheduled drain failed', {
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(delayMs: number = this.intervalMs): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.drainNow().finally(() => this.schedule());
|
||||
}, delayMs);
|
||||
unrefTimer(this.timer);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import path from 'path';
|
||||
|
||||
export class RuntimeTurnSettledSpoolPaths {
|
||||
constructor(private readonly teamsBasePath: string) {}
|
||||
|
||||
getRootDir(): string {
|
||||
return path.join(this.teamsBasePath, '.member-work-sync', 'runtime-hooks');
|
||||
}
|
||||
|
||||
getBinDir(): string {
|
||||
return path.join(this.getRootDir(), 'bin');
|
||||
}
|
||||
|
||||
getHookScriptPath(): string {
|
||||
return path.join(this.getBinDir(), 'turn-settled-hook-v1.sh');
|
||||
}
|
||||
|
||||
getIncomingDir(): string {
|
||||
return path.join(this.getRootDir(), 'incoming');
|
||||
}
|
||||
|
||||
getProcessingDir(): string {
|
||||
return path.join(this.getRootDir(), 'processing');
|
||||
}
|
||||
|
||||
getProcessedDir(): string {
|
||||
return path.join(this.getRootDir(), 'processed');
|
||||
}
|
||||
|
||||
getInvalidDir(): string {
|
||||
return path.join(this.getRootDir(), 'invalid');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import fs from 'fs/promises';
|
||||
|
||||
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||
|
||||
const HOOK_SCRIPT_CONTENT = `#!/bin/sh
|
||||
set +e
|
||||
|
||||
spool_root="$1"
|
||||
provider="$2"
|
||||
max_bytes="\${3:-262144}"
|
||||
|
||||
if [ -z "$spool_root" ] || [ -z "$provider" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$provider" in
|
||||
claude|codex) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
incoming="$spool_root/incoming"
|
||||
mkdir -p "$incoming" 2>/dev/null || exit 0
|
||||
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)"
|
||||
tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0
|
||||
suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')"
|
||||
final="$incoming/$stamp-$$-$suffix.$provider.json"
|
||||
|
||||
dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ ! -s "$tmp" ]; then
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mv "$tmp" "$final" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
exit 0
|
||||
`;
|
||||
|
||||
export class ShellRuntimeTurnSettledHookScriptInstaller {
|
||||
constructor(private readonly paths: RuntimeTurnSettledSpoolPaths) {}
|
||||
|
||||
async install(): Promise<{ scriptPath: string; spoolRoot: string }> {
|
||||
await Promise.all([
|
||||
fs.mkdir(this.paths.getBinDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getIncomingDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getProcessingDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getProcessedDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getInvalidDir(), { recursive: true }),
|
||||
]);
|
||||
|
||||
const scriptPath = this.paths.getHookScriptPath();
|
||||
await fs.writeFile(scriptPath, HOOK_SCRIPT_CONTENT, 'utf8');
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
|
||||
return {
|
||||
scriptPath,
|
||||
spoolRoot: this.paths.getRootDir(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export const MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER =
|
||||
'agent-teams:member-work-sync-turn-settled:v1';
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function buildRuntimeTurnSettledHookCommand(input: {
|
||||
scriptPath: string;
|
||||
spoolRoot: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
maxBytes?: number;
|
||||
}): string {
|
||||
return [
|
||||
'/bin/sh',
|
||||
shellQuote(input.scriptPath),
|
||||
shellQuote(input.spoolRoot),
|
||||
shellQuote(input.provider),
|
||||
shellQuote(String(input.maxBytes ?? 262_144)),
|
||||
'#',
|
||||
MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function buildRuntimeTurnSettledHookSettings(input: {
|
||||
scriptPath: string;
|
||||
spoolRoot: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
maxBytes?: number;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: buildRuntimeTurnSettledHookCommand(input),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1249,6 +1249,11 @@ async function initializeServices(): Promise<void> {
|
|||
},
|
||||
logger: createLogger('Feature:MemberWorkSync'),
|
||||
});
|
||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||
memberWorkSyncFeature
|
||||
? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,11 @@ function getHookEntryDedupeKey(value: unknown): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
const commands = value.hooks
|
||||
.map((hook) => (isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null))
|
||||
const hooks = Array.isArray(value.hooks) ? value.hooks : [];
|
||||
const commands = hooks
|
||||
.map((hook: unknown) =>
|
||||
isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null
|
||||
)
|
||||
.filter((command): command is string => Boolean(command));
|
||||
if (commands.length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '@features/anthropic-runtime-profile/main';
|
||||
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/core/domain';
|
||||
import {
|
||||
buildCodexFastModeArgs,
|
||||
resolveCodexFastMode,
|
||||
|
|
@ -4320,6 +4321,9 @@ export class TeamProvisioningService {
|
|||
private inFlightResponses = new Set<string>();
|
||||
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
|
||||
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
|
||||
private runtimeTurnSettledHookSettingsProvider:
|
||||
| ((input: { provider: RuntimeTurnSettledProvider }) => Promise<Record<string, unknown> | null>)
|
||||
| null = null;
|
||||
private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map<string, Promise<number>>();
|
||||
private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set<string>();
|
||||
private crossTeamSender:
|
||||
|
|
@ -4381,6 +4385,36 @@ export class TeamProvisioningService {
|
|||
this.controlApiBaseUrlResolver = resolver;
|
||||
}
|
||||
|
||||
setRuntimeTurnSettledHookSettingsProvider(
|
||||
provider:
|
||||
| ((input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}) => Promise<Record<string, unknown> | null>)
|
||||
| null
|
||||
): void {
|
||||
this.runtimeTurnSettledHookSettingsProvider = provider;
|
||||
}
|
||||
|
||||
private async buildRuntimeTurnSettledHookSettingsArgs(
|
||||
providerId: TeamProviderId
|
||||
): Promise<string[]> {
|
||||
if (providerId !== 'anthropic' || !this.runtimeTurnSettledHookSettingsProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await this.runtimeTurnSettledHookSettingsProvider({ provider: 'claude' });
|
||||
return settings ? ['--settings', JSON.stringify(settings)] : [];
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to build member work sync Stop hook settings: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async readRuntimeProviderLaunchFacts(params: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
|
|
@ -9869,6 +9903,8 @@ export class TeamProvisioningService {
|
|||
input.leadName
|
||||
);
|
||||
const bootstrapExpectedAfter = nowIso();
|
||||
const runtimeTurnSettledHookArgs =
|
||||
await this.buildRuntimeTurnSettledHookSettingsArgs(providerId);
|
||||
|
||||
const runtimeArgs = mergeJsonSettingsArgs([
|
||||
'--agent-id',
|
||||
|
|
@ -9894,6 +9930,7 @@ export class TeamProvisioningService {
|
|||
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
||||
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
|
||||
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
|
||||
...runtimeTurnSettledHookArgs,
|
||||
...(provisioningEnv.providerArgs ?? []),
|
||||
]);
|
||||
const command = buildDirectTmuxRestartCommand({
|
||||
|
|
@ -12970,6 +13007,8 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const resolvedProviderId = resolveTeamProviderId(request.providerId);
|
||||
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
|
||||
const runtimeTurnSettledHookArgs =
|
||||
await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId);
|
||||
const spawnArgs = mergeJsonSettingsArgs([
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
|
|
@ -12995,6 +13034,7 @@ export class TeamProvisioningService {
|
|||
...(launchModelArg ? ['--model', launchModelArg] : []),
|
||||
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
|
||||
...providerFastModeArgs,
|
||||
...runtimeTurnSettledHookArgs,
|
||||
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||
...buildDesktopTeammateModeCliArgs(teammateModeDecision),
|
||||
...parseCliArgs(request.extraCliArgs),
|
||||
|
|
@ -14102,6 +14142,8 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const resolvedProviderId = resolveTeamProviderId(request.providerId);
|
||||
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
|
||||
const runtimeTurnSettledHookArgs =
|
||||
await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId);
|
||||
if (launchModelArg) {
|
||||
launchArgs.push('--model', launchModelArg);
|
||||
}
|
||||
|
|
@ -14109,6 +14151,7 @@ export class TeamProvisioningService {
|
|||
launchArgs.push('--effort', launchIdentity.resolvedEffort);
|
||||
}
|
||||
launchArgs.push(...providerFastModeArgs);
|
||||
launchArgs.push(...runtimeTurnSettledHookArgs);
|
||||
if (request.worktree) {
|
||||
launchArgs.push('--worktree', request.worktree);
|
||||
}
|
||||
|
|
@ -22885,6 +22928,7 @@ export class TeamProvisioningService {
|
|||
for (const providerId of crossProviderIds) {
|
||||
try {
|
||||
const env = await this.buildProvisioningEnv(providerId);
|
||||
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
|
||||
if (env.providerArgs) {
|
||||
args.push(...env.providerArgs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1130,6 +1130,7 @@ export interface TeamChangeEvent {
|
|||
| 'lead-context'
|
||||
| 'lead-message'
|
||||
| 'tool-activity'
|
||||
| 'member-turn-settled'
|
||||
| 'process'
|
||||
| 'member-spawn';
|
||||
teamName: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { FileRuntimeTurnSettledEventStore } from '@features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore';
|
||||
import { RuntimeTurnSettledSpoolPaths } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function makePaths(): Promise<RuntimeTurnSettledSpoolPaths> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'runtime-turn-settled-'));
|
||||
roots.push(root);
|
||||
return new RuntimeTurnSettledSpoolPaths(root);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.allSettled(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('FileRuntimeTurnSettledEventStore', () => {
|
||||
it('claims incoming payloads by atomically moving them to processing', async () => {
|
||||
const paths = await makePaths();
|
||||
await fs.mkdir(paths.getIncomingDir(), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(paths.getIncomingDir(), '20260429-1.claude.json'),
|
||||
'{"hook_event_name":"Stop"}',
|
||||
'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.claude.json',
|
||||
provider: 'claude',
|
||||
raw: '{"hook_event_name":"Stop"}',
|
||||
});
|
||||
await expect(
|
||||
fs.stat(path.join(paths.getProcessingDir(), '20260429-1.claude.json'))
|
||||
).resolves.toMatchObject({ isFile: expect.any(Function) });
|
||||
await expect(
|
||||
fs.stat(path.join(paths.getIncomingDir(), '20260429-1.claude.json'))
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('moves processed payload and writes diagnostic metadata', async () => {
|
||||
const paths = await makePaths();
|
||||
await fs.mkdir(paths.getIncomingDir(), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(paths.getIncomingDir(), '20260429-1.claude.json'),
|
||||
'{"hook_event_name":"Stop"}',
|
||||
'utf8'
|
||||
);
|
||||
const store = new FileRuntimeTurnSettledEventStore({
|
||||
paths,
|
||||
now: () => new Date('2026-04-29T12:00:00.000Z'),
|
||||
});
|
||||
const [claimed] = await store.claimPending(1);
|
||||
|
||||
await store.markProcessed(claimed, {
|
||||
outcome: 'enqueued',
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
event: {
|
||||
schemaVersion: 1,
|
||||
provider: 'claude',
|
||||
hookEventName: 'Stop',
|
||||
sourceId: 'source-1',
|
||||
payloadHash: 'hash',
|
||||
recordedAt: '2026-04-29T12:00:00.000Z',
|
||||
},
|
||||
processedAt: '2026-04-29T12:01:00.000Z',
|
||||
});
|
||||
|
||||
const processedPath = path.join(paths.getProcessedDir(), '20260429-1.claude.json');
|
||||
await expect(fs.stat(processedPath)).resolves.toMatchObject({ isFile: expect.any(Function) });
|
||||
const meta = JSON.parse(await fs.readFile(`${processedPath}.meta.json`, 'utf8')) as {
|
||||
outcome?: string;
|
||||
teamName?: string;
|
||||
};
|
||||
expect(meta).toMatchObject({ outcome: 'enqueued', teamName: 'team-a' });
|
||||
});
|
||||
});
|
||||
|
|
@ -67,4 +67,32 @@ describe('MemberWorkSyncTeamChangeRouter', () => {
|
|||
expect(queue.dropTeam).toHaveBeenCalledWith('team-a');
|
||||
expect(queue.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes member-turn-settled events to one member reconcile', () => {
|
||||
const { queue, router } = createRouter();
|
||||
|
||||
router.noteTeamChange({
|
||||
type: 'member-turn-settled',
|
||||
teamName: 'team-a',
|
||||
detail: JSON.stringify({ memberName: 'alice', sourceId: 'source-1', provider: 'claude' }),
|
||||
});
|
||||
|
||||
expect(queue.enqueue).toHaveBeenCalledWith({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
triggerReason: 'turn_settled',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed member-turn-settled details', () => {
|
||||
const { queue, router } = createRouter();
|
||||
|
||||
router.noteTeamChange({
|
||||
type: 'member-turn-settled',
|
||||
teamName: 'team-a',
|
||||
detail: 'not-json',
|
||||
});
|
||||
|
||||
expect(queue.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { RuntimeTurnSettledSpoolPaths } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths';
|
||||
import { ShellRuntimeTurnSettledHookScriptInstaller } from '@features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
|
||||
import {
|
||||
MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER,
|
||||
buildRuntimeTurnSettledHookCommand,
|
||||
buildRuntimeTurnSettledHookSettings,
|
||||
} from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function makeTempRoot(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-turn-settled-hook-'));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('runtime turn-settled hook settings', () => {
|
||||
it('builds a shell-quoted command without embedding team identity', () => {
|
||||
const command = buildRuntimeTurnSettledHookCommand({
|
||||
scriptPath: "/tmp/agent team's hook.sh",
|
||||
spoolRoot: '/tmp/member work sync',
|
||||
provider: 'claude',
|
||||
});
|
||||
|
||||
expect(command).toContain('/bin/sh');
|
||||
expect(command).toContain("'/tmp/agent team'\\''s hook.sh'");
|
||||
expect(command).toContain("'claude'");
|
||||
expect(command).toContain(MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER);
|
||||
expect(command).not.toContain('--team-name');
|
||||
expect(command).not.toContain('--agent-name');
|
||||
});
|
||||
|
||||
it('builds Claude Stop hook settings with a stable marker', () => {
|
||||
const settings = buildRuntimeTurnSettledHookSettings({
|
||||
scriptPath: '/tmp/hook.sh',
|
||||
spoolRoot: '/tmp/spool',
|
||||
provider: 'claude',
|
||||
});
|
||||
|
||||
expect(settings).toEqual({
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: expect.stringContaining(MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('installs an executable POSIX shell writer and spool directories', async () => {
|
||||
const root = makeTempRoot();
|
||||
const paths = new RuntimeTurnSettledSpoolPaths(root);
|
||||
const installer = new ShellRuntimeTurnSettledHookScriptInstaller(paths);
|
||||
|
||||
const result = await installer.install();
|
||||
|
||||
expect(result.scriptPath).toBe(paths.getHookScriptPath());
|
||||
expect(result.spoolRoot).toBe(paths.getRootDir());
|
||||
expect(fs.existsSync(paths.getIncomingDir())).toBe(true);
|
||||
expect(fs.existsSync(paths.getProcessingDir())).toBe(true);
|
||||
expect(fs.existsSync(paths.getProcessedDir())).toBe(true);
|
||||
expect(fs.existsSync(paths.getInvalidDir())).toBe(true);
|
||||
|
||||
const stat = fs.statSync(result.scriptPath);
|
||||
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
||||
expect(fs.readFileSync(result.scriptPath, 'utf8')).toContain('dd bs="$max_bytes"');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application';
|
||||
import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
||||
|
||||
import type {
|
||||
RuntimeTurnSettledClaimedPayload,
|
||||
RuntimeTurnSettledEventStorePort,
|
||||
RuntimeTurnSettledInvalidResult,
|
||||
RuntimeTurnSettledProcessedResult,
|
||||
RuntimeTurnSettledTargetResolverPort,
|
||||
} from '@features/member-work-sync/core/application';
|
||||
|
||||
function makePayload(raw: string): RuntimeTurnSettledClaimedPayload {
|
||||
return {
|
||||
id: 'event-1.claude.json',
|
||||
fileName: 'event-1.claude.json',
|
||||
filePath: '/tmp/event-1.claude.json',
|
||||
provider: 'claude',
|
||||
raw,
|
||||
claimedAt: '2026-04-29T12:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('RuntimeTurnSettledIngestor', () => {
|
||||
it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => {
|
||||
const payload = makePayload(
|
||||
JSON.stringify({
|
||||
hook_event_name: 'Stop',
|
||||
session_id: 'ses-1',
|
||||
transcript_path: '/tmp/ses-1.jsonl',
|
||||
})
|
||||
);
|
||||
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
||||
const store: RuntimeTurnSettledEventStorePort = {
|
||||
claimPending: vi.fn(async () => [payload]),
|
||||
markProcessed: vi.fn(async (_payload, result) => {
|
||||
processed.push(result);
|
||||
}),
|
||||
markInvalid: vi.fn(),
|
||||
};
|
||||
const resolver: RuntimeTurnSettledTargetResolverPort = {
|
||||
resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'alice' })),
|
||||
};
|
||||
const enqueueRuntimeTurnSettled = vi.fn();
|
||||
|
||||
const ingestor = new RuntimeTurnSettledIngestor({
|
||||
eventStore: store,
|
||||
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
|
||||
targetResolver: resolver,
|
||||
reconcileQueue: { enqueueRuntimeTurnSettled },
|
||||
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
||||
});
|
||||
|
||||
await expect(ingestor.drainPending()).resolves.toEqual({
|
||||
claimed: 1,
|
||||
enqueued: 1,
|
||||
unresolved: 0,
|
||||
invalid: 0,
|
||||
failed: 0,
|
||||
});
|
||||
expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
event: expect.objectContaining({
|
||||
provider: 'claude',
|
||||
hookEventName: 'Stop',
|
||||
sessionId: 'ses-1',
|
||||
transcriptPath: '/tmp/ses-1.jsonl',
|
||||
}),
|
||||
});
|
||||
expect(processed[0]).toMatchObject({
|
||||
outcome: 'enqueued',
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
});
|
||||
});
|
||||
|
||||
it('quarantines malformed payloads without enqueueing reconcile', async () => {
|
||||
const payload = makePayload('not-json');
|
||||
const invalid: RuntimeTurnSettledInvalidResult[] = [];
|
||||
const store: RuntimeTurnSettledEventStorePort = {
|
||||
claimPending: vi.fn(async () => [payload]),
|
||||
markProcessed: vi.fn(),
|
||||
markInvalid: vi.fn(async (_payload, result) => {
|
||||
invalid.push(result);
|
||||
}),
|
||||
};
|
||||
|
||||
const ingestor = new RuntimeTurnSettledIngestor({
|
||||
eventStore: store,
|
||||
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
|
||||
targetResolver: { resolve: vi.fn() },
|
||||
reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() },
|
||||
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
||||
});
|
||||
|
||||
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
||||
claimed: 1,
|
||||
invalid: 1,
|
||||
enqueued: 0,
|
||||
});
|
||||
expect(invalid[0]).toMatchObject({ reason: 'invalid_json' });
|
||||
});
|
||||
|
||||
it('records unresolved Stop events as processed diagnostics', async () => {
|
||||
const payload = makePayload(
|
||||
JSON.stringify({ hook_event_name: 'Stop', transcript_path: '/tmp/unknown.jsonl' })
|
||||
);
|
||||
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
||||
const store: RuntimeTurnSettledEventStorePort = {
|
||||
claimPending: vi.fn(async () => [payload]),
|
||||
markProcessed: vi.fn(async (_payload, result) => {
|
||||
processed.push(result);
|
||||
}),
|
||||
markInvalid: vi.fn(),
|
||||
};
|
||||
|
||||
const ingestor = new RuntimeTurnSettledIngestor({
|
||||
eventStore: store,
|
||||
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
|
||||
targetResolver: {
|
||||
resolve: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
reason: 'no_matching_member_session',
|
||||
})),
|
||||
},
|
||||
reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() },
|
||||
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
||||
});
|
||||
|
||||
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
||||
claimed: 1,
|
||||
unresolved: 1,
|
||||
enqueued: 0,
|
||||
});
|
||||
expect(processed[0]).toMatchObject({
|
||||
outcome: 'unresolved',
|
||||
reason: 'no_matching_member_session',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||
import type { TeamConfig } from '@shared/types';
|
||||
|
||||
describe('TeamRuntimeTurnSettledTargetResolver', () => {
|
||||
it('resolves a Claude Stop transcript path to an active Anthropic teammate', async () => {
|
||||
const resolver = new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: {
|
||||
listTeams: vi.fn(async () => [{ teamName: 'team-a', displayName: 'team-a' } as never]),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'team-a',
|
||||
members: [{ name: 'Alice', providerId: 'anthropic' }],
|
||||
}) satisfies TeamConfig),
|
||||
},
|
||||
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
||||
memberLogsFinder: {
|
||||
listAttributedMemberFiles: vi.fn(async () => [
|
||||
{
|
||||
memberName: 'Alice',
|
||||
sessionId: 'ses-1',
|
||||
filePath: '/tmp/ses-1.jsonl',
|
||||
mtimeMs: 1,
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolve({
|
||||
schemaVersion: 1,
|
||||
provider: 'claude',
|
||||
hookEventName: 'Stop',
|
||||
sourceId: 'source-1',
|
||||
payloadHash: 'hash',
|
||||
recordedAt: '2026-04-29T12:00:00.000Z',
|
||||
sessionId: 'ses-1',
|
||||
transcriptPath: '/tmp/ses-1.jsonl',
|
||||
})
|
||||
).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'alice' });
|
||||
});
|
||||
|
||||
it('rejects matches for removed or non-Anthropic teammates', async () => {
|
||||
const resolver = new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: {
|
||||
listTeams: vi.fn(async () => [{ teamName: 'team-a', displayName: 'team-a' } as never]),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'team-a',
|
||||
members: [{ name: 'bob', providerId: 'opencode' }],
|
||||
}) satisfies TeamConfig),
|
||||
},
|
||||
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
||||
memberLogsFinder: {
|
||||
listAttributedMemberFiles: vi.fn(async () => [
|
||||
{
|
||||
memberName: 'bob',
|
||||
sessionId: 'ses-1',
|
||||
filePath: '/tmp/ses-1.jsonl',
|
||||
mtimeMs: 1,
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolve({
|
||||
schemaVersion: 1,
|
||||
provider: 'claude',
|
||||
hookEventName: 'Stop',
|
||||
sourceId: 'source-1',
|
||||
payloadHash: 'hash',
|
||||
recordedAt: '2026-04-29T12:00:00.000Z',
|
||||
sessionId: 'ses-1',
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
createMemberWorkSyncFeature,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
} from '@features/member-work-sync/main';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function makeTempRoot(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'member-work-sync-feature-'));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('createMemberWorkSyncFeature composition', () => {
|
||||
it('keeps nudge side effects opt-in even when shadow readiness becomes green', () => {
|
||||
expect(resolveMemberWorkSyncNudgeSideEffectsEnabled({})).toBe(false);
|
||||
expect(
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled({
|
||||
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: 'maybe',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['1', 'true', 'yes', 'on'])(
|
||||
'enables nudge side effects only for explicit truthy env value %s',
|
||||
(value) => {
|
||||
expect(
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled({
|
||||
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['0', 'false', 'no', 'off', ''])(
|
||||
'keeps nudge side effects disabled for explicit falsy env value %s',
|
||||
(value) => {
|
||||
expect(
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled({
|
||||
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
|
||||
})
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns an empty dispatch summary when nudge side effects are disabled', async () => {
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: makeTempRoot(),
|
||||
configReader: {} as never,
|
||||
taskReader: {} as never,
|
||||
kanbanManager: {} as never,
|
||||
membersMetaStore: {} as never,
|
||||
nudgeSideEffectsEnabled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(feature.dispatchDueNudges(['team-a'])).resolves.toEqual({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
superseded: 0,
|
||||
retryable: 0,
|
||||
terminal: 0,
|
||||
});
|
||||
} finally {
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('builds Claude Stop hook settings 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 settings = await feature.buildRuntimeTurnSettledHookSettings({ provider: 'claude' });
|
||||
expect(settings).toMatchObject({
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: expect.stringContaining('agent-teams:member-work-sync-turn-settled:v1'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
fs.promises.stat(
|
||||
path.join(root, '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh')
|
||||
)
|
||||
).resolves.toMatchObject({ mode: expect.any(Number) });
|
||||
} finally {
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue