structure: reify-orphans endpoint + "give these a home" dialog
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 27s
ci / rust (push) Successful in 3m57s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 4m3s

Turns the four synthetic tree containers into a real migration path.
Clicking any of them ("My Workspace", "Teams", "Direct",
"Ungrouped") opens a dialog that creates a real
org → company → team chain and re-parents every orphan into it, all
in one DB transaction.

cm-db (new module structure_reify)
- orphan_agents / orphan_teams / orphan_companies: workspace-scoped
  SELECTs of entities without a parent binding in team_members /
  company_teams / org_companies. Used both by the dialog's counter
  and internally by the migration.
- count_orphans: cheap combined-count via three subqueries in a
  single SELECT so the dialog only round-trips once for the header.
- reify_orphans(pool, ws, org_name, company_name, team_name):
    1. begins a tx
    2. inserts a new org + company + team (all `flat`, empty graphs
       — user can shape them later via the existing PATCH endpoints)
    3. binds company under org (org_companies "n0")
    4. binds team under company (company_teams "n0")
    5. inserts team_members rows for every orphan agent (n1, n2, …)
    6. inserts company_teams rows for every orphan team
    7. inserts org_companies rows for every orphan company
    8. commits, returns the created ids + moved counts

cm-api (routes/structure)
- GET /api/structure/orphan-counts → { agents, teams, companies }
- POST /api/structure/reify-orphans → { org_id, company_id, team_id,
  moved_* }. Trims + rejects any empty name; validates before
  starting the transaction so a 400 never rolls anything back.

Frontend
- New OrphanMigrationDialog: fetches counts on open, three name
  fields (defaults: Organization "My Workspace", Company "General",
  Team "Everyone"), POSTs on save. "Nothing to migrate" state
  disables the save button when the workspace is already fully
  wired. Copy explicitly notes that everything is renameable in the
  sidebar afterward.
- Dashboard: onTreeSelect now branches on SYNTHETIC_TREE_IDS —
  clicking a synthetic node opens the dialog instead of falling
  through to the (nonexistent) selection. On successful reify,
  router.refresh() so the sidebar + world viz reflect the new real
  chain.

What this doesn't do yet (next commit)
- Wizard auto-materialize: when creating a team/company via wizard,
  auto-create parent placeholders if they don't exist. Deferred so
  this commit stays focused.
