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.
- package/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +603 -81
- package/bin/mesh-bridge.js +340 -11
- package/bin/mesh-deploy-listener.js +119 -97
- package/bin/mesh-deploy.js +8 -0
- package/bin/mesh-task-daemon.js +1005 -40
- package/bin/mesh.js +423 -6
- 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 +300 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +59 -10
- 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 +354 -4
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-registry.js +11 -2
- 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,251 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { createCluster } from "@/lib/hooks";
|
|
6
|
+
import { RolePicker } from "./role-picker";
|
|
7
|
+
import useSWR from "swr";
|
|
8
|
+
|
|
9
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
10
|
+
|
|
11
|
+
const PRESET_COLORS = [
|
|
12
|
+
"#6366f1", "#8b5cf6", "#06b6d4", "#10b981",
|
|
13
|
+
"#f59e0b", "#ef4444", "#ec4899", "#64748b",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const MODES = ["parallel", "sequential", "review"] as const;
|
|
17
|
+
const CONVERGENCES = ["unanimous", "majority", "coordinator"] as const;
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
open: boolean;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CreateClusterDialog({ open, onClose }: Props) {
|
|
25
|
+
const [name, setName] = useState("");
|
|
26
|
+
const [description, setDescription] = useState("");
|
|
27
|
+
const [color, setColor] = useState(PRESET_COLORS[0]);
|
|
28
|
+
const [mode, setMode] = useState<string>("parallel");
|
|
29
|
+
const [convergence, setConvergence] = useState<string>("unanimous");
|
|
30
|
+
const [maxRounds, setMaxRounds] = useState(5);
|
|
31
|
+
const [selectedNodes, setSelectedNodes] = useState<
|
|
32
|
+
Array<{ nodeId: string; role: string }>
|
|
33
|
+
>([]);
|
|
34
|
+
const [saving, setSaving] = useState(false);
|
|
35
|
+
|
|
36
|
+
const { data } = useSWR<{ nodes: Array<{ nodeId: string; status: string }> }>(
|
|
37
|
+
"/api/mesh/nodes",
|
|
38
|
+
fetcher
|
|
39
|
+
);
|
|
40
|
+
const meshNodes = data?.nodes ?? [];
|
|
41
|
+
|
|
42
|
+
const toggleNode = (nodeId: string) => {
|
|
43
|
+
setSelectedNodes((prev) => {
|
|
44
|
+
const exists = prev.find((n) => n.nodeId === nodeId);
|
|
45
|
+
if (exists) return prev.filter((n) => n.nodeId !== nodeId);
|
|
46
|
+
return [...prev, { nodeId, role: "worker" }];
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const updateNodeRole = (nodeId: string, role: string) => {
|
|
51
|
+
setSelectedNodes((prev) =>
|
|
52
|
+
prev.map((n) => (n.nodeId === nodeId ? { ...n, role } : n))
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleSave = async () => {
|
|
57
|
+
if (!name.trim()) return;
|
|
58
|
+
setSaving(true);
|
|
59
|
+
try {
|
|
60
|
+
await createCluster({
|
|
61
|
+
name: name.trim(),
|
|
62
|
+
description: description.trim() || undefined,
|
|
63
|
+
color,
|
|
64
|
+
defaultMode: mode,
|
|
65
|
+
defaultConvergence: convergence,
|
|
66
|
+
maxRounds,
|
|
67
|
+
members: selectedNodes,
|
|
68
|
+
});
|
|
69
|
+
onClose();
|
|
70
|
+
setName("");
|
|
71
|
+
setDescription("");
|
|
72
|
+
setSelectedNodes([]);
|
|
73
|
+
} finally {
|
|
74
|
+
setSaving(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (!open) return null;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
82
|
+
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
|
83
|
+
{/* Header */}
|
|
84
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
85
|
+
<span className="text-sm font-semibold">New Cluster</span>
|
|
86
|
+
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
|
87
|
+
<X className="h-4 w-4" />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="space-y-4 px-4 py-4">
|
|
92
|
+
{/* Name */}
|
|
93
|
+
<input
|
|
94
|
+
type="text"
|
|
95
|
+
placeholder="Cluster name..."
|
|
96
|
+
value={name}
|
|
97
|
+
onChange={(e) => setName(e.target.value)}
|
|
98
|
+
className="w-full rounded border border-border bg-transparent px-3 py-2 text-sm outline-none focus:border-accent-foreground"
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
{/* Description */}
|
|
102
|
+
<textarea
|
|
103
|
+
placeholder="Description (optional)"
|
|
104
|
+
value={description}
|
|
105
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
106
|
+
rows={2}
|
|
107
|
+
className="w-full rounded border border-border bg-transparent px-3 py-2 text-xs outline-none focus:border-accent-foreground resize-none"
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
{/* Color */}
|
|
111
|
+
<div className="flex items-center gap-2">
|
|
112
|
+
<span className="text-xs text-muted-foreground w-12">Color</span>
|
|
113
|
+
<div className="flex gap-2">
|
|
114
|
+
{PRESET_COLORS.map((c) => (
|
|
115
|
+
<button
|
|
116
|
+
key={c}
|
|
117
|
+
onClick={() => setColor(c)}
|
|
118
|
+
className={`h-5 w-5 rounded-full border-2 transition-colors ${
|
|
119
|
+
color === c ? "border-white" : "border-transparent"
|
|
120
|
+
}`}
|
|
121
|
+
style={{ backgroundColor: c }}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Mode */}
|
|
128
|
+
<div className="flex items-center gap-2">
|
|
129
|
+
<span className="text-xs text-muted-foreground w-12">Mode</span>
|
|
130
|
+
<div className="flex gap-1">
|
|
131
|
+
{MODES.map((m) => (
|
|
132
|
+
<button
|
|
133
|
+
key={m}
|
|
134
|
+
onClick={() => setMode(m)}
|
|
135
|
+
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
136
|
+
mode === m
|
|
137
|
+
? "bg-accent text-accent-foreground"
|
|
138
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
{m}
|
|
142
|
+
</button>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Convergence */}
|
|
148
|
+
<div className="flex items-center gap-2">
|
|
149
|
+
<span className="text-xs text-muted-foreground w-12">Conv.</span>
|
|
150
|
+
<div className="flex gap-1">
|
|
151
|
+
{CONVERGENCES.map((c) => (
|
|
152
|
+
<button
|
|
153
|
+
key={c}
|
|
154
|
+
onClick={() => setConvergence(c)}
|
|
155
|
+
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
156
|
+
convergence === c
|
|
157
|
+
? "bg-accent text-accent-foreground"
|
|
158
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
159
|
+
}`}
|
|
160
|
+
>
|
|
161
|
+
{c}
|
|
162
|
+
</button>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Max rounds */}
|
|
168
|
+
<div className="flex items-center gap-2">
|
|
169
|
+
<span className="text-xs text-muted-foreground w-12">Rounds</span>
|
|
170
|
+
<input
|
|
171
|
+
type="number"
|
|
172
|
+
min={1}
|
|
173
|
+
max={20}
|
|
174
|
+
value={maxRounds}
|
|
175
|
+
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
|
176
|
+
className="w-16 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Node picker */}
|
|
181
|
+
<div>
|
|
182
|
+
<span className="text-xs text-muted-foreground">Nodes</span>
|
|
183
|
+
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
|
184
|
+
{meshNodes.map((node) => {
|
|
185
|
+
const selected = selectedNodes.find(
|
|
186
|
+
(n) => n.nodeId === node.nodeId
|
|
187
|
+
);
|
|
188
|
+
return (
|
|
189
|
+
<div
|
|
190
|
+
key={node.nodeId}
|
|
191
|
+
className={`flex items-center justify-between rounded px-3 py-2 text-xs cursor-pointer transition-colors ${
|
|
192
|
+
selected
|
|
193
|
+
? "bg-accent/40 border border-accent-foreground/20"
|
|
194
|
+
: "bg-accent/10 hover:bg-accent/20"
|
|
195
|
+
}`}
|
|
196
|
+
onClick={() => toggleNode(node.nodeId)}
|
|
197
|
+
>
|
|
198
|
+
<div className="flex items-center gap-2">
|
|
199
|
+
<span
|
|
200
|
+
className={`h-2 w-2 rounded-full ${
|
|
201
|
+
node.status === "online"
|
|
202
|
+
? "bg-green-400"
|
|
203
|
+
: node.status === "degraded"
|
|
204
|
+
? "bg-yellow-400"
|
|
205
|
+
: "bg-zinc-600"
|
|
206
|
+
}`}
|
|
207
|
+
/>
|
|
208
|
+
<span className="font-mono">{node.nodeId}</span>
|
|
209
|
+
</div>
|
|
210
|
+
{selected && (
|
|
211
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
212
|
+
<RolePicker
|
|
213
|
+
value={selected.role}
|
|
214
|
+
onChange={(role) =>
|
|
215
|
+
updateNodeRole(node.nodeId, role)
|
|
216
|
+
}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
{meshNodes.length === 0 && (
|
|
224
|
+
<p className="text-xs text-muted-foreground py-2">
|
|
225
|
+
No mesh nodes available
|
|
226
|
+
</p>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Footer */}
|
|
233
|
+
<div className="flex justify-end gap-2 border-t border-border px-4 py-3">
|
|
234
|
+
<button
|
|
235
|
+
onClick={onClose}
|
|
236
|
+
className="rounded px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
237
|
+
>
|
|
238
|
+
Cancel
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
onClick={handleSave}
|
|
242
|
+
disabled={!name.trim() || saving}
|
|
243
|
+
className="rounded bg-accent-foreground px-3 py-1.5 text-xs text-accent disabled:opacity-50 transition-colors"
|
|
244
|
+
>
|
|
245
|
+
{saving ? "Creating..." : "Create Cluster"}
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import useSWR from "swr";
|
|
5
|
+
import {
|
|
6
|
+
dispatchCollabTask,
|
|
7
|
+
useClusters,
|
|
8
|
+
type ClusterView,
|
|
9
|
+
} from "@/lib/hooks";
|
|
10
|
+
import { RolePicker, RoleBadge } from "./role-picker";
|
|
11
|
+
|
|
12
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
13
|
+
|
|
14
|
+
const MODES = ["parallel", "sequential", "review"] as const;
|
|
15
|
+
const CONVERGENCES = ["unanimous", "majority", "coordinator"] as const;
|
|
16
|
+
const SCOPE_STRATEGIES = ["shared", "leader_only", "partitioned"] as const;
|
|
17
|
+
|
|
18
|
+
export function DispatchForm() {
|
|
19
|
+
const { clusters } = useClusters();
|
|
20
|
+
const { data: nodesData } = useSWR<{
|
|
21
|
+
nodes: Array<{ nodeId: string; status: string }>;
|
|
22
|
+
}>("/api/mesh/nodes", fetcher);
|
|
23
|
+
const meshNodes = nodesData?.nodes ?? [];
|
|
24
|
+
|
|
25
|
+
// Form state
|
|
26
|
+
const [title, setTitle] = useState("");
|
|
27
|
+
const [description, setDescription] = useState("");
|
|
28
|
+
const [nodeSource, setNodeSource] = useState<"cluster" | "manual">(
|
|
29
|
+
"cluster"
|
|
30
|
+
);
|
|
31
|
+
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(
|
|
32
|
+
null
|
|
33
|
+
);
|
|
34
|
+
const [manualNodes, setManualNodes] = useState<
|
|
35
|
+
Array<{ nodeId: string; role: string }>
|
|
36
|
+
>([]);
|
|
37
|
+
const [mode, setMode] = useState<string>("parallel");
|
|
38
|
+
const [convergenceType, setConvergenceType] = useState<string>("unanimous");
|
|
39
|
+
const [threshold, setThreshold] = useState(66);
|
|
40
|
+
const [scopeStrategy, setScopeStrategy] = useState<string>("shared");
|
|
41
|
+
const [maxRounds, setMaxRounds] = useState(5);
|
|
42
|
+
const [budgetMinutes, setBudgetMinutes] = useState(30);
|
|
43
|
+
const [metric, setMetric] = useState("");
|
|
44
|
+
const [scopeText, setScopeText] = useState("");
|
|
45
|
+
const [submitting, setSubmitting] = useState(false);
|
|
46
|
+
const [result, setResult] = useState<{
|
|
47
|
+
ok: boolean;
|
|
48
|
+
message: string;
|
|
49
|
+
taskId: string | null;
|
|
50
|
+
} | null>(null);
|
|
51
|
+
|
|
52
|
+
const selectedCluster = clusters.find((c) => c.id === selectedClusterId);
|
|
53
|
+
|
|
54
|
+
const toggleManualNode = (nodeId: string) => {
|
|
55
|
+
setManualNodes((prev) => {
|
|
56
|
+
const exists = prev.find((n) => n.nodeId === nodeId);
|
|
57
|
+
if (exists) return prev.filter((n) => n.nodeId !== nodeId);
|
|
58
|
+
return [...prev, { nodeId, role: "worker" }];
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleSubmit = async () => {
|
|
63
|
+
if (!title.trim()) return;
|
|
64
|
+
setSubmitting(true);
|
|
65
|
+
setResult(null);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const scope = scopeText
|
|
69
|
+
.split("\n")
|
|
70
|
+
.map((s) => s.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
|
|
73
|
+
const data = await dispatchCollabTask({
|
|
74
|
+
title: title.trim(),
|
|
75
|
+
description: description.trim() || undefined,
|
|
76
|
+
clusterId:
|
|
77
|
+
nodeSource === "cluster" ? selectedClusterId ?? undefined : undefined,
|
|
78
|
+
nodes: nodeSource === "manual" ? manualNodes : undefined,
|
|
79
|
+
mode,
|
|
80
|
+
convergence: {
|
|
81
|
+
type: convergenceType,
|
|
82
|
+
threshold: convergenceType === "majority" ? threshold : undefined,
|
|
83
|
+
},
|
|
84
|
+
scopeStrategy,
|
|
85
|
+
budgetMinutes,
|
|
86
|
+
maxRounds,
|
|
87
|
+
metric: metric.trim() || undefined,
|
|
88
|
+
scope: scope.length > 0 ? scope : undefined,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (data.error) {
|
|
92
|
+
setResult({ ok: false, message: data.error, taskId: null });
|
|
93
|
+
} else {
|
|
94
|
+
setResult({
|
|
95
|
+
ok: true,
|
|
96
|
+
message: `Task ${data.taskId} dispatched to ${data.nodesAssigned?.length ?? 0} nodes`,
|
|
97
|
+
taskId: data.taskId,
|
|
98
|
+
});
|
|
99
|
+
// Reset form
|
|
100
|
+
setTitle("");
|
|
101
|
+
setDescription("");
|
|
102
|
+
setMetric("");
|
|
103
|
+
setScopeText("");
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
setResult({ ok: false, message: (err as Error).message, taskId: null });
|
|
107
|
+
} finally {
|
|
108
|
+
setSubmitting(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="max-w-2xl space-y-5">
|
|
114
|
+
{/* Title */}
|
|
115
|
+
<div>
|
|
116
|
+
<label className="text-xs text-muted-foreground">Title *</label>
|
|
117
|
+
<input
|
|
118
|
+
type="text"
|
|
119
|
+
value={title}
|
|
120
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
121
|
+
placeholder="Collab task title..."
|
|
122
|
+
className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-sm outline-none focus:border-accent-foreground"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Description */}
|
|
127
|
+
<div>
|
|
128
|
+
<label className="text-xs text-muted-foreground">Description</label>
|
|
129
|
+
<textarea
|
|
130
|
+
value={description}
|
|
131
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
132
|
+
placeholder="What should the cluster work on?"
|
|
133
|
+
rows={3}
|
|
134
|
+
className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-xs outline-none focus:border-accent-foreground resize-none"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* Node source toggle */}
|
|
139
|
+
<div>
|
|
140
|
+
<label className="text-xs text-muted-foreground">Nodes</label>
|
|
141
|
+
<div className="mt-1 flex gap-1">
|
|
142
|
+
<button
|
|
143
|
+
onClick={() => setNodeSource("cluster")}
|
|
144
|
+
className={`rounded px-3 py-1.5 text-xs transition-colors ${
|
|
145
|
+
nodeSource === "cluster"
|
|
146
|
+
? "bg-accent text-accent-foreground"
|
|
147
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
From Cluster
|
|
151
|
+
</button>
|
|
152
|
+
<button
|
|
153
|
+
onClick={() => setNodeSource("manual")}
|
|
154
|
+
className={`rounded px-3 py-1.5 text-xs transition-colors ${
|
|
155
|
+
nodeSource === "manual"
|
|
156
|
+
? "bg-accent text-accent-foreground"
|
|
157
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
Manual
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{nodeSource === "cluster" && (
|
|
165
|
+
<div className="mt-2 space-y-2">
|
|
166
|
+
{clusters.map((c) => (
|
|
167
|
+
<div
|
|
168
|
+
key={c.id}
|
|
169
|
+
onClick={() => setSelectedClusterId(c.id)}
|
|
170
|
+
className={`flex items-center gap-3 rounded px-3 py-2 text-xs cursor-pointer transition-colors ${
|
|
171
|
+
selectedClusterId === c.id
|
|
172
|
+
? "bg-accent/40 border border-accent-foreground/20"
|
|
173
|
+
: "bg-accent/10 hover:bg-accent/20"
|
|
174
|
+
}`}
|
|
175
|
+
>
|
|
176
|
+
{c.color && (
|
|
177
|
+
<span
|
|
178
|
+
className="h-2.5 w-2.5 rounded-full"
|
|
179
|
+
style={{ backgroundColor: c.color }}
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
<span className="font-medium">{c.name}</span>
|
|
183
|
+
<span className="text-muted-foreground">
|
|
184
|
+
{c.members.length} nodes
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
))}
|
|
188
|
+
{selectedCluster && (
|
|
189
|
+
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
190
|
+
{selectedCluster.members.map((m) => (
|
|
191
|
+
<span
|
|
192
|
+
key={m.nodeId}
|
|
193
|
+
className="inline-flex items-center gap-1 rounded bg-accent/30 px-2 py-0.5 text-[10px]"
|
|
194
|
+
>
|
|
195
|
+
<span className="font-mono">{m.nodeId.split("-")[0]}</span>
|
|
196
|
+
<RoleBadge role={m.role} />
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
{clusters.length === 0 && (
|
|
202
|
+
<p className="text-xs text-muted-foreground">
|
|
203
|
+
No clusters created yet. Switch to Clusters tab to create one.
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{nodeSource === "manual" && (
|
|
210
|
+
<div className="mt-2 space-y-1 max-h-40 overflow-y-auto">
|
|
211
|
+
{meshNodes.map((node) => {
|
|
212
|
+
const selected = manualNodes.find(
|
|
213
|
+
(n) => n.nodeId === node.nodeId
|
|
214
|
+
);
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
key={node.nodeId}
|
|
218
|
+
onClick={() => toggleManualNode(node.nodeId)}
|
|
219
|
+
className={`flex items-center justify-between rounded px-3 py-2 text-xs cursor-pointer transition-colors ${
|
|
220
|
+
selected
|
|
221
|
+
? "bg-accent/40 border border-accent-foreground/20"
|
|
222
|
+
: "bg-accent/10 hover:bg-accent/20"
|
|
223
|
+
}`}
|
|
224
|
+
>
|
|
225
|
+
<div className="flex items-center gap-2">
|
|
226
|
+
<span
|
|
227
|
+
className={`h-2 w-2 rounded-full ${
|
|
228
|
+
node.status === "online"
|
|
229
|
+
? "bg-green-400"
|
|
230
|
+
: "bg-zinc-600"
|
|
231
|
+
}`}
|
|
232
|
+
/>
|
|
233
|
+
<span className="font-mono">{node.nodeId}</span>
|
|
234
|
+
</div>
|
|
235
|
+
{selected && (
|
|
236
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
237
|
+
<RolePicker
|
|
238
|
+
value={selected.role}
|
|
239
|
+
onChange={(role) =>
|
|
240
|
+
setManualNodes((prev) =>
|
|
241
|
+
prev.map((n) =>
|
|
242
|
+
n.nodeId === node.nodeId ? { ...n, role } : n
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* Settings grid */}
|
|
257
|
+
<div className="grid grid-cols-2 gap-4">
|
|
258
|
+
{/* Mode */}
|
|
259
|
+
<div>
|
|
260
|
+
<label className="text-xs text-muted-foreground">Mode</label>
|
|
261
|
+
<div className="mt-1 flex gap-1">
|
|
262
|
+
{MODES.map((m) => (
|
|
263
|
+
<button
|
|
264
|
+
key={m}
|
|
265
|
+
onClick={() => setMode(m)}
|
|
266
|
+
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
267
|
+
mode === m
|
|
268
|
+
? "bg-accent text-accent-foreground"
|
|
269
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
{m}
|
|
273
|
+
</button>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Convergence */}
|
|
279
|
+
<div>
|
|
280
|
+
<label className="text-xs text-muted-foreground">Convergence</label>
|
|
281
|
+
<div className="mt-1 flex gap-1">
|
|
282
|
+
{CONVERGENCES.map((c) => (
|
|
283
|
+
<button
|
|
284
|
+
key={c}
|
|
285
|
+
onClick={() => setConvergenceType(c)}
|
|
286
|
+
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
287
|
+
convergenceType === c
|
|
288
|
+
? "bg-accent text-accent-foreground"
|
|
289
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
290
|
+
}`}
|
|
291
|
+
>
|
|
292
|
+
{c}
|
|
293
|
+
</button>
|
|
294
|
+
))}
|
|
295
|
+
</div>
|
|
296
|
+
{convergenceType === "majority" && (
|
|
297
|
+
<div className="mt-2 flex items-center gap-2">
|
|
298
|
+
<input
|
|
299
|
+
type="range"
|
|
300
|
+
min={50}
|
|
301
|
+
max={100}
|
|
302
|
+
value={threshold}
|
|
303
|
+
onChange={(e) => setThreshold(Number(e.target.value))}
|
|
304
|
+
className="flex-1"
|
|
305
|
+
/>
|
|
306
|
+
<span className="text-xs text-muted-foreground w-8">
|
|
307
|
+
{threshold}%
|
|
308
|
+
</span>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{/* Scope strategy */}
|
|
314
|
+
<div>
|
|
315
|
+
<label className="text-xs text-muted-foreground">
|
|
316
|
+
Scope Strategy
|
|
317
|
+
</label>
|
|
318
|
+
<div className="mt-1 flex gap-1">
|
|
319
|
+
{SCOPE_STRATEGIES.map((s) => (
|
|
320
|
+
<button
|
|
321
|
+
key={s}
|
|
322
|
+
onClick={() => setScopeStrategy(s)}
|
|
323
|
+
className={`rounded px-2 py-1 text-[10px] transition-colors ${
|
|
324
|
+
scopeStrategy === s
|
|
325
|
+
? "bg-accent text-accent-foreground"
|
|
326
|
+
: "text-muted-foreground hover:bg-accent/50"
|
|
327
|
+
}`}
|
|
328
|
+
>
|
|
329
|
+
{s.replace("_", " ")}
|
|
330
|
+
</button>
|
|
331
|
+
))}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{/* Max rounds */}
|
|
336
|
+
<div>
|
|
337
|
+
<label className="text-xs text-muted-foreground">Max Rounds</label>
|
|
338
|
+
<input
|
|
339
|
+
type="number"
|
|
340
|
+
min={1}
|
|
341
|
+
max={20}
|
|
342
|
+
value={maxRounds}
|
|
343
|
+
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
|
344
|
+
className="mt-1 w-20 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{/* Budget */}
|
|
349
|
+
<div>
|
|
350
|
+
<label className="text-xs text-muted-foreground">
|
|
351
|
+
Budget (minutes)
|
|
352
|
+
</label>
|
|
353
|
+
<input
|
|
354
|
+
type="number"
|
|
355
|
+
min={5}
|
|
356
|
+
max={120}
|
|
357
|
+
value={budgetMinutes}
|
|
358
|
+
onChange={(e) => setBudgetMinutes(Number(e.target.value))}
|
|
359
|
+
className="mt-1 w-20 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
|
360
|
+
/>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* Metric */}
|
|
365
|
+
<div>
|
|
366
|
+
<label className="text-xs text-muted-foreground">
|
|
367
|
+
Metric (shell cmd, exits 0 = pass)
|
|
368
|
+
</label>
|
|
369
|
+
<input
|
|
370
|
+
type="text"
|
|
371
|
+
value={metric}
|
|
372
|
+
onChange={(e) => setMetric(e.target.value)}
|
|
373
|
+
placeholder='e.g. npm test -- --grep "auth"'
|
|
374
|
+
className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-xs font-mono outline-none focus:border-accent-foreground"
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
{/* Scope */}
|
|
379
|
+
<div>
|
|
380
|
+
<label className="text-xs text-muted-foreground">
|
|
381
|
+
Scope (file paths, one per line)
|
|
382
|
+
</label>
|
|
383
|
+
<textarea
|
|
384
|
+
value={scopeText}
|
|
385
|
+
onChange={(e) => setScopeText(e.target.value)}
|
|
386
|
+
placeholder={"lib/auth/\nsrc/components/login/"}
|
|
387
|
+
rows={3}
|
|
388
|
+
className="mt-1 w-full rounded border border-border bg-transparent px-3 py-2 text-xs font-mono outline-none focus:border-accent-foreground resize-none"
|
|
389
|
+
/>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* Result feedback */}
|
|
393
|
+
{result && (
|
|
394
|
+
<div
|
|
395
|
+
className={`rounded px-3 py-2 text-xs ${
|
|
396
|
+
result.ok
|
|
397
|
+
? "bg-green-500/10 text-green-400 border border-green-500/20"
|
|
398
|
+
: "bg-red-500/10 text-red-400 border border-red-500/20"
|
|
399
|
+
}`}
|
|
400
|
+
>
|
|
401
|
+
{result.message}
|
|
402
|
+
{result.ok && result.taskId && (
|
|
403
|
+
<a
|
|
404
|
+
href={`/?task=${result.taskId}`}
|
|
405
|
+
className="ml-2 underline underline-offset-2 decoration-dotted hover:text-green-300"
|
|
406
|
+
>
|
|
407
|
+
View on Kanban
|
|
408
|
+
</a>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{/* Submit */}
|
|
414
|
+
<button
|
|
415
|
+
onClick={handleSubmit}
|
|
416
|
+
disabled={!title.trim() || submitting}
|
|
417
|
+
className="rounded bg-accent-foreground px-4 py-2 text-sm text-accent disabled:opacity-50 transition-colors hover:opacity-90"
|
|
418
|
+
>
|
|
419
|
+
{submitting ? "Dispatching..." : "Dispatch Collab Task"}
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|