feat(viz): the coding station fractures into the files it worked on
The engine already turned `file:src/lib/a.ts` into a real dir chain, but both copies of that loop rooted it at the origin — so a mission's files floated beside the map instead of belonging to the work that produced them. One `fileParent` helper now serves both call sites; splitting them was how half the files could end up nesting correctly and half not, decided by whichever code path saw the file first. Files hang under the coding station when there is exactly one, else the single running phase, else the origin. `world.touch` carries no phase id, so with two coding phases any attribution is invented — the fallback is the honest answer. Two ordering hazards, both silent: - the server emitted files BEFORE phases, so on the first pass a file arrived with no station to hang under and first-write-wins pinned its tree at the origin. Loops reordered, with a source-walk guard. - `setFileHome` re-parents trees rooted before the plan landed, for the reconnect case the ordering alone cannot cover. `mission.file` with `source: "tool"` is treated as motion (burst, pawn beams); `"diff"` is end-of-phase truth and only marks the file present and warm — bursting every file of a captured diff would set the whole map alight at once on reconnect. Directories taper in radius and opacity by path depth, so `src` and `src/lib/live` no longer render as identical dots. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cb8184e784
commit
c2fa8067e1
@@ -176,6 +176,58 @@ async fn world_phases(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>) -> Vec
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files a phase touched, from the `code_diff` artifact captured at delivery.
|
||||
///
|
||||
/// This is recorded fact, not inference: `mission_delivery` writes the path
|
||||
/// list with the same revision and excludes it uses for `files_changed`, so the
|
||||
/// orbs the World draws are the files git says changed.
|
||||
///
|
||||
/// It is end-of-phase detail — the capture runs when a phase finishes — so a
|
||||
/// running phase shows its station lit but no files until it lands. Live
|
||||
/// per-tool file touches are a separate, structured source.
|
||||
struct FileRow {
|
||||
mission_id: String,
|
||||
phase_id: String,
|
||||
path: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
async fn world_files(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>) -> Vec<FileRow> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ma.mission_id::text AS mission_id,
|
||||
ma.phase_id::text AS phase_id,
|
||||
f->>'path' AS path,
|
||||
f->>'status' AS status
|
||||
FROM mission_artifacts ma
|
||||
JOIN missions m ON m.id = ma.mission_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(ma.metadata->'files', '[]'::jsonb)) AS f
|
||||
WHERE m.workspace_id = $1
|
||||
AND ma.kind = 'code_diff'
|
||||
AND ma.phase_id IS NOT NULL
|
||||
AND ( m.status = 'running'
|
||||
OR ( m.status IN ('completed', 'failed')
|
||||
AND m.completed_at > now() - interval '24 hours' ) )
|
||||
AND ($2::uuid IS NULL OR m.id = $2)
|
||||
LIMIT 2000",
|
||||
)
|
||||
.bind(ws.as_uuid())
|
||||
.bind(only)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
rows.into_iter()
|
||||
.filter_map(|r| {
|
||||
Some(FileRow {
|
||||
mission_id: r.get::<String, _>("mission_id"),
|
||||
phase_id: r.get::<String, _>("phase_id"),
|
||||
path: r.get::<Option<String>, _>("path")?,
|
||||
status: r.get::<Option<String>, _>("status").unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Agents that execute a phase of this kind, via the purposes the phase runner
|
||||
/// itself uses. Returns empty for a teamless (microVM) mission.
|
||||
async fn phase_agents(
|
||||
@@ -495,6 +547,9 @@ pub async fn world_live(
|
||||
std::collections::HashMap::new();
|
||||
let mut last_phase: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
// (phase, path) pairs already announced — a delivered file is a fact
|
||||
// that happened once, not a recurring event.
|
||||
let mut last_file: HashSet<String> = HashSet::new();
|
||||
// Audit-log cursor for edge-initiated inter-agent events (delegation,
|
||||
// A2A) that bypass the run loop. -1 until seeded on the first pass.
|
||||
let mut audit_cursor: i64 = -1;
|
||||
@@ -648,6 +703,33 @@ pub async fn world_live(
|
||||
);
|
||||
}
|
||||
|
||||
// Files come AFTER the phases they belong to, deliberately: the
|
||||
// client hangs a file tree under the coding station, and parenting
|
||||
// there is first-write-wins. A file that arrived before its plan
|
||||
// would pin its whole directory tree at the origin.
|
||||
//
|
||||
// Files, once each. A file is emitted when its phase's diff was
|
||||
// captured and never again — the World keeps the node alive itself,
|
||||
// and re-sending would re-burst the orb every poll as though the
|
||||
// file had just been touched again.
|
||||
for f in world_files(&pool, ws, only_mission).await {
|
||||
let key = format!("{}|{}", f.phase_id, f.path);
|
||||
if last_file.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
last_file.insert(key);
|
||||
yield sse(
|
||||
"mission.file",
|
||||
json!({
|
||||
"missionId": f.mission_id,
|
||||
"phaseId": f.phase_id,
|
||||
"path": f.path,
|
||||
"status": f.status,
|
||||
"source": "diff",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Real convergence: each running agent beams toward its active-run node.
|
||||
for (run_id, agent_id) in &runs {
|
||||
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
|
||||
@@ -913,4 +995,28 @@ mod mission_feed_tests {
|
||||
disagree with the machine about who is working on what"
|
||||
);
|
||||
}
|
||||
|
||||
/// Files must be emitted after the phases they hang under.
|
||||
///
|
||||
/// The client parents a file's directory chain to the coding station, and
|
||||
/// parenting is first-write-wins — so a file that reaches the browser
|
||||
/// before its plan pins its whole tree at the origin permanently. Nothing
|
||||
/// errors: the tree renders, in the wrong place, and reads as a layout
|
||||
/// choice. Swapping the two loops back is a one-line-looking edit, which is
|
||||
/// exactly why it needs a guard.
|
||||
#[test]
|
||||
fn files_are_emitted_after_the_phases_they_hang_under() {
|
||||
let src = include_str!("world.rs");
|
||||
let phases = src
|
||||
.find("for p in world_phases(")
|
||||
.expect("the phase emission loop");
|
||||
let files = src
|
||||
.find("for f in world_files(")
|
||||
.expect("the file emission loop");
|
||||
assert!(
|
||||
phases < files,
|
||||
"the phase loop must run before the file loop; files parented \
|
||||
before their coding station stay at the origin forever"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user