- 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>
156 lines
4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
});
|
|
}
|