openclaw-node-harness 2.0.4 → 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.
- package/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +401 -12
- package/bin/mesh-bridge.js +66 -1
- package/bin/mesh-task-daemon.js +816 -26
- package/bin/mesh.js +403 -1
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +293 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +9 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +528 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +245 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +301 -1
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-tasks.js +67 -0
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +320 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +458 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +58 -0
- package/mission-control/src/lib/db/index.ts +69 -0
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- 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">
|