This commit is contained in:
Omar Sobh
2026-07-09 11:26:07 -07:00
parent 99e5207e69
commit 8b789beec0
21 changed files with 980 additions and 1 deletions
@@ -0,0 +1,279 @@
"use client";
// The "give these a home" migration dialog.
//
// Opens when the user clicks one of the synthetic tree scaffolding nodes
// ("my-workspace", "ws-teams", "ungrouped-co", "ungrouped-team"). Fetches
// the workspace's orphan counts so the user sees exactly what's about to
// be reified, then collects three names (Org / Company / Team) and POSTs
// to /api/structure/reify-orphans, which does the whole migration in a
// single DB transaction. All three fields default to something sensible
// so hitting the button with no edits still moves things forward.
import { useEffect, useState } from "react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type OrphanCounts = { agents: number; teams: number; companies: number };
export function OrphanMigrationDialog({
onClose,
onReified,
}: {
onClose: () => void;
/** Fired after a successful reify. Parent should refresh the workspace tree. */
onReified: (result: {
org_id: string;
company_id: string;
team_id: string;
moved_agents: number;
moved_teams: number;
moved_companies: number;
}) => void;
}) {
const [counts, setCounts] = useState<OrphanCounts | null>(null);
const [orgName, setOrgName] = useState("My Workspace");
const [companyName, setCompanyName] = useState("General");
const [teamName, setTeamName] = useState("Everyone");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
const r = await fetch("/api/structure/orphan-counts");
if (!r.ok) throw new Error(`${r.status}`);
const data = (await r.json()) as OrphanCounts;
if (alive) setCounts(data);
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : "count failed");
}
})();
return () => {
alive = false;
};
}, []);
async function save() {
if (saving) return;
const org = orgName.trim();
const company = companyName.trim();
const team = teamName.trim();
if (!org || !company || !team) {
setError("All three names are required.");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch("/api/structure/reify-orphans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_name: org,
company_name: company,
team_name: team,
}),
});
if (!res.ok) throw new Error(`POST reify-orphans → ${res.status}`);
const result = await res.json();
onReified(result);
} catch (e) {
setError(e instanceof Error ? e.message : "migration failed");
} finally {
setSaving(false);
}
}
const nothingToMigrate =
counts !== null &&
counts.agents === 0 &&
counts.teams === 0 &&
counts.companies === 0;
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 130,
background: "rgba(0,0,0,.62)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Give these a home"
style={{
width: "100%",
maxWidth: 520,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
borderRadius: 14,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div style={{ padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".14em", color: "#5a5a62" }}>
STRUCTURE
</div>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5", marginTop: 4 }}>
Give these a home
</div>
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92", marginTop: 6, lineHeight: 1.55 }}>
You have some entities that never got parented into a real org
company team chain. Naming the three below will materialize the
chain and move everything under it in one transaction. You can
click any of them in the sidebar later to rename.
</div>
</div>
{/* Counts */}
<div
style={{
padding: "12px 20px",
display: "flex",
gap: 20,
fontFamily: mono,
fontSize: 11.5,
color: "#cfcfd5",
background: "rgba(255,255,255,.02)",
}}
>
{counts === null ? (
<span style={{ color: "#6a6a72" }}>Counting</span>
) : nothingToMigrate ? (
<span style={{ color: "#7fd0a0" }}>
Nothing to migrate the workspace is already fully wired.
</span>
) : (
<>
<span>
<span style={{ color: "#ffb44a" }}>{counts.agents}</span> agent
{counts.agents === 1 ? "" : "s"}
</span>
<span>
<span style={{ color: "#ffb44a" }}>{counts.teams}</span> team
{counts.teams === 1 ? "" : "s"}
</span>
<span>
<span style={{ color: "#ffb44a" }}>{counts.companies}</span> compan
{counts.companies === 1 ? "y" : "ies"}
</span>
</>
)}
</div>
{/* Inputs */}
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
<NameField label="Organization" value={orgName} onChange={setOrgName} disabled={saving} />
<NameField label="Company" value={companyName} onChange={setCompanyName} disabled={saving} />
<NameField
label={counts && counts.agents > 0 ? "Team (holds the ungrouped agents)" : "Team"}
value={teamName}
onChange={setTeamName}
disabled={saving}
/>
</div>
{error ? (
<div style={{ padding: "0 20px 4px", fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>
{error}
</div>
) : null}
{/* Footer */}
<div
style={{
padding: 14,
borderTop: "1px solid rgba(255,255,255,.07)",
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 13,
fontWeight: 600,
cursor: saving ? "default" : "pointer",
}}
>
Later
</button>
<button
type="button"
onClick={save}
disabled={saving || nothingToMigrate}
title={nothingToMigrate ? "There's nothing to migrate." : undefined}
style={{
padding: "9px 18px",
borderRadius: 8,
border: 0,
background: saving || nothingToMigrate ? "rgba(94,200,216,.3)" : "#5ec8d8",
color: "#04181c",
fontSize: 13,
fontWeight: 700,
cursor: saving || nothingToMigrate ? "default" : "pointer",
}}
>
{saving ? "Migrating…" : "Save & migrate"}
</button>
</div>
</div>
</div>
);
}
function NameField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".08em", color: "#8a8a92", textTransform: "uppercase" }}>
{label}
</span>
<input
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{
padding: "9px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "#141417",
color: "#f3f3f5",
fontSize: 13,
outline: "none",
}}
/>
</label>
);
}