From c39167ece74beb027a31666c58528dc532037fa5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 13:06:58 +0300 Subject: [PATCH] feat: add member work sync control plane --- agent-teams-controller/src/controller.js | 3 + agent-teams-controller/src/internal/tasks.js | 8 + .../src/internal/workSync.js | 142 +++++++++++++ agent-teams-controller/src/mcpToolCatalog.js | 8 + .../test/controller.test.js | 75 +++++++ mcp-server/src/agent-teams-controller.d.ts | 8 + mcp-server/src/controller.ts | 11 +- mcp-server/src/tools/index.ts | 2 + mcp-server/src/tools/workSyncTools.ts | 83 ++++++++ mcp-server/test/tools.test.ts | 87 ++++++++ .../member-work-sync/contracts/index.ts | 2 + .../member-work-sync/contracts/ipc.ts | 2 + .../member-work-sync/contracts/types.ts | 102 +++++++++ .../MemberWorkSyncDiagnosticsReader.ts | 15 ++ .../application/MemberWorkSyncReconciler.ts | 59 ++++++ .../application/MemberWorkSyncReporter.ts | 78 +++++++ .../core/application/index.ts | 4 + .../core/application/ports.ts | 58 ++++++ .../core/domain/ActionableWorkAgenda.ts | 193 ++++++++++++++++++ .../core/domain/AgendaFingerprint.ts | 80 ++++++++ .../domain/MemberWorkSyncReportValidator.ts | 114 +++++++++++ .../core/domain/SyncDecisionPolicy.ts | 52 +++++ .../core/domain/currentReviewCycle.ts | 75 +++++++ .../member-work-sync/core/domain/index.ts | 6 + .../core/domain/memberName.ts | 15 ++ src/features/member-work-sync/index.ts | 1 + .../input/registerMemberWorkSyncIpc.ts | 48 +++++ .../adapters/output/TeamTaskAgendaSource.ts | 125 ++++++++++++ .../createMemberWorkSyncFeature.ts | 59 ++++++ src/features/member-work-sync/main/index.ts | 6 + .../infrastructure/JsonMemberWorkSyncStore.ts | 95 +++++++++ .../MemberWorkSyncStorePaths.ts | 17 ++ .../main/infrastructure/NodeHashAdapter.ts | 9 + .../main/infrastructure/SystemClockAdapter.ts | 7 + .../member-work-sync/preload/index.ts | 22 ++ src/main/http/index.ts | 2 + src/main/http/teams.ts | 95 +++++++++ src/main/index.ts | 21 ++ src/preload/index.ts | 2 + src/renderer/api/httpClient.ts | 16 +- src/shared/types/api.ts | 14 ++ .../core/ActionableWorkAgenda.test.ts | 173 ++++++++++++++++ .../MemberWorkSyncReportValidator.test.ts | 136 ++++++++++++ .../core/MemberWorkSyncUseCases.test.ts | 172 ++++++++++++++++ 44 files changed, 2299 insertions(+), 3 deletions(-) create mode 100644 agent-teams-controller/src/internal/workSync.js create mode 100644 mcp-server/src/tools/workSyncTools.ts create mode 100644 src/features/member-work-sync/contracts/index.ts create mode 100644 src/features/member-work-sync/contracts/ipc.ts create mode 100644 src/features/member-work-sync/contracts/types.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts create mode 100644 src/features/member-work-sync/core/application/index.ts create mode 100644 src/features/member-work-sync/core/application/ports.ts create mode 100644 src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts create mode 100644 src/features/member-work-sync/core/domain/AgendaFingerprint.ts create mode 100644 src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts create mode 100644 src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts create mode 100644 src/features/member-work-sync/core/domain/currentReviewCycle.ts create mode 100644 src/features/member-work-sync/core/domain/index.ts create mode 100644 src/features/member-work-sync/core/domain/memberName.ts create mode 100644 src/features/member-work-sync/index.ts create mode 100644 src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts create mode 100644 src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts create mode 100644 src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts create mode 100644 src/features/member-work-sync/main/index.ts create mode 100644 src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts create mode 100644 src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts create mode 100644 src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts create mode 100644 src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts create mode 100644 src/features/member-work-sync/preload/index.ts create mode 100644 test/features/member-work-sync/core/ActionableWorkAgenda.test.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index f48f29b7..63a6478e 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -7,6 +7,7 @@ const processes = require('./internal/processes.js'); const maintenance = require('./internal/maintenance.js'); const crossTeam = require('./internal/crossTeam.js'); const runtime = require('./internal/runtime.js'); +const workSync = require('./internal/workSync.js'); const agentBlocks = require('./internal/agentBlocks.js'); function bindModule(context, moduleApi) { @@ -31,6 +32,7 @@ function createController(options) { maintenance: bindModule(context, maintenance), crossTeam: bindModule(context, crossTeam), runtime: bindModule(context, runtime), + workSync: bindModule(context, workSync), }; } @@ -51,4 +53,5 @@ module.exports = { maintenance, crossTeam, runtime, + workSync, }; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 13dea345..dcb860af 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -781,6 +781,14 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. - Then run task_start only when you truly begin. - If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass. +15. MEMBER WORK SYNC REPORTING: + - member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks. + - Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment. + - When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself. + - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working" and that exact agendaFingerprint. + - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task. + - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint. + - Do not report more than once for the same agendaFingerprint unless your state changed. Failure to follow this protocol means the task board will show incorrect status.`); } diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js new file mode 100644 index 00000000..8a39fde9 --- /dev/null +++ b/agent-teams-controller/src/internal/workSync.js @@ -0,0 +1,142 @@ +const fs = require('fs'); +const path = require('path'); +const runtimeHelpers = require('./runtimeHelpers.js'); + +const DEFAULT_WAIT_TIMEOUT_MS = 10000; +const MIN_WAIT_TIMEOUT_MS = 1000; +const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000; +const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; + +function normalizeTimeoutMs(rawValue) { + const numeric = + typeof rawValue === 'number' && Number.isFinite(rawValue) + ? Math.floor(rawValue) + : DEFAULT_WAIT_TIMEOUT_MS; + return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric)); +} + +function readControlApiState(context) { + const filePath = path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return typeof parsed?.baseUrl === 'string' && parsed.baseUrl.trim() + ? parsed.baseUrl.trim() + : ''; + } catch { + return ''; + } +} + +function resolveControlBaseUrls(context, flags = {}) { + const explicit = + (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || + (typeof flags['control-url'] === 'string' && flags['control-url'].trim()) || + ''; + const stateFileUrl = readControlApiState(context); + const envUrl = + typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' + ? process.env.CLAUDE_TEAM_CONTROL_URL.trim() + : ''; + const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))]; + if (candidates.length === 0) { + throw new Error( + 'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.' + ); + } + return candidates; +} + +async function requestJson(baseUrl, pathname, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), normalizeTimeoutMs(options.timeoutMs)); + try { + const response = await fetch(`${baseUrl}${pathname}`, { + method: options.method || 'GET', + headers: { + accept: 'application/json', + ...(options.body ? { 'content-type': 'application/json' } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + signal: controller.signal, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const detail = + payload && typeof payload.error === 'string' && payload.error.trim() + ? payload.error.trim() + : `${response.status} ${response.statusText}`.trim(); + throw new Error(detail || 'Team control API request failed'); + } + return payload; + } finally { + clearTimeout(timer); + } +} + +async function requestJsonWithFallback(baseUrls, pathname, options = {}) { + let lastError = null; + for (const baseUrl of baseUrls) { + try { + return await requestJson(baseUrl, pathname, options); + } catch (error) { + lastError = error; + } + } + throw lastError || new Error('Team control API request failed'); +} + +function compactReportBody(context, memberName, flags = {}) { + return { + teamName: context.teamName, + memberName, + state: flags.state, + agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], + ...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}), + ...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}), + ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), + ...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim() + ? { reportedAt: flags.reportedAt.trim() } + : {}), + ...(typeof flags.leaseTtlMs === 'number' ? { leaseTtlMs: flags.leaseTtlMs } : {}), + }; +} + +async function memberWorkSyncStatus(context, flags = {}) { + const memberName = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + flags.memberName || flags.member || flags.from, + 'member work sync status member' + ); + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent( + memberName + )}`, + { timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) } + ); +} + +async function memberWorkSyncReport(context, flags = {}) { + const memberName = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + flags.memberName || flags.member || flags.from, + 'member work sync report member' + ); + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`, + { + method: 'POST', + body: compactReportBody(context, memberName, flags), + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), + } + ); +} + +module.exports = { + memberWorkSyncStatus, + memberWorkSyncReport, +}; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index ecd74b19..1a5e3b0d 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -63,6 +63,8 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [ 'runtime_heartbeat', ]; +const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES = ['member_work_sync_status', 'member_work_sync_report']; + const AGENT_TEAMS_MCP_TOOL_GROUPS = [ { id: 'team', @@ -104,6 +106,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [ teammateOperational: false, toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES, }, + { + id: 'workSync', + teammateOperational: true, + toolNames: AGENT_TEAMS_WORK_SYNC_TOOL_NAMES, + }, { id: 'crossTeam', teammateOperational: true, @@ -141,6 +148,7 @@ module.exports = { AGENT_TEAMS_PROCESS_TOOL_NAMES, AGENT_TEAMS_KANBAN_TOOL_NAMES, AGENT_TEAMS_RUNTIME_TOOL_NAMES, + AGENT_TEAMS_WORK_SYNC_TOOL_NAMES, AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES, AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index b0494fd4..4c45e659 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -148,6 +148,7 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Task briefing for bob:'); expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.'); expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.'); + expect(briefing).toContain('member_work_sync_status and member_work_sync_report'); expect(briefing).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); @@ -2250,6 +2251,80 @@ describe('agent-teams-controller API', () => { } }); + it('forwards member work sync status and reports to the app validator', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') { + return { + body: { + teamName: 'my-team', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'my-team', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['no_current_report'], + }, + }; + } + if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') { + return { body: { accepted: true, code: 'accepted', status: body } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const status = await controller.workSync.memberWorkSyncStatus({ + controlUrl: server.baseUrl, + from: 'bob', + }); + const report = await controller.workSync.memberWorkSyncReport({ + controlUrl: server.baseUrl, + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Continuing work', + leaseTtlMs: 120000, + }); + + expect(status.state).toBe('needs_sync'); + expect(report.accepted).toBe(true); + expect(calls).toEqual([ + { + method: 'GET', + url: '/api/teams/my-team/member-work-sync/bob', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/my-team/member-work-sync/report', + body: { + teamName: 'my-team', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Continuing work', + leaseTtlMs: 120000, + }, + }, + ]); + } finally { + await server.close(); + } + }); + it('prefers the published control endpoint over a stale env URL', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 79693520..9f7c9b31 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -102,6 +102,11 @@ declare module 'agent-teams-controller' { runtimeHeartbeat(flags: Record): Promise; } + export interface ControllerWorkSyncApi { + memberWorkSyncStatus(flags: Record): Promise; + memberWorkSyncReport(flags: Record): Promise; + } + export interface AgentTeamsController { tasks: ControllerTaskApi; kanban: ControllerKanbanApi; @@ -111,6 +116,7 @@ declare module 'agent-teams-controller' { maintenance: ControllerMaintenanceApi; crossTeam: ControllerCrossTeamApi; runtime: ControllerRuntimeApi; + workSync: ControllerWorkSyncApi; } export function createController(options: ControllerContextOptions): AgentTeamsController; @@ -143,6 +149,7 @@ declare module 'agent-teams-controller' { | 'message' | 'process' | 'runtime' + | 'workSync' | 'crossTeam'; export interface AgentTeamsMcpToolGroup { @@ -159,6 +166,7 @@ declare module 'agent-teams-controller' { export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index 72c8d6b4..98d725dc 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -10,10 +10,17 @@ const { createController } = controllerModule; const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; +type WorkSyncCapableController = ReturnType & { + workSync: { + memberWorkSyncStatus(flags: Record): Promise; + memberWorkSyncReport(flags: Record): Promise; + }; +}; + /** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */ export const agentBlocks = controllerModule.agentBlocks; -export function getController(teamName: string, claudeDir?: string) { +export function getController(teamName: string, claudeDir?: string): WorkSyncCapableController { const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim(); let resolvedClaudeDir = claudeDir; if (forcedClaudeDir) { @@ -24,5 +31,5 @@ export function getController(teamName: string, claudeDir?: string) { teamName, ...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}), allowUserMessageSender: false, - }); + }) as WorkSyncCapableController; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index c9937293..fa1f23d4 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -14,6 +14,7 @@ import { registerReviewTools } from './reviewTools'; import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; import { registerTeamTools } from './teamTools'; +import { registerWorkSyncTools } from './workSyncTools'; const REGISTRATION_BY_GROUP = { team: registerTeamTools, @@ -24,6 +25,7 @@ const REGISTRATION_BY_GROUP = { message: registerMessageTools, process: registerProcessTools, runtime: registerRuntimeTools, + workSync: registerWorkSyncTools, crossTeam: registerCrossTeamTools, } as const; diff --git a/mcp-server/src/tools/workSyncTools.ts b/mcp-server/src/tools/workSyncTools.ts new file mode 100644 index 00000000..2f085a57 --- /dev/null +++ b/mcp-server/src/tools/workSyncTools.ts @@ -0,0 +1,83 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; + +import { getController } from '../controller'; +import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; + +const controlContextSchema = { + teamName: z.string().min(1), + claudeDir: z.string().min(1).optional(), + controlUrl: z.string().optional(), + waitTimeoutMs: z.number().int().min(1000).max(600000).optional(), +}; + +const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']); + +export function registerWorkSyncTools(server: Pick) { + server.addTool({ + name: 'member_work_sync_status', + description: + 'Read your current actionable-work agenda and agendaFingerprint before reporting whether you are still working, blocked, or caught up.', + parameters: z.object({ + ...controlContextSchema, + memberName: z.string().min(1).optional(), + from: z.string().min(1).optional(), + }), + execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( + await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({ + ...(memberName ? { memberName } : {}), + ...(from ? { from } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ); + }, + }); + + server.addTool({ + name: 'member_work_sync_report', + description: + 'Report your validated work-sync state for the current agendaFingerprint. This never completes tasks. Use still_working while actively continuing, blocked only when the board has blocker evidence, and caught_up only when the status agenda is empty.', + parameters: z.object({ + ...controlContextSchema, + memberName: z.string().min(1).optional(), + from: z.string().min(1).optional(), + state: reportStateSchema, + agendaFingerprint: z.string().min(1), + taskIds: z.array(z.string().min(1)).optional(), + note: z.string().optional(), + leaseTtlMs: z.number().int().min(60000).max(3600000).optional(), + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + memberName, + from, + state, + agendaFingerprint, + taskIds, + note, + leaseTtlMs, + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( + await getController(teamName, claudeDir).workSync.memberWorkSyncReport({ + ...(memberName ? { memberName } : {}), + ...(from ? { from } : {}), + state, + agendaFingerprint, + ...(taskIds ? { taskIds } : {}), + ...(note ? { note } : {}), + ...(leaseTtlMs ? { leaseTtlMs } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ); + }, + }); +} diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index b9ea1d2f..50929a3f 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -400,6 +400,93 @@ describe('agent-teams-mcp tools', () => { } }); + it('forwards member work sync MCP tools through the app validator bridge', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); + const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'GET' && url === '/api/teams/alpha/member-work-sync/alice') { + return { + body: { + teamName: 'alpha', + memberName: 'alice', + state: 'needs_sync', + agenda: { + teamName: 'alpha', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['no_current_report'], + }, + }; + } + if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') { + return { body: { accepted: true, code: 'accepted', status: body } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const status = parseJsonToolResult( + await getTool('member_work_sync_status').execute({ + claudeDir, + teamName: 'alpha', + controlUrl: server.baseUrl, + from: 'alice', + }) + ); + expect(status.state).toBe('needs_sync'); + + const report = parseJsonToolResult( + await getTool('member_work_sync_report').execute({ + claudeDir, + teamName: 'alpha', + controlUrl: server.baseUrl, + memberName: 'alice', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Still working', + leaseTtlMs: 120000, + }) + ); + expect(report.accepted).toBe(true); + + expect(calls).toEqual([ + { + method: 'GET', + url: '/api/teams/alpha/member-work-sync/alice', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/alpha/member-work-sync/report', + body: { + teamName: 'alpha', + memberName: 'alice', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Still working', + leaseTtlMs: 120000, + }, + }, + ]); + } finally { + await server.close(); + } + }); + it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { diff --git a/src/features/member-work-sync/contracts/index.ts b/src/features/member-work-sync/contracts/index.ts new file mode 100644 index 00000000..14aed138 --- /dev/null +++ b/src/features/member-work-sync/contracts/index.ts @@ -0,0 +1,2 @@ +export * from './ipc'; +export * from './types'; diff --git a/src/features/member-work-sync/contracts/ipc.ts b/src/features/member-work-sync/contracts/ipc.ts new file mode 100644 index 00000000..5a000fe3 --- /dev/null +++ b/src/features/member-work-sync/contracts/ipc.ts @@ -0,0 +1,2 @@ +export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus'; +export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report'; diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts new file mode 100644 index 00000000..46e49c6e --- /dev/null +++ b/src/features/member-work-sync/contracts/types.ts @@ -0,0 +1,102 @@ +export type MemberWorkSyncReportState = 'still_working' | 'blocked' | 'caught_up'; + +export type MemberWorkSyncStatusState = + | 'caught_up' + | 'needs_sync' + | 'still_working' + | 'blocked' + | 'inactive' + | 'unknown'; + +export type MemberWorkSyncActionableWorkKind = + | 'work' + | 'review' + | 'clarification' + | 'blocked_dependency'; + +export type MemberWorkSyncActionableWorkPriority = + | 'normal' + | 'review_requested' + | 'blocked' + | 'needs_clarification'; + +export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; + +export interface MemberWorkSyncActionableWorkItem { + taskId: string; + displayId?: string; + subject: string; + kind: MemberWorkSyncActionableWorkKind; + assignee: string; + priority: MemberWorkSyncActionableWorkPriority; + reason: string; + evidence: { + status: string; + owner?: string; + reviewer?: string; + reviewState?: string; + needsClarification?: 'lead' | 'user'; + blockerTaskIds?: string[]; + blockedByTaskIds?: string[]; + historyEventIds?: string[]; + }; +} + +export interface MemberWorkSyncAgenda { + teamName: string; + memberName: string; + generatedAt: string; + fingerprint: string; + items: MemberWorkSyncActionableWorkItem[]; + diagnostics: string[]; + sourceRevision?: string; +} + +export interface MemberWorkSyncReport { + state: MemberWorkSyncReportState; + agendaFingerprint: string; + memberName: string; + teamName: string; + reportedAt: string; + expiresAt?: string; + taskIds?: string[]; + note?: string; + source?: 'mcp' | 'app' | 'test'; + accepted: boolean; + rejectionCode?: string; +} + +export interface MemberWorkSyncStatus { + teamName: string; + memberName: string; + state: MemberWorkSyncStatusState; + agenda: MemberWorkSyncAgenda; + report?: MemberWorkSyncReport; + evaluatedAt: string; + diagnostics: string[]; + providerId?: MemberWorkSyncProviderId; +} + +export interface MemberWorkSyncReportRequest { + teamName: string; + memberName: string; + state: MemberWorkSyncReportState; + agendaFingerprint: string; + taskIds?: string[]; + note?: string; + reportedAt?: string; + leaseTtlMs?: number; + source?: 'mcp' | 'app' | 'test'; +} + +export interface MemberWorkSyncReportResult { + accepted: boolean; + code: string; + message: string; + status: MemberWorkSyncStatus; +} + +export interface MemberWorkSyncStatusRequest { + teamName: string; + memberName: string; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts new file mode 100644 index 00000000..e907568a --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts @@ -0,0 +1,15 @@ +import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; +import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export class MemberWorkSyncDiagnosticsReader { + private readonly reconciler: MemberWorkSyncReconciler; + + constructor(deps: MemberWorkSyncUseCaseDeps) { + this.reconciler = new MemberWorkSyncReconciler(deps); + } + + async execute(request: MemberWorkSyncStatusRequest): Promise { + return this.reconciler.execute(request); + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts new file mode 100644 index 00000000..29ed8ad3 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -0,0 +1,59 @@ +import { + buildAgendaFingerprintPayload, + canonicalizeAgendaFingerprintPayload, + decideMemberWorkSyncStatus, + formatAgendaFingerprint, +} from '../domain'; +import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; +import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports'; + +export function finalizeMemberWorkSyncAgenda( + deps: MemberWorkSyncUseCaseDeps, + source: MemberWorkSyncAgendaSourceResult +) { + const payload = buildAgendaFingerprintPayload({ + teamName: source.agenda.teamName, + memberName: source.agenda.memberName, + items: source.agenda.items, + sourceRevision: source.agenda.sourceRevision, + }); + const fingerprint = formatAgendaFingerprint( + deps.hash.sha256Hex(canonicalizeAgendaFingerprintPayload(payload)) + ); + return { + ...source.agenda, + fingerprint, + diagnostics: [...source.agenda.diagnostics, ...source.diagnostics], + }; +} + +export class MemberWorkSyncReconciler { + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + + async execute(request: MemberWorkSyncStatusRequest): Promise { + const source = await this.deps.agendaSource.loadAgenda(request); + const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + const previous = await this.deps.statusStore.read(request); + const nowIso = this.deps.clock.now().toISOString(); + const decision = decideMemberWorkSyncStatus({ + agenda, + latestAcceptedReport: previous?.report?.accepted ? previous.report : null, + nowIso, + inactive: source.inactive, + }); + + const status: MemberWorkSyncStatus = { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: decision.state, + agenda, + ...(decision.acceptedReport ? { report: decision.acceptedReport } : {}), + evaluatedAt: nowIso, + diagnostics: [...agenda.diagnostics, ...decision.diagnostics], + ...(source.providerId ? { providerId: source.providerId } : {}), + }; + + await this.deps.statusStore.write(status); + return status; + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts new file mode 100644 index 00000000..b5e00406 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -0,0 +1,78 @@ +import type { + MemberWorkSyncReport, + MemberWorkSyncReportRequest, + MemberWorkSyncReportResult, +} from '../../contracts'; +import { validateMemberWorkSyncReport } from '../domain'; +import { finalizeMemberWorkSyncAgenda, MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export class MemberWorkSyncReporter { + private readonly reconciler: MemberWorkSyncReconciler; + + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) { + this.reconciler = new MemberWorkSyncReconciler(deps); + } + + async execute(request: MemberWorkSyncReportRequest): Promise { + const source = await this.deps.agendaSource.loadAgenda(request); + const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + const nowIso = ( + request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now() + ).toISOString(); + const validation = validateMemberWorkSyncReport({ + request, + agenda, + nowIso, + activeMemberNames: source.activeMemberNames, + }); + + if (!validation.ok) { + const status = await this.reconciler.execute(request); + await this.deps.reportStore?.appendPendingReport?.(request, validation.code); + return { + accepted: false, + code: validation.code, + message: validation.message, + status, + }; + } + + const report: MemberWorkSyncReport = { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: request.state, + agendaFingerprint: agenda.fingerprint, + reportedAt: nowIso, + ...(validation.expiresAt ? { expiresAt: validation.expiresAt } : {}), + ...(request.taskIds ? { taskIds: [...request.taskIds] } : {}), + ...(request.note ? { note: request.note } : {}), + source: request.source ?? 'app', + accepted: true, + }; + + const status = { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: + report.state === 'caught_up' + ? ('caught_up' as const) + : report.state === 'blocked' + ? ('blocked' as const) + : ('still_working' as const), + agenda, + report, + evaluatedAt: nowIso, + diagnostics: [...agenda.diagnostics, 'report_accepted'], + ...(source.providerId ? { providerId: source.providerId } : {}), + }; + + await this.deps.statusStore.write(status); + return { + accepted: true, + code: 'accepted', + message: validation.message, + status, + }; + } +} diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts new file mode 100644 index 00000000..ec75f2b2 --- /dev/null +++ b/src/features/member-work-sync/core/application/index.ts @@ -0,0 +1,4 @@ +export * from './MemberWorkSyncDiagnosticsReader'; +export * from './MemberWorkSyncReconciler'; +export * from './MemberWorkSyncReporter'; +export * from './ports'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts new file mode 100644 index 00000000..fb098c20 --- /dev/null +++ b/src/features/member-work-sync/core/application/ports.ts @@ -0,0 +1,58 @@ +import type { + MemberWorkSyncAgenda, + MemberWorkSyncProviderId, + MemberWorkSyncReport, + MemberWorkSyncReportRequest, + MemberWorkSyncStatus, +} from '../../contracts'; + +export interface MemberWorkSyncClockPort { + now(): Date; +} + +export interface MemberWorkSyncHashPort { + sha256Hex(value: string): string; +} + +export interface MemberWorkSyncLoggerPort { + debug(message: string, metadata?: Record): void; + warn(message: string, metadata?: Record): void; + error(message: string, metadata?: Record): void; +} + +export interface MemberWorkSyncAgendaSourceResult { + agenda: Omit; + activeMemberNames: string[]; + inactive: boolean; + providerId?: MemberWorkSyncProviderId; + diagnostics: string[]; +} + +export interface MemberWorkSyncAgendaSourcePort { + loadAgenda(input: { + teamName: string; + memberName: string; + }): Promise; +} + +export interface MemberWorkSyncStatusStorePort { + read(input: { teamName: string; memberName: string }): Promise; + write(status: MemberWorkSyncStatus): Promise; +} + +export interface MemberWorkSyncReportStorePort { + appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise; +} + +export interface MemberWorkSyncUseCaseDeps { + clock: MemberWorkSyncClockPort; + hash: MemberWorkSyncHashPort; + agendaSource: MemberWorkSyncAgendaSourcePort; + statusStore: MemberWorkSyncStatusStorePort; + reportStore?: MemberWorkSyncReportStorePort; + logger?: MemberWorkSyncLoggerPort; +} + +export interface LatestAcceptedReportLookup { + latestAcceptedReport?: MemberWorkSyncReport; +} diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts new file mode 100644 index 00000000..4904f6d7 --- /dev/null +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -0,0 +1,193 @@ +import type { + MemberWorkSyncActionableWorkItem, + MemberWorkSyncAgenda, + MemberWorkSyncProviderId, +} from '../../contracts'; +import { + buildAgendaFingerprintPayload, + canonicalizeAgendaFingerprintPayload, + formatAgendaFingerprint, +} from './AgendaFingerprint'; +import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle'; +import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; + +export interface MemberWorkSyncTaskLike { + id: string; + displayId?: string; + subject?: string; + status: string; + owner?: string | null; + reviewState?: string | null; + needsClarification?: 'lead' | 'user' | null; + blockedBy?: string[]; + blocks?: string[]; + deletedAt?: string | null; + historyEvents?: ReviewHistoryEventLike[]; +} + +export interface MemberWorkSyncMemberLike { + name: string; + providerId?: MemberWorkSyncProviderId | string; + model?: string; + agentType?: string; + removedAt?: string | null; +} + +export interface BuildActionableWorkAgendaInput { + teamName: string; + memberName: string; + generatedAt: string; + tasks: MemberWorkSyncTaskLike[]; + members: MemberWorkSyncMemberLike[]; + kanbanReviewersByTaskId?: Record; + sourceRevision?: string; + hash: (canonicalPayload: string) => string; +} + +function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean { + return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt); +} + +function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set { + return new Set( + members + .filter((member) => !member.removedAt) + .map((member) => normalizeMemberName(member.name)) + .filter((name) => name.length > 0 && !isReservedMemberName(name)) + ); +} + +function buildBaseItem( + task: MemberWorkSyncTaskLike, + memberName: string +): Omit { + return { + taskId: task.id, + ...(task.displayId ? { displayId: task.displayId } : {}), + subject: task.subject?.trim() || 'Untitled task', + assignee: memberName, + }; +} + +export function buildActionableWorkAgenda( + input: BuildActionableWorkAgendaInput +): MemberWorkSyncAgenda { + const memberName = normalizeMemberName(input.memberName); + const diagnostics: string[] = []; + const activeMemberNames = getActiveMemberNames(input.members); + + if (!memberName || isReservedMemberName(memberName)) { + diagnostics.push('member_invalid_or_reserved'); + } else if (!activeMemberNames.has(memberName)) { + diagnostics.push('member_not_active'); + } + + const items: MemberWorkSyncActionableWorkItem[] = []; + + if (activeMemberNames.has(memberName)) { + for (const task of input.tasks) { + if (!task.id || isCompletedOrDeleted(task)) { + continue; + } + + const owner = normalizeMemberName(task.owner); + const base = buildBaseItem(task, memberName); + const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort(); + const blocks = [...(task.blocks ?? [])].filter(Boolean).sort(); + + const reviewOwner = resolveCurrentReviewOwner({ + reviewState: task.reviewState, + kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, + historyEvents: task.historyEvents, + }); + + if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) { + items.push({ + ...base, + kind: 'review', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: task.status, + ...(owner ? { owner } : {}), + reviewer: memberName, + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + ...(reviewOwner.historyEventIds.length > 0 + ? { historyEventIds: reviewOwner.historyEventIds } + : {}), + }, + }); + continue; + } + + if (!sameMemberName(owner, memberName)) { + continue; + } + + if (task.needsClarification === 'lead' || task.needsClarification === 'user') { + items.push({ + ...base, + kind: 'clarification', + priority: 'needs_clarification', + reason: `task_needs_${task.needsClarification}_clarification`, + evidence: { + status: task.status, + owner: memberName, + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + needsClarification: task.needsClarification, + }, + }); + continue; + } + + if (blockedBy.length > 0) { + items.push({ + ...base, + kind: 'blocked_dependency', + priority: 'blocked', + reason: 'owned_task_has_blocked_dependency', + evidence: { + status: task.status, + owner: memberName, + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + blockedByTaskIds: blockedBy, + ...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}), + }, + }); + continue; + } + + if (task.status === 'pending' || task.status === 'in_progress') { + items.push({ + ...base, + kind: 'work', + priority: 'normal', + reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task', + evidence: { + status: task.status, + owner: memberName, + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + }, + }); + } + } + } + + const payload = buildAgendaFingerprintPayload({ + teamName: input.teamName, + memberName, + items, + sourceRevision: input.sourceRevision, + }); + const canonicalPayload = canonicalizeAgendaFingerprintPayload(payload); + + return { + teamName: input.teamName, + memberName, + generatedAt: input.generatedAt, + fingerprint: formatAgendaFingerprint(input.hash(canonicalPayload)), + items, + diagnostics, + ...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}), + }; +} diff --git a/src/features/member-work-sync/core/domain/AgendaFingerprint.ts b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts new file mode 100644 index 00000000..736aa636 --- /dev/null +++ b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts @@ -0,0 +1,80 @@ +import type { MemberWorkSyncActionableWorkItem } from '../../contracts'; + +export const MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX = 'agenda:v1:'; + +function stableJson(value: unknown): string { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(',')}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`; +} + +export interface AgendaFingerprintPayload { + version: 1; + teamName: string; + memberName: string; + items: Array<{ + taskId: string; + displayId?: string; + subject: string; + kind: string; + assignee: string; + priority: string; + evidence: MemberWorkSyncActionableWorkItem['evidence']; + }>; + sourceRevision?: string; +} + +export function buildAgendaFingerprintPayload(input: { + teamName: string; + memberName: string; + items: MemberWorkSyncActionableWorkItem[]; + sourceRevision?: string; +}): AgendaFingerprintPayload { + return { + version: 1, + teamName: input.teamName, + memberName: input.memberName, + ...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}), + items: [...input.items] + .sort((left, right) => { + const leftKey = `${left.kind}:${left.taskId}:${left.displayId ?? ''}`; + const rightKey = `${right.kind}:${right.taskId}:${right.displayId ?? ''}`; + return leftKey.localeCompare(rightKey); + }) + .map((item) => ({ + taskId: item.taskId, + ...(item.displayId ? { displayId: item.displayId } : {}), + subject: item.subject, + kind: item.kind, + assignee: item.assignee, + priority: item.priority, + evidence: { + ...item.evidence, + ...(item.evidence.blockerTaskIds + ? { blockerTaskIds: [...item.evidence.blockerTaskIds].sort() } + : {}), + ...(item.evidence.blockedByTaskIds + ? { blockedByTaskIds: [...item.evidence.blockedByTaskIds].sort() } + : {}), + ...(item.evidence.historyEventIds + ? { historyEventIds: [...item.evidence.historyEventIds].sort() } + : {}), + }, + })), + }; +} + +export function canonicalizeAgendaFingerprintPayload(payload: AgendaFingerprintPayload): string { + return stableJson(payload); +} + +export function formatAgendaFingerprint(hashHex: string): string { + return `${MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX}${hashHex}`; +} diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts new file mode 100644 index 00000000..dcf5efc2 --- /dev/null +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -0,0 +1,114 @@ +import type { + MemberWorkSyncAgenda, + MemberWorkSyncReportRequest, + MemberWorkSyncReportState, +} from '../../contracts'; +import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; + +export interface MemberWorkSyncReportValidation { + ok: boolean; + code: string; + message: string; + expiresAt?: string; +} + +const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000; +const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000; +const MIN_LEASE_MS = 60_000; +const MAX_LEASE_MS = 60 * 60 * 1000; + +function clampLeaseTtlMs( + value: number | undefined, + state: MemberWorkSyncReportState +): number | undefined { + if (state === 'caught_up') { + return undefined; + } + const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS; + const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback; + return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric)); +} + +function agendaHasBlockedEvidence( + agenda: MemberWorkSyncAgenda, + taskIds: string[] | undefined +): boolean { + const targetIds = new Set((taskIds ?? []).filter(Boolean)); + return agenda.items.some((item) => { + if (targetIds.size > 0 && !targetIds.has(item.taskId)) { + return false; + } + return item.kind === 'blocked_dependency' || item.priority === 'blocked'; + }); +} + +export function validateMemberWorkSyncReport(input: { + request: MemberWorkSyncReportRequest; + agenda: MemberWorkSyncAgenda; + nowIso: string; + activeMemberNames: string[]; +}): MemberWorkSyncReportValidation { + const memberName = normalizeMemberName(input.request.memberName); + const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName)); + + if (!memberName || isReservedMemberName(memberName)) { + return { ok: false, code: 'reserved_or_invalid_member', message: 'Invalid member identity.' }; + } + if (!sameMemberName(memberName, input.agenda.memberName)) { + return { + ok: false, + code: 'identity_mismatch', + message: 'Report member does not match agenda.', + }; + } + if (!activeMemberNames.has(memberName)) { + return { ok: false, code: 'member_inactive', message: 'Member is not active in this team.' }; + } + if (input.request.agendaFingerprint !== input.agenda.fingerprint) { + return { + ok: false, + code: 'stale_fingerprint', + message: 'Report fingerprint is stale. Read current member work sync status and retry.', + }; + } + + const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId)); + for (const taskId of input.request.taskIds ?? []) { + if (!agendaTaskIds.has(taskId)) { + return { + ok: false, + code: 'foreign_task_id', + message: `Task ${taskId} is not in the current actionable agenda.`, + }; + } + } + + if (input.request.state === 'caught_up' && input.agenda.items.length > 0) { + return { + ok: false, + code: 'caught_up_rejected_actionable_items_exist', + message: 'Cannot report caught_up while actionable work remains.', + }; + } + + if ( + input.request.state === 'blocked' && + !agendaHasBlockedEvidence(input.agenda, input.request.taskIds) + ) { + return { + ok: false, + code: 'blocked_without_evidence', + message: 'Blocked report requires current blocker evidence in the task board.', + }; + } + + const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state); + return { + ok: true, + code: 'accepted', + message: 'Member work sync report accepted.', + ...(leaseTtlMs + ? { expiresAt: new Date(Date.parse(input.nowIso) + leaseTtlMs).toISOString() } + : {}), + }; +} diff --git a/src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts b/src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts new file mode 100644 index 00000000..5524b8b4 --- /dev/null +++ b/src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts @@ -0,0 +1,52 @@ +import type { + MemberWorkSyncAgenda, + MemberWorkSyncReport, + MemberWorkSyncStatusState, +} from '../../contracts'; + +export interface SyncDecision { + state: MemberWorkSyncStatusState; + acceptedReport?: MemberWorkSyncReport; + diagnostics: string[]; +} + +export function decideMemberWorkSyncStatus(input: { + agenda: MemberWorkSyncAgenda; + latestAcceptedReport?: MemberWorkSyncReport | null; + nowIso: string; + inactive?: boolean; +}): SyncDecision { + if (input.inactive) { + return { state: 'inactive', diagnostics: ['member_or_team_inactive'] }; + } + + if (input.agenda.items.length === 0) { + return { + state: 'caught_up', + diagnostics: ['agenda_empty'], + acceptedReport: + input.latestAcceptedReport?.agendaFingerprint === input.agenda.fingerprint + ? input.latestAcceptedReport + : undefined, + }; + } + + const report = input.latestAcceptedReport ?? null; + if (!report) { + return { state: 'needs_sync', diagnostics: ['no_current_report'] }; + } + if (report.agendaFingerprint !== input.agenda.fingerprint) { + return { state: 'needs_sync', diagnostics: ['report_fingerprint_stale'] }; + } + if (report.expiresAt && Date.parse(report.expiresAt) <= Date.parse(input.nowIso)) { + return { state: 'needs_sync', diagnostics: ['report_lease_expired'] }; + } + if (report.state === 'still_working') { + return { state: 'still_working', acceptedReport: report, diagnostics: ['lease_still_working'] }; + } + if (report.state === 'blocked') { + return { state: 'blocked', acceptedReport: report, diagnostics: ['lease_blocked'] }; + } + + return { state: 'needs_sync', diagnostics: ['caught_up_report_not_valid_for_non_empty_agenda'] }; +} diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts new file mode 100644 index 00000000..e962649d --- /dev/null +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -0,0 +1,75 @@ +import { normalizeMemberName } from './memberName'; + +export interface ReviewHistoryEventLike { + id?: string; + type: string; + timestamp?: string; + actor?: string; + reviewer?: string; +} + +export interface CurrentReviewOwner { + reviewer: string; + historyEventIds: string[]; +} + +function compareEventsByTimestamp( + left: ReviewHistoryEventLike, + right: ReviewHistoryEventLike +): number { + const leftTime = Date.parse(left.timestamp ?? ''); + const rightTime = Date.parse(right.timestamp ?? ''); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return 0; +} + +export function resolveCurrentReviewOwner(input: { + reviewState?: string | null; + kanbanReviewer?: string | null; + historyEvents?: ReviewHistoryEventLike[]; +}): CurrentReviewOwner | null { + if (input.reviewState !== 'review') { + return null; + } + + const historyEvents = [...(input.historyEvents ?? [])] + .filter((event) => + [ + 'review_requested', + 'review_started', + 'review_approved', + 'review_changes_requested', + ].includes(event.type) + ) + .sort(compareEventsByTimestamp); + + const latest = historyEvents.at(-1); + if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') { + return null; + } + + const latestStarted = [...historyEvents] + .reverse() + .find((event) => event.type === 'review_started'); + const latestRequested = [...historyEvents] + .reverse() + .find((event) => event.type === 'review_requested'); + + const reviewer = + normalizeMemberName(latestStarted?.actor) || + normalizeMemberName(latestRequested?.reviewer) || + normalizeMemberName(input.kanbanReviewer); + + if (!reviewer) { + return null; + } + + return { + reviewer, + historyEventIds: [latestStarted?.id, latestRequested?.id].filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ), + }; +} diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts new file mode 100644 index 00000000..8e9ccf16 --- /dev/null +++ b/src/features/member-work-sync/core/domain/index.ts @@ -0,0 +1,6 @@ +export * from './ActionableWorkAgenda'; +export * from './AgendaFingerprint'; +export * from './currentReviewCycle'; +export * from './memberName'; +export * from './MemberWorkSyncReportValidator'; +export * from './SyncDecisionPolicy'; diff --git a/src/features/member-work-sync/core/domain/memberName.ts b/src/features/member-work-sync/core/domain/memberName.ts new file mode 100644 index 00000000..4a61540f --- /dev/null +++ b/src/features/member-work-sync/core/domain/memberName.ts @@ -0,0 +1,15 @@ +const RESERVED_MEMBER_NAMES = new Set(['', 'user', 'system']); + +export function normalizeMemberName(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export function isReservedMemberName(value: unknown): boolean { + return RESERVED_MEMBER_NAMES.has(normalizeMemberName(value)); +} + +export function sameMemberName(left: unknown, right: unknown): boolean { + const normalizedLeft = normalizeMemberName(left); + const normalizedRight = normalizeMemberName(right); + return normalizedLeft.length > 0 && normalizedLeft === normalizedRight; +} diff --git a/src/features/member-work-sync/index.ts b/src/features/member-work-sync/index.ts new file mode 100644 index 00000000..c7041c4c --- /dev/null +++ b/src/features/member-work-sync/index.ts @@ -0,0 +1 @@ +export * from './contracts'; diff --git a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts new file mode 100644 index 00000000..ea41cf10 --- /dev/null +++ b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts @@ -0,0 +1,48 @@ +import { + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncReportRequest, + type MemberWorkSyncReportResult, + type MemberWorkSyncStatus, + type MemberWorkSyncStatusRequest, +} from '../../../contracts'; +import { createLogger } from '@shared/utils/logger'; + +import type { MemberWorkSyncFeatureFacade } from '../../composition/createMemberWorkSyncFeature'; +import type { IpcMain } from 'electron'; + +const logger = createLogger('Feature:MemberWorkSync:IPC'); + +export function registerMemberWorkSyncIpc( + ipcMain: IpcMain, + feature: MemberWorkSyncFeatureFacade +): void { + ipcMain.handle( + MEMBER_WORK_SYNC_GET_STATUS, + async (_event, request: MemberWorkSyncStatusRequest): Promise => { + try { + return await feature.getStatus(request); + } catch (error) { + logger.error('Failed to get member work sync status', error); + throw error; + } + } + ); + + ipcMain.handle( + MEMBER_WORK_SYNC_REPORT, + async (_event, request: MemberWorkSyncReportRequest): Promise => { + try { + return await feature.report(request); + } catch (error) { + logger.error('Failed to submit member work sync report', error); + throw error; + } + } + ); +} + +export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS); + ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT); +} diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts new file mode 100644 index 00000000..33aae5bc --- /dev/null +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -0,0 +1,125 @@ +import { + buildActionableWorkAgenda, + normalizeMemberName, + type MemberWorkSyncMemberLike, +} from '../../../core/domain'; +import type { + MemberWorkSyncAgendaSourcePort, + MemberWorkSyncAgendaSourceResult, + MemberWorkSyncHashPort, +} from '../../../core/application'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; + +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; +import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; +import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; +import type { TeamMember } from '@shared/types'; + +export interface TeamTaskAgendaSourceDeps { + configReader: TeamConfigReader; + taskReader: TeamTaskReader; + kanbanManager: TeamKanbanManager; + membersMetaStore: TeamMembersMetaStore; + hash: MemberWorkSyncHashPort; + clock: { now(): Date }; +} + +function memberKey(member: Pick): string { + return normalizeMemberName(member.name); +} + +function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] { + const byName = new Map(); + 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 toMemberLike(member: TeamMember): MemberWorkSyncMemberLike { + const providerId = + normalizeOptionalTeamProviderId(member.providerId) ?? + inferTeamProviderIdFromModel(member.model); + return { + name: member.name, + ...(providerId ? { providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.agentType ? { agentType: member.agentType } : {}), + ...(member.removedAt ? { removedAt: String(member.removedAt) } : {}), + }; +} + +export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { + constructor(private readonly deps: TeamTaskAgendaSourceDeps) {} + + async loadAgenda(input: { + teamName: string; + memberName: string; + }): Promise { + const config = await this.deps.configReader.getConfig(input.teamName); + if (!config || config.deletedAt) { + const nowIso = this.deps.clock.now().toISOString(); + return { + agenda: { + teamName: input.teamName, + memberName: normalizeMemberName(input.memberName), + generatedAt: nowIso, + items: [], + diagnostics: config?.deletedAt ? ['team_deleted'] : ['team_config_missing'], + }, + activeMemberNames: [], + inactive: true, + diagnostics: [], + }; + } + + const [tasks, kanban, metaMembers] = await Promise.all([ + this.deps.taskReader.getTasks(input.teamName), + this.deps.kanbanManager.getState(input.teamName), + this.deps.membersMetaStore.getMembers(input.teamName), + ]); + const members = mergeMembers(config.members ?? [], metaMembers); + const activeMemberNames = members + .filter((member) => !member.removedAt) + .map((member) => normalizeMemberName(member.name)) + .filter(Boolean); + const normalizedMemberName = normalizeMemberName(input.memberName); + const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName); + const providerId = + normalizeOptionalTeamProviderId(member?.providerId) ?? + inferTeamProviderIdFromModel(member?.model); + + const agenda = buildActionableWorkAgenda({ + teamName: input.teamName, + memberName: input.memberName, + generatedAt: this.deps.clock.now().toISOString(), + tasks, + members: members.map(toMemberLike), + kanbanReviewersByTaskId: Object.fromEntries( + Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null]) + ), + hash: this.deps.hash.sha256Hex.bind(this.deps.hash), + }); + + return { + agenda, + activeMemberNames, + inactive: !activeMemberNames.includes(normalizedMemberName), + ...(providerId ? { providerId } : {}), + diagnostics: [], + }; + } +} diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts new file mode 100644 index 00000000..4c3d6188 --- /dev/null +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -0,0 +1,59 @@ +import type { + MemberWorkSyncReportRequest, + MemberWorkSyncReportResult, + MemberWorkSyncStatus, + MemberWorkSyncStatusRequest, +} from '../../contracts'; +import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application'; +import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; +import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; +import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; +import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; +import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; + +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; +import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; +import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; +import type { MemberWorkSyncLoggerPort } from '../../core/application'; + +export interface MemberWorkSyncFeatureFacade { + getStatus(request: MemberWorkSyncStatusRequest): Promise; + report(request: MemberWorkSyncReportRequest): Promise; +} + +export function createMemberWorkSyncFeature(deps: { + teamsBasePath: string; + configReader: TeamConfigReader; + taskReader: TeamTaskReader; + kanbanManager: TeamKanbanManager; + membersMetaStore: TeamMembersMetaStore; + logger?: MemberWorkSyncLoggerPort; +}): MemberWorkSyncFeatureFacade { + const clock = new SystemClockAdapter(); + const hash = new NodeHashAdapter(); + const agendaSource = new TeamTaskAgendaSource({ + configReader: deps.configReader, + taskReader: deps.taskReader, + kanbanManager: deps.kanbanManager, + membersMetaStore: deps.membersMetaStore, + hash, + clock, + }); + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(deps.teamsBasePath)); + const useCaseDeps = { + clock, + hash, + agendaSource, + statusStore: store, + reportStore: store, + logger: deps.logger, + }; + const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); + const reporter = new MemberWorkSyncReporter(useCaseDeps); + + return { + getStatus: (request) => diagnosticsReader.execute(request), + report: (request) => reporter.execute(request), + }; +} diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts new file mode 100644 index 00000000..e9cd3909 --- /dev/null +++ b/src/features/member-work-sync/main/index.ts @@ -0,0 +1,6 @@ +export { + registerMemberWorkSyncIpc, + removeMemberWorkSyncIpc, +} from './adapters/input/registerMemberWorkSyncIpc'; +export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature'; +export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature'; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts new file mode 100644 index 00000000..9fa9954f --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -0,0 +1,95 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { mkdir, readFile, appendFile } from 'fs/promises'; + +import type { MemberWorkSyncReportRequest, MemberWorkSyncStatus } from '../../contracts'; +import type { + MemberWorkSyncReportStorePort, + MemberWorkSyncStatusStorePort, +} from '../../core/application'; +import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; + +interface StoreFile { + schemaVersion: 1; + members: Record; +} + +function normalizeMemberKey(memberName: string): string { + return memberName.trim().toLowerCase(); +} + +function isStoreFile(value: unknown): value is StoreFile { + return ( + value != null && + typeof value === 'object' && + (value as StoreFile).schemaVersion === 1 && + (value as StoreFile).members != null && + typeof (value as StoreFile).members === 'object' && + !Array.isArray((value as StoreFile).members) + ); +} + +export class JsonMemberWorkSyncStore + implements MemberWorkSyncStatusStorePort, MemberWorkSyncReportStorePort +{ + private readonly writeQueues = new Map>(); + + constructor(private readonly paths: MemberWorkSyncStorePaths) {} + + async read(input: { + teamName: string; + memberName: string; + }): Promise { + const file = await this.readFile(input.teamName); + return file.members[normalizeMemberKey(input.memberName)] ?? null; + } + + async write(status: MemberWorkSyncStatus): Promise { + await this.enqueue(status.teamName, async () => { + const existing = await this.readFile(status.teamName); + existing.members[normalizeMemberKey(status.memberName)] = status; + await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); + await atomicWriteAsync( + this.paths.getStatusPath(status.teamName), + JSON.stringify(existing, null, 2) + ); + }); + } + + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { + await mkdir(this.paths.getTeamDir(request.teamName), { recursive: true }); + await appendFile( + this.paths.getPendingReportsPath(request.teamName), + `${JSON.stringify({ schemaVersion: 1, reason, request, recordedAt: new Date().toISOString() })}\n`, + 'utf8' + ); + } + + private async readFile(teamName: string): Promise { + try { + const raw = await readFile(this.paths.getStatusPath(teamName), 'utf8'); + const parsed = JSON.parse(raw); + if (isStoreFile(parsed)) { + return parsed; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + return { schemaVersion: 1, members: {} }; + } + + private async enqueue(teamName: string, operation: () => Promise): Promise { + const previous = this.writeQueues.get(teamName) ?? Promise.resolve(); + const next = previous.then(operation, operation); + this.writeQueues.set( + teamName, + next.finally(() => { + if (this.writeQueues.get(teamName) === next) { + this.writeQueues.delete(teamName); + } + }) + ); + await next; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts new file mode 100644 index 00000000..f82f7b4f --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -0,0 +1,17 @@ +import { join } from 'path'; + +export class MemberWorkSyncStorePaths { + constructor(private readonly teamsBasePath: string) {} + + getTeamDir(teamName: string): string { + return join(this.teamsBasePath, teamName, '.member-work-sync'); + } + + getStatusPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'status.json'); + } + + getPendingReportsPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'pending-reports.jsonl'); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts b/src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts new file mode 100644 index 00000000..4aa57958 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts @@ -0,0 +1,9 @@ +import { createHash } from 'crypto'; + +import type { MemberWorkSyncHashPort } from '../../core/application'; + +export class NodeHashAdapter implements MemberWorkSyncHashPort { + sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts b/src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts new file mode 100644 index 00000000..ec588300 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts @@ -0,0 +1,7 @@ +import type { MemberWorkSyncClockPort } from '../../core/application'; + +export class SystemClockAdapter implements MemberWorkSyncClockPort { + now(): Date { + return new Date(); + } +} diff --git a/src/features/member-work-sync/preload/index.ts b/src/features/member-work-sync/preload/index.ts new file mode 100644 index 00000000..c90efb6b --- /dev/null +++ b/src/features/member-work-sync/preload/index.ts @@ -0,0 +1,22 @@ +import { + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncReportRequest, + type MemberWorkSyncReportResult, + type MemberWorkSyncStatus, + type MemberWorkSyncStatusRequest, +} from '../contracts'; + +import type { IpcRenderer } from 'electron'; + +export interface MemberWorkSyncElectronApi { + getStatus(request: MemberWorkSyncStatusRequest): Promise; + report(request: MemberWorkSyncReportRequest): Promise; +} + +export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi { + return { + getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request), + report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request), + }; +} diff --git a/src/main/http/index.ts b/src/main/http/index.ts index 4b225922..09f7c0c6 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -9,6 +9,7 @@ import { type RecentProjectsFeatureFacade, registerRecentProjectsHttp, } from '@features/recent-projects/main'; +import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main'; import { createLogger } from '@shared/utils/logger'; import { registerConfigRoutes } from './config'; @@ -46,6 +47,7 @@ export interface HttpServices { chunkBuilder: ChunkBuilder; dataCache: DataCache; recentProjectsFeature?: RecentProjectsFeatureFacade; + memberWorkSyncFeature?: MemberWorkSyncFeatureFacade; updaterService: UpdaterService; sshConnectionManager: SshConnectionManager; teamDataService?: TeamDataService; diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 38047798..4d67535f 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -14,6 +14,7 @@ import { access } from 'fs/promises'; import { isAbsolute, join } from 'path'; import type { HttpServices } from './index'; +import type { MemberWorkSyncReportState } from '@features/member-work-sync/contracts'; import type { EffortLevel, TeamCreateConfigRequest, @@ -31,6 +32,10 @@ type CreateTeamBody = TeamCreateConfigRequest; class HttpBadRequestError extends Error {} class HttpFeatureUnavailableError extends Error {} +function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState { + return value === 'still_working' || value === 'blocked' || value === 'caught_up'; +} + function getTeamProvisioningService( services: HttpServices ): NonNullable { @@ -466,6 +471,15 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record { + if (!services.memberWorkSyncFeature) { + throw new HttpBadRequestError('Member work sync feature is unavailable'); + } + return services.memberWorkSyncFeature; +} + export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void { app.get('/api/teams', async (_request, reply) => { try { @@ -736,4 +750,85 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) } } ); + + app.get<{ Params: { teamName: string; memberName: string } }>( + '/api/teams/:teamName/member-work-sync/:memberName', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + const memberName = request.params.memberName?.trim(); + if (!memberName) { + return reply.status(400).send({ error: 'memberName is required' }); + } + return reply.send( + await getMemberWorkSyncFeature(services).getStatus({ + teamName: validatedTeamName.value!, + memberName, + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in GET /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string }; Body: Record }>( + '/api/teams/:teamName/member-work-sync/report', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + const payload = withRuntimeTeamName(validatedTeamName.value!, request.body); + const memberName = typeof payload.memberName === 'string' ? payload.memberName.trim() : ''; + const state = typeof payload.state === 'string' ? payload.state.trim() : ''; + const agendaFingerprint = + typeof payload.agendaFingerprint === 'string' ? payload.agendaFingerprint.trim() : ''; + if (!memberName || !state || !agendaFingerprint) { + return reply.status(400).send({ + error: 'memberName, state, and agendaFingerprint are required', + }); + } + if (!isMemberWorkSyncReportState(state)) { + return reply + .status(400) + .send({ error: 'state must be still_working, blocked, or caught_up' }); + } + const taskIds = Array.isArray(payload.taskIds) + ? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string') + : undefined; + return reply.send( + await getMemberWorkSyncFeature(services).report({ + teamName: validatedTeamName.value!, + memberName, + state, + agendaFingerprint, + ...(taskIds ? { taskIds } : {}), + ...(typeof payload.note === 'string' ? { note: payload.note } : {}), + ...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}), + ...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}), + source: 'mcp', + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/member-work-sync/report:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); } diff --git a/src/main/index.ts b/src/main/index.ts index 00434089..f7669d44 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,6 +36,12 @@ import { registerRecentProjectsIpc, removeRecentProjectsIpc, } from '@features/recent-projects/main'; +import { + createMemberWorkSyncFeature, + type MemberWorkSyncFeatureFacade, + registerMemberWorkSyncIpc, + removeMemberWorkSyncIpc, +} from '@features/member-work-sync/main'; import { createRuntimeProviderManagementFeature, registerRuntimeProviderManagementIpc, @@ -178,11 +184,14 @@ import { TeamMemberLogsFinder, TeamProvisioningService, TeamRuntimeAdapterRegistry, + TeamKanbanManager, + TeamMembersMetaStore, TeamTaskStallJournal, TeamTaskStallMonitor, TeamTaskStallNotifier, TeamTaskStallPolicy, TeamTaskStallSnapshotSource, + TeamTaskReader, UpdaterService, } from './services'; @@ -558,6 +567,7 @@ let codexAccountFeature: CodexAccountFeatureFacade | null = null; let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; +let memberWorkSyncFeature: MemberWorkSyncFeatureFacade; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; @@ -1215,6 +1225,14 @@ async function initializeServices(): Promise { logger: createLogger('Feature:RecentProjects'), }); runtimeProviderManagementFeature = createRuntimeProviderManagementFeature(); + memberWorkSyncFeature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader: new TeamConfigReader(), + taskReader: new TeamTaskReader(), + kanbanManager: new TeamKanbanManager(), + membersMetaStore: new TeamMembersMetaStore(), + logger: createLogger('Feature:MemberWorkSync'), + }); codexAccountFeature = createCodexAccountFeature({ logger: createLogger('Feature:CodexAccount'), configManager, @@ -1282,6 +1300,7 @@ async function initializeServices(): Promise { registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); + registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { @@ -1335,6 +1354,7 @@ async function startHttpServer( chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, recentProjectsFeature, + memberWorkSyncFeature, updaterService, sshConnectionManager, teamDataService, @@ -1467,6 +1487,7 @@ async function shutdownServices(): Promise { removeCodexAccountIpc(ipcMain); removeRecentProjectsIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain); + removeMemberWorkSyncIpc(ipcMain); }); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); diff --git a/src/preload/index.ts b/src/preload/index.ts index 47fbbe23..f175881a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { createCodexAccountBridge } from '@features/codex-account/preload'; +import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload'; import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; @@ -471,6 +472,7 @@ const electronAPI: ElectronAPI = { }), ...createRecentProjectsBridge(), runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), + memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 451149dd..171cbeaa 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -82,7 +82,7 @@ import type { WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; -import type { AgentConfig } from '@shared/types/api'; +import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api'; import type { EditorAPI, ProjectAPI } from '@shared/types/editor'; import type { TerminalAPI } from '@shared/types/terminal'; @@ -1289,6 +1289,20 @@ export class HttpAPIClient implements ElectronAPI { }), }; + memberWorkSync: MemberWorkSyncElectronApi = { + getStatus: (request) => + this.get( + `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/${encodeURIComponent( + request.memberName + )}` + ), + report: (request) => + this.post( + `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`, + request + ), + }; + tmux: TmuxAPI = { getStatus: async (): Promise => ({ platform: 'unknown', diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 7d8ddc32..633430b2 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -97,6 +97,12 @@ import type { WaterfallData } from './visualization'; import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; +import type { + MemberWorkSyncReportRequest, + MemberWorkSyncReportResult, + MemberWorkSyncStatus, + MemberWorkSyncStatusRequest, +} from '@features/member-work-sync/contracts'; import type { ConversationGroup, FileChangeEvent, @@ -604,6 +610,11 @@ export interface TeamsAPI { readFileForToolApproval: (filePath: string) => Promise; } +export interface MemberWorkSyncElectronApi { + getStatus(request: MemberWorkSyncStatusRequest): Promise; + report(request: MemberWorkSyncReportRequest): Promise; +} + // ============================================================================= // Cross-Team Communication API // ============================================================================= @@ -876,6 +887,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec // Runtime nested provider management API runtimeProviderManagement: RuntimeProviderManagementApi; + // Member actionable-work sync diagnostics API + memberWorkSync: MemberWorkSyncElectronApi; + // tmux runtime diagnostics API tmux: TmuxAPI; diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts new file mode 100644 index 00000000..ae876095 --- /dev/null +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain'; + +const hash = (value: string) => `h${value.length}`; + +describe('buildActionableWorkAgenda', () => { + it('includes owned pending and in-progress work but excludes completed tasks', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-1', + displayId: '#11111111', + subject: 'Pending', + status: 'pending', + owner: 'bob', + }, + { + id: 'task-2', + displayId: '#22222222', + subject: 'In progress', + status: 'in_progress', + owner: 'Bob', + }, + { + id: 'task-3', + displayId: '#33333333', + subject: 'Done', + status: 'completed', + owner: 'bob', + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ + ['task-1', 'work', 'owned_pending_task'], + ['task-2', 'work', 'owned_in_progress_task'], + ]); + }); + + it('assigns active review work to the current-cycle reviewer only', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Review me', + status: 'in_progress', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-1', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toHaveLength(1); + expect(agenda.items[0]).toMatchObject({ + taskId: 'task-1', + kind: 'review', + assignee: 'alice', + evidence: { reviewer: 'alice' }, + }); + }); + + it('does not resurrect a stale reviewer after review was approved', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Old review', + status: 'in_progress', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-1', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + { + id: 'evt-2', + type: 'review_approved', + timestamp: '2026-04-29T00:01:00.000Z', + actor: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toEqual([]); + }); + + it('projects clarification and blocked dependency work for the owner', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Need user', + status: 'in_progress', + owner: 'bob', + needsClarification: 'user', + }, + { + id: 'task-2', + subject: 'Blocked', + status: 'in_progress', + owner: 'bob', + blockedBy: ['task-3'], + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.kind, item.priority])).toEqual([ + ['task-1', 'clarification', 'needs_clarification'], + ['task-2', 'blocked_dependency', 'blocked'], + ]); + }); + + it('keeps fingerprint stable across generatedAt changes and changes it on owner change', () => { + const base = { + teamName: 'team-a', + memberName: 'bob', + members: [{ name: 'bob' }, { name: 'alice' }], + hash, + }; + const first = buildActionableWorkAgenda({ + ...base, + generatedAt: '2026-04-29T00:00:00.000Z', + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + }); + const second = buildActionableWorkAgenda({ + ...base, + generatedAt: '2026-04-29T00:05:00.000Z', + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + }); + const third = buildActionableWorkAgenda({ + ...base, + generatedAt: '2026-04-29T00:05:00.000Z', + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'alice' }], + }); + + expect(first.fingerprint).toBe(second.fingerprint); + expect(first.fingerprint).not.toBe(third.fingerprint); + }); +}); diff --git a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts new file mode 100644 index 00000000..8a7a902a --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildActionableWorkAgenda, + validateMemberWorkSyncReport, +} from '@features/member-work-sync/core/domain'; + +const nowIso = '2026-04-29T00:00:00.000Z'; +const hash = (value: string) => `h${value.length}`; + +function agendaWithWork() { + return buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: nowIso, + members: [{ name: 'bob' }], + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + hash, + }); +} + +describe('validateMemberWorkSyncReport', () => { + it('accepts still_working for the current agenda fingerprint', () => { + const agenda = agendaWithWork(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(result.ok).toBe(true); + expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z'); + }); + + it('rejects caught_up while actionable work remains', () => { + const agenda = agendaWithWork(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'caught_up', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(result).toMatchObject({ + ok: false, + code: 'caught_up_rejected_actionable_items_exist', + }); + }); + + it('rejects blocked without current blocker evidence', () => { + const agenda = agendaWithWork(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'blocked', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' }); + }); + + it('rejects stale fingerprints and foreign task ids', () => { + const agenda = agendaWithWork(); + const stale = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:old', + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + const foreign = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + taskIds: ['other-task'], + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(stale.code).toBe('stale_fingerprint'); + expect(foreign.code).toBe('foreign_task_id'); + }); + + it('rejects reserved and inactive member identities', () => { + const agenda = agendaWithWork(); + const reserved = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'user', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + const inactive = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: [], + }); + + expect(reserved.code).toBe('reserved_or_invalid_member'); + expect(inactive.code).toBe('member_inactive'); + }); +}); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts new file mode 100644 index 00000000..3d41a6f8 --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; + +import { + MemberWorkSyncDiagnosticsReader, + MemberWorkSyncReporter, + type MemberWorkSyncAgendaSourceResult, + type MemberWorkSyncStatusStorePort, + type MemberWorkSyncUseCaseDeps, +} from '@features/member-work-sync/core/application'; +import type { + MemberWorkSyncActionableWorkItem, + MemberWorkSyncReportRequest, + MemberWorkSyncStatus, +} from '@features/member-work-sync/contracts'; + +const workItem: MemberWorkSyncActionableWorkItem = { + taskId: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + kind: 'work', + assignee: 'bob', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'bob', + }, +}; + +class MutableClock { + private current = new Date('2026-04-29T00:00:00.000Z'); + + now(): Date { + return this.current; + } + + set(iso: string): void { + this.current = new Date(iso); + } +} + +class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { + readonly writes: MemberWorkSyncStatus[] = []; + readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = []; + + async read(): Promise { + return this.writes.at(-1) ?? null; + } + + async write(status: MemberWorkSyncStatus): Promise { + this.writes.push(status); + } + + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { + this.pendingReports.push({ request, reason }); + } +} + +function createDeps(options?: { + items?: MemberWorkSyncActionableWorkItem[]; + activeMemberNames?: string[]; + inactive?: boolean; + providerId?: 'opencode' | 'codex'; +}) { + const clock = new MutableClock(); + const store = new InMemoryStatusStore(); + const source: MemberWorkSyncAgendaSourceResult = { + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + items: options?.items ?? [workItem], + diagnostics: [], + }, + activeMemberNames: options?.activeMemberNames ?? ['bob'], + inactive: options?.inactive ?? false, + ...(options?.providerId ? { providerId: options.providerId } : {}), + diagnostics: [], + }; + const deps: MemberWorkSyncUseCaseDeps = { + clock, + hash: { + sha256Hex: (value) => `hash-${value.length}`, + }, + agendaSource: { + loadAgenda: async () => source, + }, + statusStore: store, + reportStore: store, + }; + return { clock, deps, source, store }; +} + +describe('MemberWorkSync use cases', () => { + it('reconciles actionable work into needs_sync without side effects', async () => { + const { deps, store } = createDeps(); + const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + + expect(status.state).toBe('needs_sync'); + expect(status.agenda.items).toEqual([workItem]); + expect(status.diagnostics).toContain('no_current_report'); + expect(store.pendingReports).toEqual([]); + }); + + it('accepts still_working as a bounded lease for the current fingerprint', async () => { + const { clock, deps } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + taskIds: ['task-1'], + leaseTtlMs: 120_000, + source: 'test', + }); + + expect(result.accepted).toBe(true); + expect(result.status.state).toBe('still_working'); + + clock.set('2026-04-29T00:01:59.000Z'); + expect((await reader.execute({ teamName: 'team-a', memberName: 'bob' })).state).toBe( + 'still_working' + ); + + clock.set('2026-04-29T00:02:00.000Z'); + const expired = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + expect(expired.state).toBe('needs_sync'); + expect(expired.diagnostics).toContain('report_lease_expired'); + }); + + it('rejects stale or unsafe reports and records pending intent only', async () => { + const { deps, store } = createDeps(); + const result = await new MemberWorkSyncReporter(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'caught_up', + agendaFingerprint: 'agenda:v1:stale', + source: 'test', + }); + + expect(result.accepted).toBe(false); + expect(result.code).toBe('stale_fingerprint'); + expect(result.status.state).toBe('needs_sync'); + expect(store.pendingReports).toHaveLength(1); + expect(store.pendingReports[0].reason).toBe('stale_fingerprint'); + }); + + it('accepts caught_up only when the app-side agenda is empty', async () => { + const { deps } = createDeps({ items: [] }); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'caught_up', + agendaFingerprint: current.agenda.fingerprint, + source: 'test', + }); + + expect(result.accepted).toBe(true); + expect(result.status.state).toBe('caught_up'); + }); +});