openclaw-node-harness 2.0.4 → 2.1.1
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/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +439 -28
- package/bin/mesh-bridge.js +69 -3
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +821 -26
- package/bin/mesh.js +411 -20
- 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 +296 -10
- package/lib/agent-activity.js +2 -2
- package/lib/circling-parser.js +119 -0
- package/lib/exec-safety.js +105 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +24 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +530 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +252 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +483 -165
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +79 -50
- package/lib/mesh-tasks.js +132 -49
- package/lib/nats-resolve.js +4 -4
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +322 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +461 -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/memory/search/route.ts +6 -3
- 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/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
- 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/api/workspace/read/route.ts +11 -0
- 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 +67 -0
- package/mission-control/src/lib/db/index.ts +85 -1
- 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/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.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/src/middleware.ts +82 -0
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/uninstall.sh +37 -9
- 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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-kv.ts — Sync engine for distributed Mission Control (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Bridges NATS KV (mesh truth) ↔ local SQLite (MC database).
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* - Watch MESH_TASKS KV bucket for real-time updates
|
|
8
|
+
* - Mirror KV entries into local SQLite (upsert with revision tracking)
|
|
9
|
+
* - Push local task changes to KV via CAS writes
|
|
10
|
+
* - Handle conflict resolution (KV wins on workers, SQLite wins on lead)
|
|
11
|
+
*
|
|
12
|
+
* This module is imported by the sync/tasks.ts module and the hooks layer.
|
|
13
|
+
* It does NOT run as a standalone daemon — it's part of the MC process.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getTasksKv, sc } from "@/lib/nats";
|
|
17
|
+
import { NODE_ID, NODE_ROLE } from "@/lib/config";
|
|
18
|
+
|
|
19
|
+
// ── Types ──
|
|
20
|
+
|
|
21
|
+
export interface MeshTaskEntry {
|
|
22
|
+
task_id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
status: string;
|
|
26
|
+
origin: string;
|
|
27
|
+
owner: string | null;
|
|
28
|
+
priority: number;
|
|
29
|
+
budget_minutes: number;
|
|
30
|
+
metric: string | null;
|
|
31
|
+
success_criteria: string[];
|
|
32
|
+
scope: string[];
|
|
33
|
+
tags: string[];
|
|
34
|
+
preferred_nodes: string[];
|
|
35
|
+
exclude_nodes: string[];
|
|
36
|
+
created_at: string;
|
|
37
|
+
claimed_at: string | null;
|
|
38
|
+
started_at: string | null;
|
|
39
|
+
completed_at: string | null;
|
|
40
|
+
last_activity: string | null;
|
|
41
|
+
result: Record<string, unknown> | null;
|
|
42
|
+
attempts: Array<Record<string, unknown>>;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── KV Read Operations ──
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* List all tasks from MESH_TASKS KV bucket.
|
|
50
|
+
* Returns empty array if NATS is unavailable.
|
|
51
|
+
*/
|
|
52
|
+
export async function listMeshTasks(): Promise<MeshTaskEntry[]> {
|
|
53
|
+
const kv = await getTasksKv();
|
|
54
|
+
if (!kv) return [];
|
|
55
|
+
|
|
56
|
+
const tasks: MeshTaskEntry[] = [];
|
|
57
|
+
const keys = await kv.keys();
|
|
58
|
+
|
|
59
|
+
for await (const key of keys) {
|
|
60
|
+
const entry = await kv.get(key);
|
|
61
|
+
if (!entry?.value) continue;
|
|
62
|
+
try {
|
|
63
|
+
tasks.push(JSON.parse(sc.decode(entry.value)));
|
|
64
|
+
} catch {
|
|
65
|
+
// skip malformed
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return tasks;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get a single task from KV with its revision.
|
|
74
|
+
*/
|
|
75
|
+
export async function getMeshTask(
|
|
76
|
+
taskId: string
|
|
77
|
+
): Promise<{ task: MeshTaskEntry; revision: number } | null> {
|
|
78
|
+
const kv = await getTasksKv();
|
|
79
|
+
if (!kv) return null;
|
|
80
|
+
|
|
81
|
+
const entry = await kv.get(taskId);
|
|
82
|
+
if (!entry?.value) return null;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
task: JSON.parse(sc.decode(entry.value)),
|
|
86
|
+
revision: entry.revision,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── KV Write Operations (CAS) ──
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write a task to KV. Used by the lead to publish task state.
|
|
94
|
+
* No CAS — overwrites unconditionally. Use updateMeshTaskCAS for safe updates.
|
|
95
|
+
*/
|
|
96
|
+
export async function putMeshTask(task: MeshTaskEntry): Promise<number> {
|
|
97
|
+
const kv = await getTasksKv();
|
|
98
|
+
if (!kv) throw new Error("NATS KV unavailable");
|
|
99
|
+
|
|
100
|
+
const rev = await kv.put(task.task_id, sc.encode(JSON.stringify(task)));
|
|
101
|
+
return rev;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Update a task with CAS (Compare-And-Swap).
|
|
106
|
+
* Fails if the revision doesn't match (another node wrote since our read).
|
|
107
|
+
*
|
|
108
|
+
* @returns New revision number on success
|
|
109
|
+
* @throws On revision mismatch (caller should re-read and retry)
|
|
110
|
+
*/
|
|
111
|
+
export async function updateMeshTaskCAS(
|
|
112
|
+
taskId: string,
|
|
113
|
+
updates: Partial<MeshTaskEntry>,
|
|
114
|
+
expectedRevision: number
|
|
115
|
+
): Promise<number> {
|
|
116
|
+
const kv = await getTasksKv();
|
|
117
|
+
if (!kv) throw new Error("NATS KV unavailable");
|
|
118
|
+
|
|
119
|
+
// Read current
|
|
120
|
+
const entry = await kv.get(taskId);
|
|
121
|
+
if (!entry?.value) throw new Error(`Task ${taskId} not found in KV`);
|
|
122
|
+
|
|
123
|
+
const current = JSON.parse(sc.decode(entry.value));
|
|
124
|
+
|
|
125
|
+
// Authority check
|
|
126
|
+
if (NODE_ROLE !== "lead" && current.origin !== NODE_ID) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Authority denied: worker ${NODE_ID} cannot update task from ${current.origin}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Merge and write
|
|
133
|
+
const updated = { ...current, ...updates };
|
|
134
|
+
const rev = await kv.update(
|
|
135
|
+
taskId,
|
|
136
|
+
sc.encode(JSON.stringify(updated)),
|
|
137
|
+
expectedRevision
|
|
138
|
+
);
|
|
139
|
+
return rev;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Propose a new task from a worker node.
|
|
144
|
+
* Task is created with status "proposed" — the lead daemon validates.
|
|
145
|
+
*/
|
|
146
|
+
export async function proposeMeshTask(
|
|
147
|
+
task: Omit<MeshTaskEntry, "status" | "origin">
|
|
148
|
+
): Promise<MeshTaskEntry> {
|
|
149
|
+
const kv = await getTasksKv();
|
|
150
|
+
if (!kv) throw new Error("NATS KV unavailable");
|
|
151
|
+
|
|
152
|
+
const proposed: MeshTaskEntry = {
|
|
153
|
+
...task,
|
|
154
|
+
status: NODE_ROLE === "lead" ? "queued" : "proposed",
|
|
155
|
+
origin: NODE_ID,
|
|
156
|
+
} as MeshTaskEntry;
|
|
157
|
+
|
|
158
|
+
await kv.put(proposed.task_id, sc.encode(JSON.stringify(proposed)));
|
|
159
|
+
return proposed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── KV Watcher ──
|
|
163
|
+
|
|
164
|
+
export interface KvWatchEvent {
|
|
165
|
+
key: string;
|
|
166
|
+
operation: "PUT" | "DEL";
|
|
167
|
+
task: MeshTaskEntry | null;
|
|
168
|
+
revision: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Start watching the MESH_TASKS KV bucket.
|
|
173
|
+
* Returns an async iterator of KvWatchEvent and a stop() function.
|
|
174
|
+
*
|
|
175
|
+
* Call stop() on cleanup to prevent zombie watchers leaking NATS connections.
|
|
176
|
+
*/
|
|
177
|
+
export async function watchMeshTasks(): Promise<{
|
|
178
|
+
events: AsyncIterable<KvWatchEvent>;
|
|
179
|
+
stop: () => void;
|
|
180
|
+
} | null> {
|
|
181
|
+
const kv = await getTasksKv();
|
|
182
|
+
if (!kv) return null;
|
|
183
|
+
|
|
184
|
+
const watcher = await kv.watch();
|
|
185
|
+
|
|
186
|
+
const events: AsyncIterable<KvWatchEvent> = {
|
|
187
|
+
[Symbol.asyncIterator]() {
|
|
188
|
+
return {
|
|
189
|
+
async next() {
|
|
190
|
+
const result = await (
|
|
191
|
+
watcher as AsyncIterable<any>
|
|
192
|
+
)[Symbol.asyncIterator]().next();
|
|
193
|
+
if (result.done) return { value: undefined, done: true };
|
|
194
|
+
|
|
195
|
+
const entry = result.value;
|
|
196
|
+
let task: MeshTaskEntry | null = null;
|
|
197
|
+
if (entry.value) {
|
|
198
|
+
try {
|
|
199
|
+
task = JSON.parse(sc.decode(entry.value));
|
|
200
|
+
} catch {
|
|
201
|
+
// malformed
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
value: {
|
|
207
|
+
key: entry.key,
|
|
208
|
+
operation: entry.value ? "PUT" : ("DEL" as const),
|
|
209
|
+
task,
|
|
210
|
+
revision: entry.revision,
|
|
211
|
+
},
|
|
212
|
+
done: false,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
events,
|
|
221
|
+
stop: () => {
|
|
222
|
+
if (typeof (watcher as any).stop === "function") {
|
|
223
|
+
(watcher as any).stop();
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Merge Logic (for UI layer) ──
|
|
230
|
+
|
|
231
|
+
export interface MergedTask {
|
|
232
|
+
id: string;
|
|
233
|
+
title: string;
|
|
234
|
+
source: "sqlite" | "kv" | "merged";
|
|
235
|
+
[key: string]: unknown;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Merge local SQLite tasks with KV mesh tasks.
|
|
240
|
+
* Deduplicates by task ID.
|
|
241
|
+
*
|
|
242
|
+
* On lead: SQLite wins (has richer fields like kanbanColumn, sortOrder)
|
|
243
|
+
* On worker: KV wins (more up-to-date for mesh-coordinated tasks)
|
|
244
|
+
*/
|
|
245
|
+
export function mergeTasks(
|
|
246
|
+
sqliteTasks: Array<{ id: string; [key: string]: unknown }>,
|
|
247
|
+
kvTasks: Array<{ task_id: string; [key: string]: unknown }>,
|
|
248
|
+
nodeRole: "lead" | "worker" = NODE_ROLE
|
|
249
|
+
): MergedTask[] {
|
|
250
|
+
const merged = new Map<string, MergedTask>();
|
|
251
|
+
|
|
252
|
+
// SQLite tasks first
|
|
253
|
+
for (const t of sqliteTasks) {
|
|
254
|
+
merged.set(t.id, { ...t, id: t.id, title: String(t.title || ""), source: "sqlite" });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// KV tasks: on worker, KV wins for mesh tasks; on lead, SQLite wins
|
|
258
|
+
for (const t of kvTasks) {
|
|
259
|
+
const existing = merged.get(t.task_id);
|
|
260
|
+
if (!existing) {
|
|
261
|
+
merged.set(t.task_id, {
|
|
262
|
+
...t,
|
|
263
|
+
id: t.task_id,
|
|
264
|
+
title: String(t.title || ""),
|
|
265
|
+
source: "kv",
|
|
266
|
+
});
|
|
267
|
+
} else if (nodeRole === "worker") {
|
|
268
|
+
merged.set(t.task_id, {
|
|
269
|
+
...t,
|
|
270
|
+
id: t.task_id,
|
|
271
|
+
title: String(t.title || ""),
|
|
272
|
+
source: "kv",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// Lead: SQLite wins (don't overwrite)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return Array.from(merged.values());
|
|
279
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { eq } from "drizzle-orm";
|
|
4
4
|
import { tasks } from "@/lib/db/schema";
|
|
5
5
|
import { ACTIVE_TASKS_MD } from "@/lib/config";
|
|
6
|
+
import { NODE_ROLE } from "@/lib/config";
|
|
6
7
|
import {
|
|
7
8
|
parseTasksMarkdown,
|
|
8
9
|
serializeTasksMarkdown,
|
|
@@ -118,6 +119,11 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
|
|
|
118
119
|
metric: task.metric || null,
|
|
119
120
|
budgetMinutes: task.budgetMinutes || 30,
|
|
120
121
|
scope: task.scope?.length ? JSON.stringify(task.scope) : null,
|
|
122
|
+
// Collab routing fields
|
|
123
|
+
collaboration: task.collaboration ? JSON.stringify(task.collaboration) : null,
|
|
124
|
+
preferredNodes: task.preferredNodes?.length ? JSON.stringify(task.preferredNodes) : null,
|
|
125
|
+
excludeNodes: task.excludeNodes?.length ? JSON.stringify(task.excludeNodes) : null,
|
|
126
|
+
clusterId: task.clusterId || null,
|
|
121
127
|
updatedAt: task.updatedAt || now,
|
|
122
128
|
};
|
|
123
129
|
|
|
@@ -167,6 +173,11 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
|
|
|
167
173
|
metric: task.metric || null,
|
|
168
174
|
budgetMinutes: task.budgetMinutes || 30,
|
|
169
175
|
scope: task.scope?.length ? JSON.stringify(task.scope) : null,
|
|
176
|
+
// Collab routing fields
|
|
177
|
+
collaboration: task.collaboration ? JSON.stringify(task.collaboration) : null,
|
|
178
|
+
preferredNodes: task.preferredNodes?.length ? JSON.stringify(task.preferredNodes) : null,
|
|
179
|
+
excludeNodes: task.excludeNodes?.length ? JSON.stringify(task.excludeNodes) : null,
|
|
180
|
+
clusterId: task.clusterId || null,
|
|
170
181
|
updatedAt: task.updatedAt || now,
|
|
171
182
|
createdAt: now,
|
|
172
183
|
})
|
|
@@ -178,7 +189,7 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
|
|
|
178
189
|
// but PRESERVE roadmap/pipeline tasks (project, phase, pipeline types)
|
|
179
190
|
// and any tasks tagged with a project (they live in the DB, not markdown).
|
|
180
191
|
const allDbTasks = db
|
|
181
|
-
.select({ id: tasks.id, type: tasks.type, project: tasks.project })
|
|
192
|
+
.select({ id: tasks.id, type: tasks.type, project: tasks.project, execution: tasks.execution, status: tasks.status })
|
|
182
193
|
.from(tasks)
|
|
183
194
|
.all();
|
|
184
195
|
for (const row of allDbTasks) {
|
|
@@ -187,6 +198,8 @@ export function syncTasksFromMarkdown(db: DrizzleDb): void {
|
|
|
187
198
|
if (row.type === "project" || row.type === "phase" || row.type === "pipeline") continue;
|
|
188
199
|
// Preserve tasks owned by a project (imported via pipeline, not markdown-managed)
|
|
189
200
|
if (row.project) continue;
|
|
201
|
+
// Preserve in-flight mesh tasks (only clean up terminal ones)
|
|
202
|
+
if (row.execution === "mesh" && row.status !== "done" && row.status !== "cancelled") continue;
|
|
190
203
|
if (!incomingIds.has(row.id)) {
|
|
191
204
|
db.delete(tasks).where(eq(tasks.id, row.id)).run();
|
|
192
205
|
}
|
|
@@ -210,6 +223,10 @@ export function syncTasksFromMarkdownIfChanged(db: DrizzleDb): void {
|
|
|
210
223
|
* Tracks the mtime after write to avoid re-importing our own changes.
|
|
211
224
|
*/
|
|
212
225
|
export function syncTasksToMarkdown(db: DrizzleDb): void {
|
|
226
|
+
// Workers must NOT write to active-tasks.md — the lead owns this file.
|
|
227
|
+
// Writing from workers would cause conflict loops in the mesh sync.
|
|
228
|
+
if (NODE_ROLE === "worker") return;
|
|
229
|
+
|
|
213
230
|
const allTasks = db
|
|
214
231
|
.select()
|
|
215
232
|
.from(tasks)
|
|
@@ -250,6 +267,11 @@ export function syncTasksToMarkdown(db: DrizzleDb): void {
|
|
|
250
267
|
metric: t.metric || null,
|
|
251
268
|
budgetMinutes: t.budgetMinutes || 30,
|
|
252
269
|
scope: t.scope ? JSON.parse(t.scope) : [],
|
|
270
|
+
// Collab routing fields
|
|
271
|
+
collaboration: t.collaboration ? JSON.parse(t.collaboration) : null,
|
|
272
|
+
preferredNodes: t.preferredNodes ? JSON.parse(t.preferredNodes) : [],
|
|
273
|
+
excludeNodes: t.excludeNodes ? JSON.parse(t.excludeNodes) : [],
|
|
274
|
+
clusterId: t.clusterId || null,
|
|
253
275
|
updatedAt: t.updatedAt,
|
|
254
276
|
}));
|
|
255
277
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { tasks } from "@/lib/db/schema";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a task ID in the format T-YYYYMMDD-NNN.
|
|
6
|
+
* NNN is a zero-padded sequence number based on existing tasks for that date.
|
|
7
|
+
*/
|
|
8
|
+
export function generateTaskId(
|
|
9
|
+
db: ReturnType<typeof getDb>,
|
|
10
|
+
date: Date
|
|
11
|
+
): string {
|
|
12
|
+
const dateStr =
|
|
13
|
+
date.getFullYear().toString() +
|
|
14
|
+
(date.getMonth() + 1).toString().padStart(2, "0") +
|
|
15
|
+
date.getDate().toString().padStart(2, "0");
|
|
16
|
+
|
|
17
|
+
const prefix = `T-${dateStr}-`;
|
|
18
|
+
|
|
19
|
+
// Find highest existing sequence number for this date prefix
|
|
20
|
+
const existing = db
|
|
21
|
+
.select({ id: tasks.id })
|
|
22
|
+
.from(tasks)
|
|
23
|
+
.all()
|
|
24
|
+
.filter((t) => t.id.startsWith(prefix))
|
|
25
|
+
.map((t) => {
|
|
26
|
+
const seq = parseInt(t.id.slice(prefix.length), 10);
|
|
27
|
+
return isNaN(seq) ? 0 : seq;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const nextSeq = existing.length > 0 ? Math.max(...existing) + 1 : 1;
|
|
31
|
+
return `${prefix}${nextSeq.toString().padStart(3, "0")}`;
|
|
32
|
+
}
|
|
@@ -4,14 +4,28 @@ import { createEdgeTtsProvider } from "./edge";
|
|
|
4
4
|
|
|
5
5
|
export type { TtsRequest, TtsResponse, TtsProvider } from "./types";
|
|
6
6
|
|
|
7
|
+
// ── Pluggable TTS Provider Registry ──
|
|
8
|
+
// Built-in providers are registered by default. External code can register
|
|
9
|
+
// additional providers via registerTtsProvider() without modifying this file.
|
|
10
|
+
|
|
7
11
|
const providers: Record<string, () => TtsProvider> = {
|
|
8
12
|
google: createGoogleTtsProvider,
|
|
9
13
|
edge: createEdgeTtsProvider,
|
|
10
14
|
};
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
/** Register a custom TTS provider factory (e.g. "elevenlabs", "openai-tts"). */
|
|
17
|
+
export function registerTtsProvider(name: string, factory: () => TtsProvider): void {
|
|
18
|
+
providers[name] = factory;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** List all registered TTS provider names. */
|
|
22
|
+
export function listTtsProviders(): string[] {
|
|
23
|
+
return Object.keys(providers);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getProvider(name: string): TtsProvider {
|
|
13
27
|
const factory = providers[name];
|
|
14
|
-
if (!factory) throw new Error(`Unknown TTS provider: ${name}`);
|
|
28
|
+
if (!factory) throw new Error(`Unknown TTS provider: ${name}. Available: ${Object.keys(providers).join(", ")}`);
|
|
15
29
|
return factory();
|
|
16
30
|
}
|
|
17
31
|
|
|
@@ -22,18 +36,28 @@ export interface SynthesisResult extends TtsResponse {
|
|
|
22
36
|
|
|
23
37
|
export async function synthesizeWithFallback(
|
|
24
38
|
req: TtsRequest,
|
|
25
|
-
preferred:
|
|
39
|
+
preferred: string = "google"
|
|
26
40
|
): Promise<SynthesisResult> {
|
|
27
41
|
try {
|
|
28
42
|
const result = await getProvider(preferred).synthesize(req);
|
|
29
43
|
return { ...result, actualProvider: preferred };
|
|
30
44
|
} catch (err) {
|
|
31
|
-
|
|
45
|
+
// Find first available fallback that isn't the preferred provider
|
|
46
|
+
const fallbackNames = Object.keys(providers).filter((n) => n !== preferred);
|
|
32
47
|
const reason = err instanceof Error ? err.message : String(err);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
|
|
49
|
+
for (const fallback of fallbackNames) {
|
|
50
|
+
try {
|
|
51
|
+
console.warn(
|
|
52
|
+
`TTS provider "${preferred}" failed, trying "${fallback}": ${reason}`
|
|
53
|
+
);
|
|
54
|
+
const result = await getProvider(fallback).synthesize(req);
|
|
55
|
+
return { ...result, actualProvider: fallback, fallbackReason: reason };
|
|
56
|
+
} catch {
|
|
57
|
+
// try next fallback
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(`All TTS providers failed. Last error: ${reason}`);
|
|
38
62
|
}
|
|
39
63
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* API authentication middleware.
|
|
5
|
+
*
|
|
6
|
+
* Protects all /api/* routes with a Bearer token check.
|
|
7
|
+
* Token is read from MC_AUTH_TOKEN env var. If unset, auth is disabled
|
|
8
|
+
* (localhost-only deployments). When set, every API request must include:
|
|
9
|
+
* Authorization: Bearer <token>
|
|
10
|
+
*
|
|
11
|
+
* Page routes (non-API) are not gated — the dashboard is a local UI.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const AUTH_TOKEN = process.env.MC_AUTH_TOKEN || "";
|
|
15
|
+
|
|
16
|
+
export function middleware(request: NextRequest) {
|
|
17
|
+
// Only gate API routes
|
|
18
|
+
if (!request.nextUrl.pathname.startsWith("/api/")) {
|
|
19
|
+
return NextResponse.next();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Body size limit (1MB) for mutation requests
|
|
23
|
+
const contentLength = parseInt(request.headers.get("content-length") || "0", 10);
|
|
24
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1MB
|
|
25
|
+
if (contentLength > MAX_BODY_SIZE) {
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: `Request body too large (${contentLength} bytes, max ${MAX_BODY_SIZE})` },
|
|
28
|
+
{ status: 413 }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// SSE endpoints use EventSource which can't set headers — allow if
|
|
33
|
+
// the token is passed as a query param instead.
|
|
34
|
+
const tokenFromQuery = request.nextUrl.searchParams.get("token");
|
|
35
|
+
|
|
36
|
+
// If no token is configured, auth is disabled (backwards-compatible)
|
|
37
|
+
if (!AUTH_TOKEN) {
|
|
38
|
+
return NextResponse.next();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const authHeader = request.headers.get("authorization") || "";
|
|
42
|
+
const bearer = authHeader.startsWith("Bearer ")
|
|
43
|
+
? authHeader.slice(7).trim()
|
|
44
|
+
: "";
|
|
45
|
+
|
|
46
|
+
const providedToken = bearer || tokenFromQuery || "";
|
|
47
|
+
|
|
48
|
+
if (!providedToken) {
|
|
49
|
+
return NextResponse.json(
|
|
50
|
+
{ error: "Missing Authorization header" },
|
|
51
|
+
{ status: 401 }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Constant-time comparison to prevent timing attacks
|
|
56
|
+
if (!timingSafeEqual(providedToken, AUTH_TOKEN)) {
|
|
57
|
+
return NextResponse.json({ error: "Invalid token" }, { status: 403 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return NextResponse.next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Constant-time string comparison (Edge Runtime compatible). */
|
|
64
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
65
|
+
if (a.length !== b.length) {
|
|
66
|
+
// Still do a full comparison to avoid length-based timing leak
|
|
67
|
+
let result = a.length ^ b.length;
|
|
68
|
+
for (let i = 0; i < a.length; i++) {
|
|
69
|
+
result |= a.charCodeAt(i) ^ (b.charCodeAt(i % b.length) || 0);
|
|
70
|
+
}
|
|
71
|
+
return result === 0;
|
|
72
|
+
}
|
|
73
|
+
let result = 0;
|
|
74
|
+
for (let i = 0; i < a.length; i++) {
|
|
75
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
76
|
+
}
|
|
77
|
+
return result === 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const config = {
|
|
81
|
+
matcher: "/api/:path*",
|
|
82
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: {
|
|
7
|
+
"@": path.resolve(__dirname, "src"),
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
test: {
|
|
11
|
+
include: ["src/**/*.test.ts"],
|
|
12
|
+
environment: "node",
|
|
13
|
+
},
|
|
14
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-node-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "One-command installer for the OpenClaw node layer — identity, skills, souls, daemon, and Mission Control.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"openclaw-node": "./cli.js"
|
|
@@ -39,10 +39,23 @@
|
|
|
39
39
|
"type": "git",
|
|
40
40
|
"url": "https://github.com/moltyguibros-design/openclaw-node.git"
|
|
41
41
|
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test test/*.test.js test/*.test.mjs",
|
|
44
|
+
"test:unit": "node --test test/*.test.js test/*.test.mjs",
|
|
45
|
+
"test:mc": "cd mission-control && npx vitest run src/lib/__tests__/",
|
|
46
|
+
"test:integration": "node test/distributed-mc.test.js",
|
|
47
|
+
"test:all": "npm run test:unit && npm run test:mc && npm run test:integration"
|
|
48
|
+
},
|
|
42
49
|
"engines": {
|
|
43
50
|
"node": ">=18"
|
|
44
51
|
},
|
|
45
52
|
"dependencies": {
|
|
46
|
-
"
|
|
53
|
+
"@huggingface/transformers": "^3.0.0",
|
|
54
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
55
|
+
"better-sqlite3": "^11.7.0",
|
|
56
|
+
"js-yaml": "^4.1.1",
|
|
57
|
+
"nats": "^2.28.2",
|
|
58
|
+
"playwright": "^1.51.0",
|
|
59
|
+
"sqlite-vec": "^0.1.0"
|
|
47
60
|
}
|
|
48
61
|
}
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
<key>Minute</key>
|
|
21
21
|
<integer>0</integer>
|
|
22
22
|
</dict>
|
|
23
|
+
<key>RunAtLoad</key>
|
|
24
|
+
<false/>
|
|
25
|
+
<key>KeepAlive</key>
|
|
26
|
+
<false/>
|
|
27
|
+
<key>EnvironmentVariables</key>
|
|
28
|
+
<dict>
|
|
29
|
+
<key>HOME</key>
|
|
30
|
+
<string>${HOME}</string>
|
|
31
|
+
<key>PATH</key>
|
|
32
|
+
<string>${HOME}/.bun/bin:${HOME}/.local/bin:${HOME}/.npm-global/bin:${HOME}/bin:${HOME}/.volta/bin:${HOME}/.asdf/shims:${HOME}/Library/Application Support/fnm/aliases/default/bin:${HOME}/.fnm/aliases/default/bin:${HOME}/Library/pnpm:${HOME}/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
33
|
+
</dict>
|
|
23
34
|
<key>StandardOutPath</key>
|
|
24
35
|
<string>${HOME}/.openclaw/logs/log-rotate.log</string>
|
|
25
36
|
<key>StandardErrorPath</key>
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
</array>
|
|
13
13
|
<key>EnvironmentVariables</key>
|
|
14
14
|
<dict>
|
|
15
|
+
<key>HOME</key>
|
|
16
|
+
<string>${HOME}</string>
|
|
17
|
+
<key>PATH</key>
|
|
18
|
+
<string>${HOME}/.bun/bin:${HOME}/.local/bin:${HOME}/.npm-global/bin:${HOME}/bin:${HOME}/.volta/bin:${HOME}/.asdf/shims:${HOME}/Library/Application Support/fnm/aliases/default/bin:${HOME}/.fnm/aliases/default/bin:${HOME}/Library/pnpm:${HOME}/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
15
19
|
<key>OPENCLAW_NODE_ID</key>
|
|
16
20
|
<string>${OPENCLAW_NODE_ID}</string>
|
|
17
21
|
<key>OPENCLAW_NATS</key>
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
</array>
|
|
13
13
|
<key>EnvironmentVariables</key>
|
|
14
14
|
<dict>
|
|
15
|
+
<key>HOME</key>
|
|
16
|
+
<string>${HOME}</string>
|
|
17
|
+
<key>PATH</key>
|
|
18
|
+
<string>${HOME}/.bun/bin:${HOME}/.local/bin:${HOME}/.npm-global/bin:${HOME}/bin:${HOME}/.volta/bin:${HOME}/.asdf/shims:${HOME}/Library/Application Support/fnm/aliases/default/bin:${HOME}/.fnm/aliases/default/bin:${HOME}/Library/pnpm:${HOME}/.local/share/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
15
19
|
<key>OPENCLAW_NODE_ID</key>
|
|
16
20
|
<string>${OPENCLAW_NODE_ID}</string>
|
|
17
21
|
<key>OPENCLAW_NATS</key>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
{ "name": "openclaw-mesh-health-publisher", "role": "both", "autostart": true },
|
|
6
6
|
{ "name": "openclaw-mesh-deploy-listener", "role": "both", "autostart": true },
|
|
7
7
|
{ "name": "openclaw-mesh-tool-discord", "role": "lead", "autostart": true },
|
|
8
|
-
{ "name": "openclaw-mission-control", "role": "
|
|
8
|
+
{ "name": "openclaw-mission-control", "role": "both", "autostart": true },
|
|
9
9
|
{ "name": "openclaw-gateway", "role": "both", "autostart": true },
|
|
10
10
|
{ "name": "openclaw-lane-watchdog", "role": "both", "autostart": true },
|
|
11
11
|
{ "name": "openclaw-memory-daemon", "role": "both", "autostart": true },
|