mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
feat(monitor): exec events visualization + hierarchical spawn layout (#14)
Merging select features from https://github.com/luccast/crabwalk/pull/11 * feat(exec-events): bead-1 - add exec protocol and monitor types * feat(exec-events): bead-2 - parse exec started output completed * feat(exec-events): bead-3 - add exec collection and aggregation * feat(exec-events): bead-4 - persist exec events * feat(exec-events): bead-5 - hydrate exec events after actions * feat(exec-events): bead-6 - emit exec events in subscription * feat(exec-events): bead-7 - wire exec events into monitor route * feat(exec-events): bead-8 - add exec node component * feat(exec-events): bead-9 - render exec nodes under sessions * feat(exec-events): bead-10 - final validation and hydrate exec-only data * fix(parser): remove incorrect sessionKey assignment * feat(monitor): link subagents to parent sessions via spawnedBy, add timestamps to session nodes * feat(monitor): add clear completed execs button - Add clearCompletedExecs() to remove completed/failed execs - Add getCompletedExecCount() for UI badge count - Add clearInactiveSessions() for optional inactive session cleanup - Add "Clear Completed" button in monitor header with badge count - Button only shows when there are clearable items - Add keyboard shortcut: Ctrl+K / Cmd+K to clear - Add vitest test infrastructure with 8 tests covering: - Clearing completed execs - Clearing failed execs - Preserving running execs - Counting clearable items - Clearing inactive sessions by threshold * feat(monitor): add copy PID button to ExecNode header - Copy icon positioned left of status indicator - Click copies PID to clipboard - Animated feedback: copy → checkmark for 1.5s - stopPropagation prevents expand/collapse on click * feat(monitor): add copy session key button to SessionNode header - Copy icon positioned left of status indicator - Click copies full session key to clipboard - Animated feedback: copy → checkmark for 1.5s - stopPropagation prevents node selection interference * feat(ui): horizontal spawn layout for action graph - Redesigned graph layout algorithm for horizontal spawn positioning - Sessions arranged in columns (X = spawn depth hierarchy) - Events within a session flow DOWN vertically (Y = time progression) - Child sessions appear to the RIGHT at the Y-level where spawned - Added left/right handles on SessionNode for horizontal spawn edges - Smoothstep edges connect parent → child sessions horizontally - Collision avoidance for multiple sessions at same depth - Orphan nodes (without session) get positioned in separate area - Compact action nodes (180x80) vs larger session/exec nodes * fix(ui): improve graph spacing and alignment - Increase COLUMN_GAP from 300 to 400 for wider horizontal separation - Increase ROW_GAP from 40 to 80 for more vertical breathing room - Enlarge NODE_DIMENSIONS for better layout calculations - Add MIN_SESSION_GAP (120px) for collision avoidance - Implement adjustSpawnY to shift overlapping sessions down - Sort sessions by column before positioning (parents first) - Ensure all nodes in same depth share same X coordinate * chore: remove non-feature files from contributor PR Remove planning docs, security audit, vitest config, and revert package.json/lockfile to original state. * fix(monitor): add periodic update for session node component - Introduced useEffect to trigger a state update every 30 seconds - Simplified relative time calculation for better readability * fix: Removed the Ctrl+K shortcut. The "clear completed" button in the UI still works — no need for a global keyboard shortcut that conflicts with browser/OS defaults. * chore: update package dependencies and remove unused packages - Removed "@types/dagre" and "dagre" from package.json and package-lock.json - Added "peer": true to several dependencies in package-lock.json for better compatibility * fix(clawdbot): optimize output chunk truncation logic The new version iterates forward, accumulating dropped chars from the front until the remaining tail fits within budget. This keeps the maximum number of recent chunks possible. - Refactored the logic for truncating output chunks to improve performance and readability. - Changed variable declarations for clarity and adjusted the loop to determine the starting index for slicing the capped array. * refactor(monitor, graph): introduce nodeData utility for type casting - Added a utility function `nodeData` to cast domain data to ReactFlow's Node data type for improved type safety. - Updated the ActionGraph and graph layout components to utilize the new `nodeData` function for data handling. * fix(monitor): handle clipboard copy errors in ExecNode and SessionNode - Updated clipboard copy functionality in ExecNode and SessionNode to handle potential errors by adding a catch block to the writeText method. - Ensured that the user experience remains smooth by maintaining the existing feedback mechanism for copy actions.
This commit is contained in:
Generated
+17
-34
@@ -17,9 +17,7 @@
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
@@ -70,6 +68,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1547,6 +1546,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
|
||||
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -1581,6 +1581,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.157.13.tgz",
|
||||
"integrity": "sha512-rLr6swdDhLjadAHbukg/XYurNi4q8AmmjP99QiWUnhRPKfrfmQ/O1UCvaowQ9GcaiyOG2COiJ9OlDAQU3k9MFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.154.14",
|
||||
"@tanstack/react-store": "^0.8.0",
|
||||
@@ -1727,6 +1728,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.157.13.tgz",
|
||||
"integrity": "sha512-dl1avpRH2Pi1tYhxDzGEqI6DU9VQUf5zBeW1JJFs3LfJuVkGd2lG93dSUzH9seJNp77hm8JWhoX+k4QYMEB6KA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.154.14",
|
||||
"@tanstack/store": "^0.8.0",
|
||||
@@ -2037,6 +2039,7 @@
|
||||
"https://trpc.io/sponsor"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.7.2"
|
||||
}
|
||||
@@ -2135,12 +2138,6 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dagre": {
|
||||
"version": "0.7.53",
|
||||
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
|
||||
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -2204,6 +2201,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2432,6 +2430,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2666,7 +2665,8 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
@@ -2725,6 +2725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -2773,16 +2774,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/dagre": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphlib": "^2.1.8",
|
||||
"lodash": "^4.17.15"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3156,15 +3147,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphlib": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.15"
|
||||
}
|
||||
},
|
||||
"node_modules/h3-v2": {
|
||||
"name": "h3",
|
||||
"version": "2.0.1-rc.11",
|
||||
@@ -3707,12 +3689,6 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -4568,6 +4544,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4577,6 +4554,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4776,6 +4754,7 @@
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
|
||||
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -4953,6 +4932,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5042,6 +5022,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5258,6 +5239,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -5368,6 +5350,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.29.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
|
||||
@@ -15,18 +15,29 @@ import {
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { SessionNode } from './SessionNode'
|
||||
import { ActionNode } from './ActionNode'
|
||||
import { ExecNode } from './ExecNode'
|
||||
import { CrabNode } from './CrabNode'
|
||||
import { ChaserCrabNode, type ChaserCrabState } from './ChaserCrabNode'
|
||||
import { layoutGraph } from '~/lib/graph-layout'
|
||||
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
|
||||
import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecProcess,
|
||||
} from '~/integrations/clawdbot'
|
||||
|
||||
interface ActionGraphProps {
|
||||
sessions: MonitorSession[]
|
||||
actions: MonitorAction[]
|
||||
execs: MonitorExecProcess[]
|
||||
selectedSession: string | null
|
||||
onSessionSelect: (key: string | null) => void
|
||||
}
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
function nodeData<T>(data: T): Record<string, unknown> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
const CRAB_NODE_ID = 'crab-origin'
|
||||
const CHASER_CRAB_ID = 'chaser-crab'
|
||||
|
||||
@@ -46,6 +57,7 @@ const SIDEWAYS_DRIFT = 0.4 // crabs scuttle sideways
|
||||
const nodeTypes: NodeTypes = {
|
||||
session: SessionNode as any,
|
||||
action: ActionNode as any,
|
||||
exec: ExecNode as any,
|
||||
crab: CrabNode as any,
|
||||
chaserCrab: ChaserCrabNode as any,
|
||||
}
|
||||
@@ -63,6 +75,7 @@ interface CrabAI {
|
||||
function ActionGraphInner({
|
||||
sessions,
|
||||
actions,
|
||||
execs,
|
||||
selectedSession,
|
||||
onSessionSelect,
|
||||
}: ActionGraphProps) {
|
||||
@@ -87,11 +100,17 @@ function ActionGraphInner({
|
||||
return actions.filter((a) => a.sessionKey === selectedSession)
|
||||
}, [actions, selectedSession])
|
||||
|
||||
const visibleExecs = useMemo(() => {
|
||||
if (!selectedSession) return execs.slice(-50)
|
||||
return execs.filter((exec) => exec.sessionKey === selectedSession)
|
||||
}, [execs, selectedSession])
|
||||
|
||||
// Build nodes
|
||||
const rawNodes = useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
|
||||
const hasActivity = sessions.length > 0 || visibleActions.length > 0
|
||||
const hasActivity =
|
||||
sessions.length > 0 || visibleActions.length > 0 || visibleExecs.length > 0
|
||||
nodes.push({
|
||||
id: CRAB_NODE_ID,
|
||||
type: 'crab',
|
||||
@@ -108,7 +127,7 @@ function ActionGraphInner({
|
||||
id: `session-${session.key}`,
|
||||
type: 'session',
|
||||
position: { x: 0, y: 0 },
|
||||
data: session as unknown as Record<string, unknown>,
|
||||
data: nodeData(session),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,12 +136,21 @@ function ActionGraphInner({
|
||||
id: `action-${action.id}`,
|
||||
type: 'action',
|
||||
position: { x: 0, y: 0 },
|
||||
data: action as unknown as Record<string, unknown>,
|
||||
data: nodeData(action),
|
||||
})
|
||||
}
|
||||
|
||||
for (const exec of visibleExecs) {
|
||||
nodes.push({
|
||||
id: `exec-${exec.id}`,
|
||||
type: 'exec',
|
||||
position: { x: 0, y: 0 },
|
||||
data: nodeData(exec),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}, [sessions, visibleActions, selectedSession])
|
||||
}, [sessions, visibleActions, visibleExecs, selectedSession])
|
||||
|
||||
// Build edges
|
||||
const rawEdges = useMemo(() => {
|
||||
@@ -132,18 +160,23 @@ function ActionGraphInner({
|
||||
? sessions.filter((s) => s.key === selectedSession)
|
||||
: sessions
|
||||
|
||||
for (const session of visibleSessions) {
|
||||
edges.push({
|
||||
id: `e-crab-${session.key}`,
|
||||
source: CRAB_NODE_ID,
|
||||
target: `session-${session.key}`,
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
style: { stroke: '#ef4444', strokeWidth: 2 },
|
||||
})
|
||||
// Build a set of visible session keys for parent lookup
|
||||
const visibleSessionKeys = new Set(visibleSessions.map((s) => s.key))
|
||||
|
||||
// Edge styles
|
||||
const spawnEdgeStyle = {
|
||||
animated: true,
|
||||
style: { stroke: '#00ffd5', strokeWidth: 2, strokeDasharray: '8 4' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
|
||||
}
|
||||
|
||||
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
|
||||
const crabEdgeStyle = {
|
||||
animated: false,
|
||||
style: { stroke: '#ef4444', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
}
|
||||
|
||||
// Group actions by session for spawn point lookup
|
||||
const sessionActions = new Map<string, MonitorAction[]>()
|
||||
for (const action of visibleActions) {
|
||||
const key = action.sessionKey
|
||||
@@ -152,6 +185,39 @@ function ActionGraphInner({
|
||||
list.push(action)
|
||||
sessionActions.set(key, list)
|
||||
}
|
||||
// Sort each session's actions by timestamp
|
||||
for (const [key, actions] of sessionActions) {
|
||||
sessionActions.set(key, [...actions].sort((a, b) => a.timestamp - b.timestamp))
|
||||
}
|
||||
|
||||
// Connect sessions to their spawn sources
|
||||
for (const session of visibleSessions) {
|
||||
const parentSessionKey = session.spawnedBy
|
||||
|
||||
if (parentSessionKey && visibleSessionKeys.has(parentSessionKey)) {
|
||||
// This session was spawned by another session
|
||||
// Connect from parent's right handle to child's left handle (horizontal spawn)
|
||||
edges.push({
|
||||
id: `e-spawn-${session.key}`,
|
||||
source: `session-${parentSessionKey}`,
|
||||
target: `session-${session.key}`,
|
||||
sourceHandle: 'spawn-source',
|
||||
targetHandle: 'spawn-target',
|
||||
type: 'smoothstep',
|
||||
...spawnEdgeStyle,
|
||||
})
|
||||
} else {
|
||||
// Root session - connect from crab
|
||||
edges.push({
|
||||
id: `e-crab-${session.key}`,
|
||||
source: CRAB_NODE_ID,
|
||||
target: `session-${session.key}`,
|
||||
...crabEdgeStyle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
|
||||
|
||||
const getEdgeStyle = (action: MonitorAction) => {
|
||||
switch (action.type) {
|
||||
@@ -194,8 +260,9 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
|
||||
// Connect actions within each session (vertical flow)
|
||||
for (const [sessionKey, actions] of sessionActions) {
|
||||
const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
const sorted = actions // Already sorted above
|
||||
const sessionId = `session-${sessionKey}`
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
@@ -203,6 +270,7 @@ function ActionGraphInner({
|
||||
const edgeStyle = getEdgeStyle(action)
|
||||
|
||||
if (i === 0) {
|
||||
// First action connects from session node
|
||||
if (sessionNodeIds.has(sessionId)) {
|
||||
edges.push({
|
||||
id: `e-session-${action.id}`,
|
||||
@@ -212,6 +280,7 @@ function ActionGraphInner({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Subsequent actions connect from previous action
|
||||
const prev = sorted[i - 1]!
|
||||
edges.push({
|
||||
id: `e-${prev.id}-${action.id}`,
|
||||
@@ -223,8 +292,47 @@ function ActionGraphInner({
|
||||
}
|
||||
}
|
||||
|
||||
const getExecEdgeStyle = (exec: MonitorExecProcess) => {
|
||||
switch (exec.status) {
|
||||
case 'running':
|
||||
return {
|
||||
animated: true,
|
||||
style: { stroke: '#00ffd5', strokeDasharray: '4 4' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
|
||||
}
|
||||
case 'failed':
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#ef4444' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
}
|
||||
case 'completed':
|
||||
default:
|
||||
return {
|
||||
animated: false,
|
||||
style: { stroke: '#98ffc8' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect execs to their session
|
||||
for (const exec of visibleExecs) {
|
||||
const key = exec.sessionKey
|
||||
if (!key) continue
|
||||
const sessionId = `session-${key}`
|
||||
if (!sessionNodeIds.has(sessionId)) continue
|
||||
const edgeStyle = getExecEdgeStyle(exec)
|
||||
edges.push({
|
||||
id: `e-session-exec-${exec.id}`,
|
||||
source: sessionId,
|
||||
target: `exec-${exec.id}`,
|
||||
...edgeStyle,
|
||||
})
|
||||
}
|
||||
|
||||
return edges
|
||||
}, [sessions, visibleActions, selectedSession])
|
||||
}, [sessions, visibleActions, visibleExecs, selectedSession])
|
||||
|
||||
// Apply layout
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||
@@ -608,6 +716,12 @@ function ActionGraphInner({
|
||||
if (node.type === 'crab') return '#ef4444'
|
||||
if (node.type === 'chaserCrab') return '#ef4444'
|
||||
if (node.type === 'session') return '#98ffc8'
|
||||
if (node.type === 'exec') {
|
||||
const status = (node.data as unknown as MonitorExecProcess).status
|
||||
if (status === 'running') return '#00ffd5'
|
||||
if (status === 'failed') return '#ef4444'
|
||||
return '#98ffc8'
|
||||
}
|
||||
return '#52526e'
|
||||
}}
|
||||
maskColor="rgba(10, 10, 15, 0.8)"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { CheckCircle, Copy, Check, Loader2, Terminal, XCircle } from 'lucide-react'
|
||||
import type { MonitorExecProcess, MonitorExecOutputChunk } from '~/integrations/clawdbot'
|
||||
|
||||
interface ExecNodeProps {
|
||||
data: MonitorExecProcess
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const secs = ms / 1000
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remainSecs = Math.floor(secs % 60)
|
||||
return `${mins}m ${remainSecs}s`
|
||||
}
|
||||
|
||||
function tailLinesFromChunks(chunks: MonitorExecOutputChunk[], maxLines: number): string {
|
||||
if (chunks.length === 0) return ''
|
||||
const merged = chunks.map((c) => c.text).join('')
|
||||
const lines = merged.split(/\r?\n/)
|
||||
return lines.slice(-maxLines).join('\n').trim()
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
MonitorExecProcess['status'],
|
||||
{
|
||||
icon: typeof Loader2
|
||||
borderColor: string
|
||||
badgeColor: string
|
||||
iconColor: string
|
||||
animate: boolean
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
running: {
|
||||
icon: Loader2,
|
||||
borderColor: 'border-neon-cyan',
|
||||
badgeColor: 'bg-neon-cyan/15 text-neon-cyan',
|
||||
iconColor: 'text-neon-cyan',
|
||||
animate: true,
|
||||
label: 'Running',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle,
|
||||
borderColor: 'border-neon-mint',
|
||||
badgeColor: 'bg-neon-mint/15 text-neon-mint',
|
||||
iconColor: 'text-neon-mint',
|
||||
animate: false,
|
||||
label: 'Completed',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
borderColor: 'border-crab-500',
|
||||
badgeColor: 'bg-crab-500/15 text-crab-300',
|
||||
iconColor: 'text-crab-400',
|
||||
animate: false,
|
||||
label: 'Failed',
|
||||
},
|
||||
}
|
||||
|
||||
function streamStyle(stream: MonitorExecOutputChunk['stream']): string {
|
||||
if (stream === 'stderr') {
|
||||
return 'text-crab-200 bg-crab-950/30 border-crab-900/60'
|
||||
}
|
||||
return 'text-neon-cyan/90 bg-shell-950 border-shell-800'
|
||||
}
|
||||
|
||||
export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const status = statusConfig[data.status]
|
||||
const StatusIcon = status.icon
|
||||
|
||||
const preview = useMemo(() => tailLinesFromChunks(data.outputs, 3), [data.outputs])
|
||||
const hasOutput = data.outputs.length > 0
|
||||
|
||||
const handleCopyPid = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(String(data.pid)).catch(() => {})
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}, [data.pid])
|
||||
|
||||
const displayDuration =
|
||||
data.durationMs != null
|
||||
? formatDuration(data.durationMs)
|
||||
: data.completedAt != null
|
||||
? formatDuration(Math.max(0, data.completedAt - data.startedAt))
|
||||
: null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className={`
|
||||
px-3 py-2.5 rounded-lg border-2 min-w-[220px] cursor-pointer
|
||||
bg-shell-900 ${status.borderColor}
|
||||
${selected ? 'ring-2 ring-white/30' : ''}
|
||||
${expanded ? 'max-w-[680px]' : 'max-w-[360px]'}
|
||||
transition-all duration-150 hover:bg-shell-800
|
||||
`}
|
||||
style={{
|
||||
boxShadow: selected
|
||||
? '0 0 15px rgba(239, 68, 68, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.35)',
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="bg-shell-600! w-2! h-2! border-shell-800!"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Terminal size={13} className="text-shell-400" />
|
||||
<span className="font-display text-xs font-medium text-gray-300 uppercase tracking-wide">
|
||||
Exec
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
ml-1 px-2 py-0.5 rounded-md border text-[10px] font-console truncate max-w-[220px]
|
||||
border-shell-700 ${status.badgeColor}
|
||||
`}
|
||||
title={data.command}
|
||||
>
|
||||
{data.command}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleCopyPid}
|
||||
className="p-1 rounded hover:bg-shell-700 transition-colors"
|
||||
title={`Copy PID: ${data.pid}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
<StatusIcon
|
||||
size={14}
|
||||
className={`${status.iconColor} ${status.animate ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="font-console text-xs text-shell-500 mb-1.5">
|
||||
<span className="text-crab-600">></span> {formatTime(data.lastActivityAt)}
|
||||
</div>
|
||||
|
||||
<div className="font-console text-xs text-shell-400 mb-1.5 flex gap-2 flex-wrap">
|
||||
<span>
|
||||
<span className="text-shell-500">pid:</span> {data.pid}
|
||||
</span>
|
||||
{data.exitCode != null && (
|
||||
<span>
|
||||
<span className="text-shell-500">exit:</span>{' '}
|
||||
<span className={data.exitCode === 0 ? 'text-neon-mint' : 'text-crab-300'}>
|
||||
{data.exitCode}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{displayDuration && (
|
||||
<span className="text-neon-cyan">{displayDuration}</span>
|
||||
)}
|
||||
{data.status === 'running' && (
|
||||
<span className="text-neon-peach">live</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.outputTruncated && (
|
||||
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
|
||||
output truncated
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOutput && !expanded && (
|
||||
<pre className="font-console text-[11px] text-shell-300 bg-shell-950 border border-shell-800 rounded p-2 overflow-hidden line-clamp-4 whitespace-pre-wrap">
|
||||
{preview || '(no output)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{hasOutput && expanded && (
|
||||
<div className="mt-1.5 border border-shell-800 rounded bg-shell-950/60 max-h-[320px] overflow-auto">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[10px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
|
||||
<span>{status.label}</span>
|
||||
<span>{data.outputs.length} chunks</span>
|
||||
</div>
|
||||
<div className="p-2 flex flex-col gap-1.5">
|
||||
{data.outputs.map((chunk) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
className={`border rounded px-2 py-1 ${streamStyle(chunk.stream)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1 text-[10px] font-console text-shell-500">
|
||||
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
|
||||
{chunk.stream}
|
||||
</span>
|
||||
<span>{formatTime(chunk.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="font-console text-[11px] whitespace-pre-wrap break-words">
|
||||
{chunk.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="bg-shell-600! w-2! h-2! border-shell-800!"
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Users, User } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Users, User, Clock, Copy, Check } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
import type { MonitorSession } from '~/integrations/clawdbot'
|
||||
|
||||
@@ -15,13 +15,50 @@ const platformIcons: Record<string, string> = {
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
subagent: '🤖',
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (seconds < 60) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export const SessionNode = memo(function SessionNode({
|
||||
data,
|
||||
selected,
|
||||
}: SessionNodeProps) {
|
||||
const platformIcon = platformIcons[data.platform] ?? '📱'
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Detect if this is a subagent session by checking if platform is "subagent" or if key contains "subagent"
|
||||
const isSubagent = data.platform === 'subagent' || data.key.includes('subagent') || Boolean(data.spawnedBy)
|
||||
const platformIcon = isSubagent ? platformIcons.subagent : (platformIcons[data.platform] ?? '📱')
|
||||
const displayPlatform = isSubagent ? 'subagent' : data.platform
|
||||
|
||||
const relativeTime = (!data.lastActivityAt || data.lastActivityAt <= 0)
|
||||
? null
|
||||
: formatRelativeTime(data.lastActivityAt)
|
||||
|
||||
const handleCopyKey = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(data.key).catch(() => {})
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}, [data.key])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -32,22 +69,57 @@ export const SessionNode = memo(function SessionNode({
|
||||
bg-shell-900 text-white
|
||||
${selected ? 'border-crab-500' : 'border-shell-600'}
|
||||
${data.status === 'thinking' ? 'border-neon-peach' : ''}
|
||||
${isSubagent ? 'border-neon-cyan border-opacity-50' : ''}
|
||||
transition-all duration-150 hover:bg-shell-800
|
||||
`}
|
||||
style={{
|
||||
boxShadow: selected
|
||||
? '0 0 20px rgba(239, 68, 68, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
: isSubagent
|
||||
? '0 0 12px rgba(0, 255, 213, 0.2), 0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
|
||||
<Handle type="target" id="spawn-target" position={Position.Left} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">{platformIcon}</span>
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-wide text-gray-200">
|
||||
{data.platform}
|
||||
{displayPlatform}
|
||||
</span>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className="p-1 rounded hover:bg-shell-700 transition-colors"
|
||||
title={`Copy key: ${data.key}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Check size={12} className="text-neon-mint" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
<StatusIndicator status={data.status} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -65,7 +137,15 @@ export const SessionNode = memo(function SessionNode({
|
||||
<span className="text-crab-600">></span> {data.agentId}
|
||||
</div>
|
||||
|
||||
{relativeTime && (
|
||||
<div className="flex items-center gap-1 mt-2 font-console text-[10px] text-shell-500">
|
||||
<Clock size={10} className="text-shell-600" />
|
||||
<span>{relativeTime}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
|
||||
<Handle type="source" id="spawn-source" position={Position.Right} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ export { ActionGraph } from './ActionGraph'
|
||||
export { SessionList } from './SessionList'
|
||||
export { SessionNode } from './SessionNode'
|
||||
export { ActionNode } from './ActionNode'
|
||||
export { ExecNode } from './ExecNode'
|
||||
export { CrabNode } from './CrabNode'
|
||||
export { StatusIndicator } from './StatusIndicator'
|
||||
export { SettingsPanel } from './SettingsPanel'
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
|
||||
import { parseSessionKey, type MonitorSession, type MonitorAction } from './protocol'
|
||||
import {
|
||||
parseSessionKey,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
type MonitorExecEvent,
|
||||
type MonitorExecProcess,
|
||||
type MonitorExecOutputChunk,
|
||||
type MonitorExecProcessStatus,
|
||||
} from './protocol'
|
||||
|
||||
// Track runId → sessionKey mapping (learned from chat events)
|
||||
const runSessionMap = new Map<string, string>()
|
||||
@@ -60,6 +68,100 @@ export const actionsCollection = createCollection(
|
||||
})
|
||||
)
|
||||
|
||||
export const execsCollection = createCollection(
|
||||
localOnlyCollectionOptions<MonitorExecProcess>({
|
||||
id: 'clawdbot-execs',
|
||||
getKey: (item) => item.id,
|
||||
})
|
||||
)
|
||||
|
||||
const EXEC_PLACEHOLDER_COMMAND = 'Exec'
|
||||
const MAX_EXEC_OUTPUT_CHUNKS = 200
|
||||
const MAX_EXEC_OUTPUT_CHARS = 50000
|
||||
const MAX_EXEC_CHUNK_CHARS = 4000
|
||||
|
||||
function resolveSessionKey(event: MonitorExecEvent): string | undefined {
|
||||
return event.sessionKey || runSessionMap.get(event.runId) || event.sessionId
|
||||
}
|
||||
|
||||
function backfillExecSessionKey(runId: string, sessionKey: string) {
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
if (exec.runId !== runId) continue
|
||||
if (exec.sessionKey && exec.sessionKey !== exec.sessionId) continue
|
||||
execsCollection.update(exec.id, (draft) => {
|
||||
draft.sessionKey = sessionKey
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function mapExecStatus(exitCode?: number, status?: string): MonitorExecProcessStatus {
|
||||
if (typeof exitCode === 'number' && exitCode !== 0) return 'failed'
|
||||
if (typeof status === 'string') {
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized.includes('fail') || normalized.includes('error')) {
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function capExecOutputs(outputs: MonitorExecOutputChunk[]): {
|
||||
outputs: MonitorExecOutputChunk[]
|
||||
truncated: boolean
|
||||
} {
|
||||
let truncated = false
|
||||
const normalized: MonitorExecOutputChunk[] = outputs.map((chunk) => {
|
||||
if (chunk.text.length <= MAX_EXEC_CHUNK_CHARS) {
|
||||
return chunk
|
||||
}
|
||||
truncated = true
|
||||
return {
|
||||
...chunk,
|
||||
text: chunk.text.slice(0, MAX_EXEC_CHUNK_CHARS) + '\n...[truncated]',
|
||||
}
|
||||
})
|
||||
|
||||
let capped = normalized
|
||||
if (capped.length > MAX_EXEC_OUTPUT_CHUNKS) {
|
||||
truncated = true
|
||||
capped = capped.slice(-MAX_EXEC_OUTPUT_CHUNKS)
|
||||
}
|
||||
|
||||
const totalChars = capped.reduce((sum, chunk) => sum + chunk.text.length, 0)
|
||||
if (totalChars > MAX_EXEC_OUTPUT_CHARS) {
|
||||
truncated = true
|
||||
let dropped = 0
|
||||
let startIdx = 0
|
||||
for (let i = 0; i < capped.length; i++) {
|
||||
if (totalChars - dropped <= MAX_EXEC_OUTPUT_CHARS) break
|
||||
dropped += capped[i]!.text.length
|
||||
startIdx = i + 1
|
||||
}
|
||||
capped = capped.slice(startIdx)
|
||||
}
|
||||
|
||||
return { outputs: capped, truncated }
|
||||
}
|
||||
|
||||
function createPlaceholderExec(event: MonitorExecEvent, sessionKey?: string): MonitorExecProcess {
|
||||
const startedAt = event.startedAt ?? event.timestamp
|
||||
return {
|
||||
id: event.execId,
|
||||
runId: event.runId,
|
||||
pid: event.pid,
|
||||
command: event.command || EXEC_PLACEHOLDER_COMMAND,
|
||||
sessionId: event.sessionId,
|
||||
sessionKey,
|
||||
status: event.eventType === 'completed'
|
||||
? mapExecStatus(event.exitCode, event.status)
|
||||
: 'running',
|
||||
startedAt,
|
||||
timestamp: startedAt,
|
||||
outputs: [],
|
||||
lastActivityAt: event.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update or insert session
|
||||
export function upsertSession(session: MonitorSession) {
|
||||
// Track activity on parent sessions
|
||||
@@ -100,7 +202,11 @@ export function upsertSession(session: MonitorSession) {
|
||||
export function addAction(action: MonitorAction) {
|
||||
// Learn runId → sessionKey mapping from actions with real session keys
|
||||
if (action.sessionKey && !action.sessionKey.includes('lifecycle')) {
|
||||
const previous = runSessionMap.get(action.runId)
|
||||
runSessionMap.set(action.runId, action.sessionKey)
|
||||
if (previous !== action.sessionKey) {
|
||||
backfillExecSessionKey(action.runId, action.sessionKey)
|
||||
}
|
||||
|
||||
// Track activity on parent sessions for spawn inference
|
||||
if (isParentSession(action.sessionKey)) {
|
||||
@@ -191,6 +297,98 @@ export function addAction(action: MonitorAction) {
|
||||
}
|
||||
}
|
||||
|
||||
export function addExecEvent(event: MonitorExecEvent) {
|
||||
const sessionKey = resolveSessionKey(event)
|
||||
const existing = execsCollection.state.get(event.execId)
|
||||
|
||||
if (event.eventType === 'started') {
|
||||
if (existing) {
|
||||
execsCollection.update(event.execId, (draft) => {
|
||||
draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND
|
||||
draft.sessionId = event.sessionId || draft.sessionId
|
||||
draft.sessionKey = sessionKey || draft.sessionKey
|
||||
draft.status = 'running'
|
||||
draft.startedAt = event.startedAt ?? draft.startedAt ?? event.timestamp
|
||||
draft.timestamp = draft.startedAt
|
||||
draft.lastActivityAt = event.timestamp
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
execsCollection.insert({
|
||||
...createPlaceholderExec(event, sessionKey),
|
||||
command: event.command || EXEC_PLACEHOLDER_COMMAND,
|
||||
startedAt: event.startedAt ?? event.timestamp,
|
||||
timestamp: event.startedAt ?? event.timestamp,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.eventType === 'output') {
|
||||
const stream = event.stream || 'stdout'
|
||||
const text = event.output ?? ''
|
||||
const chunk: MonitorExecOutputChunk = {
|
||||
id: event.id,
|
||||
stream,
|
||||
text,
|
||||
timestamp: event.timestamp,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
execsCollection.update(event.execId, (draft) => {
|
||||
draft.sessionId = event.sessionId || draft.sessionId
|
||||
draft.sessionKey = sessionKey || draft.sessionKey
|
||||
draft.lastActivityAt = event.timestamp
|
||||
if (text) {
|
||||
const capped = capExecOutputs([...draft.outputs, chunk])
|
||||
draft.outputs = capped.outputs
|
||||
draft.outputTruncated = draft.outputTruncated || capped.truncated
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const placeholder = createPlaceholderExec(event, sessionKey)
|
||||
if (text) {
|
||||
const capped = capExecOutputs([chunk])
|
||||
placeholder.outputs = capped.outputs
|
||||
placeholder.outputTruncated = capped.truncated
|
||||
}
|
||||
execsCollection.insert(placeholder)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.eventType === 'completed') {
|
||||
const completedStatus = mapExecStatus(event.exitCode, event.status)
|
||||
if (existing) {
|
||||
execsCollection.update(event.execId, (draft) => {
|
||||
draft.sessionId = event.sessionId || draft.sessionId
|
||||
draft.sessionKey = sessionKey || draft.sessionKey
|
||||
draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND
|
||||
draft.exitCode = event.exitCode ?? draft.exitCode
|
||||
draft.durationMs = event.durationMs ?? draft.durationMs
|
||||
const completedAt = draft.durationMs != null
|
||||
? draft.startedAt + draft.durationMs
|
||||
: event.timestamp
|
||||
draft.completedAt = completedAt
|
||||
draft.status = completedStatus
|
||||
draft.lastActivityAt = event.timestamp
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const placeholder = createPlaceholderExec(event, sessionKey)
|
||||
placeholder.command = event.command || placeholder.command
|
||||
placeholder.exitCode = event.exitCode
|
||||
placeholder.durationMs = event.durationMs
|
||||
placeholder.completedAt = placeholder.durationMs != null
|
||||
? placeholder.startedAt + placeholder.durationMs
|
||||
: event.timestamp
|
||||
placeholder.status = completedStatus
|
||||
execsCollection.insert(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to update session status
|
||||
export function updateSessionStatus(
|
||||
key: string,
|
||||
@@ -246,12 +444,61 @@ export function clearCollections() {
|
||||
for (const action of actionsCollection.state.values()) {
|
||||
actionsCollection.delete(action.id)
|
||||
}
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
execsCollection.delete(exec.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Get count of completed/failed execs (for UI badge)
|
||||
export function getCompletedExecCount(): number {
|
||||
let count = 0
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
if (exec.status === 'completed' || exec.status === 'failed') {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Clear completed and failed execs from state
|
||||
// Returns number of items cleared
|
||||
export function clearCompletedExecs(): number {
|
||||
const toDelete: string[] = []
|
||||
for (const exec of execsCollection.state.values()) {
|
||||
if (exec.status === 'completed' || exec.status === 'failed') {
|
||||
toDelete.push(exec.id)
|
||||
}
|
||||
}
|
||||
for (const id of toDelete) {
|
||||
execsCollection.delete(id)
|
||||
}
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
// Clear inactive sessions (idle sessions with no activity for thresholdMs)
|
||||
// Returns number of sessions cleared
|
||||
export function clearInactiveSessions(thresholdMs: number): number {
|
||||
const now = Date.now()
|
||||
const toDelete: string[] = []
|
||||
for (const session of sessionsCollection.state.values()) {
|
||||
// Only clear idle sessions - preserve thinking/active ones
|
||||
if (session.status !== 'idle') continue
|
||||
const inactiveTime = now - session.lastActivityAt
|
||||
if (inactiveTime >= thresholdMs) {
|
||||
toDelete.push(session.key)
|
||||
}
|
||||
}
|
||||
for (const key of toDelete) {
|
||||
sessionsCollection.delete(key)
|
||||
}
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
// Hydrate collections from server persistence
|
||||
export function hydrateFromServer(
|
||||
sessions: MonitorSession[],
|
||||
actions: MonitorAction[]
|
||||
actions: MonitorAction[],
|
||||
execEvents: MonitorExecEvent[] = []
|
||||
) {
|
||||
// First clear existing data
|
||||
clearCollections()
|
||||
@@ -286,4 +533,10 @@ export function hydrateFromServer(
|
||||
for (const action of sortedActions) {
|
||||
addAction(action)
|
||||
}
|
||||
|
||||
// Replay exec events after actions to maximize sessionKey resolution
|
||||
const sortedExecEvents = [...execEvents].sort((a, b) => a.timestamp - b.timestamp)
|
||||
for (const event of sortedExecEvents) {
|
||||
addExecEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ import type {
|
||||
EventFrame,
|
||||
ChatEvent,
|
||||
AgentEvent,
|
||||
ExecStartedEvent,
|
||||
ExecOutputEvent,
|
||||
ExecCompletedEvent,
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecEvent,
|
||||
SessionInfo,
|
||||
} from './protocol'
|
||||
import { parseSessionKey } from './protocol'
|
||||
@@ -148,7 +152,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
|
||||
|
||||
export function parseEventFrame(
|
||||
frame: EventFrame
|
||||
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
|
||||
): {
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
execEvent?: MonitorExecEvent
|
||||
} | null {
|
||||
// Skip system events
|
||||
if (frame.event === 'health' || frame.event === 'tick') {
|
||||
return null
|
||||
@@ -189,5 +197,95 @@ export function parseEventFrame(
|
||||
return null
|
||||
}
|
||||
|
||||
if (frame.event === 'exec.started' && frame.payload) {
|
||||
const exec = frame.payload as ExecStartedEvent
|
||||
const execId = `exec-${exec.runId}-${exec.pid}`
|
||||
const timestamp = Date.now()
|
||||
const id = frame.seq != null
|
||||
? `${execId}-started-${frame.seq}`
|
||||
: `${execId}-started-${timestamp}`
|
||||
|
||||
return {
|
||||
execEvent: {
|
||||
id,
|
||||
execId,
|
||||
runId: exec.runId,
|
||||
pid: exec.pid,
|
||||
sessionId: exec.sessionId,
|
||||
eventType: 'started',
|
||||
command: exec.command,
|
||||
startedAt: exec.startedAt,
|
||||
timestamp,
|
||||
},
|
||||
session: exec.sessionId
|
||||
? {
|
||||
key: exec.sessionId,
|
||||
status: 'thinking',
|
||||
lastActivityAt: timestamp,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.event === 'exec.output' && frame.payload) {
|
||||
const exec = frame.payload as ExecOutputEvent
|
||||
const execId = `exec-${exec.runId}-${exec.pid}`
|
||||
const timestamp = Date.now()
|
||||
const id = frame.seq != null
|
||||
? `${execId}-output-${frame.seq}`
|
||||
: `${execId}-output-${timestamp}`
|
||||
|
||||
return {
|
||||
execEvent: {
|
||||
id,
|
||||
execId,
|
||||
runId: exec.runId,
|
||||
pid: exec.pid,
|
||||
sessionId: exec.sessionId,
|
||||
eventType: 'output',
|
||||
stream: exec.stream,
|
||||
output: exec.output,
|
||||
timestamp,
|
||||
},
|
||||
session: exec.sessionId
|
||||
? {
|
||||
key: exec.sessionId,
|
||||
lastActivityAt: timestamp,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.event === 'exec.completed' && frame.payload) {
|
||||
const exec = frame.payload as ExecCompletedEvent
|
||||
const execId = `exec-${exec.runId}-${exec.pid}`
|
||||
const timestamp = Date.now()
|
||||
const id = frame.seq != null
|
||||
? `${execId}-completed-${frame.seq}`
|
||||
: `${execId}-completed-${timestamp}`
|
||||
|
||||
return {
|
||||
execEvent: {
|
||||
id,
|
||||
execId,
|
||||
runId: exec.runId,
|
||||
pid: exec.pid,
|
||||
sessionId: exec.sessionId,
|
||||
eventType: 'completed',
|
||||
durationMs: exec.durationMs,
|
||||
exitCode: exec.exitCode,
|
||||
status: exec.status,
|
||||
timestamp,
|
||||
},
|
||||
session: exec.sessionId
|
||||
? {
|
||||
key: exec.sessionId,
|
||||
status: 'active',
|
||||
lastActivityAt: timestamp,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { MonitorSession, MonitorAction } from './protocol'
|
||||
import type { MonitorSession, MonitorAction, MonitorExecEvent } from './protocol'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json')
|
||||
const ACTIONS_FILE = path.join(DATA_DIR, 'actions.jsonl')
|
||||
const EXEC_EVENTS_FILE = path.join(DATA_DIR, 'exec-events.jsonl')
|
||||
const STATE_FILE = path.join(DATA_DIR, 'state.json')
|
||||
const MAX_ACTIONS = 10000
|
||||
const MAX_EXEC_EVENTS = 20000
|
||||
|
||||
interface PersistenceState {
|
||||
enabled: boolean
|
||||
@@ -16,6 +18,7 @@ interface PersistenceState {
|
||||
class PersistenceService {
|
||||
private sessions: Map<string, MonitorSession> = new Map()
|
||||
private actions: MonitorAction[] = []
|
||||
private execEvents: MonitorExecEvent[] = []
|
||||
private enabled = false
|
||||
private startedAt: number | null = null
|
||||
|
||||
@@ -90,6 +93,28 @@ class PersistenceService {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Load exec events (JSONL)
|
||||
try {
|
||||
if (fs.existsSync(EXEC_EVENTS_FILE)) {
|
||||
const content = fs.readFileSync(EXEC_EVENTS_FILE, 'utf-8')
|
||||
const lines = content.trim().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line) as MonitorExecEvent
|
||||
this.execEvents.push(event)
|
||||
} catch {
|
||||
// skip bad lines
|
||||
}
|
||||
}
|
||||
if (this.execEvents.length > MAX_EXEC_EVENTS) {
|
||||
this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS)
|
||||
this.saveExecEvents()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveSessions() {
|
||||
@@ -102,10 +127,19 @@ class PersistenceService {
|
||||
fs.writeFileSync(ACTIONS_FILE, content)
|
||||
}
|
||||
|
||||
private saveExecEvents() {
|
||||
const content = this.execEvents.map((e) => JSON.stringify(e)).join('\n')
|
||||
fs.writeFileSync(EXEC_EVENTS_FILE, content)
|
||||
}
|
||||
|
||||
private appendAction(action: MonitorAction) {
|
||||
fs.appendFileSync(ACTIONS_FILE, JSON.stringify(action) + '\n')
|
||||
}
|
||||
|
||||
private appendExecEvent(event: MonitorExecEvent) {
|
||||
fs.appendFileSync(EXEC_EVENTS_FILE, JSON.stringify(event) + '\n')
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return this.enabled
|
||||
}
|
||||
@@ -131,12 +165,14 @@ class PersistenceService {
|
||||
startedAt: number | null
|
||||
sessionCount: number
|
||||
actionCount: number
|
||||
execEventCount: number
|
||||
} {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
startedAt: this.startedAt,
|
||||
sessionCount: this.sessions.size,
|
||||
actionCount: this.actions.length,
|
||||
execEventCount: this.execEvents.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,19 +204,44 @@ class PersistenceService {
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(): { sessions: MonitorSession[]; actions: MonitorAction[] } {
|
||||
addExecEvent(event: MonitorExecEvent) {
|
||||
if (!this.enabled) return
|
||||
|
||||
const existingIdx = this.execEvents.findIndex((e) => e.id === event.id)
|
||||
if (existingIdx >= 0) {
|
||||
this.execEvents[existingIdx] = event
|
||||
this.saveExecEvents()
|
||||
} else {
|
||||
this.execEvents.push(event)
|
||||
this.appendExecEvent(event)
|
||||
|
||||
if (this.execEvents.length > MAX_EXEC_EVENTS) {
|
||||
this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS)
|
||||
this.saveExecEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(): {
|
||||
sessions: MonitorSession[]
|
||||
actions: MonitorAction[]
|
||||
execEvents: MonitorExecEvent[]
|
||||
} {
|
||||
return {
|
||||
sessions: Array.from(this.sessions.values()),
|
||||
actions: [...this.actions],
|
||||
execEvents: [...this.execEvents],
|
||||
}
|
||||
}
|
||||
|
||||
clear(): { cleared: boolean } {
|
||||
this.sessions.clear()
|
||||
this.actions = []
|
||||
this.execEvents = []
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) fs.unlinkSync(SESSIONS_FILE)
|
||||
if (fs.existsSync(ACTIONS_FILE)) fs.unlinkSync(ACTIONS_FILE)
|
||||
if (fs.existsSync(EXEC_EVENTS_FILE)) fs.unlinkSync(EXEC_EVENTS_FILE)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -85,6 +85,32 @@ export interface AgentEvent {
|
||||
sessionKey?: string
|
||||
}
|
||||
|
||||
// Exec events
|
||||
export interface ExecStartedEvent {
|
||||
pid: number
|
||||
command: string
|
||||
sessionId: string
|
||||
runId: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface ExecOutputEvent {
|
||||
pid: number
|
||||
runId: string
|
||||
sessionId?: string
|
||||
stream: 'stdout' | 'stderr' | string
|
||||
output: string
|
||||
}
|
||||
|
||||
export interface ExecCompletedEvent {
|
||||
pid: number
|
||||
runId: string
|
||||
sessionId?: string
|
||||
exitCode: number
|
||||
durationMs: number
|
||||
status: string
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export interface SessionsListParams {
|
||||
limit?: number
|
||||
@@ -137,6 +163,53 @@ export interface MonitorAction {
|
||||
stopReason?: string
|
||||
}
|
||||
|
||||
export type MonitorExecEventType = 'started' | 'output' | 'completed'
|
||||
|
||||
export interface MonitorExecEvent {
|
||||
id: string
|
||||
execId: string
|
||||
runId: string
|
||||
pid: number
|
||||
sessionId?: string
|
||||
sessionKey?: string
|
||||
eventType: MonitorExecEventType
|
||||
command?: string
|
||||
stream?: 'stdout' | 'stderr' | string
|
||||
output?: string
|
||||
startedAt?: number
|
||||
durationMs?: number
|
||||
exitCode?: number
|
||||
status?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type MonitorExecProcessStatus = 'running' | 'completed' | 'failed'
|
||||
|
||||
export interface MonitorExecOutputChunk {
|
||||
id: string
|
||||
stream: 'stdout' | 'stderr' | string
|
||||
text: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface MonitorExecProcess {
|
||||
id: string
|
||||
runId: string
|
||||
pid: number
|
||||
command: string
|
||||
sessionId?: string
|
||||
sessionKey?: string
|
||||
status: MonitorExecProcessStatus
|
||||
startedAt: number
|
||||
completedAt?: number
|
||||
durationMs?: number
|
||||
exitCode?: number
|
||||
outputs: MonitorExecOutputChunk[]
|
||||
outputTruncated?: boolean
|
||||
timestamp: number
|
||||
lastActivityAt: number
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export function parseSessionKey(key: string): {
|
||||
agentId: string
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sessionInfoToMonitor,
|
||||
type MonitorSession,
|
||||
type MonitorAction,
|
||||
type MonitorExecEvent,
|
||||
} from '~/integrations/clawdbot'
|
||||
|
||||
// Server-side debug mode state
|
||||
@@ -137,9 +138,10 @@ const clawdbotRouter = router({
|
||||
|
||||
events: publicProcedure.subscription(() => {
|
||||
return observable<{
|
||||
type: 'session' | 'action'
|
||||
type: 'session' | 'action' | 'exec'
|
||||
session?: Partial<MonitorSession>
|
||||
action?: MonitorAction
|
||||
execEvent?: MonitorExecEvent
|
||||
}>((emit) => {
|
||||
const client = getClawdbotClient()
|
||||
const persistence = getPersistenceService()
|
||||
@@ -163,6 +165,9 @@ const clawdbotRouter = router({
|
||||
if (debugMode && parsed.action) {
|
||||
console.log('[DEBUG] Parsed action:', parsed.action.type, parsed.action.eventType, 'sessionKey:', parsed.action.sessionKey)
|
||||
}
|
||||
if (debugMode && parsed.execEvent) {
|
||||
console.log('[DEBUG] Parsed exec:', parsed.execEvent.eventType, 'runId:', parsed.execEvent.runId, 'pid:', parsed.execEvent.pid)
|
||||
}
|
||||
if (parsed.session) {
|
||||
emit.next({ type: 'session', session: parsed.session })
|
||||
}
|
||||
@@ -171,6 +176,10 @@ const clawdbotRouter = router({
|
||||
persistence.addAction(parsed.action)
|
||||
emit.next({ type: 'action', action: parsed.action })
|
||||
}
|
||||
if (parsed.execEvent) {
|
||||
persistence.addExecEvent(parsed.execEvent)
|
||||
emit.next({ type: 'exec', execEvent: parsed.execEvent })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+279
-39
@@ -1,5 +1,14 @@
|
||||
import dagre from 'dagre'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type {
|
||||
MonitorSession,
|
||||
MonitorAction,
|
||||
MonitorExecProcess,
|
||||
} from '~/integrations/clawdbot'
|
||||
|
||||
/** Cast domain data to ReactFlow's Node data type */
|
||||
function nodeData<T>(data: T): Record<string, unknown> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction?: 'TB' | 'LR' | 'BT' | 'RL'
|
||||
@@ -9,60 +18,291 @@ export interface LayoutOptions {
|
||||
nodeSep?: number
|
||||
}
|
||||
|
||||
// Node sizing configuration - sized generously for layout calculations
|
||||
const NODE_DIMENSIONS = {
|
||||
session: { width: 280, height: 140 }, // Wider for session cards
|
||||
exec: { width: 300, height: 120 }, // Exec processes need room
|
||||
action: { width: 220, height: 100 }, // Chat events with padding
|
||||
crab: { width: 64, height: 64 },
|
||||
}
|
||||
|
||||
// Layout constants - generous spacing for clarity
|
||||
const COLUMN_GAP = 400 // Horizontal gap between session columns
|
||||
const ROW_GAP = 80 // Vertical gap between items in a column
|
||||
const SPAWN_OFFSET = 60 // Extra Y offset when spawning to right
|
||||
const CRAB_OFFSET = { x: -120, y: -100 }
|
||||
const MIN_SESSION_GAP = 120 // Minimum vertical gap between sessions in same column
|
||||
|
||||
interface SessionColumn {
|
||||
sessionKey: string
|
||||
columnIndex: number
|
||||
spawnY: number // Y position where this session was spawned from parent
|
||||
items: Array<{
|
||||
nodeId: string
|
||||
type: 'session' | 'action' | 'exec'
|
||||
timestamp: number
|
||||
data: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal spawn layout algorithm:
|
||||
* - Sessions arranged in columns (X = spawn depth)
|
||||
* - Events within a session flow DOWN (Y = time progression)
|
||||
* - Child sessions appear to the RIGHT at the Y-level where they were spawned
|
||||
*/
|
||||
export function layoutGraph(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: LayoutOptions = {}
|
||||
_options: LayoutOptions = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const {
|
||||
direction = 'TB',
|
||||
nodeWidth = 200,
|
||||
nodeHeight = 80,
|
||||
rankSep = 80,
|
||||
nodeSep = 40,
|
||||
} = options
|
||||
// Build session hierarchy and columns
|
||||
const sessions = nodes
|
||||
.filter((n) => n.type === 'session')
|
||||
.map((n) => n.data as unknown as MonitorSession)
|
||||
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
|
||||
const actions = nodes
|
||||
.filter((n) => n.type === 'action')
|
||||
.map((n) => ({ id: n.id.replace('action-', ''), data: n.data as unknown as MonitorAction }))
|
||||
|
||||
// Add nodes
|
||||
for (const node of nodes) {
|
||||
const width = node.measured?.width ?? nodeWidth
|
||||
const height = node.measured?.height ?? nodeHeight
|
||||
g.setNode(node.id, { width, height })
|
||||
}
|
||||
const execs = nodes
|
||||
.filter((n) => n.type === 'exec')
|
||||
.map((n) => ({ id: n.id.replace('exec-', ''), data: n.data as unknown as MonitorExecProcess }))
|
||||
|
||||
// Add edges
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target)
|
||||
}
|
||||
const crabNode = nodes.find((n) => n.type === 'crab')
|
||||
|
||||
// Run layout
|
||||
dagre.layout(g)
|
||||
// Build session column map - which column is each session in?
|
||||
const sessionColumns = new Map<string, SessionColumn>()
|
||||
const columnOccupancy = new Map<number, number>() // columnIndex -> maxY used
|
||||
|
||||
// Apply positions
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id)
|
||||
const width = node.measured?.width ?? nodeWidth
|
||||
const height = node.measured?.height ?? nodeHeight
|
||||
// First pass: determine column for each session based on spawn hierarchy
|
||||
const getSessionColumn = (sessionKey: string, visited = new Set<string>()): number => {
|
||||
if (visited.has(sessionKey)) return 0
|
||||
visited.add(sessionKey)
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - width / 2,
|
||||
y: nodeWithPosition.y - height / 2,
|
||||
},
|
||||
const session = sessions.find((s) => s.key === sessionKey)
|
||||
if (!session) return 0
|
||||
|
||||
if (session.spawnedBy) {
|
||||
return getSessionColumn(session.spawnedBy, visited) + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Assign columns to all sessions
|
||||
for (const session of sessions) {
|
||||
const columnIndex = getSessionColumn(session.key)
|
||||
sessionColumns.set(session.key, {
|
||||
sessionKey: session.key,
|
||||
columnIndex,
|
||||
spawnY: 0,
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Group actions by session and sort by timestamp
|
||||
const actionsBySession = new Map<string, typeof actions>()
|
||||
for (const action of actions) {
|
||||
const sessionKey = action.data.sessionKey
|
||||
if (!sessionKey) continue
|
||||
const list = actionsBySession.get(sessionKey) ?? []
|
||||
list.push(action)
|
||||
actionsBySession.set(sessionKey, list)
|
||||
}
|
||||
for (const [key, list] of actionsBySession) {
|
||||
list.sort((a, b) => a.data.timestamp - b.data.timestamp)
|
||||
actionsBySession.set(key, list)
|
||||
}
|
||||
|
||||
// Group execs by session
|
||||
const execsBySession = new Map<string, typeof execs>()
|
||||
for (const exec of execs) {
|
||||
const sessionKey = exec.data.sessionKey
|
||||
if (!sessionKey) continue
|
||||
const list = execsBySession.get(sessionKey) ?? []
|
||||
list.push(exec)
|
||||
execsBySession.set(sessionKey, list)
|
||||
}
|
||||
for (const [key, list] of execsBySession) {
|
||||
list.sort((a, b) => a.data.startedAt - b.data.startedAt)
|
||||
execsBySession.set(key, list)
|
||||
}
|
||||
|
||||
// Build items list for each session (session node + actions + execs)
|
||||
for (const session of sessions) {
|
||||
const col = sessionColumns.get(session.key)
|
||||
if (!col) continue
|
||||
|
||||
// Add session node itself
|
||||
col.items.push({
|
||||
nodeId: `session-${session.key}`,
|
||||
type: 'session',
|
||||
timestamp: session.lastActivityAt ?? 0,
|
||||
data: session,
|
||||
})
|
||||
|
||||
// Add actions
|
||||
const sessionActions = actionsBySession.get(session.key) ?? []
|
||||
for (const action of sessionActions) {
|
||||
col.items.push({
|
||||
nodeId: `action-${action.id}`,
|
||||
type: 'action',
|
||||
timestamp: action.data.timestamp,
|
||||
data: action.data,
|
||||
})
|
||||
}
|
||||
|
||||
// Add execs
|
||||
const sessionExecs = execsBySession.get(session.key) ?? []
|
||||
for (const exec of sessionExecs) {
|
||||
col.items.push({
|
||||
nodeId: `exec-${exec.id}`,
|
||||
type: 'exec',
|
||||
timestamp: exec.data.startedAt,
|
||||
data: exec.data,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort all items by timestamp (session node first since it's the start)
|
||||
col.items.sort((a, b) => {
|
||||
if (a.type === 'session') return -1
|
||||
if (b.type === 'session') return 1
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate spawn Y positions for child sessions
|
||||
// When a session is spawned, find the Y position of the parent at that time
|
||||
for (const session of sessions) {
|
||||
if (!session.spawnedBy) continue
|
||||
|
||||
const parentCol = sessionColumns.get(session.spawnedBy)
|
||||
const childCol = sessionColumns.get(session.key)
|
||||
if (!parentCol || !childCol) continue
|
||||
|
||||
// Find the approximate position in parent where spawn happened
|
||||
// Use the child's creation time (approximated by first action time or session activity)
|
||||
const childActions = actionsBySession.get(session.key) ?? []
|
||||
const childCreationTime = childActions[0]?.data.timestamp ?? session.lastActivityAt ?? Date.now()
|
||||
|
||||
// Count how many items in parent were before this spawn
|
||||
let parentItemsBeforeSpawn = 0
|
||||
for (const item of parentCol.items) {
|
||||
if (item.type === 'session') {
|
||||
parentItemsBeforeSpawn++
|
||||
continue
|
||||
}
|
||||
if (item.timestamp <= childCreationTime) {
|
||||
parentItemsBeforeSpawn++
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y based on parent's item count
|
||||
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
|
||||
}
|
||||
|
||||
// Position all nodes
|
||||
const positionedNodes: Node[] = []
|
||||
const positionedNodeIds = new Set<string>()
|
||||
|
||||
// Position crab node
|
||||
if (crabNode) {
|
||||
positionedNodes.push({
|
||||
...crabNode,
|
||||
position: { x: CRAB_OFFSET.x, y: CRAB_OFFSET.y },
|
||||
})
|
||||
positionedNodeIds.add(crabNode.id)
|
||||
}
|
||||
|
||||
// Track column usage for collision avoidance: columnIndex -> list of {startY, endY} ranges
|
||||
const columnRanges = new Map<number, Array<{ startY: number; endY: number }>>()
|
||||
|
||||
// Get X position for a column (all nodes in same column share same X)
|
||||
const getColumnX = (columnIndex: number): number => {
|
||||
return columnIndex * COLUMN_GAP
|
||||
}
|
||||
|
||||
// Adjust spawn Y to avoid collisions with existing sessions in same column
|
||||
const adjustSpawnY = (columnIndex: number, desiredY: number, itemCount: number): number => {
|
||||
const ranges = columnRanges.get(columnIndex) ?? []
|
||||
const estimatedHeight = itemCount * (NODE_DIMENSIONS.action.height + ROW_GAP) + MIN_SESSION_GAP
|
||||
|
||||
let adjustedY = desiredY
|
||||
|
||||
// Check for overlaps and shift down if needed
|
||||
for (const range of ranges) {
|
||||
// If our desired position overlaps with an existing range
|
||||
if (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) {
|
||||
// Shift below this range with minimum gap
|
||||
adjustedY = range.endY + MIN_SESSION_GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Record our range
|
||||
ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight })
|
||||
columnRanges.set(columnIndex, ranges)
|
||||
|
||||
return adjustedY
|
||||
}
|
||||
|
||||
// Sort sessions by column index (process column 0 first, then 1, etc.)
|
||||
// This ensures parent sessions are positioned before children
|
||||
const sortedSessionKeys = Array.from(sessionColumns.keys()).sort((a, b) => {
|
||||
const colA = sessionColumns.get(a)!.columnIndex
|
||||
const colB = sessionColumns.get(b)!.columnIndex
|
||||
if (colA !== colB) return colA - colB
|
||||
// Within same column, sort by spawn Y (earlier spawns first)
|
||||
return sessionColumns.get(a)!.spawnY - sessionColumns.get(b)!.spawnY
|
||||
})
|
||||
|
||||
return { nodes: layoutedNodes, edges }
|
||||
// Position each session's column
|
||||
for (const sessionKey of sortedSessionKeys) {
|
||||
const col = sessionColumns.get(sessionKey)!
|
||||
const columnX = getColumnX(col.columnIndex)
|
||||
|
||||
// Adjust Y position to avoid collisions with other sessions in same column
|
||||
const adjustedY = adjustSpawnY(col.columnIndex, col.spawnY, col.items.length)
|
||||
let currentY = adjustedY
|
||||
|
||||
for (const item of col.items) {
|
||||
const dims = NODE_DIMENSIONS[item.type]
|
||||
|
||||
positionedNodes.push({
|
||||
id: item.nodeId,
|
||||
type: item.type,
|
||||
position: { x: columnX, y: currentY },
|
||||
data: nodeData(item.data),
|
||||
})
|
||||
positionedNodeIds.add(item.nodeId)
|
||||
|
||||
currentY += dims.height + ROW_GAP
|
||||
}
|
||||
|
||||
// Track max Y for this column
|
||||
columnOccupancy.set(col.columnIndex, Math.max(
|
||||
columnOccupancy.get(col.columnIndex) ?? 0,
|
||||
currentY
|
||||
))
|
||||
}
|
||||
|
||||
// Handle orphan nodes (actions/execs without a session)
|
||||
let orphanY = Math.max(...Array.from(columnOccupancy.values()), 0) + 100
|
||||
for (const node of nodes) {
|
||||
if (!positionedNodeIds.has(node.id)) {
|
||||
const dims = NODE_DIMENSIONS[node.type as keyof typeof NODE_DIMENSIONS] ?? { width: 180, height: 80 }
|
||||
positionedNodes.push({
|
||||
...node,
|
||||
position: { x: -200, y: orphanY },
|
||||
})
|
||||
orphanY += dims.height + ROW_GAP
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: positionedNodes, edges }
|
||||
}
|
||||
|
||||
// Group nodes by session for better visual organization
|
||||
export function groupNodesBySession(
|
||||
nodes: Node[]
|
||||
): Map<string, Node[]> {
|
||||
export function groupNodesBySession(nodes: Node[]): Map<string, Node[]> {
|
||||
const groups = new Map<string, Node[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useLiveQuery } from '@tanstack/react-db'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Loader2, HardDrive } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import {
|
||||
sessionsCollection,
|
||||
actionsCollection,
|
||||
execsCollection,
|
||||
upsertSession,
|
||||
addAction,
|
||||
addExecEvent,
|
||||
updateSessionStatus,
|
||||
clearCollections,
|
||||
hydrateFromServer,
|
||||
clearCompletedExecs,
|
||||
} from '~/integrations/clawdbot'
|
||||
import {
|
||||
ActionGraph,
|
||||
@@ -83,9 +86,22 @@ function MonitorPage() {
|
||||
// Live queries from TanStack DB collections
|
||||
const sessionsQuery = useLiveQuery(sessionsCollection)
|
||||
const actionsQuery = useLiveQuery(actionsCollection)
|
||||
const execsQuery = useLiveQuery(execsCollection)
|
||||
|
||||
const sessions = sessionsQuery.data ?? []
|
||||
const actions = actionsQuery.data ?? []
|
||||
const execs = execsQuery.data ?? []
|
||||
|
||||
// Count clearable items (completed/failed execs)
|
||||
const completedCount = useMemo(() => {
|
||||
return execs.filter(e => e.status === 'completed' || e.status === 'failed').length
|
||||
}, [execs])
|
||||
|
||||
// Handler for clearing completed execs
|
||||
const handleClearCompleted = useCallback(() => {
|
||||
const count = clearCompletedExecs()
|
||||
console.log(`[monitor] cleared ${count} completed execs`)
|
||||
}, [])
|
||||
|
||||
|
||||
// Check connection status and persistence on mount
|
||||
@@ -143,10 +159,12 @@ function MonitorPage() {
|
||||
const hydrateFromPersistence = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
if (status.sessionCount > 0 || status.actionCount > 0) {
|
||||
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
|
||||
const data = await trpc.clawdbot.persistenceHydrate.query()
|
||||
hydrateFromServer(data.sessions, data.actions)
|
||||
console.log(`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions`)
|
||||
hydrateFromServer(data.sessions, data.actions, data.execEvents ?? [])
|
||||
console.log(
|
||||
`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions, ${(data.execEvents ?? []).length} exec events`
|
||||
)
|
||||
}
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
@@ -331,6 +349,9 @@ function MonitorPage() {
|
||||
if (data.type === 'action' && data.action) {
|
||||
addAction(data.action)
|
||||
}
|
||||
if (data.type === 'exec' && data.execEvent) {
|
||||
addExecEvent(data.execEvent)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('[monitor] subscription error:', err)
|
||||
@@ -383,6 +404,23 @@ function MonitorPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Clear Completed button */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all bg-shell-800/50 hover:bg-crab-900/50 hover:border-crab-700/50 border border-transparent group"
|
||||
title={`Clear ${completedCount} completed item${completedCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Trash2
|
||||
size={14}
|
||||
className="text-shell-400 group-hover:text-crab-400 transition-colors"
|
||||
/>
|
||||
<span className="font-console text-xs text-shell-400 group-hover:text-crab-400 transition-colors">
|
||||
{completedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Persistence indicator */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
@@ -458,6 +496,7 @@ function MonitorPage() {
|
||||
<ActionGraph
|
||||
sessions={sessions}
|
||||
actions={actions}
|
||||
execs={execs}
|
||||
selectedSession={selectedSession}
|
||||
onSessionSelect={setSelectedSession}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user