feat: add member work sync control plane
This commit is contained in:
parent
d99cd98153
commit
c39167ece7
44 changed files with 2299 additions and 3 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
142
agent-teams-controller/src/internal/workSync.js
Normal file
142
agent-teams-controller/src/internal/workSync.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
8
mcp-server/src/agent-teams-controller.d.ts
vendored
8
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -102,6 +102,11 @@ declare module 'agent-teams-controller' {
|
|||
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ControllerWorkSyncApi {
|
||||
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
|
||||
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
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[];
|
||||
|
|
|
|||
|
|
@ -10,10 +10,17 @@ const { createController } = controllerModule;
|
|||
|
||||
const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||
|
||||
type WorkSyncCapableController = ReturnType<typeof createController> & {
|
||||
workSync: {
|
||||
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
|
||||
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
83
mcp-server/src/tools/workSyncTools.ts
Normal file
83
mcp-server/src/tools/workSyncTools.ts
Normal file
|
|
@ -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<FastMCP, 'addTool'>) {
|
||||
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 } : {}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
2
src/features/member-work-sync/contracts/index.ts
Normal file
2
src/features/member-work-sync/contracts/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ipc';
|
||||
export * from './types';
|
||||
2
src/features/member-work-sync/contracts/ipc.ts
Normal file
2
src/features/member-work-sync/contracts/ipc.ts
Normal file
|
|
@ -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';
|
||||
102
src/features/member-work-sync/contracts/types.ts
Normal file
102
src/features/member-work-sync/contracts/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<MemberWorkSyncStatus> {
|
||||
return this.reconciler.execute(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MemberWorkSyncStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MemberWorkSyncReportResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
4
src/features/member-work-sync/core/application/index.ts
Normal file
4
src/features/member-work-sync/core/application/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './MemberWorkSyncDiagnosticsReader';
|
||||
export * from './MemberWorkSyncReconciler';
|
||||
export * from './MemberWorkSyncReporter';
|
||||
export * from './ports';
|
||||
58
src/features/member-work-sync/core/application/ports.ts
Normal file
58
src/features/member-work-sync/core/application/ports.ts
Normal file
|
|
@ -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<string, unknown>): void;
|
||||
warn(message: string, metadata?: Record<string, unknown>): void;
|
||||
error(message: string, metadata?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncAgendaSourceResult {
|
||||
agenda: Omit<MemberWorkSyncAgenda, 'fingerprint'>;
|
||||
activeMemberNames: string[];
|
||||
inactive: boolean;
|
||||
providerId?: MemberWorkSyncProviderId;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncAgendaSourcePort {
|
||||
loadAgenda(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<MemberWorkSyncAgendaSourceResult>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncStatusStorePort {
|
||||
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
|
||||
write(status: MemberWorkSyncStatus): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportStorePort {
|
||||
appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncUseCaseDeps {
|
||||
clock: MemberWorkSyncClockPort;
|
||||
hash: MemberWorkSyncHashPort;
|
||||
agendaSource: MemberWorkSyncAgendaSourcePort;
|
||||
statusStore: MemberWorkSyncStatusStorePort;
|
||||
reportStore?: MemberWorkSyncReportStorePort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
export interface LatestAcceptedReportLookup {
|
||||
latestAcceptedReport?: MemberWorkSyncReport;
|
||||
}
|
||||
|
|
@ -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<string, string | null | undefined>;
|
||||
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<string> {
|
||||
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<MemberWorkSyncActionableWorkItem, 'kind' | 'priority' | 'reason' | 'evidence'> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -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() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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'] };
|
||||
}
|
||||
|
|
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
6
src/features/member-work-sync/core/domain/index.ts
Normal file
6
src/features/member-work-sync/core/domain/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './ActionableWorkAgenda';
|
||||
export * from './AgendaFingerprint';
|
||||
export * from './currentReviewCycle';
|
||||
export * from './memberName';
|
||||
export * from './MemberWorkSyncReportValidator';
|
||||
export * from './SyncDecisionPolicy';
|
||||
15
src/features/member-work-sync/core/domain/memberName.ts
Normal file
15
src/features/member-work-sync/core/domain/memberName.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
1
src/features/member-work-sync/index.ts
Normal file
1
src/features/member-work-sync/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './contracts';
|
||||
|
|
@ -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<MemberWorkSyncStatus> => {
|
||||
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<MemberWorkSyncReportResult> => {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<TeamMember, 'name'>): string {
|
||||
return normalizeMemberName(member.name);
|
||||
}
|
||||
|
||||
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
|
||||
const byName = new Map<string, TeamMember>();
|
||||
for (const member of configMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, member);
|
||||
}
|
||||
}
|
||||
for (const member of metaMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, { ...byName.get(key), ...member });
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function 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<MemberWorkSyncAgendaSourceResult> {
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MemberWorkSyncStatus>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
6
src/features/member-work-sync/main/index.ts
Normal file
6
src/features/member-work-sync/main/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
} from './adapters/input/registerMemberWorkSyncIpc';
|
||||
export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature';
|
||||
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';
|
||||
|
|
@ -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<string, MemberWorkSyncStatus>;
|
||||
}
|
||||
|
||||
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<string, Promise<void>>();
|
||||
|
||||
constructor(private readonly paths: MemberWorkSyncStorePaths) {}
|
||||
|
||||
async read(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<MemberWorkSyncStatus | null> {
|
||||
const file = await this.readFile(input.teamName);
|
||||
return file.members[normalizeMemberKey(input.memberName)] ?? null;
|
||||
}
|
||||
|
||||
async write(status: MemberWorkSyncStatus): Promise<void> {
|
||||
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<void> {
|
||||
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<StoreFile> {
|
||||
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<void>): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { MemberWorkSyncClockPort } from '../../core/application';
|
||||
|
||||
export class SystemClockAdapter implements MemberWorkSyncClockPort {
|
||||
now(): Date {
|
||||
return new Date();
|
||||
}
|
||||
}
|
||||
22
src/features/member-work-sync/preload/index.ts
Normal file
22
src/features/member-work-sync/preload/index.ts
Normal file
|
|
@ -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<MemberWorkSyncStatus>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<HttpServices['teamProvisioningService']> {
|
||||
|
|
@ -466,6 +471,15 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record<string, un
|
|||
return { ...payload, teamName };
|
||||
}
|
||||
|
||||
function getMemberWorkSyncFeature(
|
||||
services: HttpServices
|
||||
): NonNullable<HttpServices['memberWorkSyncFeature']> {
|
||||
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<string, unknown> }>(
|
||||
'/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) });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
removeCodexAccountIpc(ipcMain);
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
removeRuntimeProviderManagementIpc(ipcMain);
|
||||
removeMemberWorkSyncIpc(ipcMain);
|
||||
});
|
||||
|
||||
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<TmuxStatus> => ({
|
||||
platform: 'unknown',
|
||||
|
|
|
|||
|
|
@ -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<ToolApprovalFileContent>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncElectronApi {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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;
|
||||
|
||||
|
|
|
|||
173
test/features/member-work-sync/core/ActionableWorkAgenda.test.ts
Normal file
173
test/features/member-work-sync/core/ActionableWorkAgenda.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<MemberWorkSyncStatus | null> {
|
||||
return this.writes.at(-1) ?? null;
|
||||
}
|
||||
|
||||
async write(status: MemberWorkSyncStatus): Promise<void> {
|
||||
this.writes.push(status);
|
||||
}
|
||||
|
||||
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue