openclaw-node-harness 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +603 -81
  4. package/bin/mesh-bridge.js +340 -11
  5. package/bin/mesh-deploy-listener.js +119 -97
  6. package/bin/mesh-deploy.js +8 -0
  7. package/bin/mesh-task-daemon.js +1005 -40
  8. package/bin/mesh.js +423 -6
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +300 -8
  29. package/lib/circling-parser.js +119 -0
  30. package/lib/hyperagent-store.mjs +652 -0
  31. package/lib/kanban-io.js +59 -10
  32. package/lib/mcp-knowledge/bench.mjs +118 -0
  33. package/lib/mcp-knowledge/core.mjs +528 -0
  34. package/lib/mcp-knowledge/package.json +25 -0
  35. package/lib/mcp-knowledge/server.mjs +245 -0
  36. package/lib/mcp-knowledge/test.mjs +802 -0
  37. package/lib/memory-budget.mjs +261 -0
  38. package/lib/mesh-collab.js +354 -4
  39. package/lib/mesh-harness.js +427 -0
  40. package/lib/mesh-plans.js +13 -5
  41. package/lib/mesh-registry.js +11 -2
  42. package/lib/mesh-tasks.js +67 -0
  43. package/lib/plan-templates.js +226 -0
  44. package/lib/pre-compression-flush.mjs +320 -0
  45. package/lib/role-loader.js +292 -0
  46. package/lib/rule-loader.js +358 -0
  47. package/lib/session-store.mjs +458 -0
  48. package/lib/transcript-parser.mjs +292 -0
  49. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  50. package/mission-control/drizzle.config.ts +1 -4
  51. package/mission-control/package-lock.json +1571 -83
  52. package/mission-control/package.json +6 -2
  53. package/mission-control/scripts/gen-chronology.js +3 -3
  54. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  55. package/mission-control/scripts/import-pipeline.js +0 -15
  56. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  57. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  58. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  59. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  60. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  61. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  62. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  63. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  64. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  65. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  66. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  67. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  68. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  69. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  70. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  71. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  72. package/mission-control/src/app/api/tasks/route.ts +21 -30
  73. package/mission-control/src/app/cowork/page.tsx +261 -0
  74. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  75. package/mission-control/src/app/graph/page.tsx +26 -0
  76. package/mission-control/src/app/memory/page.tsx +1 -1
  77. package/mission-control/src/app/obsidian/page.tsx +36 -6
  78. package/mission-control/src/app/roadmap/page.tsx +24 -0
  79. package/mission-control/src/app/souls/page.tsx +2 -2
  80. package/mission-control/src/components/board/execution-config.tsx +431 -0
  81. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  82. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  83. package/mission-control/src/components/board/task-card.tsx +55 -2
  84. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  85. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  86. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  87. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  88. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  89. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  90. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  91. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  92. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  93. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  94. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  95. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  96. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  97. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  98. package/mission-control/src/lib/config.ts +58 -0
  99. package/mission-control/src/lib/db/index.ts +69 -0
  100. package/mission-control/src/lib/db/schema.ts +61 -3
  101. package/mission-control/src/lib/hooks.ts +309 -0
  102. package/mission-control/src/lib/memory/entities.ts +3 -2
  103. package/mission-control/src/lib/nats.ts +66 -1
  104. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  105. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  106. package/mission-control/src/lib/scheduler.ts +12 -11
  107. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  108. package/mission-control/src/lib/sync/tasks.ts +23 -1
  109. package/mission-control/src/lib/task-id.ts +32 -0
  110. package/mission-control/src/lib/tts/index.ts +33 -9
  111. package/mission-control/tsconfig.json +2 -1
  112. package/mission-control/vitest.config.ts +14 -0
  113. package/package.json +15 -2
  114. package/services/service-manifest.json +1 -1
  115. package/skills/cc-godmode/references/agents.md +8 -8
  116. package/workspace-bin/memory-daemon.mjs +199 -5
  117. package/workspace-bin/session-search.mjs +204 -0
  118. package/workspace-bin/web-fetch.mjs +65 -0
