world: pre-seed repo tree on clone (V3)
Repo focus mode used to open into an empty tree — the file/dir nodes only synthesized as agents touched files via tool calls. Cold-start users saw a lonely repo:<id> orb with nothing under it. Now the tree pre-seeds from the actual cloned repo the moment a client subscribes. Backend: - active_research_topics also returns repo_workspace_path. - preseed_repo_paths(clone_path) runs `git ls-files` (bounded to top 200) against the on-disk clone. Silently returns empty on any failure so a missing clone / git-off-PATH / empty repo just degrades to the pre-V3 behavior (tree still builds on touch). - SSE loop, on first sight of a topic per client, emits one node.activity per pre-seeded path (label = leaf name, heat 0) so the tree is quiet-solid at rest. Frontend: - engine.onNodeActivity now synthesizes the dir:<partial> chain for file: nodeIds the same way onTouch does — otherwise the pre-seed would render as flat leaves under ROOT. - Same 5-line synthesis extracted from onTouch; both paths now agree on the layout. Cap of 200 keeps the SSE payload bounded on huge repos; the tail fills in as agents actually touch files. When we later add per-file heat map (V4), the 200 already-known files get first-class treatment out of the gate.
This commit is contained in:
@@ -62,16 +62,21 @@ async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Active research topics + their assigned agents. Each returned row is a
|
/// Active research topics + their assigned agents. Each returned row is a
|
||||||
/// `(topic_id, title, agent_id)` — one row per (topic, agent) pair. Emitted
|
/// `(topic_id, title, agent_id, repo_workspace_path)` — one row per
|
||||||
/// from the SSE loop as `repo:<topic_id>` project orbs so the World shows a
|
/// (topic, agent) pair. Emitted from the SSE loop as `repo:<topic_id>`
|
||||||
/// clickable, labeled landmark for every in-flight R&D initiative — no need
|
/// project orbs so the World shows a clickable, labeled landmark for
|
||||||
/// for a file touch to land first.
|
/// every in-flight R&D initiative — no need for a file touch to land
|
||||||
|
/// first. `repo_workspace_path` (when non-null) is the on-disk clone
|
||||||
|
/// location; the SSE loop uses it to pre-seed the repo tree.
|
||||||
async fn active_research_topics(
|
async fn active_research_topics(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
ws: WorkspaceId,
|
ws: WorkspaceId,
|
||||||
) -> Vec<(String, String, String)> {
|
) -> Vec<(String, String, String, Option<String>)> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT t.id::text AS topic_id, t.title AS title, ra.agent_id::text AS agent_id
|
"SELECT t.id::text AS topic_id,
|
||||||
|
t.title AS title,
|
||||||
|
t.repo_workspace_path AS repo_path,
|
||||||
|
ra.agent_id::text AS agent_id
|
||||||
FROM research_topics t
|
FROM research_topics t
|
||||||
JOIN research_topic_agents ra ON ra.topic_id = t.id
|
JOIN research_topic_agents ra ON ra.topic_id = t.id
|
||||||
WHERE t.workspace_id = $1
|
WHERE t.workspace_id = $1
|
||||||
@@ -87,11 +92,46 @@ async fn active_research_topics(
|
|||||||
r.get::<String, _>("topic_id"),
|
r.get::<String, _>("topic_id"),
|
||||||
r.get::<String, _>("title"),
|
r.get::<String, _>("title"),
|
||||||
r.get::<String, _>("agent_id"),
|
r.get::<String, _>("agent_id"),
|
||||||
|
r.try_get::<Option<String>, _>("repo_path").unwrap_or(None),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap on pre-seeded file entries per repo. Large repos surface only the
|
||||||
|
/// top N so the SSE payload stays bounded — a subsequent tool call
|
||||||
|
/// exercising a specific path will fill in additional nodes on demand.
|
||||||
|
const REPO_PRESEED_CAP: usize = 200;
|
||||||
|
|
||||||
|
/// Read the top-level file list of a topic's cloned repo via `git ls-files`
|
||||||
|
/// so the SSE loop can pre-seed dir:/file: nodes in the client engine.
|
||||||
|
/// Bounded by `REPO_PRESEED_CAP`. Returns an empty vec on any failure
|
||||||
|
/// (missing clone, git not on PATH, empty repo) — a missing pre-seed
|
||||||
|
/// degrades gracefully to the pre-V3 behavior (tree builds as agents
|
||||||
|
/// touch files).
|
||||||
|
async fn preseed_repo_paths(clone_path: &str) -> Vec<String> {
|
||||||
|
let path = std::path::Path::new(clone_path);
|
||||||
|
if !path.join(".git").exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let out = tokio::process::Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(path)
|
||||||
|
.arg("ls-files")
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let Ok(out) = out else { return Vec::new() };
|
||||||
|
if !out.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.take(REPO_PRESEED_CAP)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Enabled scheduled loops + their assigned agents. Same shape as
|
/// Enabled scheduled loops + their assigned agents. Same shape as
|
||||||
/// `active_research_topics` — `(loop_id, title, agent_id)` per (loop, agent).
|
/// `active_research_topics` — `(loop_id, title, agent_id)` per (loop, agent).
|
||||||
/// Emitted as `loop:<loop_id>` landmark orbs so recurring/scheduled work is
|
/// Emitted as `loop:<loop_id>` landmark orbs so recurring/scheduled work is
|
||||||
@@ -428,13 +468,36 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
|
|||||||
// so the affinity is visible even in idle windows.
|
// so the affinity is visible even in idle windows.
|
||||||
let research = active_research_topics(&pool, ws).await;
|
let research = active_research_topics(&pool, ws).await;
|
||||||
let mut seen_topics = std::collections::HashSet::new();
|
let mut seen_topics = std::collections::HashSet::new();
|
||||||
for (topic_id, title, agent_id) in &research {
|
for (topic_id, title, agent_id, repo_path) in &research {
|
||||||
let node_id = format!("repo:{topic_id}");
|
let node_id = format!("repo:{topic_id}");
|
||||||
if seen_topics.insert(topic_id.clone()) {
|
if seen_topics.insert(topic_id.clone()) {
|
||||||
yield sse(
|
yield sse(
|
||||||
"node.activity",
|
"node.activity",
|
||||||
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
|
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
|
||||||
);
|
);
|
||||||
|
// Pre-seed the repo tree (V3). One-shot on first sight
|
||||||
|
// of the topic per SSE client. Each file emits with
|
||||||
|
// heat=0 so the tree is quiet-solid at rest — activity
|
||||||
|
// still hot-swaps as agents touch files. Bounded to
|
||||||
|
// REPO_PRESEED_CAP so payload stays reasonable.
|
||||||
|
if let Some(clone_path) = repo_path {
|
||||||
|
for p in preseed_repo_paths(clone_path).await {
|
||||||
|
let leaf = std::path::Path::new(&p)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(&p)
|
||||||
|
.to_string();
|
||||||
|
yield sse(
|
||||||
|
"node.activity",
|
||||||
|
json!({
|
||||||
|
"nodeId": format!("file:{p}"),
|
||||||
|
"label": leaf,
|
||||||
|
"kind": "service",
|
||||||
|
"heat": 0.0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
yield sse(
|
yield sse(
|
||||||
"world.touch",
|
"world.touch",
|
||||||
|
|||||||
@@ -350,7 +350,28 @@ export class WorldEngine {
|
|||||||
: e.kind === "event"
|
: e.kind === "event"
|
||||||
? "event"
|
? "event"
|
||||||
: "service";
|
: "service";
|
||||||
const node = this.ensureNode(e.nodeId, tier, e.label ?? e.nodeId, ROOT, 1);
|
// file:<path> events (e.g. pre-seeded from the SSE loop's repo tree
|
||||||
|
// snapshot) also need the dir:<partial> chain synthesized, otherwise
|
||||||
|
// the tree renders as flat leaves under ROOT. Mirrors onTouch — kept
|
||||||
|
// as a small local helper so both paths agree on the layout.
|
||||||
|
let parentId: string = ROOT;
|
||||||
|
let depth = 1;
|
||||||
|
let label = e.label ?? e.nodeId;
|
||||||
|
if (e.nodeId.startsWith("file:")) {
|
||||||
|
const path = e.nodeId.slice(5);
|
||||||
|
const segs = path.split("/").filter((s) => s.length > 0);
|
||||||
|
let acc = "";
|
||||||
|
for (let i = 0; i < segs.length - 1; i++) {
|
||||||
|
const seg = segs[i];
|
||||||
|
acc = acc ? `${acc}/${seg}` : seg;
|
||||||
|
const dirId = `dir:${acc}`;
|
||||||
|
this.ensureNode(dirId, "service", seg, parentId, i + 1);
|
||||||
|
parentId = dirId;
|
||||||
|
depth = i + 2;
|
||||||
|
}
|
||||||
|
label = e.label ?? segs[segs.length - 1] ?? path;
|
||||||
|
}
|
||||||
|
const node = this.ensureNode(e.nodeId, tier, label, parentId, depth);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user