const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const runtimeHelpers = require('./runtimeHelpers.js'); const { withFileLockSync } = require('./fileLock.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(); const error = new Error(detail || 'Team control API request failed'); error.controlApiStatus = response.status; throw error; } 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) { if (error && error.controlApiStatus) { throw error; } lastError = error; } } throw lastError || new Error('Team control API request failed'); } function compactReportBody(context, memberName, flags = {}) { const taskIds = normalizeTaskIds( Array.isArray(flags['task-ids']) ? flags['task-ids'] : flags.taskIds ); return { teamName: context.teamName, memberName, state: flags.state, agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], reportToken: flags.reportToken || flags['report-token'], ...(taskIds.length > 0 ? { taskIds } : {}), ...(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 } : {}), }; } function normalizeTaskIds(value) { return Array.isArray(value) ? Array.from(new Set(value.map((taskId) => String(taskId).trim()).filter(Boolean))) : []; } function stableStringify(value) { if (value == null || typeof value !== 'object') { return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; } return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) .join(',')}}`; } function buildPendingIntentId(body) { const taskIds = Array.isArray(body.taskIds) ? Array.from( new Set(body.taskIds.map((taskId) => String(taskId).trim()).filter(Boolean)) ).sort() : []; const payload = { teamName: body.teamName, memberName: String(body.memberName || '').trim().toLowerCase(), state: body.state, agendaFingerprint: body.agendaFingerprint, reportToken: body.reportToken || '', ...(taskIds.length > 0 ? { taskIds } : {}), ...(body.note ? { note: body.note } : {}), ...(body.leaseTtlMs ? { leaseTtlMs: body.leaseTtlMs } : {}), ...(body.source ? { source: body.source } : {}), }; return `member-work-sync-intent:${crypto .createHash('sha256') .update(stableStringify(payload)) .digest('hex')}`; } function readPendingReportFile(filePath) { try { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); if ( parsed && typeof parsed === 'object' && parsed.schemaVersion === 1 && parsed.intents && typeof parsed.intents === 'object' && !Array.isArray(parsed.intents) ) { return parsed; } } catch (error) { if (!error || error.code !== 'ENOENT') { throw error; } } return { schemaVersion: 1, intents: {} }; } function writePendingReportFile(filePath, data) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); fs.renameSync(tempPath, filePath); } function appendPendingReportIntent(context, body, reason) { const filePath = path.join(context.paths.teamDir, '.member-work-sync', 'pending-reports.json'); const { id } = withFileLockSync(filePath, () => { const data = readPendingReportFile(filePath); const request = { ...body, source: 'mcp', }; const intentId = buildPendingIntentId(request); const current = data.intents[intentId]; if (!current || current.status === 'pending') { data.intents[intentId] = { id: intentId, teamName: body.teamName, memberName: body.memberName, request, reason, status: 'pending', recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(), }; writePendingReportFile(filePath, data); } return { id: intentId }; }); return { accepted: false, pendingValidation: true, code: 'pending_validation', message: 'Member work sync report was recorded for app validation. Continue concrete task work; do not treat this as a confirmed lease yet.', intentId: id, }; } function assertReportBody(body) { if (!body.state || !['still_working', 'blocked', 'caught_up'].includes(body.state)) { throw new Error('state must be still_working, blocked, or caught_up'); } if (!body.agendaFingerprint) { throw new Error('agendaFingerprint is required'); } if (!body.reportToken) { throw new Error('reportToken is required'); } } 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 )}/refresh`, { method: 'POST', body: {}, 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 body = compactReportBody(context, memberName, flags); assertReportBody(body); const pathname = `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`; const options = { method: 'POST', body, timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), }; let baseUrls; try { baseUrls = resolveControlBaseUrls(context, flags); } catch { return appendPendingReportIntent(context, body, 'control_api_unavailable'); } try { return await requestJsonWithFallback(baseUrls, pathname, options); } catch (error) { if (error && error.controlApiStatus) { throw error; } return appendPendingReportIntent(context, body, 'control_api_unavailable'); } } module.exports = { memberWorkSyncStatus, memberWorkSyncReport, };