world: loop:<id> landmark orbs (V1)
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:
@@ -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)]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,10 +269,13 @@ 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.nodeId.startsWith("loop:")
|
||||||
|
? "loop"
|
||||||
: e.kind === "event"
|
: e.kind === "event"
|
||||||
? "event"
|
? "event"
|
||||||
: "service";
|
: "service";
|
||||||
@@ -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,11 +339,14 @@ 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.nodeId.startsWith("loop:")
|
||||||
|
? "loop"
|
||||||
: e.kind === "event"
|
: e.kind === "event"
|
||||||
? "event"
|
? "event"
|
||||||
: "service";
|
: "service";
|
||||||
|
|||||||
Reference in New Issue
Block a user