agent-ecosystem/agent-teams-controller/src/internal/workSync.js

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,
};