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
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncStatusState, number>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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<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 './MemberWorkSyncMetricsReader';
|
||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||
export * from './MemberWorkSyncReconciler';
|
||||
export * from './MemberWorkSyncReporter';
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncStatus | null>;
|
||||
write(status: MemberWorkSyncStatus): Promise<void>;
|
||||
readTeamMetrics?(teamName: string): Promise<MemberWorkSyncTeamMetrics>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportStorePort {
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncTeamMetrics> => {
|
||||
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<MemberWorkSyncReportResult> => {
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
noteTeamChange(event: TeamChangeEvent): void;
|
||||
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<string, MemberWorkSyncStatus>;
|
||||
metrics?: {
|
||||
recentEvents: MemberWorkSyncMetricEvent[];
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
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<void> {
|
||||
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<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> {
|
||||
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<PendingReportFile> {
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>): 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue