world: loop:<id> landmark orbs (V1)
ci / gates (push) Successful in 7s
ci / rust (push) Failing after 24s
ci / frontend (push) Successful in 4m12s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Mirror the repo: landmark pattern for scheduled loops. Every enabled
loop in the workspace gets a labeled amber orb in the World, whether
it's currently running or between fires. Assigned agents converge on
it with a soft 0.15 touch — the loop is a persistent landmark, not a
transient run.

Backend:
- active_loops(pool, ws) query joins loops + loop_agents where enabled,
  returning (loop_id, title, agent_id) — one row per (loop, agent).
- SSE loop emits node.activity + world.touch symmetric to the research
  block. Seen-once set dedupes the label emission across agents.

Frontend:
- New "loop" tier in the Tier alias, LEVEL_COLOR (#f0b866 warm amber),
  and ensureNode radius (11 — same landmark size as repo).
- engine.onTouch / onNodeActivity preserve the tier from the loop:
  prefix (previously would have collapsed to service).
- Pawn fireColor tinted amber for loop: touches.
- WorldCanvas: loop tier joins the struct group for solid-at-rest glow
  + always-on labels. Focus mode recognizes loop: prefix (click →
  focused subtree, Esc to exit). Focus pill switches to
  "LOOP FOCUS" in amber when the selected id is a loop.

Contrast with repo: (transient — only appears when a topic is in
processing/reviewing/publishing). Loops are persistent because their
whole point is recurrence.
This commit is contained in:
Omar Sobh
2026-07-09 13:58:47 -07:00
parent 708f45d09b
commit 5a2340587e
3 changed files with 99 additions and 21 deletions
+50
View File
@@ -92,6 +92,35 @@ async fn active_research_topics(
.collect() .collect()
} }
/// Enabled scheduled loops + their assigned agents. Same shape as
/// `active_research_topics` — `(loop_id, title, agent_id)` per (loop, agent).
/// Emitted as `loop:<loop_id>` landmark orbs so recurring/scheduled work is
/// visible in the World at all times, not just while a run is mid-flight.
/// Contrast with research topics (transient statuses processing/reviewing/
/// publishing) — loops are persistent landmarks the user can click.
async fn active_loops(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String, String)> {
let rows = sqlx::query(
"SELECT l.id::text AS loop_id, l.title AS title, la.agent_id::text AS agent_id
FROM loops l
JOIN loop_agents la ON la.loop_id = l.id
WHERE l.workspace_id = $1
AND l.enabled = TRUE",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|r| {
(
r.get::<String, _>("loop_id"),
r.get::<String, _>("title"),
r.get::<String, _>("agent_id"),
)
})
.collect()
}
/// A short human label for a tool's input (for the tool-call target). /// A short human label for a tool's input (for the tool-call target).
fn summarize_input(input: &Value) -> String { fn summarize_input(input: &Value) -> String {
for k in ["target", "path", "url", "query", "name", "file", "command"] { for k in ["target", "path", "url", "query", "name", "file", "command"] {
@@ -413,6 +442,27 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
); );
} }
// Scheduled loops → landmark orbs, symmetric to research topics.
// Persistent landmarks: emitted whenever a loop is enabled, so a
// loop between fires still reads as an in-flight project. When
// a loop actually runs, the topology_worker journals events
// which the run-cursor block below picks up and heats the orb.
let loops = active_loops(&pool, ws).await;
let mut seen_loops = std::collections::HashSet::new();
for (loop_id, title, agent_id) in &loops {
let node_id = format!("loop:{loop_id}");
if seen_loops.insert(loop_id.clone()) {
yield sse(
"node.activity",
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
);
}
yield sse(
"world.touch",
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
);
}
// Real convergence: each running agent beams toward its active-run node. // Real convergence: each running agent beams toward its active-run node.
for (run_id, agent_id) in &runs { for (run_id, agent_id) in &runs {
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]); let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
+20 -7
View File
@@ -465,7 +465,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
sel.startsWith("file:") || sel.startsWith("file:") ||
sel.startsWith("dir:") || sel.startsWith("dir:") ||
sel.startsWith("run:") || sel.startsWith("run:") ||
sel.startsWith("repo:") sel.startsWith("repo:") ||
sel.startsWith("loop:")
) { ) {
onSelectRef.current(""); onSelectRef.current("");
} }
@@ -658,7 +659,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
(focusRoot.startsWith("file:") || (focusRoot.startsWith("file:") ||
focusRoot.startsWith("dir:") || focusRoot.startsWith("dir:") ||
focusRoot.startsWith("run:") || focusRoot.startsWith("run:") ||
focusRoot.startsWith("repo:")); focusRoot.startsWith("repo:") ||
focusRoot.startsWith("loop:"));
let visibleNodes: Set<string> | null = null; let visibleNodes: Set<string> | null = null;
if (isRepoFocus && focusRoot && engine.nodes.has(focusRoot)) { if (isRepoFocus && focusRoot && engine.nodes.has(focusRoot)) {
visibleNodes = new Set<string>([focusRoot]); visibleNodes = new Set<string>([focusRoot]);
@@ -717,7 +719,11 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// and bloom on heat. Services/events keep their permanent 0.22 // and bloom on heat. Services/events keep their permanent 0.22
// ambient glow since they *are* activity signals. // ambient glow since they *are* activity signals.
const struct = const struct =
n.tier === "org" || n.tier === "company" || n.tier === "team" || n.tier === "repo"; n.tier === "org" ||
n.tier === "company" ||
n.tier === "team" ||
n.tier === "repo" ||
n.tier === "loop";
gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha; gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha;
if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) { if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) {
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
@@ -886,7 +892,11 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// heats them up. Services/events (tool: file:) only label once they // heats them up. Services/events (tool: file:) only label once they
// actually see traffic or are selected. // actually see traffic or are selected.
const struct = const struct =
n.tier === "org" || n.tier === "company" || n.tier === "team" || n.tier === "repo"; n.tier === "org" ||
n.tier === "company" ||
n.tier === "team" ||
n.tier === "repo" ||
n.tier === "loop";
const hot = (n.tier === "service" || n.tier === "event") && (n.heat > 0.12 || n.id === selectedRef.current); const hot = (n.tier === "service" || n.tier === "event") && (n.heat > 0.12 || n.id === selectedRef.current);
if (struct || hot) if (struct || hot)
wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" }); wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" });
@@ -992,7 +1002,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
(selectedId.startsWith("file:") || (selectedId.startsWith("file:") ||
selectedId.startsWith("dir:") || selectedId.startsWith("dir:") ||
selectedId.startsWith("run:") || selectedId.startsWith("run:") ||
selectedId.startsWith("repo:")) ? ( selectedId.startsWith("repo:") ||
selectedId.startsWith("loop:")) ? (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -1013,9 +1024,11 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
backdropFilter: "blur(6px)", backdropFilter: "blur(6px)",
}} }}
> >
<span style={{ color: "#ff8a7a", letterSpacing: ".08em" }}>REPO FOCUS</span> <span style={{ color: selectedId.startsWith("loop:") ? "#f0b866" : "#ff8a7a", letterSpacing: ".08em" }}>
{selectedId.startsWith("loop:") ? "LOOP FOCUS" : "REPO FOCUS"}
</span>
<span style={{ color: "#cfcfd5", maxWidth: 340, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ color: "#cfcfd5", maxWidth: 340, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{selectedId.replace(/^(file|dir|repo|run):/, "")} {selectedId.replace(/^(file|dir|repo|run|loop):/, "")}
</span> </span>
<button <button
type="button" type="button"
+29 -14
View File
@@ -9,7 +9,7 @@
import type { TaxonomyEvents } from "@/lib/live/taxonomy"; import type { TaxonomyEvents } from "@/lib/live/taxonomy";
export type Formation = "hierarchy" | "flat" | "live"; export type Formation = "hierarchy" | "flat" | "live";
export type Tier = "root" | "org" | "company" | "team" | "repo" | "service" | "event"; export type Tier = "root" | "org" | "company" | "team" | "repo" | "loop" | "service" | "event";
export interface GNode { export interface GNode {
id: string; id: string;
@@ -106,6 +106,7 @@ const LEVEL_COLOR: Record<string, string> = {
company: "#8a9af0", company: "#8a9af0",
team: "#6fd0c0", team: "#6fd0c0",
repo: "#8ec5ff", // R&D projects — soft sky-blue, distinct landmark tone repo: "#8ec5ff", // R&D projects — soft sky-blue, distinct landmark tone
loop: "#f0b866", // scheduled loops — warm amber, reads as "recurring / cyclical"
service: "#5ec8d8", service: "#5ec8d8",
event: "#ff8a7a", event: "#ff8a7a",
root: "#ff6f61", root: "#ff6f61",
@@ -176,7 +177,13 @@ export class WorldEngine {
y: (parent?.y ?? 0) + Math.sin(ang) * rad * 0.4, y: (parent?.y ?? 0) + Math.sin(ang) * rad * 0.4,
vx: 0, vx: 0,
vy: 0, vy: 0,
r: tier === "org" ? 15 : tier === "company" ? 12 : tier === "repo" ? 11 : tier === "team" ? 9 : 7, r:
tier === "org" ? 15
: tier === "company" ? 12
: tier === "repo" ? 11
: tier === "loop" ? 11
: tier === "team" ? 9
: 7,
color: LEVEL_COLOR[tier] ?? "#9a9aa2", color: LEVEL_COLOR[tier] ?? "#9a9aa2",
heat: 0, heat: 0,
burst: 0, burst: 0,
@@ -262,13 +269,16 @@ export class WorldEngine {
} }
label = segs[segs.length - 1] || path; label = segs[segs.length - 1] || path;
} }
// Repo landmarks keep their `repo` tier so we can render them as solid // Landmark orbs (repo:, loop:) keep their distinct tiers so we can
// project orbs (see LEVEL_COLOR and the label rules in WorldCanvas). // render them as solid project/schedule orbs — see LEVEL_COLOR and the
// label rules in WorldCanvas.
const tier: Tier = e.nodeId.startsWith("repo:") const tier: Tier = e.nodeId.startsWith("repo:")
? "repo" ? "repo"
: e.kind === "event" : e.nodeId.startsWith("loop:")
? "event" ? "loop"
: "service"; : e.kind === "event"
? "event"
: "service";
const node = this.ensureNode(e.nodeId, tier, label, parentId, depth); const node = this.ensureNode(e.nodeId, tier, label, parentId, depth);
const p = this.ensurePawn(e.agentId); const p = this.ensurePawn(e.agentId);
p.targetId = e.nodeId; p.targetId = e.nodeId;
@@ -281,11 +291,13 @@ export class WorldEngine {
// permanent hot spot regardless of intent. // permanent hot spot regardless of intent.
node.heat = Math.min(1, node.heat + w * 0.6); node.heat = Math.min(1, node.heat + w * 0.6);
node.burst = Math.max(node.burst, w); // file ops (weight 1) → explosive burst node.burst = Math.max(node.burst, w); // file ops (weight 1) → explosive burst
// activity type → signal colour: file=coral, tool=cyan, run=green, repo=sky // activity type → signal colour: file=coral, tool=cyan, run=green,
// repo=sky-blue, loop=amber
if (e.nodeId.startsWith("tool:")) p.fireColor = w >= 0.9 ? "#ff6f61" : "#5ec8d8"; if (e.nodeId.startsWith("tool:")) p.fireColor = w >= 0.9 ? "#ff6f61" : "#5ec8d8";
else if (e.nodeId.startsWith("run:")) p.fireColor = "#5fd08a"; else if (e.nodeId.startsWith("run:")) p.fireColor = "#5fd08a";
else if (e.nodeId.startsWith("file:")) p.fireColor = "#ff8a7a"; else if (e.nodeId.startsWith("file:")) p.fireColor = "#ff8a7a";
else if (e.nodeId.startsWith("repo:")) p.fireColor = "#8ec5ff"; else if (e.nodeId.startsWith("repo:")) p.fireColor = "#8ec5ff";
else if (e.nodeId.startsWith("loop:")) p.fireColor = "#f0b866";
} }
/** A reasoning token from an agent — marks it active and tints its signals. */ /** A reasoning token from an agent — marks it active and tints its signals. */
@@ -327,14 +339,17 @@ export class WorldEngine {
} }
} }
onNodeActivity(e: TaxonomyEvents["node.activity"]) { onNodeActivity(e: TaxonomyEvents["node.activity"]) {
// Preserve the `repo` tier when the SSE stream marks a landmark project // Preserve landmark tiers (repo:, loop:) so the SSE stream keeps the
// orb — otherwise everything collapses back to service/event and the // distinct color + radius treatment — otherwise the tier collapses back
// World loses the distinct color+radius treatment for R&D projects. // to service/event and R&D projects and scheduled loops look identical
// to a tool call.
const tier: Tier = e.nodeId.startsWith("repo:") const tier: Tier = e.nodeId.startsWith("repo:")
? "repo" ? "repo"
: e.kind === "event" : e.nodeId.startsWith("loop:")
? "event" ? "loop"
: "service"; : e.kind === "event"
? "event"
: "service";
const node = this.ensureNode(e.nodeId, tier, e.label ?? e.nodeId, ROOT, 1); const node = this.ensureNode(e.nodeId, tier, e.label ?? e.nodeId, ROOT, 1);
if (e.label) node.label = e.label; if (e.label) node.label = e.label;
if (e.heat != null) node.heat = Math.max(node.heat, e.heat); if (e.heat != null) node.heat = Math.max(node.heat, e.heat);