feat(member-work-sync): expose shadow metrics
This commit is contained in:
parent
bf1f3b6b02
commit
246f31bbdb
16 changed files with 388 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Member Work Sync Control Plane Plan
|
# 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
|
**Scope:** Team management, task work synchronization, agent work coordination
|
||||||
**Primary repo:** `claude_team`
|
**Primary repo:** `claude_team`
|
||||||
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
|
**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:
|
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 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.
|
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus';
|
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';
|
export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report';
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,42 @@ export interface MemberWorkSyncStatus {
|
||||||
providerId?: MemberWorkSyncProviderId;
|
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<MemberWorkSyncStatusState, number>;
|
||||||
|
actionableItemCount: number;
|
||||||
|
wouldNudgeCount: number;
|
||||||
|
fingerprintChangeCount: number;
|
||||||
|
reportAcceptedCount: number;
|
||||||
|
reportRejectedCount: number;
|
||||||
|
recentEvents: MemberWorkSyncMetricEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncReportRequest {
|
export interface MemberWorkSyncReportRequest {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
|
|
@ -126,3 +162,7 @@ export interface MemberWorkSyncStatusRequest {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncMetricsRequest {
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<MemberWorkSyncTeamMetrics> {
|
||||||
|
if (!this.deps.statusStore.readTeamMetrics) {
|
||||||
|
return emptyMetrics(request.teamName, this.deps.clock.now().toISOString());
|
||||||
|
}
|
||||||
|
return this.deps.statusStore.readTeamMetrics(request.teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
MemberWorkSyncReport,
|
MemberWorkSyncReport,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
MemberWorkSyncReportResult,
|
MemberWorkSyncReportResult,
|
||||||
|
MemberWorkSyncStatus,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import { validateMemberWorkSyncReport } from '../domain';
|
import { validateMemberWorkSyncReport } from '../domain';
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,11 +28,12 @@ export class MemberWorkSyncReporter {
|
||||||
: true;
|
: true;
|
||||||
if (!teamActive) {
|
if (!teamActive) {
|
||||||
const status = await this.reconciler.execute(request);
|
const status = await this.reconciler.execute(request);
|
||||||
|
const rejectedStatus = await this.recordRejectedReport(status, request, 'team_runtime_inactive');
|
||||||
return {
|
return {
|
||||||
accepted: false,
|
accepted: false,
|
||||||
code: 'team_runtime_inactive',
|
code: 'team_runtime_inactive',
|
||||||
message: 'Team runtime is not active. Restart the team before reporting work sync state.',
|
message: 'Team runtime is not active. Restart the team before reporting work sync state.',
|
||||||
status,
|
status: rejectedStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const tokenValidation = this.deps.reportToken
|
const tokenValidation = this.deps.reportToken
|
||||||
|
|
@ -53,11 +55,12 @@ export class MemberWorkSyncReporter {
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
const status = await this.reconciler.execute(request);
|
const status = await this.reconciler.execute(request);
|
||||||
|
const rejectedStatus = await this.recordRejectedReport(status, request, validation.code);
|
||||||
return {
|
return {
|
||||||
accepted: false,
|
accepted: false,
|
||||||
code: validation.code,
|
code: validation.code,
|
||||||
message: validation.message,
|
message: validation.message,
|
||||||
status,
|
status: rejectedStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,4 +106,29 @@ export class MemberWorkSyncReporter {
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recordRejectedReport(
|
||||||
|
status: MemberWorkSyncStatus,
|
||||||
|
request: MemberWorkSyncReportRequest,
|
||||||
|
rejectionCode: string
|
||||||
|
): Promise<MemberWorkSyncStatus> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './MemberWorkSyncDiagnosticsReader';
|
export * from './MemberWorkSyncDiagnosticsReader';
|
||||||
|
export * from './MemberWorkSyncMetricsReader';
|
||||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||||
export * from './MemberWorkSyncReconciler';
|
export * from './MemberWorkSyncReconciler';
|
||||||
export * from './MemberWorkSyncReporter';
|
export * from './MemberWorkSyncReporter';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncAgenda,
|
MemberWorkSyncAgenda,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
MemberWorkSyncProviderId,
|
MemberWorkSyncProviderId,
|
||||||
MemberWorkSyncReport,
|
MemberWorkSyncReport,
|
||||||
MemberWorkSyncReportIntent,
|
MemberWorkSyncReportIntent,
|
||||||
|
|
@ -73,6 +74,7 @@ export interface MemberWorkSyncAgendaSourcePort {
|
||||||
export interface MemberWorkSyncStatusStorePort {
|
export interface MemberWorkSyncStatusStorePort {
|
||||||
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
|
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
|
||||||
write(status: MemberWorkSyncStatus): Promise<void>;
|
write(status: MemberWorkSyncStatus): Promise<void>;
|
||||||
|
readTeamMetrics?(teamName: string): Promise<MemberWorkSyncTeamMetrics>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncReportStorePort {
|
export interface MemberWorkSyncReportStorePort {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import {
|
import {
|
||||||
|
MEMBER_WORK_SYNC_GET_METRICS,
|
||||||
MEMBER_WORK_SYNC_GET_STATUS,
|
MEMBER_WORK_SYNC_GET_STATUS,
|
||||||
MEMBER_WORK_SYNC_REPORT,
|
MEMBER_WORK_SYNC_REPORT,
|
||||||
|
type MemberWorkSyncMetricsRequest,
|
||||||
type MemberWorkSyncReportRequest,
|
type MemberWorkSyncReportRequest,
|
||||||
type MemberWorkSyncReportResult,
|
type MemberWorkSyncReportResult,
|
||||||
type MemberWorkSyncStatus,
|
type MemberWorkSyncStatus,
|
||||||
type MemberWorkSyncStatusRequest,
|
type MemberWorkSyncStatusRequest,
|
||||||
|
type MemberWorkSyncTeamMetrics,
|
||||||
} from '../../../contracts';
|
} from '../../../contracts';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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<MemberWorkSyncTeamMetrics> => {
|
||||||
|
try {
|
||||||
|
return await feature.getMetrics(request);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get member work sync metrics', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
MEMBER_WORK_SYNC_REPORT,
|
MEMBER_WORK_SYNC_REPORT,
|
||||||
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
|
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
|
||||||
|
|
@ -44,5 +59,6 @@ export function registerMemberWorkSyncIpc(
|
||||||
|
|
||||||
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
|
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
|
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
|
||||||
|
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS);
|
||||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
|
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import type {
|
import type {
|
||||||
|
MemberWorkSyncMetricsRequest,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
MemberWorkSyncReportResult,
|
MemberWorkSyncReportResult,
|
||||||
MemberWorkSyncStatus,
|
MemberWorkSyncStatus,
|
||||||
MemberWorkSyncStatusRequest,
|
MemberWorkSyncStatusRequest,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import {
|
import {
|
||||||
MemberWorkSyncDiagnosticsReader,
|
MemberWorkSyncDiagnosticsReader,
|
||||||
|
MemberWorkSyncMetricsReader,
|
||||||
MemberWorkSyncPendingReportIntentReplayer,
|
MemberWorkSyncPendingReportIntentReplayer,
|
||||||
type MemberWorkSyncPendingReportReplaySummary,
|
type MemberWorkSyncPendingReportReplaySummary,
|
||||||
MemberWorkSyncReconciler,
|
MemberWorkSyncReconciler,
|
||||||
|
|
@ -33,6 +36,7 @@ import type { MemberWorkSyncLoggerPort } from '../../core/application';
|
||||||
|
|
||||||
export interface MemberWorkSyncFeatureFacade {
|
export interface MemberWorkSyncFeatureFacade {
|
||||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||||
|
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||||
noteTeamChange(event: TeamChangeEvent): void;
|
noteTeamChange(event: TeamChangeEvent): void;
|
||||||
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
||||||
|
|
@ -74,6 +78,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
};
|
};
|
||||||
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
|
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
|
||||||
|
const metricsReader = new MemberWorkSyncMetricsReader(useCaseDeps);
|
||||||
const reporter = new MemberWorkSyncReporter(useCaseDeps);
|
const reporter = new MemberWorkSyncReporter(useCaseDeps);
|
||||||
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
||||||
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
||||||
|
|
@ -88,6 +93,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getStatus: (request) => diagnosticsReader.execute(request),
|
getStatus: (request) => diagnosticsReader.execute(request),
|
||||||
|
getMetrics: (request) => metricsReader.execute(request),
|
||||||
report: (request) => reporter.execute(request),
|
report: (request) => reporter.execute(request),
|
||||||
noteTeamChange: (event) => router.noteTeamChange(event),
|
noteTeamChange: (event) => router.noteTeamChange(event),
|
||||||
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),
|
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@ import { mkdir, readFile, rename } from 'fs/promises';
|
||||||
|
|
||||||
import { withFileLock } from '@main/services/team/fileLock';
|
import { withFileLock } from '@main/services/team/fileLock';
|
||||||
import type {
|
import type {
|
||||||
|
MemberWorkSyncMetricEvent,
|
||||||
MemberWorkSyncReportIntent,
|
MemberWorkSyncReportIntent,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
MemberWorkSyncStatus,
|
MemberWorkSyncStatus,
|
||||||
|
MemberWorkSyncStatusState,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncReportStorePort,
|
MemberWorkSyncReportStorePort,
|
||||||
|
|
@ -17,6 +20,9 @@ import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
|
||||||
interface StoreFile {
|
interface StoreFile {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
members: Record<string, MemberWorkSyncStatus>;
|
members: Record<string, MemberWorkSyncStatus>;
|
||||||
|
metrics?: {
|
||||||
|
recentEvents: MemberWorkSyncMetricEvent[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingReportFile {
|
interface PendingReportFile {
|
||||||
|
|
@ -39,6 +45,17 @@ function isStoreFile(value: unknown): value is StoreFile {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyStateCounts(): Record<MemberWorkSyncStatusState, number> {
|
||||||
|
return {
|
||||||
|
caught_up: 0,
|
||||||
|
needs_sync: 0,
|
||||||
|
still_working: 0,
|
||||||
|
blocked: 0,
|
||||||
|
inactive: 0,
|
||||||
|
unknown: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isPendingReportFile(value: unknown): value is PendingReportFile {
|
function isPendingReportFile(value: unknown): value is PendingReportFile {
|
||||||
return (
|
return (
|
||||||
value != null &&
|
value != null &&
|
||||||
|
|
@ -82,6 +99,91 @@ function buildPendingReportIntentId(request: MemberWorkSyncReportRequest): strin
|
||||||
.digest('hex')}`;
|
.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<void> {
|
async function quarantineFile(filePath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await rename(filePath, `${filePath}.invalid.${Date.now()}`);
|
await rename(filePath, `${filePath}.invalid.${Date.now()}`);
|
||||||
|
|
@ -110,6 +212,7 @@ export class JsonMemberWorkSyncStore
|
||||||
await withFileLock(this.paths.getStatusPath(status.teamName), async () => {
|
await withFileLock(this.paths.getStatusPath(status.teamName), async () => {
|
||||||
const existing = await this.readFile(status.teamName);
|
const existing = await this.readFile(status.teamName);
|
||||||
existing.members[normalizeMemberKey(status.memberName)] = status;
|
existing.members[normalizeMemberKey(status.memberName)] = status;
|
||||||
|
appendMetricEvents(existing, status);
|
||||||
await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true });
|
await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true });
|
||||||
await atomicWriteAsync(
|
await atomicWriteAsync(
|
||||||
this.paths.getStatusPath(status.teamName),
|
this.paths.getStatusPath(status.teamName),
|
||||||
|
|
@ -119,6 +222,36 @@ export class JsonMemberWorkSyncStore
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readTeamMetrics(teamName: string): Promise<MemberWorkSyncTeamMetrics> {
|
||||||
|
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<void> {
|
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
|
||||||
const id = buildPendingReportIntentId(request);
|
const id = buildPendingReportIntentId(request);
|
||||||
await this.enqueue(request.teamName, async () => {
|
await this.enqueue(request.teamName, async () => {
|
||||||
|
|
@ -190,7 +323,7 @@ export class JsonMemberWorkSyncStore
|
||||||
await quarantineFile(filePath);
|
await quarantineFile(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { schemaVersion: 1, members: {} };
|
return { schemaVersion: 1, members: {}, metrics: { recentEvents: [] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readPendingFile(teamName: string): Promise<PendingReportFile> {
|
private async readPendingFile(teamName: string): Promise<PendingReportFile> {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
import {
|
import {
|
||||||
|
MEMBER_WORK_SYNC_GET_METRICS,
|
||||||
MEMBER_WORK_SYNC_GET_STATUS,
|
MEMBER_WORK_SYNC_GET_STATUS,
|
||||||
MEMBER_WORK_SYNC_REPORT,
|
MEMBER_WORK_SYNC_REPORT,
|
||||||
|
type MemberWorkSyncMetricsRequest,
|
||||||
type MemberWorkSyncReportRequest,
|
type MemberWorkSyncReportRequest,
|
||||||
type MemberWorkSyncReportResult,
|
type MemberWorkSyncReportResult,
|
||||||
type MemberWorkSyncStatus,
|
type MemberWorkSyncStatus,
|
||||||
type MemberWorkSyncStatusRequest,
|
type MemberWorkSyncStatusRequest,
|
||||||
|
type MemberWorkSyncTeamMetrics,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
|
|
||||||
import type { IpcRenderer } from 'electron';
|
import type { IpcRenderer } from 'electron';
|
||||||
|
|
||||||
export interface MemberWorkSyncElectronApi {
|
export interface MemberWorkSyncElectronApi {
|
||||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||||
|
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
|
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
|
||||||
return {
|
return {
|
||||||
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
|
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),
|
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 } }>(
|
app.get<{ Params: { teamName: string; memberName: string } }>(
|
||||||
'/api/teams/:teamName/member-work-sync/:memberName',
|
'/api/teams/:teamName/member-work-sync/:memberName',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
|
|
||||||
|
|
@ -1296,6 +1296,8 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
request.memberName
|
request.memberName
|
||||||
)}`
|
)}`
|
||||||
),
|
),
|
||||||
|
getMetrics: (request) =>
|
||||||
|
this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`),
|
||||||
report: (request) =>
|
report: (request) =>
|
||||||
this.post(
|
this.post(
|
||||||
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`,
|
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`,
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,12 @@ import type { CodexAccountElectronApi } from '@features/codex-account/contracts'
|
||||||
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
|
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
|
||||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||||
import type {
|
import type {
|
||||||
|
MemberWorkSyncMetricsRequest,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
MemberWorkSyncReportResult,
|
MemberWorkSyncReportResult,
|
||||||
MemberWorkSyncStatus,
|
MemberWorkSyncStatus,
|
||||||
MemberWorkSyncStatusRequest,
|
MemberWorkSyncStatusRequest,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
} from '@features/member-work-sync/contracts';
|
} from '@features/member-work-sync/contracts';
|
||||||
import type {
|
import type {
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
|
|
@ -612,6 +614,7 @@ export interface TeamsAPI {
|
||||||
|
|
||||||
export interface MemberWorkSyncElectronApi {
|
export interface MemberWorkSyncElectronApi {
|
||||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||||
|
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,12 @@ describe('MemberWorkSync use cases', () => {
|
||||||
expect(result.accepted).toBe(false);
|
expect(result.accepted).toBe(false);
|
||||||
expect(result.code).toBe('stale_fingerprint');
|
expect(result.code).toBe('stale_fingerprint');
|
||||||
expect(result.status.state).toBe('needs_sync');
|
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);
|
expect(store.pendingReports).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -288,6 +294,10 @@ describe('MemberWorkSync use cases', () => {
|
||||||
|
|
||||||
expect(result.accepted).toBe(false);
|
expect(result.accepted).toBe(false);
|
||||||
expect(result.code).toBe('invalid_report_token');
|
expect(result.code).toBe('invalid_report_token');
|
||||||
|
expect(result.status.report).toMatchObject({
|
||||||
|
accepted: false,
|
||||||
|
rejectionCode: 'invalid_report_token',
|
||||||
|
});
|
||||||
expect(store.pendingReports).toHaveLength(0);
|
expect(store.pendingReports).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,42 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
|
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
|
||||||
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
|
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
|
||||||
|
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
|
||||||
|
|
||||||
|
function makeStatus(overrides: Partial<MemberWorkSyncStatus>): 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', () => {
|
describe('JsonMemberWorkSyncStore', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
|
|
@ -69,4 +105,44 @@ describe('JsonMemberWorkSyncStore', () => {
|
||||||
resultCode: 'accepted',
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue