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.
Files changed (115) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +401 -12
  4. package/bin/mesh-bridge.js +66 -1
  5. package/bin/mesh-task-daemon.js +816 -26
  6. package/bin/mesh.js +403 -1
  7. package/config/claude-settings.json +95 -0
  8. package/config/daemon.json.template +2 -1
  9. package/config/git-hooks/pre-commit +13 -0
  10. package/config/git-hooks/pre-push +12 -0
  11. package/config/harness-rules.json +174 -0
  12. package/config/plan-templates/team-bugfix.yaml +52 -0
  13. package/config/plan-templates/team-deploy.yaml +50 -0
  14. package/config/plan-templates/team-feature.yaml +71 -0
  15. package/config/roles/qa-engineer.yaml +36 -0
  16. package/config/roles/solidity-dev.yaml +51 -0
  17. package/config/roles/tech-architect.yaml +36 -0
  18. package/config/rules/framework/solidity.md +22 -0
  19. package/config/rules/framework/typescript.md +21 -0
  20. package/config/rules/framework/unity.md +21 -0
  21. package/config/rules/universal/design-docs.md +18 -0
  22. package/config/rules/universal/git-hygiene.md +18 -0
  23. package/config/rules/universal/security.md +19 -0
  24. package/config/rules/universal/test-standards.md +19 -0
  25. package/identity/DELEGATION.md +6 -6
  26. package/install.sh +293 -8
  27. package/lib/circling-parser.js +119 -0
  28. package/lib/hyperagent-store.mjs +652 -0
  29. package/lib/kanban-io.js +9 -0
  30. package/lib/mcp-knowledge/bench.mjs +118 -0
  31. package/lib/mcp-knowledge/core.mjs +528 -0
  32. package/lib/mcp-knowledge/package.json +25 -0
  33. package/lib/mcp-knowledge/server.mjs +245 -0
  34. package/lib/mcp-knowledge/test.mjs +802 -0
  35. package/lib/memory-budget.mjs +261 -0
  36. package/lib/mesh-collab.js +301 -1
  37. package/lib/mesh-harness.js +427 -0
  38. package/lib/mesh-plans.js +13 -5
  39. package/lib/mesh-tasks.js +67 -0
  40. package/lib/plan-templates.js +226 -0
  41. package/lib/pre-compression-flush.mjs +320 -0
  42. package/lib/role-loader.js +292 -0
  43. package/lib/rule-loader.js +358 -0
  44. package/lib/session-store.mjs +458 -0
  45. package/lib/transcript-parser.mjs +292 -0
  46. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  47. package/mission-control/drizzle.config.ts +1 -4
  48. package/mission-control/package-lock.json +1571 -83
  49. package/mission-control/package.json +6 -2
  50. package/mission-control/scripts/gen-chronology.js +3 -3
  51. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  52. package/mission-control/scripts/import-pipeline.js +0 -15
  53. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  54. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  55. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  56. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  57. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  58. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  59. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  60. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  61. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  62. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  63. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  64. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  65. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  66. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  67. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  68. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  69. package/mission-control/src/app/api/tasks/route.ts +21 -30
  70. package/mission-control/src/app/cowork/page.tsx +261 -0
  71. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  72. package/mission-control/src/app/graph/page.tsx +26 -0
  73. package/mission-control/src/app/memory/page.tsx +1 -1
  74. package/mission-control/src/app/obsidian/page.tsx +36 -6
  75. package/mission-control/src/app/roadmap/page.tsx +24 -0
  76. package/mission-control/src/app/souls/page.tsx +2 -2
  77. package/mission-control/src/components/board/execution-config.tsx +431 -0
  78. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  79. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  80. package/mission-control/src/components/board/task-card.tsx +55 -2
  81. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  82. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  83. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  84. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  85. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  86. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  87. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  88. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  89. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  90. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  91. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  92. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  93. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  94. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  95. package/mission-control/src/lib/config.ts +58 -0
  96. package/mission-control/src/lib/db/index.ts +69 -0
  97. package/mission-control/src/lib/db/schema.ts +61 -3
  98. package/mission-control/src/lib/hooks.ts +309 -0
  99. package/mission-control/src/lib/memory/entities.ts +3 -2
  100. package/mission-control/src/lib/nats.ts +66 -1
  101. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  102. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  103. package/mission-control/src/lib/scheduler.ts +12 -11
  104. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  105. package/mission-control/src/lib/sync/tasks.ts +23 -1
  106. package/mission-control/src/lib/task-id.ts +32 -0
  107. package/mission-control/src/lib/tts/index.ts +33 -9
  108. package/mission-control/tsconfig.json +2 -1
  109. package/mission-control/vitest.config.ts +14 -0
  110. package/package.json +15 -2
  111. package/services/service-manifest.json +1 -1
  112. package/skills/cc-godmode/references/agents.md +8 -8
  113. package/workspace-bin/memory-daemon.mjs +199 -5
  114. package/workspace-bin/session-search.mjs +204 -0
  115. 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(DB_PATH);
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 = path.join(__dirname, '..', '..', 'arcane', 'ARCANE_RAPTURE_TASK_CHRONOLOGY.md');
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
+ }