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
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"dev": "next dev",
|
|
10
10
|
"build": "next build",
|
|
11
11
|
"start": "next start",
|
|
12
|
-
"lint": "eslint"
|
|
12
|
+
"lint": "eslint",
|
|
13
|
+
"test": "vitest run src/lib/__tests__/",
|
|
14
|
+
"test:unit": "vitest run src/lib/__tests__/",
|
|
15
|
+
"test:watch": "vitest src/lib/__tests__/"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
18
|
"@andresaya/edge-tts": "^1.8.0",
|
|
@@ -44,6 +47,7 @@
|
|
|
44
47
|
"eslint": "^9",
|
|
45
48
|
"eslint-config-next": "16.1.6",
|
|
46
49
|
"tailwindcss": "^4.2.1",
|
|
47
|
-
"typescript": "5.9.3"
|
|
50
|
+
"typescript": "5.9.3",
|
|
51
|
+
"vitest": "^4.1.0"
|
|
48
52
|
}
|
|
49
53
|
}
|
|
@@ -2,8 +2,8 @@ const Database = require('better-sqlite3');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
|
|
5
|
-
const DB_PATH = path.join(__dirname, '..', 'data', 'mission-control.db');
|
|
6
|
-
const db = new Database(
|
|
5
|
+
const DB_PATH = path.join(__dirname, '..', 'Users', 'moltymac', '.openclaw', 'workspace', 'projects', 'mission-control', 'data', 'mission-control.db');
|
|
6
|
+
const db = new Database('/Users/moltymac/.openclaw/workspace/projects/mission-control/data/mission-control.db');
|
|
7
7
|
|
|
8
8
|
const rows = db.prepare(`
|
|
9
9
|
SELECT id, title, type, scheduled_date, start_date, end_date, status, description, parent_id
|
|
@@ -96,7 +96,7 @@ lines.push('---');
|
|
|
96
96
|
lines.push(`**Total: ${taskNum} tasks across ${sortedDates.length} days**`);
|
|
97
97
|
|
|
98
98
|
const output = lines.join('\n');
|
|
99
|
-
const outPath =
|
|
99
|
+
const outPath = '/Users/moltymac/.openclaw/workspace/projects/arcane/ARCANE_RAPTURE_TASK_CHRONOLOGY.md';
|
|
100
100
|
fs.writeFileSync(outPath, output);
|
|
101
101
|
console.log(`Written to ${outPath}`);
|
|
102
102
|
console.log(`${taskNum} tasks, ${sortedDates.length} dates`);
|
|
@@ -307,22 +307,6 @@ function main() {
|
|
|
307
307
|
db.pragma("journal_mode = WAL");
|
|
308
308
|
db.pragma("foreign_keys = ON");
|
|
309
309
|
|
|
310
|
-
// Guard: verify MC runtime migrations have run (import needs columns beyond the base drizzle schema)
|
|
311
|
-
const cols = db.prepare("PRAGMA table_info(tasks)").all().map(c => c.name);
|
|
312
|
-
const required = ["type", "parent_id", "project", "start_date", "end_date", "color", "description", "scheduled_date", "needs_approval"];
|
|
313
|
-
const missing = required.filter(c => !cols.includes(c));
|
|
314
|
-
if (missing.length > 0) {
|
|
315
|
-
console.error(`DB schema missing columns: ${missing.join(", ")}`);
|
|
316
|
-
console.error("Start Mission Control first (npm run dev) to run migrations, then re-run this script.");
|
|
317
|
-
process.exit(1);
|
|
318
|
-
}
|
|
319
|
-
// Also ensure dependencies table exists
|
|
320
|
-
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dependencies'").get();
|
|
321
|
-
if (!tables) {
|
|
322
|
-
console.error("DB missing 'dependencies' table. Start Mission Control first to run migrations.");
|
|
323
|
-
process.exit(1);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
310
|
const importAll = db.transaction(() => {
|
|
327
311
|
// 1. Clean existing ARCANE pipeline entries
|
|
328
312
|
const importIds = new Set([
|
|
@@ -70,21 +70,6 @@ function main() {
|
|
|
70
70
|
db.pragma("journal_mode = WAL");
|
|
71
71
|
db.pragma("foreign_keys = ON");
|
|
72
72
|
|
|
73
|
-
// Guard: verify MC runtime migrations have run
|
|
74
|
-
const cols = db.prepare("PRAGMA table_info(tasks)").all().map(c => c.name);
|
|
75
|
-
const required = ["type", "parent_id", "project", "start_date", "end_date", "color", "description", "needs_approval"];
|
|
76
|
-
const missing = required.filter(c => !cols.includes(c));
|
|
77
|
-
if (missing.length > 0) {
|
|
78
|
-
console.error(`DB schema missing columns: ${missing.join(", ")}`);
|
|
79
|
-
console.error("Start Mission Control first (npm run dev) to run migrations, then re-run this script.");
|
|
80
|
-
process.exit(1);
|
|
81
|
-
}
|
|
82
|
-
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dependencies'").get();
|
|
83
|
-
if (!tables) {
|
|
84
|
-
console.error("DB missing 'dependencies' table. Start Mission Control first to run migrations.");
|
|
85
|
-
process.exit(1);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
73
|
// Wrap everything in a transaction
|
|
89
74
|
const importAll = db.transaction(() => {
|
|
90
75
|
// 1. Clean existing ARCANE pipeline entries (safe re-run)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { clusterMembers } from "@/lib/db/schema";
|
|
4
|
+
import { eq, and } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/cowork/clusters/[id]/members
|
|
10
|
+
*
|
|
11
|
+
* Add a member to a cluster. 409 if already exists.
|
|
12
|
+
*/
|
|
13
|
+
export async function POST(
|
|
14
|
+
request: NextRequest,
|
|
15
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
16
|
+
) {
|
|
17
|
+
const { id: clusterId } = await params;
|
|
18
|
+
const { nodeId, role } = await request.json();
|
|
19
|
+
|
|
20
|
+
if (!nodeId) {
|
|
21
|
+
return NextResponse.json({ error: "nodeId required" }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const db = getDb();
|
|
25
|
+
|
|
26
|
+
// Check unique constraint
|
|
27
|
+
const existing = db
|
|
28
|
+
.select()
|
|
29
|
+
.from(clusterMembers)
|
|
30
|
+
.where(
|
|
31
|
+
and(
|
|
32
|
+
eq(clusterMembers.clusterId, clusterId),
|
|
33
|
+
eq(clusterMembers.nodeId, nodeId)
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
.get();
|
|
37
|
+
|
|
38
|
+
if (existing) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: "Node already in cluster" },
|
|
41
|
+
{ status: 409 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
db.insert(clusterMembers)
|
|
46
|
+
.values({ clusterId, nodeId, role: role || "worker" })
|
|
47
|
+
.run();
|
|
48
|
+
|
|
49
|
+
return NextResponse.json({ ok: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PATCH /api/cowork/clusters/[id]/members
|
|
54
|
+
*
|
|
55
|
+
* Update role for a member. Body: { nodeId, role }
|
|
56
|
+
*/
|
|
57
|
+
export async function PATCH(
|
|
58
|
+
request: NextRequest,
|
|
59
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
60
|
+
) {
|
|
61
|
+
const { id: clusterId } = await params;
|
|
62
|
+
const { nodeId, role } = await request.json();
|
|
63
|
+
|
|
64
|
+
if (!nodeId || !role) {
|
|
65
|
+
return NextResponse.json(
|
|
66
|
+
{ error: "nodeId and role required" },
|
|
67
|
+
{ status: 400 }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const db = getDb();
|
|
72
|
+
|
|
73
|
+
db.update(clusterMembers)
|
|
74
|
+
.set({ role })
|
|
75
|
+
.where(
|
|
76
|
+
and(
|
|
77
|
+
eq(clusterMembers.clusterId, clusterId),
|
|
78
|
+
eq(clusterMembers.nodeId, nodeId)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
.run();
|
|
82
|
+
|
|
83
|
+
return NextResponse.json({ ok: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* DELETE /api/cowork/clusters/[id]/members?nodeId=X
|
|
88
|
+
*
|
|
89
|
+
* Remove a member from a cluster.
|
|
90
|
+
*/
|
|
91
|
+
export async function DELETE(
|
|
92
|
+
request: NextRequest,
|
|
93
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
94
|
+
) {
|
|
95
|
+
const { id: clusterId } = await params;
|
|
96
|
+
const nodeId = request.nextUrl.searchParams.get("nodeId");
|
|
97
|
+
|
|
98
|
+
if (!nodeId) {
|
|
99
|
+
return NextResponse.json(
|
|
100
|
+
{ error: "nodeId query param required" },
|
|
101
|
+
{ status: 400 }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const db = getDb();
|
|
106
|
+
|
|
107
|
+
db.delete(clusterMembers)
|
|
108
|
+
.where(
|
|
109
|
+
and(
|
|
110
|
+
eq(clusterMembers.clusterId, clusterId),
|
|
111
|
+
eq(clusterMembers.nodeId, nodeId)
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
.run();
|
|
115
|
+
|
|
116
|
+
return NextResponse.json({ ok: true });
|
|
117
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { clusters, clusterMembers } from "@/lib/db/schema";
|
|
4
|
+
import { eq, and } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/cowork/clusters/[id]
|
|
10
|
+
*/
|
|
11
|
+
export async function GET(
|
|
12
|
+
_request: NextRequest,
|
|
13
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
14
|
+
) {
|
|
15
|
+
const { id } = await params;
|
|
16
|
+
const db = getDb();
|
|
17
|
+
|
|
18
|
+
const cluster = db.select().from(clusters).where(eq(clusters.id, id)).get();
|
|
19
|
+
if (!cluster) {
|
|
20
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const members = db
|
|
24
|
+
.select()
|
|
25
|
+
.from(clusterMembers)
|
|
26
|
+
.where(eq(clusterMembers.clusterId, id))
|
|
27
|
+
.all();
|
|
28
|
+
|
|
29
|
+
return NextResponse.json({ ...cluster, members });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* PATCH /api/cowork/clusters/[id]
|
|
34
|
+
*
|
|
35
|
+
* Partial update — only touches fields present in request body.
|
|
36
|
+
*/
|
|
37
|
+
export async function PATCH(
|
|
38
|
+
request: NextRequest,
|
|
39
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
40
|
+
) {
|
|
41
|
+
const { id } = await params;
|
|
42
|
+
const body = await request.json();
|
|
43
|
+
const db = getDb();
|
|
44
|
+
|
|
45
|
+
const existing = db.select().from(clusters).where(eq(clusters.id, id)).get();
|
|
46
|
+
if (!existing) {
|
|
47
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
|
51
|
+
if (body.name !== undefined) updates.name = body.name;
|
|
52
|
+
if (body.description !== undefined) updates.description = body.description;
|
|
53
|
+
if (body.color !== undefined) updates.color = body.color;
|
|
54
|
+
if (body.defaultMode !== undefined) updates.defaultMode = body.defaultMode;
|
|
55
|
+
if (body.defaultConvergence !== undefined)
|
|
56
|
+
updates.defaultConvergence = body.defaultConvergence;
|
|
57
|
+
if (body.convergenceThreshold !== undefined)
|
|
58
|
+
updates.convergenceThreshold = body.convergenceThreshold;
|
|
59
|
+
if (body.maxRounds !== undefined) updates.maxRounds = body.maxRounds;
|
|
60
|
+
|
|
61
|
+
db.update(clusters).set(updates).where(eq(clusters.id, id)).run();
|
|
62
|
+
|
|
63
|
+
return NextResponse.json({ ok: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* DELETE /api/cowork/clusters/[id]
|
|
68
|
+
*
|
|
69
|
+
* Soft-delete: sets status to "archived".
|
|
70
|
+
*/
|
|
71
|
+
export async function DELETE(
|
|
72
|
+
_request: NextRequest,
|
|
73
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
74
|
+
) {
|
|
75
|
+
const { id } = await params;
|
|
76
|
+
const db = getDb();
|
|
77
|
+
|
|
78
|
+
db.update(clusters)
|
|
79
|
+
.set({ status: "archived", updatedAt: new Date().toISOString() })
|
|
80
|
+
.where(eq(clusters.id, id))
|
|
81
|
+
.run();
|
|
82
|
+
|
|
83
|
+
return NextResponse.json({ ok: true });
|
|
84
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { clusters, clusterMembers } from "@/lib/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { getHealthKv, sc } from "@/lib/nats";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET /api/cowork/clusters
|
|
11
|
+
*
|
|
12
|
+
* List all active clusters with members. Enriches members with live node status.
|
|
13
|
+
*/
|
|
14
|
+
export async function GET() {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
const kv = await getHealthKv();
|
|
17
|
+
|
|
18
|
+
const allClusters = db
|
|
19
|
+
.select()
|
|
20
|
+
.from(clusters)
|
|
21
|
+
.where(eq(clusters.status, "active"))
|
|
22
|
+
.all();
|
|
23
|
+
|
|
24
|
+
const allMembers = db.select().from(clusterMembers).all();
|
|
25
|
+
|
|
26
|
+
// Build node status cache from NATS KV
|
|
27
|
+
const nodeStatus = new Map<string, string>();
|
|
28
|
+
if (kv) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const nodeIds = [...new Set(allMembers.map((m) => m.nodeId))];
|
|
31
|
+
for (const nodeId of nodeIds) {
|
|
32
|
+
try {
|
|
33
|
+
const entry = await kv.get(nodeId);
|
|
34
|
+
if (entry && entry.value) {
|
|
35
|
+
const health = JSON.parse(sc.decode(entry.value));
|
|
36
|
+
const reportedAt = health.reportedAt
|
|
37
|
+
? new Date(health.reportedAt).getTime()
|
|
38
|
+
: now;
|
|
39
|
+
const stale = (now - reportedAt) / 1000;
|
|
40
|
+
nodeStatus.set(
|
|
41
|
+
nodeId,
|
|
42
|
+
stale < 45 ? "online" : stale < 120 ? "degraded" : "offline"
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
nodeStatus.set(nodeId, "offline");
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
nodeStatus.set(nodeId, "offline");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = allClusters.map((c) => ({
|
|
54
|
+
...c,
|
|
55
|
+
members: allMembers
|
|
56
|
+
.filter((m) => m.clusterId === c.id)
|
|
57
|
+
.map((m) => ({
|
|
58
|
+
id: m.id,
|
|
59
|
+
nodeId: m.nodeId,
|
|
60
|
+
role: m.role,
|
|
61
|
+
nodeStatus: nodeStatus.get(m.nodeId) ?? "unknown",
|
|
62
|
+
createdAt: m.createdAt,
|
|
63
|
+
})),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
return NextResponse.json({ clusters: result });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* POST /api/cowork/clusters
|
|
71
|
+
*
|
|
72
|
+
* Create a new cluster with members.
|
|
73
|
+
*/
|
|
74
|
+
export async function POST(request: NextRequest) {
|
|
75
|
+
const body = await request.json();
|
|
76
|
+
const {
|
|
77
|
+
name,
|
|
78
|
+
description,
|
|
79
|
+
color,
|
|
80
|
+
defaultMode,
|
|
81
|
+
defaultConvergence,
|
|
82
|
+
convergenceThreshold,
|
|
83
|
+
maxRounds,
|
|
84
|
+
members,
|
|
85
|
+
} = body;
|
|
86
|
+
|
|
87
|
+
if (!name) {
|
|
88
|
+
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Auto-slug from name
|
|
92
|
+
const id =
|
|
93
|
+
body.id ||
|
|
94
|
+
name
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
97
|
+
.replace(/^-|-$/g, "");
|
|
98
|
+
|
|
99
|
+
const db = getDb();
|
|
100
|
+
|
|
101
|
+
// Check duplicate
|
|
102
|
+
const existing = db.select().from(clusters).where(eq(clusters.id, id)).get();
|
|
103
|
+
if (existing) {
|
|
104
|
+
return NextResponse.json(
|
|
105
|
+
{ error: `Cluster "${id}" already exists` },
|
|
106
|
+
{ status: 409 }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
|
|
112
|
+
db.insert(clusters)
|
|
113
|
+
.values({
|
|
114
|
+
id,
|
|
115
|
+
name,
|
|
116
|
+
description: description || null,
|
|
117
|
+
color: color || null,
|
|
118
|
+
defaultMode: defaultMode || "parallel",
|
|
119
|
+
defaultConvergence: defaultConvergence || "unanimous",
|
|
120
|
+
convergenceThreshold: convergenceThreshold ?? 66,
|
|
121
|
+
maxRounds: maxRounds ?? 5,
|
|
122
|
+
status: "active",
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
})
|
|
125
|
+
.run();
|
|
126
|
+
|
|
127
|
+
// Insert members
|
|
128
|
+
if (Array.isArray(members)) {
|
|
129
|
+
for (const m of members) {
|
|
130
|
+
db.insert(clusterMembers)
|
|
131
|
+
.values({
|
|
132
|
+
clusterId: id,
|
|
133
|
+
nodeId: m.nodeId,
|
|
134
|
+
role: m.role || "worker",
|
|
135
|
+
})
|
|
136
|
+
.run();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return NextResponse.json({ id, name });
|
|
141
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getNats, sc } from "@/lib/nats";
|
|
3
|
+
import { getDb } from "@/lib/db";
|
|
4
|
+
import { tasks, clusterMembers } from "@/lib/db/schema";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import { generateTaskId } from "@/lib/task-id";
|
|
7
|
+
import { statusToKanban } from "@/lib/parsers/task-markdown";
|
|
8
|
+
import { syncTasksToMarkdown } from "@/lib/sync/tasks";
|
|
9
|
+
import { logActivity } from "@/lib/activity";
|
|
10
|
+
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/cowork/dispatch
|
|
15
|
+
*
|
|
16
|
+
* Create a Kanban task with collaboration spec. The mesh-bridge picks it up
|
|
17
|
+
* from active-tasks.md and submits to NATS — no direct NATS call here.
|
|
18
|
+
*
|
|
19
|
+
* A wake signal is published to reduce bridge poll latency to ~1s.
|
|
20
|
+
*/
|
|
21
|
+
export async function POST(request: NextRequest) {
|
|
22
|
+
const body = await request.json();
|
|
23
|
+
const {
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
clusterId,
|
|
27
|
+
nodes: manualNodes,
|
|
28
|
+
mode = "parallel",
|
|
29
|
+
convergence = { type: "unanimous" },
|
|
30
|
+
scopeStrategy = "shared",
|
|
31
|
+
budgetMinutes = 30,
|
|
32
|
+
maxRounds = 5,
|
|
33
|
+
metric,
|
|
34
|
+
scope = [],
|
|
35
|
+
} = body;
|
|
36
|
+
|
|
37
|
+
if (!title) {
|
|
38
|
+
return NextResponse.json({ error: "title is required" }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve nodes from cluster or manual list
|
|
42
|
+
const db = getDb();
|
|
43
|
+
let nodeIds: string[] = [];
|
|
44
|
+
|
|
45
|
+
if (clusterId) {
|
|
46
|
+
const members = db
|
|
47
|
+
.select()
|
|
48
|
+
.from(clusterMembers)
|
|
49
|
+
.where(eq(clusterMembers.clusterId, clusterId))
|
|
50
|
+
.all();
|
|
51
|
+
nodeIds = members.map((m) => m.nodeId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(manualNodes) && manualNodes.length > 0) {
|
|
55
|
+
const manualIds = manualNodes.map((n: any) => n.node_id || n.nodeId);
|
|
56
|
+
nodeIds = [...new Set([...nodeIds, ...manualIds])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (nodeIds.length < 2) {
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ error: "Collab requires at least 2 nodes" },
|
|
62
|
+
{ status: 400 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build collaboration spec (bridge reads this from markdown)
|
|
67
|
+
const collabSpec = {
|
|
68
|
+
mode,
|
|
69
|
+
min_nodes: Math.min(nodeIds.length, 2),
|
|
70
|
+
max_nodes: nodeIds.length,
|
|
71
|
+
join_window_s: 30,
|
|
72
|
+
max_rounds: maxRounds,
|
|
73
|
+
convergence: {
|
|
74
|
+
type: convergence.type || "unanimous",
|
|
75
|
+
threshold: (convergence.threshold ?? 66) / 100,
|
|
76
|
+
metric: convergence.metric || null,
|
|
77
|
+
min_quorum: Math.min(nodeIds.length, 2),
|
|
78
|
+
},
|
|
79
|
+
scope_strategy: scopeStrategy,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const taskId = generateTaskId(db, now);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
db.insert(tasks)
|
|
87
|
+
.values({
|
|
88
|
+
id: taskId,
|
|
89
|
+
title,
|
|
90
|
+
status: "queued",
|
|
91
|
+
kanbanColumn: statusToKanban("queued"),
|
|
92
|
+
description: description || null,
|
|
93
|
+
execution: "mesh",
|
|
94
|
+
needsApproval: 0,
|
|
95
|
+
collaboration: JSON.stringify(collabSpec),
|
|
96
|
+
preferredNodes: JSON.stringify(nodeIds),
|
|
97
|
+
clusterId: clusterId || null,
|
|
98
|
+
metric: metric || null,
|
|
99
|
+
budgetMinutes,
|
|
100
|
+
scope: Array.isArray(scope) && scope.length ? JSON.stringify(scope) : null,
|
|
101
|
+
updatedAt: now.toISOString(),
|
|
102
|
+
createdAt: now.toISOString(),
|
|
103
|
+
})
|
|
104
|
+
.run();
|
|
105
|
+
|
|
106
|
+
// Sync to markdown so bridge can pick it up
|
|
107
|
+
syncTasksToMarkdown(db);
|
|
108
|
+
|
|
109
|
+
logActivity("task_created", `Collab dispatch: ${title}`, taskId);
|
|
110
|
+
|
|
111
|
+
// Wake the bridge for immediate pickup (~1s vs ~15s poll)
|
|
112
|
+
const nc = await getNats();
|
|
113
|
+
if (nc) {
|
|
114
|
+
nc.publish("mesh.bridge.wake", sc.encode(""));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return NextResponse.json({
|
|
118
|
+
taskId,
|
|
119
|
+
clusterId: clusterId || null,
|
|
120
|
+
nodesAssigned: nodeIds,
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return NextResponse.json(
|
|
124
|
+
{ error: (err as Error).message },
|
|
125
|
+
{ status: 500 }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getNats, sc } from "@/lib/nats";
|
|
2
|
+
|
|
3
|
+
export const dynamic = "force-dynamic";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SSE endpoint for collab-specific events.
|
|
7
|
+
* Subscribes to mesh.events.collab.> and forwards to connected browsers.
|
|
8
|
+
*/
|
|
9
|
+
export async function GET() {
|
|
10
|
+
const nc = await getNats();
|
|
11
|
+
if (!nc) {
|
|
12
|
+
return new Response("NATS unavailable", { status: 503 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sub = nc.subscribe("mesh.events.collab.>");
|
|
16
|
+
let closed = false;
|
|
17
|
+
|
|
18
|
+
const stream = new ReadableStream({
|
|
19
|
+
async start(controller) {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
23
|
+
|
|
24
|
+
const keepalive = setInterval(() => {
|
|
25
|
+
if (!closed) {
|
|
26
|
+
try {
|
|
27
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
28
|
+
} catch {
|
|
29
|
+
// controller closed
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}, 30000);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
for await (const msg of sub) {
|
|
36
|
+
if (closed) break;
|
|
37
|
+
try {
|
|
38
|
+
const data = sc.decode(msg.data);
|
|
39
|
+
const eventType = msg.subject.replace("mesh.events.collab.", "");
|
|
40
|
+
controller.enqueue(
|
|
41
|
+
encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`)
|
|
42
|
+
);
|
|
43
|
+
} catch {
|
|
44
|
+
// skip malformed
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
clearInterval(keepalive);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
cancel() {
|
|
52
|
+
closed = true;
|
|
53
|
+
sub.unsubscribe();
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return new Response(stream, {
|
|
58
|
+
headers: {
|
|
59
|
+
"Content-Type": "text/event-stream",
|
|
60
|
+
"Cache-Control": "no-cache, no-transform",
|
|
61
|
+
Connection: "keep-alive",
|
|
62
|
+
"X-Accel-Buffering": "no",
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|