diff --git a/docs/team-management/member-work-sync-control-plane-plan.md b/docs/team-management/member-work-sync-control-plane-plan.md index da99ee0a..eae41bbe 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -1,6 +1,6 @@ # Member Work Sync Control Plane Plan -**Status:** Phase 1 implemented, Phase 2 deferred until shadow metrics are reviewed +**Status:** Phase 1 and Phase 1.5 observability implemented, Phase 2 deferred until shadow metrics are reviewed **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -31,7 +31,7 @@ Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint ch Current implementation note: -- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, and read-only renderer view models. +- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and read-only renderer view models. - Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. diff --git a/src/features/member-work-sync/contracts/ipc.ts b/src/features/member-work-sync/contracts/ipc.ts index 5a000fe3..5b4e070f 100644 --- a/src/features/member-work-sync/contracts/ipc.ts +++ b/src/features/member-work-sync/contracts/ipc.ts @@ -1,2 +1,3 @@ export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus'; +export const MEMBER_WORK_SYNC_GET_METRICS = 'member-work-sync:getMetrics'; 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 index e98fbe92..3139a921 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -102,6 +102,42 @@ export interface MemberWorkSyncStatus { providerId?: MemberWorkSyncProviderId; } +export type MemberWorkSyncMetricEventKind = + | 'status_evaluated' + | 'would_nudge' + | 'fingerprint_changed' + | 'report_accepted' + | 'report_rejected'; + +export interface MemberWorkSyncMetricEvent { + id: string; + teamName: string; + memberName: string; + kind: MemberWorkSyncMetricEventKind; + state: MemberWorkSyncStatusState; + agendaFingerprint: string; + recordedAt: string; + actionableCount: number; + providerId?: MemberWorkSyncProviderId; + previousFingerprint?: string; + triggerReasons?: string[]; + reportState?: MemberWorkSyncReportState; + rejectionCode?: string; +} + +export interface MemberWorkSyncTeamMetrics { + teamName: string; + generatedAt: string; + memberCount: number; + stateCounts: Record; + actionableItemCount: number; + wouldNudgeCount: number; + fingerprintChangeCount: number; + reportAcceptedCount: number; + reportRejectedCount: number; + recentEvents: MemberWorkSyncMetricEvent[]; +} + export interface MemberWorkSyncReportRequest { teamName: string; memberName: string; @@ -126,3 +162,7 @@ export interface MemberWorkSyncStatusRequest { teamName: string; memberName: string; } + +export interface MemberWorkSyncMetricsRequest { + teamName: string; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts new file mode 100644 index 00000000..41cb9d4a --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts @@ -0,0 +1,35 @@ +import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics { + return { + teamName, + generatedAt, + memberCount: 0, + stateCounts: { + caught_up: 0, + needs_sync: 0, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }, + actionableItemCount: 0, + wouldNudgeCount: 0, + fingerprintChangeCount: 0, + reportAcceptedCount: 0, + reportRejectedCount: 0, + recentEvents: [], + }; +} + +export class MemberWorkSyncMetricsReader { + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + + async execute(request: MemberWorkSyncMetricsRequest): Promise { + if (!this.deps.statusStore.readTeamMetrics) { + return emptyMetrics(request.teamName, this.deps.clock.now().toISOString()); + } + return this.deps.statusStore.readTeamMetrics(request.teamName); + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index 3e401d5a..de2bd018 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -2,6 +2,7 @@ import type { MemberWorkSyncReport, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, + MemberWorkSyncStatus, } from '../../contracts'; import { validateMemberWorkSyncReport } from '../domain'; import { @@ -27,11 +28,12 @@ export class MemberWorkSyncReporter { : true; if (!teamActive) { const status = await this.reconciler.execute(request); + const rejectedStatus = await this.recordRejectedReport(status, request, 'team_runtime_inactive'); return { accepted: false, code: 'team_runtime_inactive', message: 'Team runtime is not active. Restart the team before reporting work sync state.', - status, + status: rejectedStatus, }; } const tokenValidation = this.deps.reportToken @@ -53,11 +55,12 @@ export class MemberWorkSyncReporter { if (!validation.ok) { const status = await this.reconciler.execute(request); + const rejectedStatus = await this.recordRejectedReport(status, request, validation.code); return { accepted: false, code: validation.code, message: validation.message, - status, + status: rejectedStatus, }; } @@ -103,4 +106,29 @@ export class MemberWorkSyncReporter { status, }; } + + private async recordRejectedReport( + status: MemberWorkSyncStatus, + request: MemberWorkSyncReportRequest, + rejectionCode: string + ): Promise { + const rejectedStatus: MemberWorkSyncStatus = { + ...status, + report: { + teamName: status.teamName, + memberName: status.memberName, + state: request.state, + agendaFingerprint: request.agendaFingerprint, + reportedAt: status.evaluatedAt, + ...(request.taskIds ? { taskIds: [...request.taskIds] } : {}), + ...(request.note ? { note: request.note } : {}), + source: request.source ?? 'app', + accepted: false, + rejectionCode, + }, + diagnostics: [...status.diagnostics, `report_rejected:${rejectionCode}`], + }; + await this.deps.statusStore.write(rejectedStatus); + return rejectedStatus; + } } diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 6e5a81aa..b47b511c 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,4 +1,5 @@ export * from './MemberWorkSyncDiagnosticsReader'; +export * from './MemberWorkSyncMetricsReader'; export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 32aacc1f..b3865eb8 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -1,5 +1,6 @@ import type { MemberWorkSyncAgenda, + MemberWorkSyncTeamMetrics, MemberWorkSyncProviderId, MemberWorkSyncReport, MemberWorkSyncReportIntent, @@ -73,6 +74,7 @@ export interface MemberWorkSyncAgendaSourcePort { export interface MemberWorkSyncStatusStorePort { read(input: { teamName: string; memberName: string }): Promise; write(status: MemberWorkSyncStatus): Promise; + readTeamMetrics?(teamName: string): Promise; } export interface MemberWorkSyncReportStorePort { diff --git a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts index ea41cf10..821f1ee2 100644 --- a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts +++ b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts @@ -1,10 +1,13 @@ import { + MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncMetricsRequest, type MemberWorkSyncReportRequest, type MemberWorkSyncReportResult, type MemberWorkSyncStatus, type MemberWorkSyncStatusRequest, + type MemberWorkSyncTeamMetrics, } from '../../../contracts'; import { createLogger } from '@shared/utils/logger'; @@ -29,6 +32,18 @@ export function registerMemberWorkSyncIpc( } ); + ipcMain.handle( + MEMBER_WORK_SYNC_GET_METRICS, + async (_event, request: MemberWorkSyncMetricsRequest): Promise => { + try { + return await feature.getMetrics(request); + } catch (error) { + logger.error('Failed to get member work sync metrics', error); + throw error; + } + } + ); + ipcMain.handle( MEMBER_WORK_SYNC_REPORT, async (_event, request: MemberWorkSyncReportRequest): Promise => { @@ -44,5 +59,6 @@ export function registerMemberWorkSyncIpc( export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS); + ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS); ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT); } diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index ffdbf949..40034a4e 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -1,11 +1,14 @@ import type { + MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, MemberWorkSyncStatus, MemberWorkSyncStatusRequest, + MemberWorkSyncTeamMetrics, } from '../../contracts'; import { MemberWorkSyncDiagnosticsReader, + MemberWorkSyncMetricsReader, MemberWorkSyncPendingReportIntentReplayer, type MemberWorkSyncPendingReportReplaySummary, MemberWorkSyncReconciler, @@ -33,6 +36,7 @@ import type { MemberWorkSyncLoggerPort } from '../../core/application'; export interface MemberWorkSyncFeatureFacade { getStatus(request: MemberWorkSyncStatusRequest): Promise; + getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; noteTeamChange(event: TeamChangeEvent): void; enqueueStartupScan(teamNames: string[]): Promise; @@ -74,6 +78,7 @@ export function createMemberWorkSyncFeature(deps: { logger: deps.logger, }; const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); + const metricsReader = new MemberWorkSyncMetricsReader(useCaseDeps); const reporter = new MemberWorkSyncReporter(useCaseDeps); const reconciler = new MemberWorkSyncReconciler(useCaseDeps); const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps); @@ -88,6 +93,7 @@ export function createMemberWorkSyncFeature(deps: { return { getStatus: (request) => diagnosticsReader.execute(request), + getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), noteTeamChange: (event) => router.noteTeamChange(event), enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames), diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index ec2df82e..a71cf6ff 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -4,9 +4,12 @@ import { mkdir, readFile, rename } from 'fs/promises'; import { withFileLock } from '@main/services/team/fileLock'; import type { + MemberWorkSyncMetricEvent, MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, MemberWorkSyncStatus, + MemberWorkSyncStatusState, + MemberWorkSyncTeamMetrics, } from '../../contracts'; import type { MemberWorkSyncReportStorePort, @@ -17,6 +20,9 @@ import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; interface StoreFile { schemaVersion: 1; members: Record; + metrics?: { + recentEvents: MemberWorkSyncMetricEvent[]; + }; } interface PendingReportFile { @@ -39,6 +45,17 @@ function isStoreFile(value: unknown): value is StoreFile { ); } +function emptyStateCounts(): Record { + return { + caught_up: 0, + needs_sync: 0, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }; +} + function isPendingReportFile(value: unknown): value is PendingReportFile { return ( value != null && @@ -82,6 +99,91 @@ function buildPendingReportIntentId(request: MemberWorkSyncReportRequest): strin .digest('hex')}`; } +function buildMetricEventId(status: MemberWorkSyncStatus, kind: MemberWorkSyncMetricEvent['kind']) { + return `member-work-sync-metric:${createHash('sha256') + .update( + stableStringify({ + teamName: status.teamName, + memberName: normalizeMemberKey(status.memberName), + kind, + state: status.state, + agendaFingerprint: status.agenda.fingerprint, + evaluatedAt: status.evaluatedAt, + reportState: status.report?.state ?? '', + rejectionCode: status.report?.rejectionCode ?? '', + }) + ) + .digest('hex')}`; +} + +function buildMetricEvents(status: MemberWorkSyncStatus): MemberWorkSyncMetricEvent[] { + const base = { + teamName: status.teamName, + memberName: status.memberName, + state: status.state, + agendaFingerprint: status.agenda.fingerprint, + recordedAt: status.evaluatedAt, + actionableCount: status.agenda.items.length, + ...(status.providerId ? { providerId: status.providerId } : {}), + ...(status.shadow?.previousFingerprint + ? { previousFingerprint: status.shadow.previousFingerprint } + : {}), + ...(status.shadow?.triggerReasons?.length + ? { triggerReasons: [...status.shadow.triggerReasons] } + : {}), + ...(status.report?.state ? { reportState: status.report.state } : {}), + ...(status.report?.rejectionCode ? { rejectionCode: status.report.rejectionCode } : {}), + }; + const events: MemberWorkSyncMetricEvent[] = [ + { + ...base, + id: buildMetricEventId(status, 'status_evaluated'), + kind: 'status_evaluated', + }, + ]; + if (status.shadow?.wouldNudge) { + events.push({ + ...base, + id: buildMetricEventId(status, 'would_nudge'), + kind: 'would_nudge', + }); + } + if (status.shadow?.fingerprintChanged) { + events.push({ + ...base, + id: buildMetricEventId(status, 'fingerprint_changed'), + kind: 'fingerprint_changed', + }); + } + if (status.report?.accepted) { + events.push({ + ...base, + id: buildMetricEventId(status, 'report_accepted'), + kind: 'report_accepted', + }); + } else if (status.report?.rejectionCode) { + events.push({ + ...base, + id: buildMetricEventId(status, 'report_rejected'), + kind: 'report_rejected', + }); + } + return events; +} + +function appendMetricEvents(file: StoreFile, status: MemberWorkSyncStatus): void { + const current = file.metrics?.recentEvents ?? []; + const byId = new Map(current.map((event) => [event.id, event])); + for (const event of buildMetricEvents(status)) { + byId.set(event.id, event); + } + file.metrics = { + recentEvents: [...byId.values()] + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)) + .slice(-200), + }; +} + async function quarantineFile(filePath: string): Promise { try { await rename(filePath, `${filePath}.invalid.${Date.now()}`); @@ -110,6 +212,7 @@ export class JsonMemberWorkSyncStore await withFileLock(this.paths.getStatusPath(status.teamName), async () => { const existing = await this.readFile(status.teamName); existing.members[normalizeMemberKey(status.memberName)] = status; + appendMetricEvents(existing, status); await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); await atomicWriteAsync( this.paths.getStatusPath(status.teamName), @@ -119,6 +222,36 @@ export class JsonMemberWorkSyncStore }); } + async readTeamMetrics(teamName: string): Promise { + const file = await this.readFile(teamName); + const stateCounts = emptyStateCounts(); + const members = Object.values(file.members); + let actionableItemCount = 0; + for (const status of members) { + stateCounts[status.state] += 1; + actionableItemCount += status.agenda.items.length; + } + const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) => + left.recordedAt.localeCompare(right.recordedAt) + ); + return { + teamName, + generatedAt: new Date().toISOString(), + memberCount: members.length, + stateCounts, + actionableItemCount, + wouldNudgeCount: recentEvents.filter((event) => event.kind === 'would_nudge').length, + fingerprintChangeCount: recentEvents.filter( + (event) => event.kind === 'fingerprint_changed' + ).length, + reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted') + .length, + reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected') + .length, + recentEvents, + }; + } + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { const id = buildPendingReportIntentId(request); await this.enqueue(request.teamName, async () => { @@ -190,7 +323,7 @@ export class JsonMemberWorkSyncStore await quarantineFile(filePath); } } - return { schemaVersion: 1, members: {} }; + return { schemaVersion: 1, members: {}, metrics: { recentEvents: [] } }; } private async readPendingFile(teamName: string): Promise { diff --git a/src/features/member-work-sync/preload/index.ts b/src/features/member-work-sync/preload/index.ts index c90efb6b..4f53d1d7 100644 --- a/src/features/member-work-sync/preload/index.ts +++ b/src/features/member-work-sync/preload/index.ts @@ -1,22 +1,27 @@ import { + MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncMetricsRequest, type MemberWorkSyncReportRequest, type MemberWorkSyncReportResult, type MemberWorkSyncStatus, type MemberWorkSyncStatusRequest, + type MemberWorkSyncTeamMetrics, } from '../contracts'; import type { IpcRenderer } from 'electron'; export interface MemberWorkSyncElectronApi { getStatus(request: MemberWorkSyncStatusRequest): Promise; + getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; } export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi { return { getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request), + getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request), report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request), }; } diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 2a93459c..906d0915 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -751,6 +751,31 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) } ); + app.get<{ Params: { teamName: string } }>( + '/api/teams/:teamName/member-work-sync/metrics', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getMemberWorkSyncFeature(services).getMetrics({ + teamName: validatedTeamName.value!, + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in GET /api/teams/${request.params.teamName}/member-work-sync/metrics:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + app.get<{ Params: { teamName: string; memberName: string } }>( '/api/teams/:teamName/member-work-sync/:memberName', async (request, reply) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 171cbeaa..77479873 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1296,6 +1296,8 @@ export class HttpAPIClient implements ElectronAPI { request.memberName )}` ), + getMetrics: (request) => + this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`), report: (request) => this.post( `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 633430b2..c55a31bf 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -98,10 +98,12 @@ 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 { + MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, MemberWorkSyncStatus, MemberWorkSyncStatusRequest, + MemberWorkSyncTeamMetrics, } from '@features/member-work-sync/contracts'; import type { ConversationGroup, @@ -612,6 +614,7 @@ export interface TeamsAPI { export interface MemberWorkSyncElectronApi { getStatus(request: MemberWorkSyncStatusRequest): Promise; + getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; } diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 45e2d2cb..18fea140 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -214,6 +214,12 @@ describe('MemberWorkSync use cases', () => { expect(result.accepted).toBe(false); expect(result.code).toBe('stale_fingerprint'); expect(result.status.state).toBe('needs_sync'); + expect(result.status.report).toMatchObject({ + accepted: false, + rejectionCode: 'stale_fingerprint', + agendaFingerprint: 'agenda:v1:stale', + }); + expect(store.writes.at(-1)?.diagnostics).toContain('report_rejected:stale_fingerprint'); expect(store.pendingReports).toHaveLength(0); }); @@ -288,6 +294,10 @@ describe('MemberWorkSync use cases', () => { expect(result.accepted).toBe(false); expect(result.code).toBe('invalid_report_token'); + expect(result.status.report).toMatchObject({ + accepted: false, + rejectionCode: 'invalid_report_token', + }); expect(store.pendingReports).toHaveLength(0); }); diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 847766fc..a1af8cf3 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -5,6 +5,42 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +function makeStatus(overrides: Partial): MemberWorkSyncStatus { + return { + teamName: 'team-a', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [ + { + taskId: 'task-1', + displayId: '11111111', + subject: 'Ship UI', + kind: 'work', + assignee: 'bob', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { status: 'pending', owner: 'bob' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + ...overrides, + }; +} describe('JsonMemberWorkSyncStore', () => { let root: string; @@ -69,4 +105,44 @@ describe('JsonMemberWorkSyncStore', () => { resultCode: 'accepted', }); }); + + it('records bounded shadow metrics from status writes', async () => { + await store.write(makeStatus({})); + await store.write( + makeStatus({ + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:01:00.000Z', + fingerprint: 'agenda:v1:def', + items: [], + diagnostics: [], + }, + state: 'caught_up', + shadow: { + reconciledBy: 'request', + wouldNudge: false, + fingerprintChanged: true, + previousFingerprint: 'agenda:v1:abc', + }, + evaluatedAt: '2026-04-29T00:01:00.000Z', + }) + ); + + const metrics = await store.readTeamMetrics('team-a'); + expect(metrics).toMatchObject({ + teamName: 'team-a', + memberCount: 1, + actionableItemCount: 0, + wouldNudgeCount: 1, + fingerprintChangeCount: 1, + }); + expect(metrics.stateCounts.caught_up).toBe(1); + expect(metrics.recentEvents.map((event) => event.kind)).toEqual([ + 'status_evaluated', + 'would_nudge', + 'status_evaluated', + 'fingerprint_changed', + ]); + }); });