287 lines
9 KiB
JavaScript
287 lines
9 KiB
JavaScript
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,
|
|
};
|