Recursive deploy ladder: Company + Org tiers, mesh mark, two-tier rail
Completes the scale ladder (single → team → company → org). Every tier is a
topology whose nodes are the tier below; running a parent recursively runs each
child's sub-topology down to the leaf claws.
Backend:
- migration 0011: companies/company_teams, orgs/org_companies, topology_runs.tier
- cm-db repos for companies + orgs (mirror teams)
- TurnRequest.attrs (forwarded from node.attrs) for child-id binding
- SubTopologyExecutor (recursive_exec.rs): a parent "turn" runs the child's
sub-topology; durability via parent updated_at keepalive + cancel propagation
+ depth cap; boxed future breaks the org→company recursion
- topology_worker selects executor by job.tier
- routes: /api/companies, /api/orgs (create/list/get/run) + unified
/api/structure/{level}/{id} for the zoom canvas
Frontend:
- MeshMark: node-mesh brand glyph (replaces the claw PNG), tier variants
- TopologyGraphView: optional onNodeClick/nodeMeta + dark-token theming
- StructureCanvas + Breadcrumb: one recursive zoom view for every tier
(drill down on node click, breadcrumb up); TeamRunPanel extracted + shared
- two-tier Discord-style rail: StructureRail (mesh mark + org/company/team
glyphs + tools popover + deploy + user) | RosterColumn (selected group's
children, or your claws); SecondaryNav for cross-cutting tools
- ComposeWizard (company/org) wired into DeployWizard; /companies + /orgs pages
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bba18a4687
commit
3eca4ed70c
@@ -1,4 +1,4 @@
|
||||
import type { TopologyGraph } from "@/lib/api/topology";
|
||||
import type { TopologyGraph, TopologyNode } from "@/lib/api/topology";
|
||||
|
||||
const ROW_KINDS = new Set(["pipeline", "ring"]);
|
||||
const STAR_KINDS = new Set(["hierarchical", "hub_spoke", "star_moe", "market"]);
|
||||
@@ -6,6 +6,13 @@ const STAR_KINDS = new Set(["hierarchical", "hub_spoke", "star_moe", "market"]);
|
||||
const W = 640;
|
||||
const H = 340;
|
||||
|
||||
/** Per-node display hints for the recursive zoom canvas: a human label and
|
||||
* whether clicking it drills into a child level. */
|
||||
export interface NodeMeta {
|
||||
label?: string;
|
||||
drillable?: boolean;
|
||||
}
|
||||
|
||||
/** Position nodes by topology kind: a row for pipeline/ring, a center+row star
|
||||
* for delegation kinds, a circle otherwise. */
|
||||
function layout(kind: string, n: number): { x: number; y: number }[] {
|
||||
@@ -33,15 +40,29 @@ function layout(kind: string, n: number): { x: number; y: number }[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders a topology graph as a lightweight SVG (no external deps). */
|
||||
export function TopologyGraphView({ graph }: { graph: TopologyGraph }) {
|
||||
interface TopologyGraphViewProps {
|
||||
graph: TopologyGraph;
|
||||
/** When set, nodes become interactive (drill down a level). */
|
||||
onNodeClick?: (node: TopologyNode) => void;
|
||||
/** Optional per-node label / drillability overrides (keyed by node id). */
|
||||
nodeMeta?: Record<string, NodeMeta>;
|
||||
}
|
||||
|
||||
/** Renders a topology graph as a lightweight SVG (no external deps), themed for
|
||||
* the dark app shell. With `onNodeClick`, nodes are keyboard-accessible drill
|
||||
* targets (the recursive zoom canvas uses this to descend org→company→team→claw). */
|
||||
export function TopologyGraphView({
|
||||
graph,
|
||||
onNodeClick,
|
||||
nodeMeta,
|
||||
}: TopologyGraphViewProps) {
|
||||
const index = new Map(graph.nodes.map((node, i) => [node.id, i]));
|
||||
const pos = layout(graph.kind, graph.nodes.length);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-auto w-full"
|
||||
className="h-auto w-full text-muted-foreground"
|
||||
role="img"
|
||||
aria-label={`${graph.kind} topology with ${graph.nodes.length} nodes`}
|
||||
>
|
||||
@@ -56,20 +77,65 @@ export function TopologyGraphView({ graph }: { graph: TopologyGraph }) {
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke="#64748b"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.35}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((node, i) => {
|
||||
const p = pos[i];
|
||||
const meta = nodeMeta?.[node.id];
|
||||
const drillable = !!onNodeClick && meta?.drillable !== false;
|
||||
const label = meta?.label ?? node.id;
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<circle cx={p.x} cy={p.y} r={24} fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<text x={p.x} y={p.y + 4} textAnchor="middle" fontSize={11} fontWeight={600} fill="#0b1220">
|
||||
{node.id}
|
||||
<g
|
||||
key={node.id}
|
||||
className={
|
||||
drillable
|
||||
? "cursor-pointer outline-none [&:hover_circle]:stroke-coral [&:focus_circle]:stroke-coral"
|
||||
: undefined
|
||||
}
|
||||
role={drillable ? "button" : undefined}
|
||||
tabIndex={drillable ? 0 : undefined}
|
||||
aria-label={drillable ? `Open ${label}` : undefined}
|
||||
onClick={drillable ? () => onNodeClick?.(node) : undefined}
|
||||
onKeyDown={
|
||||
drillable
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onNodeClick?.(node);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={24}
|
||||
fill="var(--color-surface-warm-muted)"
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill="var(--color-foreground)"
|
||||
>
|
||||
{label.length > 8 ? `${label.slice(0, 7)}…` : label}
|
||||
</text>
|
||||
<text x={p.x} y={p.y + 42} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y + 42}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="currentColor"
|
||||
>
|
||||
{node.role}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
Reference in New Issue
Block a user