agent-ecosystem/test/main/services/team/memberWorkSyncLiveHarness.ts

362 lines
11 KiB
TypeScript

import { constants as fsConstants, promises as fs } from 'node:fs';
import * as http from 'node:http';
import * as path from 'node:path';
import { encodePath } from '../../../../src/main/utils/pathDecoder';
import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
export class FatalWaitError extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalWaitError';
}
}
export interface MemberWorkSyncLiveControlServer {
baseUrl: string;
close(): Promise<void>;
}
export async function startMemberWorkSyncControlServer(
feature: MemberWorkSyncFeatureFacade
): Promise<MemberWorkSyncLiveControlServer> {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
if (
request.method === 'GET' &&
parts.length === 5 &&
parts[0] === 'api' &&
parts[1] === 'teams' &&
parts[3] === 'member-work-sync'
) {
const payload = await feature.getStatus({
teamName: parts[2],
memberName: parts[4],
});
sendJson(response, 200, payload);
return;
}
if (
request.method === 'POST' &&
parts.length === 6 &&
parts[0] === 'api' &&
parts[1] === 'teams' &&
parts[3] === 'member-work-sync' &&
parts[5] === 'refresh'
) {
const payload = await feature.refreshStatus({
teamName: parts[2],
memberName: parts[4],
});
sendJson(response, 200, payload);
return;
}
if (
request.method === 'POST' &&
parts.length === 5 &&
parts[0] === 'api' &&
parts[1] === 'teams' &&
parts[3] === 'member-work-sync' &&
parts[4] === 'report'
) {
const body = (await readRequestJson(request)) as MemberWorkSyncReportRequest;
const payload = await feature.report({
...body,
teamName: parts[2],
source: 'mcp',
});
sendJson(
response,
payload.accepted ? 200 : 400,
payload.accepted ? payload : { error: payload.code }
);
return;
}
sendJson(response, 404, { error: `Unhandled ${request.method} ${url.pathname}` });
} catch (error) {
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
}
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to bind member work sync control server');
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}
export function restoreEnv(name: string, previous: string | undefined): void {
if (previous === undefined) {
delete process.env[name];
} else {
process.env[name] = previous;
}
}
export async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}
export async function waitUntil(
predicate: () => Promise<boolean>,
timeoutMs: number,
pollMs = 2_000,
getDiagnostics?: () => Promise<string>
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
if (await predicate()) {
return;
}
} catch (error) {
if (error instanceof FatalWaitError) {
throw error;
}
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
const suffix =
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
const diagnostics = getDiagnostics ? `\n${await getDiagnostics().catch(String)}` : '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${diagnostics}`);
}
export function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {
return progressEvents
.map((progress) =>
[
progress.state,
progress.message,
progress.messageSeverity,
progress.error,
progress.cliLogsTail,
]
.filter(Boolean)
.join(' | ')
)
.join('\n');
}
export async function formatMemberWorkSyncDiagnostics(input: {
feature: MemberWorkSyncFeatureFacade;
teamName: string;
memberName: string;
taskId?: string;
}): Promise<string> {
const [{ TeamTaskReader }] = await Promise.all([
import('../../../../src/main/services/team/TeamTaskReader'),
]);
const [status, metrics, tasks] = await Promise.all([
input.feature.getStatus({ teamName: input.teamName, memberName: input.memberName }),
input.feature.getMetrics({ teamName: input.teamName }),
input.taskId ? new TeamTaskReader().getTasks(input.teamName) : Promise.resolve([]),
]);
const task = input.taskId ? tasks.find((candidate) => candidate.id === input.taskId) : undefined;
return [
'Member work sync live diagnostics:',
JSON.stringify(
{
state: status.state,
diagnostics: status.diagnostics,
agendaFingerprint: status.agenda.fingerprint,
agendaItems: status.agenda.items.map((item) => ({
taskId: item.taskId,
subject: item.subject,
assignee: item.assignee,
kind: item.kind,
})),
report: status.report,
shadow: status.shadow,
queue: input.feature.getQueueDiagnostics(),
comments: task?.comments?.map((comment) => ({
author: comment.author,
text: comment.text,
})),
recentEvents: metrics.recentEvents.slice(-12),
},
null,
2
),
].join('\n');
}
export async function throwIfClaudeTranscriptApiError(input: {
claudeRoot: string;
context: string;
projectPath?: string;
sinceMs?: number;
}): Promise<void> {
const transcriptRoots = await resolveClaudeTranscriptRoots(input.claudeRoot, input.projectPath);
const transcriptFiles = (await Promise.all(transcriptRoots.map(findJsonlFiles))).flat();
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
for (const filePath of transcriptFiles) {
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
for (const line of raw.split(/\r?\n/)) {
if (!line.includes('"isApiErrorMessage"') && !line.includes('"error"')) {
continue;
}
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
continue;
}
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
continue;
}
const message = parsed.message as Record<string, unknown> | undefined;
apiErrors.push({
filePath,
error: typeof parsed.error === 'string' ? parsed.error : 'api_error',
text: extractClaudeMessageText(message),
});
}
}
if (apiErrors.length === 0) {
return;
}
const latest = apiErrors.at(-1)!;
throw new FatalWaitError(
[
`${input.context}: Claude API error detected in live transcript.`,
`error=${latest.error}`,
latest.text ? `message=${latest.text}` : undefined,
`transcript=${latest.filePath}`,
]
.filter(Boolean)
.join('\n')
);
}
async function resolveClaudeTranscriptRoots(
claudeRoot: string,
projectPath: string | undefined
): Promise<string[]> {
const projectsRoot = path.join(claudeRoot, 'projects');
if (!projectPath) {
return [projectsRoot];
}
const candidateRoots = new Set<string>();
const addCandidate = (candidatePath: string) => {
candidateRoots.add(path.join(projectsRoot, encodePath(candidatePath)));
};
addCandidate(path.resolve(projectPath));
const realProjectPath = await fs.realpath(projectPath).catch(() => null);
if (realProjectPath) {
addCandidate(realProjectPath);
}
const existingRoots: string[] = [];
for (const candidateRoot of candidateRoots) {
const stats = await fs.stat(candidateRoot).catch(() => null);
if (stats?.isDirectory()) {
existingRoots.push(candidateRoot);
}
}
return existingRoots;
}
function isTranscriptRecordBefore(record: Record<string, unknown>, sinceMs: number): boolean {
const timestamp = record.timestamp;
const timestampMs =
typeof timestamp === 'string'
? Date.parse(timestamp)
: typeof timestamp === 'number'
? timestamp
: Number.NaN;
return Number.isFinite(timestampMs) && timestampMs < sinceMs;
}
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
Array<{
filePath: string;
meta: Record<string, unknown>;
}>
> {
const processedDir = path.join(teamsBasePath, '.member-work-sync', 'runtime-hooks', 'processed');
const entries = await fs.readdir(processedDir, { withFileTypes: true }).catch(() => []);
const metas = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.meta.json'))
.map(async (entry) => {
const filePath = path.join(processedDir, entry.name);
const raw = await fs.readFile(filePath, 'utf8');
return { filePath, meta: JSON.parse(raw) as Record<string, unknown> };
})
);
return metas.sort((left, right) => left.filePath.localeCompare(right.filePath));
}
async function findJsonlFiles(rootPath: string): Promise<string[]> {
const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []);
const nested = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
return findJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith('.jsonl') ? [entryPath] : [];
})
);
return nested.flat().sort((left, right) => left.localeCompare(right));
}
function extractClaudeMessageText(message: Record<string, unknown> | undefined): string {
const content = message?.content;
if (!Array.isArray(content)) {
return '';
}
return content
.map((part) => {
if (!part || typeof part !== 'object') {
return '';
}
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
})
.filter(Boolean)
.join('\n');
}
async function readRequestJson(request: http.IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
return raw ? JSON.parse(raw) : {};
}
function sendJson(response: http.ServerResponse, statusCode: number, payload: unknown): void {
response.writeHead(statusCode, {
'content-type': 'application/json',
});
response.end(JSON.stringify(payload));
}