@@ -0,0 +1,261 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import { AlertTriangle, Plus, Users2 } from "lucide-react";
5
+ import {
6
+ useCollabSessions,
7
+ useClusters,
8
+ useTasks,
9
+ addClusterMember,
10
+ } from "@/lib/hooks";
11
+ import { SessionCard } from "@/components/cowork/session-card";
12
+ import { ClusterCard } from "@/components/cowork/cluster-card";
13
+ import { CreateClusterDialog } from "@/components/cowork/create-cluster-dialog";
14
+ import { DispatchForm } from "@/components/cowork/dispatch-form";
15
+ import useSWR from "swr";
16
+ import { RolePicker } from "@/components/cowork/role-picker";
17
+
18
+ const fetcher = (url: string) => fetch(url).then((r) => r.json());
19
+
20
+ type Tab = "sessions" | "clusters" | "dispatch";
21
+
22
+ export default function CoworkPage() {
23
+ const [tab, setTab] = useState<Tab>("sessions");
24
+ const [createOpen, setCreateOpen] = useState(false);
25
+ const [addNodeTarget, setAddNodeTarget] = useState<string | null>(null);
26
+
27
+ const { sessions: activeSessions, natsAvailable } = useCollabSessions(
28
+ "recruiting,active",
29
+ 5000
30
+ );
31
+ const { sessions: recentSessions } = useCollabSessions(
32
+ "converged,completed,aborted",
33
+ 30000
34
+ );
35
+ const { clusters } = useClusters();
36
+ const { tasks } = useTasks();
37
+
38
+ // Build a map from task_id to Task for cross-referencing sessions
39
+ const taskMap = useMemo(() => {
40
+ const map = new Map<string, typeof tasks[0]>();
41
+ for (const t of tasks) map.set(t.id, t);
42
+ return map;
43
+ }, [tasks]);
44
+
45
+ const handleDispatchFromCluster = (clusterId: string) => {
46
+ setTab("dispatch");
47
+ };
48
+
49
+ return (
50
+ <div className="flex flex-col h-full">
51
+ {/* Header */}
52
+ <header className="flex items-center justify-between border-b border-border px-6 py-4">
53
+ <div className="flex items-center gap-4">
54
+ <h1 className="text-lg font-semibold">Cowork</h1>
55
+ {activeSessions.length > 0 && (
56
+ <span className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs text-green-400">
57
+ <span className="h-1.5 w-1.5 rounded-full bg-green-400 animate-pulse" />
58
+ {activeSessions.length} active
59
+ </span>
60
+ )}
61
+ <span className="rounded-full bg-accent px-2.5 py-0.5 text-xs text-muted-foreground">
62
+ {clusters.length} clusters
63
+ </span>
64
+ </div>
65
+
66
+ {/* Tab bar */}
67
+ <div className="flex rounded-lg bg-accent/50 p-0.5">
68
+ {(["sessions", "clusters", "dispatch"] as Tab[]).map((t) => (
69
+ <button
70
+ key={t}
71
+ onClick={() => setTab(t)}
72
+ className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
73
+ tab === t
74
+ ? "bg-card text-foreground shadow-sm"
75
+ : "text-muted-foreground hover:text-foreground"
76
+ }`}
77
+ >
78
+ {t.charAt(0).toUpperCase() + t.slice(1)}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ </header>
83
+
84
+ {/* Content */}
85
+ <main className="flex-1 overflow-y-auto p-6 space-y-6">
86
+ {/* Sessions tab */}
87
+ {tab === "sessions" && (
88
+ <>
89
+ {!natsAvailable && (
90
+ <div className="flex items-center gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5 px-4 py-3 text-xs text-yellow-400">
91
+ <AlertTriangle className="h-4 w-4" />
92
+ NATS unavailable — session data may be stale
93
+ </div>
94
+ )}
95
+
96
+ {activeSessions.length > 0 && (
97
+ <section>
98
+ <h2 className="text-sm font-medium text-muted-foreground mb-3">
99
+ Active Sessions
100
+ </h2>
101
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
102
+ {activeSessions.map((s) => (
103
+ <SessionCard key={s.session_id} session={s} linkedTask={taskMap.get(s.task_id)} />
104
+ ))}
105
+ </div>
106
+ </section>
107
+ )}
108
+
109
+ {recentSessions.length > 0 && (
110
+ <section>
111
+ <h2 className="text-sm font-medium text-muted-foreground mb-3">
112
+ Recent Sessions
113
+ </h2>
114
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
115
+ {recentSessions.slice(0, 8).map((s) => (
116
+ <SessionCard key={s.session_id} session={s} linkedTask={taskMap.get(s.task_id)} />
117
+ ))}
118
+ </div>
119
+ </section>
120
+ )}
121
+
122
+ {activeSessions.length === 0 && recentSessions.length === 0 && (
123
+ <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
124
+ <Users2 className="h-8 w-8 mb-3 opacity-30" />
125
+ <p className="text-sm">No collab sessions yet</p>
126
+ <p className="text-xs mt-1">
127
+ Dispatch a task from the Dispatch tab to start one
128
+ </p>
129
+ </div>
130
+ )}
131
+ </>
132
+ )}
133
+
134
+ {/* Clusters tab */}
135
+ {tab === "clusters" && (
136
+ <>
137
+ <div className="flex items-center justify-between">
138
+ <h2 className="text-sm font-medium text-muted-foreground">
139
+ Clusters
140
+ </h2>
141
+ <button
142
+ onClick={() => setCreateOpen(true)}
143
+ className="flex items-center gap-1 rounded px-3 py-1.5 text-xs bg-accent-foreground text-accent transition-colors hover:opacity-90"
144
+ >
145
+ <Plus className="h-3.5 w-3.5" />
146
+ New Cluster
147
+ </button>
148
+ </div>
149
+
150
+ {clusters.length > 0 ? (
151
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
152
+ {clusters.map((c) => (
153
+ <ClusterCard
154
+ key={c.id}
155
+ cluster={c}
156
+ onDispatch={handleDispatchFromCluster}
157
+ onAddNode={(id) => setAddNodeTarget(id)}
158
+ />
159
+ ))}
160
+ </div>
161
+ ) : (
162
+ <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
163
+ <Users2 className="h-8 w-8 mb-3 opacity-30" />
164
+ <p className="text-sm">No clusters yet</p>
165
+ <p className="text-xs mt-1">
166
+ Create one to organize your mesh nodes into teams
167
+ </p>
168
+ </div>
169
+ )}
170
+
171
+ <CreateClusterDialog
172
+ open={createOpen}
173
+ onClose={() => setCreateOpen(false)}
174
+ />
175
+
176
+ {/* Add node mini-dialog */}
177
+ {addNodeTarget && (
178
+ <AddNodeDialog
179
+ clusterId={addNodeTarget}
180
+ existingNodeIds={
181
+ clusters
182
+ .find((c) => c.id === addNodeTarget)
183
+ ?.members.map((m) => m.nodeId) ?? []
184
+ }
185
+ onClose={() => setAddNodeTarget(null)}
186
+ />
187
+ )}
188
+ </>
189
+ )}
190
+
191
+ {/* Dispatch tab */}
192
+ {tab === "dispatch" && <DispatchForm />}
193
+ </main>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function AddNodeDialog({
199
+ clusterId,
200
+ existingNodeIds,
201
+ onClose,
202
+ }: {
203
+ clusterId: string;
204
+ existingNodeIds: string[];
205
+ onClose: () => void;
206
+ }) {
207
+ const { data } = useSWR<{
208
+ nodes: Array<{ nodeId: string; status: string }>;
209
+ }>("/api/mesh/nodes", fetcher);
210
+ const meshNodes = (data?.nodes ?? []).filter(
211
+ (n) => !existingNodeIds.includes(n.nodeId)
212
+ );
213
+
214
+ const [selectedRole, setSelectedRole] = useState("worker");
215
+
216
+ const handleAdd = async (nodeId: string) => {
217
+ await addClusterMember(clusterId, nodeId, selectedRole);
218
+ onClose();
219
+ };
220
+
221
+ return (
222
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
223
+ <div className="w-full max-w-sm rounded-lg border border-border bg-card shadow-xl">
224
+ <div className="flex items-center justify-between border-b border-border px-4 py-3">
225
+ <span className="text-sm font-semibold">Add Node</span>
226
+ <button
227
+ onClick={onClose}
228
+ className="text-muted-foreground hover:text-foreground text-xs"
229
+ >
230
+ close
231
+ </button>
232
+ </div>
233
+ <div className="px-4 py-3 space-y-2">
234
+ <div className="flex items-center gap-2 mb-3">
235
+ <span className="text-xs text-muted-foreground">Role:</span>
236
+ <RolePicker value={selectedRole} onChange={setSelectedRole} />
237
+ </div>
238
+ {meshNodes.map((node) => (
239
+ <button
240
+ key={node.nodeId}
241
+ onClick={() => handleAdd(node.nodeId)}
242
+ className="flex w-full items-center gap-2 rounded px-3 py-2 text-xs hover:bg-accent/50 transition-colors"
243
+ >
244
+ <span
245
+ className={`h-2 w-2 rounded-full ${
246
+ node.status === "online" ? "bg-green-400" : "bg-zinc-600"
247
+ }`}
248
+ />
249
+ <span className="font-mono">{node.nodeId}</span>
250
+ </button>
251
+ ))}
252
+ {meshNodes.length === 0 && (
253
+ <p className="text-xs text-muted-foreground py-2">
254
+ All nodes already in this cluster
255
+ </p>
256
+ )}
257
+ </div>
258
+ </div>
259
+ </div>
260
+ );
261
+ }
@@ -0,0 +1,385 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { RefreshCw, CheckCircle, XCircle, Play, AlertTriangle, Clock } from "lucide-react";
5
+
6
+ // ── Types ──
7
+
8
+ interface DiagnosticData {
9
+ tasks: {
10
+ total: number;
11
+ byStatus: Array<{ status: string; count: number }>;
12
+ byType: Array<{ type: string; count: number }>;
13
+ byKanban: Array<{ kanban_column: string; count: number }>;
14
+ };
15
+ memory: { docs: number; items: number; entities: number; relations: number };
16
+ cowork: { clusters: number; members: number };
17
+ sync: { exists: boolean; taskCount: number; roundTripOk: boolean; diffLines: number };
18
+ nats: string;
19
+ workspace: boolean;
20
+ }
21
+
22
+ interface TestResult {
23
+ suite: string;
24
+ name: string;
25
+ status: "pass" | "fail" | "skip";
26
+ detail?: string;
27
+ durationMs: number;
28
+ }
29
+
30
+ interface TestReport {
31
+ summary: { total: number; passed: number; failed: number; skipped: number; durationMs: number };
32
+ results: TestResult[];
33
+ timestamp: string;
34
+ }
35
+
36
+ // ── Helpers ──
37
+
38
+ function StatusIcon({ ok }: { ok: boolean }) {
39
+ return ok ? <CheckCircle className="h-4 w-4 text-green-400" /> : <XCircle className="h-4 w-4 text-red-400" />;
40
+ }
41
+
42
+ function StatRow({ label, value, warn }: { label: string; value: string | number; warn?: boolean }) {
43
+ return (
44
+ <div className="flex items-center justify-between py-1.5 border-b border-border/30">
45
+ <span className="text-xs text-muted-foreground">{label}</span>
46
+ <span className={`text-xs font-mono ${warn ? "text-yellow-400" : "text-foreground"}`}>{value}</span>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ // ── Tabs ──
52
+
53
+ type Tab = "health" | "tests" | "logs";
54
+
55
+ // ── Page ──
56
+
57
+ export default function DiagnosticsPage() {
58
+ const [tab, setTab] = useState<Tab>("tests");
59
+ const [data, setData] = useState<DiagnosticData | null>(null);
60
+ const [healthLoading, setHealthLoading] = useState(false);
61
+ const [healthError, setHealthError] = useState<string | null>(null);
62
+
63
+ const [testReport, setTestReport] = useState<TestReport | null>(null);
64
+ const [testRunning, setTestRunning] = useState(false);
65
+ const [testError, setTestError] = useState<string | null>(null);
66
+
67
+ const [logs, setLogs] = useState<string[]>([]);
68
+
69
+ const log = useCallback((msg: string) => {
70
+ const ts = new Date().toLocaleTimeString("en-CA", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
71
+ setLogs((prev) => [`[${ts}] ${msg}`, ...prev].slice(0, 500));
72
+ }, []);
73
+
74
+ // ── Health fetch ──
75
+
76
+ const fetchHealth = async () => {
77
+ setHealthLoading(true);
78
+ setHealthError(null);
79
+ log("Fetching system health...");
80
+ try {
81
+ const res = await fetch("/api/diagnostics");
82
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
83
+ const d = await res.json();
84
+ setData(d);
85
+ log(`Health OK — ${d.tasks.total} tasks, ${d.memory.docs} docs, NATS: ${d.nats}`);
86
+ } catch (e) {
87
+ const msg = (e as Error).message;
88
+ setHealthError(msg);
89
+ log(`Health FAIL: ${msg}`);
90
+ } finally {
91
+ setHealthLoading(false);
92
+ }
93
+ };
94
+
95
+ // ── Test runner ──
96
+
97
+ const runTests = async () => {
98
+ setTestRunning(true);
99
+ setTestError(null);
100
+ setTestReport(null);
101
+ log("Starting test suite...");
102
+ try {
103
+ const res = await fetch("/api/diagnostics/test-runner", { method: "POST" });
104
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
105
+ const report: TestReport = await res.json();
106
+ setTestReport(report);
107
+ const { passed, failed, total, durationMs } = report.summary;
108
+ log(`Tests complete: ${passed}/${total} passed, ${failed} failed (${durationMs}ms)`);
109
+ if (failed > 0) {
110
+ for (const r of report.results.filter((r) => r.status === "fail")) {
111
+ log(` FAIL: [${r.suite}] ${r.name} — ${r.detail || "no detail"}`);
112
+ }
113
+ }
114
+ } catch (e) {
115
+ const msg = (e as Error).message;
116
+ setTestError(msg);
117
+ log(`Test runner error: ${msg}`);
118
+ } finally {
119
+ setTestRunning(false);
120
+ }
121
+ };
122
+
123
+ useEffect(() => {
124
+ fetchHealth();
125
+ }, []);
126
+
127
+ // ── Group test results by suite ──
128
+ const suites = testReport
129
+ ? Array.from(
130
+ testReport.results.reduce((map, r) => {
131
+ if (!map.has(r.suite)) map.set(r.suite, []);
132
+ map.get(r.suite)!.push(r);
133
+ return map;
134
+ }, new Map<string, TestResult[]>())
135
+ )
136
+ : [];
137
+
138
+ return (
139
+ <div className="h-full flex flex-col">
140
+ {/* Header */}
141
+ <header className="border-b border-border px-6 py-4 flex items-center justify-between shrink-0">
142
+ <div>
143
+ <h1 className="text-xl font-bold text-foreground">Diagnostics</h1>
144
+ <p className="text-xs text-muted-foreground mt-0.5">System health, integration tests, and logs</p>
145
+ </div>
146
+ <div className="flex items-center gap-2">
147
+ {/* Tab switcher */}
148
+ <div className="flex rounded-lg bg-accent/50 p-0.5 mr-3">
149
+ {(["tests", "health", "logs"] as Tab[]).map((t) => (
150
+ <button
151
+ key={t}
152
+ onClick={() => setTab(t)}
153
+ className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
154
+ tab === t ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
155
+ }`}
156
+ >
157
+ {t === "tests" ? "Tests" : t === "health" ? "Health" : "Logs"}
158
+ </button>
159
+ ))}
160
+ </div>
161
+ <button
162
+ onClick={runTests}
163
+ disabled={testRunning}
164
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
165
+ >
166
+ {testRunning ? (
167
+ <RefreshCw className="h-3.5 w-3.5 animate-spin" />
168
+ ) : (
169
+ <Play className="h-3.5 w-3.5" />
170
+ )}
171
+ {testRunning ? "Running..." : "Run All Tests"}
172
+ </button>
173
+ </div>
174
+ </header>
175
+
176
+ <div className="flex-1 overflow-y-auto p-6">
177
+ {/* ═══ TESTS TAB ═══ */}
178
+ {tab === "tests" && (
179
+ <div className="space-y-4">
180
+ {/* Summary bar */}
181
+ {testReport && (
182
+ <div className={`flex items-center gap-4 rounded-lg border px-4 py-3 ${
183
+ testReport.summary.failed === 0 ? "border-green-500/30 bg-green-500/5" : "border-red-500/30 bg-red-500/5"
184
+ }`}>
185
+ {testReport.summary.failed === 0 ? (
186
+ <CheckCircle className="h-5 w-5 text-green-400" />
187
+ ) : (
188
+ <XCircle className="h-5 w-5 text-red-400" />
189
+ )}
190
+ <div className="flex-1">
191
+ <span className="text-sm font-semibold text-foreground">
192
+ {testReport.summary.failed === 0 ? "All Tests Passed" : `${testReport.summary.failed} Test${testReport.summary.failed > 1 ? "s" : ""} Failed`}
193
+ </span>
194
+ <span className="text-xs text-muted-foreground ml-3">
195
+ {testReport.summary.passed} passed, {testReport.summary.failed} failed, {testReport.summary.total} total
196
+ </span>
197
+ </div>
198
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
199
+ <Clock className="h-3 w-3" />
200
+ {testReport.summary.durationMs}ms
201
+ </div>
202
+ </div>
203
+ )}
204
+
205
+ {testError && (
206
+ <div className="rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3 text-xs text-red-400">
207
+ Test runner error: {testError}
208
+ </div>
209
+ )}
210
+
211
+ {!testReport && !testRunning && !testError && (
212
+ <div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-3">
213
+ <Play className="h-8 w-8 opacity-30" />
214
+ <p className="text-sm">Click "Run All Tests" to start the integration test suite</p>
215
+ <p className="text-xs text-muted-foreground/60 max-w-md text-center">
216
+ Tests exercise: status mapping, task CRUD, done-gate enforcement, markdown parser round-trip,
217
+ DB sync, cowork clusters, memory/graph tables, schema integrity, NATS connectivity, and workspace health.
218
+ </p>
219
+ </div>
220
+ )}
221
+
222
+ {/* Results by suite */}
223
+ {suites.map(([suite, tests]) => {
224
+ const allPass = tests.every((t) => t.status === "pass");
225
+ const failCount = tests.filter((t) => t.status === "fail").length;
226
+ return (
227
+ <div key={suite} className="rounded-lg border border-border bg-card">
228
+ <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
229
+ {allPass ? (
230
+ <CheckCircle className="h-3.5 w-3.5 text-green-400" />
231
+ ) : (
232
+ <XCircle className="h-3.5 w-3.5 text-red-400" />
233
+ )}
234
+ <span className="text-xs font-semibold text-foreground">{suite}</span>
235
+ <span className="text-[10px] text-muted-foreground ml-auto">
236
+ {tests.length - failCount}/{tests.length}
237
+ </span>
238
+ </div>
239
+ <div className="divide-y divide-border/30">
240
+ {tests.map((t, i) => (
241
+ <div key={i} className="flex items-center gap-2 px-4 py-1.5">
242
+ {t.status === "pass" ? (
243
+ <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />
244
+ ) : t.status === "fail" ? (
245
+ <span className="h-1.5 w-1.5 rounded-full bg-red-400 shrink-0" />
246
+ ) : (
247
+ <span className="h-1.5 w-1.5 rounded-full bg-zinc-500 shrink-0" />
248
+ )}
249
+ <span className={`text-xs flex-1 ${t.status === "fail" ? "text-red-400" : "text-foreground"}`}>
250
+ {t.name}
251
+ </span>
252
+ {t.detail && (
253
+ <span className="text-[10px] text-muted-foreground font-mono truncate max-w-[200px]">
254
+ {t.detail}
255
+ </span>
256
+ )}
257
+ <span className="text-[10px] text-muted-foreground/50 tabular-nums w-10 text-right shrink-0">
258
+ {t.durationMs}ms
259
+ </span>
260
+ </div>
261
+ ))}
262
+ </div>
263
+ </div>
264
+ );
265
+ })}
266
+ </div>
267
+ )}
268
+
269
+ {/* ═══ HEALTH TAB ═══ */}
270
+ {tab === "health" && (
271
+ <div className="space-y-4">
272
+ <div className="flex justify-end">
273
+ <button
274
+ onClick={fetchHealth}
275
+ disabled={healthLoading}
276
+ className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50"
277
+ >
278
+ <RefreshCw className={`h-3.5 w-3.5 ${healthLoading ? "animate-spin" : ""}`} />
279
+ Refresh
280
+ </button>
281
+ </div>
282
+
283
+ {healthError && (
284
+ <div className="rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3 text-xs text-red-400">
285
+ {healthError}
286
+ </div>
287
+ )}
288
+
289
+ {data && (
290
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
291
+ {/* System Status */}
292
+ <div className="rounded-lg border border-border bg-card p-4">
293
+ <h2 className="text-sm font-semibold text-foreground mb-3">System Status</h2>
294
+ <div className="space-y-2">
295
+ <div className="flex items-center gap-2"><StatusIcon ok={data.workspace} /><span className="text-xs">Workspace</span></div>
296
+ <div className="flex items-center gap-2"><StatusIcon ok={data.nats === "connected"} /><span className="text-xs">NATS: {data.nats}</span></div>
297
+ <div className="flex items-center gap-2"><StatusIcon ok={data.sync.exists} /><span className="text-xs">active-tasks.md: {data.sync.exists ? "found" : "missing"}</span></div>
298
+ <div className="flex items-center gap-2">
299
+ <StatusIcon ok={data.sync.roundTripOk} />
300
+ <span className="text-xs">
301
+ Parser round-trip: {data.sync.roundTripOk ? "OK" : "DRIFT"}
302
+ {data.sync.diffLines > 0 && ` (${data.sync.diffLines} line diff)`}
303
+ </span>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ {/* Tasks */}
309
+ <div className="rounded-lg border border-border bg-card p-4">
310
+ <h2 className="text-sm font-semibold text-foreground mb-3">Tasks ({data.tasks.total})</h2>
311
+ <div className="mb-3">
312
+ <p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">By Status</p>
313
+ {data.tasks.byStatus.map((s) => <StatRow key={s.status} label={s.status || "(empty)"} value={s.count} />)}
314
+ </div>
315
+ <div className="mb-3">
316
+ <p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">By Kanban</p>
317
+ {data.tasks.byKanban.map((k) => <StatRow key={k.kanban_column} label={k.kanban_column || "(empty)"} value={k.count} />)}
318
+ </div>
319
+ <div>
320
+ <p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">By Type</p>
321
+ {data.tasks.byType.map((t) => <StatRow key={t.type} label={t.type || "task"} value={t.count} />)}
322
+ </div>
323
+ </div>
324
+
325
+ {/* Memory */}
326
+ <div className="rounded-lg border border-border bg-card p-4">
327
+ <h2 className="text-sm font-semibold text-foreground mb-3">Memory</h2>
328
+ <StatRow label="Indexed docs" value={data.memory.docs} warn={data.memory.docs === 0} />
329
+ <StatRow label="Active items" value={data.memory.items} />
330
+ <StatRow label="Entities (graph)" value={data.memory.entities} warn={data.memory.entities === 0} />
331
+ <StatRow label="Active relations" value={data.memory.relations} />
332
+ </div>
333
+
334
+ {/* Cowork */}
335
+ <div className="rounded-lg border border-border bg-card p-4">
336
+ <h2 className="text-sm font-semibold text-foreground mb-3">Cowork</h2>
337
+ <StatRow label="Active clusters" value={data.cowork.clusters} />
338
+ <StatRow label="Cluster members" value={data.cowork.members} />
339
+ </div>
340
+
341
+ {/* Sync */}
342
+ <div className="rounded-lg border border-border bg-card p-4">
343
+ <h2 className="text-sm font-semibold text-foreground mb-3">Markdown Sync</h2>
344
+ <StatRow label="Tasks in markdown" value={data.sync.taskCount} />
345
+ <StatRow label="Round-trip" value={data.sync.roundTripOk ? "PASS" : "FAIL"} warn={!data.sync.roundTripOk} />
346
+ <StatRow label="Line diff" value={`${data.sync.diffLines}`} warn={data.sync.diffLines > 5} />
347
+ </div>
348
+ </div>
349
+ )}
350
+ </div>
351
+ )}
352
+
353
+ {/* ═══ LOGS TAB ═══ */}
354
+ {tab === "logs" && (
355
+ <div className="space-y-3">
356
+ <div className="flex items-center justify-between">
357
+ <span className="text-xs text-muted-foreground">{logs.length} entries</span>
358
+ <button
359
+ onClick={() => setLogs([])}
360
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
361
+ >
362
+ Clear
363
+ </button>
364
+ </div>
365
+ <div className="rounded-lg border border-border bg-[#0a0a0f] p-4 font-mono text-[11px] leading-5 max-h-[600px] overflow-y-auto">
366
+ {logs.length === 0 ? (
367
+ <span className="text-muted-foreground/50">No logs yet. Run tests or refresh health to see output.</span>
368
+ ) : (
369
+ logs.map((line, i) => (
370
+ <div key={i} className={`${
371
+ line.includes("FAIL") ? "text-red-400" :
372
+ line.includes("OK") || line.includes("passed") ? "text-green-400" :
373
+ "text-muted-foreground"
374
+ }`}>
375
+ {line}
376
+ </div>
377
+ ))
378
+ )}
379
+ </div>
380
+ </div>
381
+ )}
382
+ </div>
383
+ </div>
384
+ );
385
+ }
@@ -211,6 +211,32 @@ export default function GraphPage() {
211
211
  );
212
212
  }
213
213
 
214
+ // Empty state: no entities seeded
215
+ if (!loading && graphData && graphData.nodes.length === 0) {
216
+ return (
217
+ <div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-3">
218
+ <p className="text-sm">No entities in the knowledge graph</p>
219
+ <p className="text-xs text-muted-foreground/60 max-w-xs text-center">
220
+ Seed known entities to populate the graph with people, projects, tools, and their relationships.
221
+ </p>
222
+ <button
223
+ onClick={async () => {
224
+ setLoading(true);
225
+ try {
226
+ await fetch("/api/memory/graph", { method: "POST" });
227
+ window.location.reload();
228
+ } catch {
229
+ setLoading(false);
230
+ }
231
+ }}
232
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors"
233
+ >
234
+ Seed Known Entities
235
+ </button>
236
+ </div>
237
+ );
238
+ }
239
+
214
240
  return (
215
241
  <div className="h-full flex flex-col">
216
242
  <header className="border-b border-border px-6 py-3 flex items-center justify-between shrink-0">