diff --git a/orchestrator/templates/page-graph3d.js b/orchestrator/templates/page-graph3d.js index 01f4da0..0d3a3d3 100644 --- a/orchestrator/templates/page-graph3d.js +++ b/orchestrator/templates/page-graph3d.js @@ -5,6 +5,7 @@ ]; const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"}; const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"}; + const SPAWNED = new Set(["twig", "subagent"]); const DEPTH_GAP = 90; const TIME_DEPTH = 640; const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""}; @@ -61,13 +62,14 @@ function agentModel() { const depthOf = {operator: 0, chair: 1, session: 2, subagent: 3}; + const born = (node) => ((spawn.subs.get(node.id) || {}).data || {}).since; const nodes = state.graph.nodes.map((node) => ({ - id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: (node.timeline[0] || {}).since || state.since, + id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since, parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(), })); for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) { const walk = (twig, parent, depth) => { - nodes.push({id: twig.id, kind: "twig", label: twig.label, depth, ts: twig.row.started, parent, session: twig.session, sub: twig.row.id}); + nodes.push({id: twig.id, kind: "twig", label: twig.label, depth, ts: twig.row.since || twig.row.started, parent, session: twig.session, sub: twig.row.id}); twig.children.forEach((child) => walk(child, twig.id, depth + 1)); }; twigs(node.id).forEach((twig) => walk(twig, node.id, depthOf[node.kind] + 1)); @@ -77,7 +79,7 @@ for (const link of aggregate().filter((entry) => entry.target !== "*" && ids.has(entry.source) && ids.has(entry.target) && entry.source !== entry.target)) { links.push({source: link.source, target: link.target, kind: tone(link.cls), count: link.count}); } - return {nodes, links}; + return {nodes: SpaceView.notBeforeParent(nodes, SPAWNED), links}; } function model3d(tab) { @@ -155,7 +157,7 @@ } function signature3d(tab, data) { - return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth]), data.links.map((link) => [link.source, link.target, link.kind])]); + return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts]), data.links.map((link) => [link.source, link.target, link.kind])]); } async function stage3d(tab, host) { diff --git a/orchestrator/templates/space-view.js b/orchestrator/templates/space-view.js index 179da36..26bdfda 100644 --- a/orchestrator/templates/space-view.js +++ b/orchestrator/templates/space-view.js @@ -74,6 +74,27 @@ const SpaceView = (() => { return 0; } + /** + * @param nodes - {id, kind, parent, ts} records. + * @param spawned - the kinds spawned by their parent, such as subagents. + * @returns copies of the nodes in which a spawned node without a time or + * with one before its parent's takes its parent's time. + */ + function notBeforeParent(nodes, spawned) { + const byId = new Map(nodes.map((node) => [node.id, node])); + const settled = new Map(); + const at = (node) => { + if (!settled.has(node.id)) { + settled.set(node.id, node.ts); + const parent = spawned.has(node.kind) ? byId.get(node.parent) : undefined; + const floor = parent ? at(parent) : NaN; + settled.set(node.id, Number.isFinite(floor) && !(node.ts >= floor) ? floor : node.ts); + } + return settled.get(node.id); + }; + return nodes.map((node) => ({...node, ts: at(node)})); + } + function clampView(kind, view) { const limits = LIMITS[kind]; const wrapped = kind === "graph" ? {...view, yaw: signed(view.yaw)} : {...view, azimuth: around(view.azimuth)}; @@ -119,7 +140,7 @@ const SpaceView = (() => { return { DEFAULTS, LIMITS, AXES, DEPTH, LEVELS, REACH, - clamp, ease, turn, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, lens, place, horizon, compass, + clamp, ease, turn, rotation, apply, camera, project, depthSort, depthOf, notBeforeParent, clampView, tidy, tween, lens, place, horizon, compass, }; })(); if (typeof module === "object" && module.exports) { diff --git a/orchestrator/templates/space.js b/orchestrator/templates/space.js index d76c6e7..7db337e 100644 --- a/orchestrator/templates/space.js +++ b/orchestrator/templates/space.js @@ -1,13 +1,13 @@ const Space = (() => { "use strict"; - const {DEFAULTS, LIMITS, AXES, DEPTH, REACH, ease, turn, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, lens, place, horizon, compass} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js"); + const {DEFAULTS, LIMITS, AXES, DEPTH, REACH, ease, turn, rotation, apply, camera, project, depthSort, depthOf, notBeforeParent, clampView, tidy, tween, lens, place, horizon, compass} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js"); const {TONES, ARC_CAP, bend, moon, nameAt, tree, capArcs, scene} = typeof SpaceScene === "object" ? SpaceScene : require("./space-scene.js"); const {paint} = typeof SpacePaint === "object" ? SpacePaint : require("./space-paint.js"); const {pick} = typeof SpacePick === "object" ? SpacePick : require("./space-pick.js"); return { DEFAULTS, LIMITS, AXES, TONES, ARC_CAP, DEPTH, REACH, - ease, turn, bend, moon, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, nameAt, tree, capArcs, scene, paint, pick, + ease, turn, bend, moon, rotation, apply, camera, project, depthSort, depthOf, notBeforeParent, clampView, tidy, tween, nameAt, tree, capArcs, scene, paint, pick, lens, place, horizon, compass, }; })(); diff --git a/tests/test_hub_space.py b/tests/test_hub_space.py index 3448ba7..3421a2f 100644 --- a/tests/test_hub_space.py +++ b/tests/test_hub_space.py @@ -92,6 +92,11 @@ console.log(JSON.stringify({ spawn: [0, 1, 2, 4, 9].map((level) => S.depthOf("spawn", {ts: now, level}, [low, now])), none: [S.depthOf("none", {ts: low, level: 3}, [low, now]), S.depthOf("none", {ts: now, level: 0}, [low, now])], }, + parents: S.notBeforeParent([ + {id: "c", kind: "chair", ts: 200}, {id: "s", kind: "session", parent: "c", ts: 100}, {id: "w", kind: "twig", parent: "v", ts: 120}, + {id: "t", kind: "twig", parent: "s", ts: 50}, {id: "u", kind: "twig", parent: "t"}, {id: "v", kind: "twig", parent: "s", ts: 150}, + {id: "x", kind: "subagent", parent: "gone", ts: 30}, {id: "y", kind: "twig", parent: "z", ts: 5}, {id: "z", kind: "twig", parent: "y", ts: 7}, + ], new Set(["twig", "subagent"])).map((node) => [node.id, node.ts]), same: [S.scene(input), S.scene(JSON.parse(JSON.stringify(input)))].every((other) => JSON.stringify(other) === JSON.stringify(built)), lines: Object.fromEntries(built.lines.map((entry) => [entry.sub || entry.node, {kind: entry.kind, level: entry.level, alive: entry.alive, stamps: entry.points.map((point) => point.ts), first: entry.points[0], name: entry.timeline[0].name}])), labels: built.labels.filter((label) => built.lines[label.line].node === "s5").map((label) => [label.at.ts, label.text]), @@ -174,6 +179,9 @@ class TestSpaceModule(unittest.TestCase): self.assertEqual(axis["spawn"], [-300, -150, 0, 300, 300]) self.assertEqual(axis["none"], [0, 0]) + def test_a_subagent_never_lies_before_the_agent_that_spawned_it(self): + self.assertEqual(self.data["parents"], [["c", 200], ["s", 100], ["w", 150], ["t", 100], ["u", 100], ["v", 150], ["x", 30], ["y", 7], ["z", 7]]) + def test_world_lines_are_deterministic_and_follow_the_layout_of_their_time(self): self.assertTrue(self.data["same"]) lines = self.data["lines"]