singularity-forge/web/lib/swarm-status.ts
Mikael Hugo 077fd0a2a7 remove A2A; swarm enrollment + status projection + web swarms view; headless refactor
- A2A removal per M054/R071 cancellation 2026-05-17 (-2294 lines):
  - docs/plans/A2A_ADOPTION_PLAN.md, MISSION-A2A-ADOPTION.md deleted
  - src/resources/extensions/sf/uok/a2a-agent-server.js,
    a2a-transport.js deleted
  - tests/a2a-auth.test.mjs deleted
  - swarm-dispatch.js purged of A2A-conditional code paths
- New: scripts/sf-swarm-enroll.mjs + test (operator-facing swarm
  enrollment, replaces former A2A pairing flow)
- New: src/status-projection.ts + test, web/lib/swarm-status.ts +
  test, web/components/sf/swarms-view.tsx, web/app/api/swarms/
  (web swarms-view surface — direct visibility into running swarm
  state without requiring TUI; aligns with project_tui_deprecating)
- headless-{answers,query,ui,headless}.ts: coordinated tweaks
  consistent with the headless-as-default direction (R124 proposal)
- docs/dev/drafts/M053-per-repo-supervisor.md: design refinement
- .sf/REQUIREMENTS.md: small text fixes (6/6 churn)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:04:06 +02:00

156 lines
4 KiB
TypeScript

import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export const SWARMS_REGISTRY_PATH = join(homedir(), ".sf", "swarms.json");
export const STATUS_PROJECTION_FILE = "status.projection.json";
export interface SwarmRegistryEntry {
id?: string;
name?: string;
path?: string;
repoPath?: string;
}
export interface SwarmStatusProjection {
projectionVersion: number;
swarmId: string;
repoPath: string;
repoName: string;
writer: {
surface: string;
pid: number;
writtenAt: string;
};
supervisor: {
kind: string;
health: string;
currentPid: number | null;
};
state: {
activeMilestoneId: string | null;
activeMilestoneTitle: string | null;
activeSliceId: string | null;
activeSliceTitle: string | null;
phase: string;
nextAction: string;
nextUnitType: string | null;
nextUnitId: string | null;
queueDepth: number;
lastCycleOutcome: string | null;
currentUnit: string | null;
};
health: {
verdict: string | null;
classification: string | null;
issues: number;
};
}
export interface SwarmDashboardRow {
id: string;
name: string;
repoPath: string;
projectionPath: string;
status: "ok" | "missing" | "degraded";
error: string | null;
projection: SwarmStatusProjection | null;
updatedAt: string | null;
}
function normalizeRegistry(raw: unknown): SwarmRegistryEntry[] {
if (Array.isArray(raw)) return raw.filter(isRegistryEntry);
if (raw && typeof raw === "object") {
const entries = (raw as Record<string, unknown>).swarms;
if (Array.isArray(entries)) return entries.filter(isRegistryEntry);
}
return [];
}
function isRegistryEntry(value: unknown): value is SwarmRegistryEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Record<string, unknown>;
return typeof entry.path === "string" || typeof entry.repoPath === "string";
}
function readRegistry(
registryPath = SWARMS_REGISTRY_PATH,
): SwarmRegistryEntry[] {
if (!existsSync(registryPath)) return [];
const parsed = JSON.parse(readFileSync(registryPath, "utf-8"));
return normalizeRegistry(parsed);
}
function validateProjection(value: unknown): SwarmStatusProjection {
if (!value || typeof value !== "object") {
throw new Error("projection is not an object");
}
const projection = value as SwarmStatusProjection;
if (projection.projectionVersion !== 1) {
throw new Error(
`unsupported projectionVersion ${projection.projectionVersion}`,
);
}
if (
typeof projection.repoPath !== "string" ||
projection.repoPath.length === 0
) {
throw new Error("projection repoPath missing");
}
if (!projection.state || typeof projection.state !== "object") {
throw new Error("projection state missing");
}
return projection;
}
export function readSwarmDashboardRows(
registryPath = SWARMS_REGISTRY_PATH,
): SwarmDashboardRow[] {
const entries = readRegistry(registryPath);
return entries.map((entry, index) => {
const repoPath = resolve(entry.repoPath ?? entry.path ?? "");
const projectionPath = join(repoPath, ".sf", STATUS_PROJECTION_FILE);
const fallbackName =
repoPath.split(/[\\/]/).filter(Boolean).at(-1) ?? repoPath;
const id = entry.id ?? repoPath;
const name = entry.name ?? fallbackName;
if (!existsSync(projectionPath)) {
return {
id,
name,
repoPath,
projectionPath,
status: "missing",
error: "status projection missing",
projection: null,
updatedAt: null,
};
}
try {
const projection = validateProjection(
JSON.parse(readFileSync(projectionPath, "utf-8")),
);
return {
id: projection.swarmId || id || `swarm-${index + 1}`,
name: entry.name ?? projection.repoName ?? name,
repoPath,
projectionPath,
status: "ok",
error: null,
projection,
updatedAt: statSync(projectionPath).mtime.toISOString(),
};
} catch (error) {
return {
id,
name,
repoPath,
projectionPath,
status: "degraded",
error: error instanceof Error ? error.message : String(error),
projection: null,
updatedAt: null,
};
}
});
}