import { selectLoopKeyStore } from "./key-store.js"; // ───────────────────────────────────────────────────────────────── // Loop API V2 HTTP Client + Cross-Team Aggregation Engine // // ┌─ Load LoopTeamKey nodes (Neo4j) ─┐ // │ │ // ▼ │ // For each key: │ // 1. Decrypt API key │ // 2. Check permissions │ // 3. HTTP GET/POST/PUT → Loop API V2 │ // │ │ // ▼ │ // Promise.allSettled → merge results │ // Log aggregate summary │ // ───────────────────────────────────────────────────────────────── const LOOP_BASE_URL = "https://api.loop.software"; const REQUEST_TIMEOUT_MS = 10_000; const TIMEOUT_RETRY_BACKOFF_MS = 2_000; const DEFAULT_LIMIT_PER_TEAM = 50; const DEFAULT_LIMIT_TOTAL = 200; // One bounded retry on transient timeout — recovers from intermittent // Loop-API latency spikes that would otherwise drop a team from coverage. function isTimeoutError(err: unknown): boolean { if (!(err instanceof Error)) return false; return ( err.name === "TimeoutError" || err.name === "AbortError" || /aborted due to timeout/i.test(err.message) ); } // ─── Loop API V2 Envelope ──────────────────────────────────────── // // Every Loop API V2 response is wrapped in a status envelope: // // { statusCode: "success", httpResponseCode: 200, // errors: [], warnings: [], data: } // // unwrapEnvelope detects this shape and returns the inner `data`, // so tool handlers receive bare arrays/objects — not the wrapper. // Non-envelope responses (if any) pass through unchanged. // ────────────────────────────────────────────────────────────────── interface LoopEnvelope { statusCode: string; httpResponseCode: number; errors?: string[]; warnings?: string[]; data: T; } function isEnvelope(val: unknown): val is LoopEnvelope { return ( val !== null && typeof val === "object" && !Array.isArray(val) && "statusCode" in val && "httpResponseCode" in val && "data" in val ); } function unwrapEnvelope(parsed: unknown, toolName: string, teamName: string): T { if (!isEnvelope(parsed)) { return parsed as T; } if (parsed.statusCode !== "success") { const errMsg = parsed.errors?.length ? parsed.errors.join("; ") : `statusCode=${parsed.statusCode}`; throw new Error( `Loop API error for team=${teamName} tool=${toolName}: ${errMsg} (httpResponseCode=${parsed.httpResponseCode})` ); } if (parsed.warnings?.length) { console.error( `[loop] ${toolName} team=${teamName} warnings: ${parsed.warnings.join("; ")}` ); } return parsed.data as T; } export interface LoopTeamKey { teamName: string; apiKey: string; teamAddress: string; agentId: string; permissions: string[]; createdAt: string; updatedAt: string; } export interface TeamResult { teamName: string; data: T[]; } export interface AggregationFailure { teamName: string; reason: string; kind: "timeout" | "other"; } export interface AggregationResult { results: TeamResult[]; failures: AggregationFailure[]; skipped: { teamName: string; reason: string }[]; totalTeams: number; } /** * Load all LoopTeamKey nodes for an account from Neo4j. */ export async function loadTeamKeys(accountId: string): Promise { return selectLoopKeyStore().loadAll(accountId); } // Truncate to 200 chars, escape newlines, single line — `[loop] team= // status= body="<...>"`. Emitted at the wrapper boundary before throwing so // the body is visible regardless of how downstream catch sites format the error // (the aggregator at line ~388 reduces LoopApiError to "HTTP " and // would otherwise discard the body). function logErrorBody( toolName: string, teamName: string, status: number, body: string ): void { const truncated = body.slice(0, 200).replace(/\n/g, "\\n"); console.error( `[loop] ${toolName} team=${teamName} status=${status} body="${truncated}"` ); } /** * Make a GET request to the Loop API V2. */ export async function loopGet( apiKey: string, path: string, toolName: string, teamName: string ): Promise { const url = `${LOOP_BASE_URL}${path}`; const start = Date.now(); const response = await fetch(url, { headers: { "X-Api-Key": apiKey, "Accept": "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const duration = Date.now() - start; console.error( `[loop] ${toolName} team=${teamName} endpoint=GET ${path} status=${response.status} duration=${duration}ms` ); if (response.status === 204) { return [] as unknown as T; } if (!response.ok) { const errorBody = await response.text().catch(() => ""); logErrorBody(toolName, teamName, response.status, errorBody); throw new LoopApiError(response.status, teamName, path, errorBody); } const text = await response.text(); try { const parsed = JSON.parse(text); return unwrapEnvelope(parsed, toolName, teamName); } catch (err) { if (err instanceof SyntaxError) { throw new Error( `Loop returned non-JSON response for team=${teamName} path=${path} ` + `(status=${response.status}, length=${text.length})` ); } throw err; } } /** * Make a POST request to the Loop API V2. * For write operations: create viewings, record feedback, submit enquiries, etc. * Returns parsed JSON response. Treats 204 as success with empty result. */ export async function loopPost( apiKey: string, path: string, body: unknown, toolName: string, teamName: string ): Promise { const url = `${LOOP_BASE_URL}${path}`; const start = Date.now(); const response = await fetch(url, { method: "POST", headers: { "X-Api-Key": apiKey, "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const duration = Date.now() - start; console.error( `[loop] WRITE ${toolName} team=${teamName} endpoint=POST ${path} status=${response.status} duration=${duration}ms` ); if (response.status === 204) { return { success: true } as unknown as T; } if (!response.ok) { const errorBody = await response.text().catch(() => ""); logErrorBody(toolName, teamName, response.status, errorBody); throw new LoopApiError(response.status, teamName, path, errorBody); } const text = await response.text(); try { const parsed = JSON.parse(text); return unwrapEnvelope(parsed, toolName, teamName); } catch (err) { if (err instanceof SyntaxError) { throw new Error( `Loop returned non-JSON response for team=${teamName} path=${path} ` + `(status=${response.status}, length=${text.length})` ); } throw err; } } /** * Make a PUT request to the Loop API V2. * For update operations: batch matching, submit quotes, update preferences, etc. */ export async function loopPut( apiKey: string, path: string, body: unknown, toolName: string, teamName: string ): Promise { const url = `${LOOP_BASE_URL}${path}`; const start = Date.now(); const response = await fetch(url, { method: "PUT", headers: { "X-Api-Key": apiKey, "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const duration = Date.now() - start; console.error( `[loop] WRITE ${toolName} team=${teamName} endpoint=PUT ${path} status=${response.status} duration=${duration}ms` ); if (response.status === 204) { return { success: true } as unknown as T; } if (!response.ok) { const errorBody = await response.text().catch(() => ""); logErrorBody(toolName, teamName, response.status, errorBody); throw new LoopApiError(response.status, teamName, path, errorBody); } const text = await response.text(); try { const parsed = JSON.parse(text); return unwrapEnvelope(parsed, toolName, teamName); } catch (err) { if (err instanceof SyntaxError) { throw new Error( `Loop returned non-JSON response for team=${teamName} path=${path} ` + `(status=${response.status}, length=${text.length})` ); } throw err; } } export class LoopApiError extends Error { constructor( public readonly status: number, public readonly teamName: string, public readonly path: string, public readonly responseBody?: string ) { const detail = responseBody ? ` — ${responseBody.slice(0, 200)}` : ""; super(`Loop API returned ${status} for team=${teamName} path=${path}${detail}`); this.name = "LoopApiError"; } } /** * Fan out a request across all registered teams for an account, merging results. * * Each tool provides a `buildRequest` function that takes a decrypted API key * and returns the data for that team. The aggregation engine handles: * - Key loading and decryption * - Permission checking * - Concurrent fan-out via Promise.allSettled * - Result merging with teamName tags * - Aggregate logging * * When `teamName` is provided, only that team is queried (no fan-out). */ export async function aggregateAcrossTeams( accountId: string, endpointGroup: string, toolName: string, buildRequest: (apiKey: string, teamName: string) => Promise, options?: { teamName?: string; limitPerTeam?: number; limitTotal?: number } ): Promise> { const allKeys = await loadTeamKeys(accountId); if (allKeys.length === 0) { return { results: [], failures: [], skipped: [], totalTeams: 0 }; } // Filter to specific team if requested const keys = options?.teamName ? allKeys.filter((k) => k.teamName === options.teamName) : allKeys; if (keys.length === 0 && options?.teamName) { return { results: [], failures: [{ teamName: options.teamName, reason: "Team not found", kind: "other" }], skipped: [], totalTeams: allKeys.length, }; } const limitPerTeam = options?.limitPerTeam ?? DEFAULT_LIMIT_PER_TEAM; const limitTotal = options?.limitTotal ?? DEFAULT_LIMIT_TOTAL; const results: TeamResult[] = []; const failures: AggregationFailure[] = []; const skipped: { teamName: string; reason: string }[] = []; // Check permissions and build per-team request thunks. Thunks (not stored // promises) are required so a timed-out attempt can be re-invoked on retry. const tasks: { key: LoopTeamKey; run: () => Promise }[] = []; for (const key of keys) { if (!key.permissions.includes(endpointGroup)) { console.error( `[loop] ${toolName} team=${key.teamName} — permission denied (requires: ${endpointGroup})` ); skipped.push({ teamName: key.teamName, reason: `No ${endpointGroup} permission`, }); continue; } const apiKey = key.apiKey; const teamName = key.teamName; tasks.push({ key, run: async () => { try { return await buildRequest(apiKey, teamName); } catch (err) { if (!isTimeoutError(err)) throw err; console.error( `[loop] ${toolName} team=${teamName} timed out — retrying in ${TIMEOUT_RETRY_BACKOFF_MS}ms` ); await new Promise((resolve) => setTimeout(resolve, TIMEOUT_RETRY_BACKOFF_MS)); return await buildRequest(apiKey, teamName); } }, }); } // Fan out concurrent requests const settled = await Promise.allSettled(tasks.map((t) => t.run())); for (let i = 0; i < settled.length; i++) { const outcome = settled[i]; const teamName = tasks[i].key.teamName; if (outcome.status === "fulfilled") { const data = Array.isArray(outcome.value) ? outcome.value.slice(0, limitPerTeam) : []; results.push({ teamName, data }); } else { const reason = outcome.reason instanceof LoopApiError ? `HTTP ${outcome.reason.status}` : outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); const kind: AggregationFailure["kind"] = isTimeoutError(outcome.reason) ? "timeout" : "other"; console.error(`[loop] ${toolName} team=${teamName} failed: ${reason}`); failures.push({ teamName, reason, kind }); } } // Apply total limit across all teams let totalItems = 0; for (const r of results) { const remaining = limitTotal - totalItems; if (remaining <= 0) { r.data = []; } else if (r.data.length > remaining) { r.data = r.data.slice(0, remaining); } totalItems += r.data.length; } const timedOutCount = failures.filter((f) => f.kind === "timeout").length; const erroredCount = failures.length - timedOutCount; console.error( `[loop] aggregate tool=${toolName} teams=${keys.length} ` + `succeeded=${results.length} failed=${failures.length} skipped=${skipped.length}` ); console.error( `[loop] aggregate-coverage tool=${toolName} requested=${keys.length} ` + `answered=${results.length} timed_out=${timedOutCount} errored=${erroredCount}` ); return { results, failures, skipped, totalTeams: allKeys.length }; } /** * Resolve a single team's API key with permission checking. * Handles key loading, decryption, and permission validation. * Used for both reads and writes that target a specific team — no fan-out. */ export async function withTeamKey( accountId: string, teamName: string, endpointGroup: string, toolName: string, execute: (apiKey: string) => Promise ): Promise { const allKeys = await loadTeamKeys(accountId); if (allKeys.length === 0) { throw new Error("No Loop teams registered. Use loop-key-register to add team API keys."); } const key = allKeys.find((k) => k.teamName === teamName); if (!key) { const available = allKeys.map((k) => k.teamName).join(", "); throw new Error(`Team "${teamName}" not found. Available teams: ${available}`); } if (!key.permissions.includes(endpointGroup)) { throw new Error( `Team "${teamName}" does not have ${endpointGroup} permission. ` + `Current permissions: ${key.permissions.join(", ")}` ); } return execute(key.apiKey); } /** * Format an aggregation result into a human-readable text response. */ export function formatAggregationResult( result: AggregationResult, formatItem: (item: T, teamName: string) => string, entityName: string ): string { if (result.totalTeams === 0) { return "No Loop teams registered. Use loop-key-register to add team API keys."; } const allSkippedOrFailed = result.results.length === 0 && result.failures.length + result.skipped.length > 0; if (allSkippedOrFailed) { const reasons = [ ...result.failures.map((f) => `${f.teamName}: ${f.reason}`), ...result.skipped.map((s) => `${s.teamName}: ${s.reason}`), ]; return `All teams failed or were skipped:\n${reasons.join("\n")}`; } const lines: string[] = []; let totalItems = 0; for (const teamResult of result.results) { if (teamResult.data.length === 0) continue; totalItems += teamResult.data.length; lines.push(`\n## ${teamResult.teamName}`); for (const item of teamResult.data) { lines.push(formatItem(item, teamResult.teamName)); } } // Coverage = teams that had a chance to answer (succeeded + failed). Skipped // teams are excluded from the denominator: they were configured-out, not lost. const answered = result.results.length; const failed = result.failures.length; const totalCovered = answered + failed; const coverageNote = formatCoverageNote(result.failures); if (totalItems === 0) { if (failed > 0) { return `No ${entityName} found across ${answered} of ${totalCovered} teams (${coverageNote}).`; } const teamCount = result.results.length === 1 ? result.results[0].teamName : `${result.results.length} teams`; return `No ${entityName} found across ${teamCount}.`; } const teamCountFragment = failed > 0 ? `${answered} of ${totalCovered} team(s)` : `${answered} team(s)`; let header = `${totalItems} ${entityName} across ${teamCountFragment}:`; const parts: string[] = [header, ...lines]; if (failed > 0) { parts.push(`\n---\nNote: ${coverageNote}.`); } if (result.skipped.length > 0) { parts.push( `Note: ${result.skipped.length} team(s) skipped (insufficient permissions).` ); } return parts.join("\n"); } function formatCoverageNote(failures: AggregationFailure[]): string { const timedOut = failures.filter((f) => f.kind === "timeout"); const errored = failures.filter((f) => f.kind !== "timeout"); const fragments: string[] = []; if (timedOut.length > 0) { fragments.push( `${timedOut.length} timed out: ${timedOut.map((f) => f.teamName).join(", ")}` ); } if (errored.length > 0) { fragments.push( `${errored.length} errored: ${errored.map((f) => f.teamName).join(", ")}` ); } return fragments.join("; "); }