openclaw-node-harness 2.0.2 → 2.0.4
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/bin/fleet-deploy.js +1 -1
- package/bin/mesh-agent.js +217 -84
- package/bin/mesh-bridge.js +274 -10
- package/bin/mesh-deploy-listener.js +120 -98
- package/bin/mesh-deploy.js +11 -3
- package/bin/mesh-health-publisher.js +1 -1
- package/bin/mesh-task-daemon.js +190 -15
- package/bin/mesh.js +170 -22
- package/bin/openclaw-node-init.js +147 -3
- package/install.sh +7 -0
- package/lib/kanban-io.js +50 -10
- package/lib/mesh-collab.js +53 -3
- package/lib/mesh-registry.js +11 -2
- package/lib/mesh-tasks.js +6 -7
- package/package.json +1 -1
package/bin/mesh-bridge.js
CHANGED
|
@@ -21,7 +21,7 @@ const path = require('path');
|
|
|
21
21
|
const { readTasks, updateTaskInPlace, isoTimestamp, ACTIVE_TASKS_PATH } = require('../lib/kanban-io');
|
|
22
22
|
|
|
23
23
|
const sc = StringCodec();
|
|
24
|
-
const { NATS_URL
|
|
24
|
+
const { NATS_URL } = require('../lib/nats-resolve');
|
|
25
25
|
const DISPATCH_INTERVAL = parseInt(process.env.BRIDGE_DISPATCH_INTERVAL || '10000'); // 10s
|
|
26
26
|
const LOG_DIR = path.join(process.env.HOME, '.openclaw', 'workspace', 'memory', 'mesh-logs');
|
|
27
27
|
const WORKSPACE = path.join(process.env.HOME, '.openclaw', 'workspace');
|
|
@@ -186,6 +186,11 @@ async function dispatchTask(task) {
|
|
|
186
186
|
success_criteria: task.success_criteria || [],
|
|
187
187
|
scope: task.scope || [],
|
|
188
188
|
priority: task.auto_priority || 0,
|
|
189
|
+
llm_provider: task.llm_provider || null,
|
|
190
|
+
llm_model: task.llm_model || null,
|
|
191
|
+
preferred_nodes: task.preferred_nodes || [],
|
|
192
|
+
exclude_nodes: task.exclude_nodes || [],
|
|
193
|
+
collaboration: task.collaboration || undefined,
|
|
189
194
|
});
|
|
190
195
|
|
|
191
196
|
log(`SUBMITTED: ${meshTask.task_id} to mesh (budget: ${meshTask.budget_minutes}m)`);
|
|
@@ -203,6 +208,245 @@ async function dispatchTask(task) {
|
|
|
203
208
|
log(`UPDATED: ${task.task_id} → submitted`);
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
// ── Collab Events → Kanban ───────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Handle collab-specific events. Updates kanban with collaboration progress.
|
|
215
|
+
*/
|
|
216
|
+
function handleCollabEvent(eventType, taskId, data) {
|
|
217
|
+
// Only process events for tasks we dispatched
|
|
218
|
+
if (!dispatched.has(taskId)) return;
|
|
219
|
+
|
|
220
|
+
const session = data; // collab events include the session object
|
|
221
|
+
|
|
222
|
+
switch (eventType) {
|
|
223
|
+
case 'collab.created':
|
|
224
|
+
log(`COLLAB CREATED: session ${session.session_id} for task ${taskId} (mode: ${session.mode})`);
|
|
225
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
226
|
+
next_action: `Collab session created (mode: ${session.mode}). Recruiting ${session.min_nodes}+ nodes...`,
|
|
227
|
+
updated_at: isoTimestamp(),
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case 'collab.joined':
|
|
232
|
+
log(`COLLAB JOINED: ${session.nodes?.length || '?'} nodes in session for ${taskId}`);
|
|
233
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
234
|
+
next_action: `${session.nodes?.length || '?'} nodes joined. ${session.status === 'recruiting' ? 'Recruiting...' : 'Working...'}`,
|
|
235
|
+
updated_at: isoTimestamp(),
|
|
236
|
+
});
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'collab.round_started':
|
|
240
|
+
log(`COLLAB ROUND ${session.current_round}: started for ${taskId}`);
|
|
241
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
242
|
+
next_action: `Round ${session.current_round}/${session.max_rounds} in progress (${session.nodes?.length || '?'} nodes)`,
|
|
243
|
+
updated_at: isoTimestamp(),
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'collab.reflection_received': {
|
|
248
|
+
const totalReflections = session.rounds?.[session.rounds.length - 1]?.reflections?.length || 0;
|
|
249
|
+
const totalNodes = session.nodes?.length || '?';
|
|
250
|
+
log(`COLLAB REFLECT: ${totalReflections}/${totalNodes} for R${session.current_round} of ${taskId}`);
|
|
251
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
252
|
+
next_action: `R${session.current_round}: ${totalReflections}/${totalNodes} reflections received`,
|
|
253
|
+
updated_at: isoTimestamp(),
|
|
254
|
+
});
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'collab.converged':
|
|
259
|
+
log(`COLLAB CONVERGED: ${taskId} after ${session.current_round} rounds`);
|
|
260
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
261
|
+
next_action: `Converged after ${session.current_round} rounds. Collecting artifacts...`,
|
|
262
|
+
updated_at: isoTimestamp(),
|
|
263
|
+
});
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'collab.completed': {
|
|
267
|
+
const result = session.result || {};
|
|
268
|
+
const nodeNames = Object.keys(result.node_contributions || {});
|
|
269
|
+
const artifactCount = (result.artifacts || []).length;
|
|
270
|
+
log(`COLLAB COMPLETED: ${taskId} — ${result.rounds_taken} rounds, ${nodeNames.length} nodes, ${artifactCount} artifacts`);
|
|
271
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
272
|
+
next_action: `Collab completed: ${result.rounds_taken || '?'} rounds, ${nodeNames.length} nodes. ${artifactCount} artifact(s). ${result.summary || ''}`.trim(),
|
|
273
|
+
collab_result: {
|
|
274
|
+
rounds_taken: result.rounds_taken,
|
|
275
|
+
node_contributions: result.node_contributions,
|
|
276
|
+
artifacts: result.artifacts,
|
|
277
|
+
summary: result.summary,
|
|
278
|
+
},
|
|
279
|
+
updated_at: isoTimestamp(),
|
|
280
|
+
});
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'collab.aborted':
|
|
285
|
+
log(`COLLAB ABORTED: ${taskId} — ${session.result?.summary || 'unknown reason'}`);
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
default:
|
|
289
|
+
log(`COLLAB EVENT: ${eventType} for ${taskId}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Plan Events → Kanban ────────────────────────────
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Handle plan-specific events. Materializes subtasks in kanban and tracks progress.
|
|
297
|
+
*/
|
|
298
|
+
function handlePlanEvent(eventType, data) {
|
|
299
|
+
const plan = data;
|
|
300
|
+
const parentTaskId = plan?.parent_task_id;
|
|
301
|
+
|
|
302
|
+
switch (eventType) {
|
|
303
|
+
case 'plan.created':
|
|
304
|
+
log(`PLAN CREATED: ${plan.plan_id} for task ${parentTaskId} (${plan.subtasks?.length || 0} subtasks, ${plan.estimated_waves || 0} waves)`);
|
|
305
|
+
if (parentTaskId) {
|
|
306
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
307
|
+
next_action: `Plan created: ${plan.subtasks?.length || 0} subtasks in ${plan.estimated_waves || 0} waves. ${plan.requires_approval ? 'Awaiting approval.' : 'Auto-executing.'}`,
|
|
308
|
+
updated_at: isoTimestamp(),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
|
|
313
|
+
case 'plan.approved':
|
|
314
|
+
log(`PLAN APPROVED: ${plan.plan_id}`);
|
|
315
|
+
if (parentTaskId) {
|
|
316
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
317
|
+
status: 'running',
|
|
318
|
+
next_action: `Plan approved. Dispatching wave 0...`,
|
|
319
|
+
updated_at: isoTimestamp(),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
case 'plan.wave_started': {
|
|
325
|
+
// Materialize new subtasks into kanban as child tasks
|
|
326
|
+
const readySubtasks = (plan.subtasks || []).filter(st => st.status === 'queued' || st.status === 'blocked');
|
|
327
|
+
log(`PLAN WAVE: ${plan.plan_id} — materializing ${readySubtasks.length} subtasks in kanban`);
|
|
328
|
+
|
|
329
|
+
for (const st of readySubtasks) {
|
|
330
|
+
// Only materialize local/soul/human subtasks — mesh subtasks are tracked via mesh events
|
|
331
|
+
if (st.delegation?.mode === 'local' || st.delegation?.mode === 'soul' || st.delegation?.mode === 'human') {
|
|
332
|
+
materializeSubtask(plan, st);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (parentTaskId) {
|
|
337
|
+
const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
|
|
338
|
+
const total = (plan.subtasks || []).length;
|
|
339
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
340
|
+
next_action: `Plan executing: ${completed}/${total} subtasks done`,
|
|
341
|
+
updated_at: isoTimestamp(),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'plan.subtask_completed': {
|
|
348
|
+
const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
|
|
349
|
+
const total = (plan.subtasks || []).length;
|
|
350
|
+
log(`PLAN PROGRESS: ${plan.plan_id} — ${completed}/${total} subtasks done`);
|
|
351
|
+
if (parentTaskId) {
|
|
352
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
353
|
+
next_action: `Plan: ${completed}/${total} subtasks complete`,
|
|
354
|
+
updated_at: isoTimestamp(),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
case 'plan.completed':
|
|
361
|
+
log(`PLAN COMPLETED: ${plan.plan_id}`);
|
|
362
|
+
// Parent task completion handled by the daemon (marks waiting-user)
|
|
363
|
+
break;
|
|
364
|
+
|
|
365
|
+
case 'plan.aborted':
|
|
366
|
+
log(`PLAN ABORTED: ${plan.plan_id}`);
|
|
367
|
+
if (parentTaskId) {
|
|
368
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
369
|
+
status: 'blocked',
|
|
370
|
+
next_action: `Plan aborted — needs triage`,
|
|
371
|
+
updated_at: isoTimestamp(),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
|
|
376
|
+
default:
|
|
377
|
+
log(`PLAN EVENT: ${eventType} for plan ${plan?.plan_id || 'unknown'}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Materialize a plan subtask as a child task in active-tasks.md.
|
|
383
|
+
* For local/soul/human delegation modes only — mesh subtasks use the existing mesh dispatch.
|
|
384
|
+
*/
|
|
385
|
+
function materializeSubtask(plan, subtask) {
|
|
386
|
+
try {
|
|
387
|
+
const tasks = readTasks(ACTIVE_TASKS_PATH);
|
|
388
|
+
|
|
389
|
+
// Check if already materialized
|
|
390
|
+
if (tasks.find(t => t.task_id === subtask.subtask_id)) {
|
|
391
|
+
log(` SKIP: ${subtask.subtask_id} already in kanban`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const mode = subtask.delegation?.mode || 'local';
|
|
396
|
+
const soulId = subtask.delegation?.soul_id || null;
|
|
397
|
+
|
|
398
|
+
const taskEntry = {
|
|
399
|
+
task_id: subtask.subtask_id,
|
|
400
|
+
title: subtask.title,
|
|
401
|
+
status: mode === 'human' ? 'blocked' : 'queued',
|
|
402
|
+
owner: mode === 'soul' ? 'Daedalus' : null,
|
|
403
|
+
soul_id: soulId,
|
|
404
|
+
success_criteria: subtask.success_criteria || [],
|
|
405
|
+
artifacts: [],
|
|
406
|
+
next_action: mode === 'human'
|
|
407
|
+
? 'Needs Gui input'
|
|
408
|
+
: `Plan subtask (${mode}${soulId ? `: ${soulId}` : ''})`,
|
|
409
|
+
parent_id: plan.parent_task_id,
|
|
410
|
+
project: null, // inherited from parent via MC
|
|
411
|
+
description: `${subtask.description || ''}\n\n_Delegation: ${mode}${soulId ? ` → ${soulId}` : ''} | Plan: ${plan.plan_id} | Budget: ${subtask.budget_minutes}m_`,
|
|
412
|
+
updated_at: isoTimestamp(),
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Append to active-tasks.md
|
|
416
|
+
const content = fs.readFileSync(ACTIVE_TASKS_PATH, 'utf-8');
|
|
417
|
+
const yamlEntry = formatTaskYaml(taskEntry);
|
|
418
|
+
fs.writeFileSync(ACTIVE_TASKS_PATH, content + '\n' + yamlEntry);
|
|
419
|
+
|
|
420
|
+
log(` MATERIALIZED: ${subtask.subtask_id} → kanban (${mode})`);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
log(` ERROR materializing ${subtask.subtask_id}: ${err.message}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Format a task entry as YAML for active-tasks.md.
|
|
428
|
+
*/
|
|
429
|
+
function formatTaskYaml(task) {
|
|
430
|
+
const lines = [];
|
|
431
|
+
lines.push(`- task_id: ${task.task_id}`);
|
|
432
|
+
lines.push(` title: ${task.title}`);
|
|
433
|
+
lines.push(` status: ${task.status}`);
|
|
434
|
+
if (task.owner) lines.push(` owner: ${task.owner}`);
|
|
435
|
+
if (task.soul_id) lines.push(` soul_id: ${task.soul_id}`);
|
|
436
|
+
if (task.success_criteria?.length > 0) {
|
|
437
|
+
lines.push(' success_criteria:');
|
|
438
|
+
for (const sc of task.success_criteria) {
|
|
439
|
+
lines.push(` - ${sc}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
lines.push(` artifacts:`);
|
|
443
|
+
if (task.next_action) lines.push(` next_action: "${task.next_action.replace(/"/g, '\\"')}"`);
|
|
444
|
+
if (task.parent_id) lines.push(` parent_id: ${task.parent_id}`);
|
|
445
|
+
if (task.description) lines.push(` description: "${task.description.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
|
|
446
|
+
lines.push(` updated_at: ${task.updated_at}`);
|
|
447
|
+
return lines.join('\n');
|
|
448
|
+
}
|
|
449
|
+
|
|
206
450
|
// ── Results: Mesh → Kanban (event-driven) ───────────
|
|
207
451
|
|
|
208
452
|
/**
|
|
@@ -247,6 +491,11 @@ function handleEvent(eventType, taskId, meshTask) {
|
|
|
247
491
|
lastHeartbeat.set(taskId, Date.now());
|
|
248
492
|
return;
|
|
249
493
|
default:
|
|
494
|
+
// Handle collab events
|
|
495
|
+
if (eventType.startsWith('collab.')) {
|
|
496
|
+
handleCollabEvent(eventType, taskId, meshTask);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
250
499
|
// submitted, started — informational only
|
|
251
500
|
log(`EVENT: ${eventType} ${taskId}`);
|
|
252
501
|
return;
|
|
@@ -428,19 +677,30 @@ async function main() {
|
|
|
428
677
|
log(` Dispatch interval: ${DISPATCH_INTERVAL / 1000}s`);
|
|
429
678
|
log(` Mode: ${DRY_RUN ? 'dry run' : 'live'}`);
|
|
430
679
|
|
|
431
|
-
nc = await connect(
|
|
680
|
+
nc = await connect({
|
|
681
|
+
servers: NATS_URL,
|
|
682
|
+
timeout: 5000,
|
|
683
|
+
reconnect: true,
|
|
684
|
+
maxReconnectAttempts: 10,
|
|
685
|
+
reconnectTimeWait: 2000,
|
|
686
|
+
});
|
|
432
687
|
log('Connected to NATS');
|
|
433
688
|
|
|
434
689
|
// Re-reconcile on reconnect — catches events missed during NATS blip (#2)
|
|
435
|
-
//
|
|
690
|
+
// Exit on permanent disconnect so launchd restarts us
|
|
436
691
|
(async () => {
|
|
437
692
|
for await (const s of nc.status()) {
|
|
693
|
+
log(`NATS status: ${s.type}`);
|
|
438
694
|
if (s.type === 'reconnect') {
|
|
439
695
|
log('NATS reconnected — running reconciliation');
|
|
440
696
|
reconcile().catch(err => log(`RECONCILE on reconnect failed: ${err.message}`));
|
|
441
697
|
}
|
|
442
698
|
}
|
|
443
699
|
})();
|
|
700
|
+
nc.closed().then(() => {
|
|
701
|
+
log('NATS connection permanently closed — exiting for launchd restart');
|
|
702
|
+
process.exit(1);
|
|
703
|
+
});
|
|
444
704
|
|
|
445
705
|
// Reconcile any orphaned tasks from a previous crash (#2, #10)
|
|
446
706
|
await reconcile();
|
|
@@ -454,7 +714,12 @@ async function main() {
|
|
|
454
714
|
for await (const msg of sub) {
|
|
455
715
|
try {
|
|
456
716
|
const payload = JSON.parse(sc.decode(msg.data));
|
|
457
|
-
|
|
717
|
+
// Route plan events separately (they use plan_id, not task_id)
|
|
718
|
+
if (payload.event && payload.event.startsWith('plan.')) {
|
|
719
|
+
handlePlanEvent(payload.event, payload.plan || payload);
|
|
720
|
+
} else {
|
|
721
|
+
handleEvent(payload.event, payload.task_id, payload.task);
|
|
722
|
+
}
|
|
458
723
|
} catch (err) {
|
|
459
724
|
log(`ERROR parsing event: ${err.message}`);
|
|
460
725
|
}
|
|
@@ -467,13 +732,11 @@ async function main() {
|
|
|
467
732
|
|
|
468
733
|
// Dispatch loop (polls active-tasks.md)
|
|
469
734
|
while (running) {
|
|
470
|
-
let lastAttemptedTask = null;
|
|
471
735
|
try {
|
|
472
736
|
// Only dispatch if no active tasks in the mesh from this bridge
|
|
473
737
|
if (dispatched.size === 0) {
|
|
474
738
|
const task = findDispatchable();
|
|
475
739
|
if (task) {
|
|
476
|
-
lastAttemptedTask = task;
|
|
477
740
|
await dispatchTask(task);
|
|
478
741
|
consecutiveSubmitFailures = 0; // reset on success
|
|
479
742
|
}
|
|
@@ -498,11 +761,12 @@ async function main() {
|
|
|
498
761
|
log(`DISPATCH ERROR (${consecutiveSubmitFailures}/${MAX_SUBMIT_FAILURES}): ${err.message}`);
|
|
499
762
|
|
|
500
763
|
if (consecutiveSubmitFailures >= MAX_SUBMIT_FAILURES) {
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
764
|
+
// Find the task we were trying to dispatch and mark it blocked
|
|
765
|
+
const failedTask = findDispatchable();
|
|
766
|
+
if (failedTask) {
|
|
767
|
+
log(`BLOCKING: ${failedTask.task_id} after ${MAX_SUBMIT_FAILURES} consecutive submit failures`);
|
|
504
768
|
try {
|
|
505
|
-
updateTaskInPlace(ACTIVE_TASKS_PATH,
|
|
769
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, failedTask.task_id, {
|
|
506
770
|
status: 'blocked',
|
|
507
771
|
next_action: `Mesh submit failed ${MAX_SUBMIT_FAILURES}x — check NATS connectivity`,
|
|
508
772
|
updated_at: isoTimestamp(),
|
|
@@ -25,8 +25,12 @@ const os = require('os');
|
|
|
25
25
|
|
|
26
26
|
const NODE_ID = process.env.OPENCLAW_NODE_ID ||
|
|
27
27
|
os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
28
|
+
// NOTE: REPO_DIR defaults to ~/openclaw (runtime). The git repo lives at
|
|
29
|
+
// ~/openclaw-node. See mesh-deploy.js "Two-directory problem" comment.
|
|
28
30
|
const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
|
|
29
|
-
path.join(os.homedir(), 'openclaw
|
|
31
|
+
path.join(os.homedir(), 'openclaw');
|
|
32
|
+
const REPO_REMOTE_URL = process.env.OPENCLAW_REPO_URL ||
|
|
33
|
+
'https://github.com/moltyguibros-design/openclaw-node.git';
|
|
30
34
|
const DEPLOY_SCRIPT = path.join(REPO_DIR, 'bin', 'mesh-deploy.js');
|
|
31
35
|
|
|
32
36
|
const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
|
|
@@ -60,122 +64,140 @@ let deploying = false; // prevent concurrent deploys
|
|
|
60
64
|
|
|
61
65
|
async function executeDeploy(trigger, resultsKv, nodesKv) {
|
|
62
66
|
if (deploying) {
|
|
63
|
-
console.log(`[deploy-listener] Already deploying — ignoring trigger for ${trigger.sha}`);
|
|
67
|
+
console.log(`[deploy-listener] Already deploying — ignoring trigger for ${trigger.sha} (from ${trigger.initiator || 'unknown'})`);
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
deploying = true;
|
|
68
72
|
const startedAt = new Date().toISOString();
|
|
69
|
-
|
|
73
|
+
// Sanitize sha for NATS KV key safety (KV rejects whitespace, path seps, etc.)
|
|
74
|
+
const safeSha = (trigger.sha || 'unknown').replace(/[^a-fA-F0-9.-]/g, '');
|
|
75
|
+
const resultKey = `${safeSha}-${NODE_ID}`;
|
|
70
76
|
|
|
71
|
-
console.log(`[deploy-listener] ═══ Deploy triggered: ${trigger.sha} by ${trigger.initiator} ═══`);
|
|
72
|
-
|
|
73
|
-
// Write "deploying" status so lead sees we're working
|
|
74
77
|
try {
|
|
75
|
-
|
|
76
|
-
nodeId: NODE_ID, sha: trigger.sha, status: 'deploying', startedAt,
|
|
77
|
-
})));
|
|
78
|
-
} catch {}
|
|
78
|
+
console.log(`[deploy-listener] ═══ Deploy triggered: ${trigger.sha} by ${trigger.initiator} ═══`);
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
durationSeconds: 0,
|
|
87
|
-
componentsDeployed: [],
|
|
88
|
-
warnings: [],
|
|
89
|
-
errors: [],
|
|
90
|
-
log: '',
|
|
91
|
-
};
|
|
80
|
+
// Write "deploying" status so lead sees we're working
|
|
81
|
+
try {
|
|
82
|
+
await resultsKv.put(resultKey, sc.encode(JSON.stringify({
|
|
83
|
+
nodeId: NODE_ID, sha: trigger.sha, status: 'deploying', startedAt,
|
|
84
|
+
})));
|
|
85
|
+
} catch {}
|
|
92
86
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
const result = {
|
|
88
|
+
nodeId: NODE_ID,
|
|
89
|
+
sha: trigger.sha,
|
|
90
|
+
status: 'success',
|
|
91
|
+
startedAt,
|
|
92
|
+
completedAt: null,
|
|
93
|
+
durationSeconds: 0,
|
|
94
|
+
componentsDeployed: [],
|
|
95
|
+
warnings: [],
|
|
96
|
+
errors: [],
|
|
97
|
+
log: '',
|
|
98
|
+
};
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
try {
|
|
101
|
+
// Validate branch name to prevent command injection (trigger.branch comes from NATS)
|
|
102
|
+
const branch = (trigger.branch || 'main').replace(/[^a-zA-Z0-9._/-]/g, '');
|
|
103
|
+
if (!branch || branch !== (trigger.branch || 'main')) {
|
|
104
|
+
throw new Error(`Invalid branch name: ${trigger.branch}`);
|
|
105
|
+
}
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
107
|
+
// Bootstrap git repo if directory exists but .git doesn't
|
|
108
|
+
// (provisioner may have copied files without git clone)
|
|
109
|
+
if (!fs.existsSync(path.join(REPO_DIR, '.git'))) {
|
|
110
|
+
if (!fs.existsSync(REPO_DIR)) {
|
|
111
|
+
throw new Error(`Repo dir not found at ${REPO_DIR}`);
|
|
112
|
+
}
|
|
113
|
+
console.log(`[deploy-listener] No .git found — bootstrapping git repo`);
|
|
114
|
+
execSync('git init', { cwd: REPO_DIR, encoding: 'utf8', timeout: 10000 });
|
|
115
|
+
execSync(`git remote add origin ${REPO_REMOTE_URL}`, {
|
|
116
|
+
cwd: REPO_DIR, encoding: 'utf8', timeout: 10000,
|
|
117
|
+
});
|
|
118
|
+
execSync(`git fetch origin ${branch}`, {
|
|
119
|
+
cwd: REPO_DIR, encoding: 'utf8', timeout: 60000,
|
|
120
|
+
});
|
|
121
|
+
execSync(`git reset --hard origin/${branch}`, {
|
|
122
|
+
cwd: REPO_DIR, encoding: 'utf8', timeout: 30000,
|
|
123
|
+
});
|
|
124
|
+
console.log(`[deploy-listener] Git bootstrapped from origin/${branch}`);
|
|
125
|
+
} else {
|
|
126
|
+
// Normal path: fetch + ff merge
|
|
127
|
+
execSync(`git fetch origin ${branch}`, {
|
|
128
|
+
cwd: REPO_DIR, encoding: 'utf8', timeout: 60000,
|
|
129
|
+
});
|
|
130
|
+
execSync(`git merge origin/${branch} --ff-only`, {
|
|
131
|
+
cwd: REPO_DIR, encoding: 'utf8', timeout: 30000,
|
|
132
|
+
});
|
|
125
133
|
}
|
|
126
|
-
for (const c of applicable) cmd += ` --component ${c}`;
|
|
127
|
-
}
|
|
128
|
-
if (trigger.force) cmd += ' --force';
|
|
129
|
-
|
|
130
|
-
console.log(`[deploy-listener] Running: ${cmd}`);
|
|
131
|
-
const output = execSync(cmd, {
|
|
132
|
-
cwd: REPO_DIR,
|
|
133
|
-
encoding: 'utf8',
|
|
134
|
-
timeout: 300000, // 5 min max (npm install can be slow)
|
|
135
|
-
env: { ...process.env, OPENCLAW_REPO_DIR: REPO_DIR },
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
result.log = output.slice(-5000);
|
|
139
|
-
result.status = 'success';
|
|
140
|
-
result.sha = execSync('git rev-parse --short HEAD', {
|
|
141
|
-
cwd: REPO_DIR, encoding: 'utf8',
|
|
142
|
-
}).trim();
|
|
143
134
|
|
|
144
|
-
|
|
135
|
+
// Build deploy command — filter requested components against what this node runs
|
|
136
|
+
let cmd = `"${process.execPath}" "${DEPLOY_SCRIPT}" --local`;
|
|
137
|
+
if (trigger.components && !trigger.components.includes('all')) {
|
|
138
|
+
const applicable = trigger.components.filter(c => NODE_COMPONENTS.has(c));
|
|
139
|
+
if (applicable.length === 0) {
|
|
140
|
+
console.log(`[deploy-listener] No applicable components for role=${NODE_ROLE} — skipping`);
|
|
141
|
+
result.status = 'skipped';
|
|
142
|
+
result.log = `No matching components for role ${NODE_ROLE}`;
|
|
143
|
+
result.completedAt = new Date().toISOString();
|
|
144
|
+
try { await resultsKv.put(resultKey, sc.encode(JSON.stringify(result))); } catch {}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const c of applicable) cmd += ` --component ${c}`;
|
|
148
|
+
}
|
|
149
|
+
if (trigger.force) cmd += ' --force';
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
console.log(`[deploy-listener] Running: ${cmd}`);
|
|
152
|
+
const output = execSync(cmd, {
|
|
153
|
+
cwd: REPO_DIR,
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
timeout: 300000, // 5 min max (npm install can be slow)
|
|
156
|
+
env: { ...process.env, OPENCLAW_REPO_DIR: REPO_DIR },
|
|
157
|
+
});
|
|
152
158
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
result.log = output.slice(-5000);
|
|
160
|
+
result.status = 'success';
|
|
161
|
+
result.sha = execSync('git rev-parse --short HEAD', {
|
|
162
|
+
cwd: REPO_DIR, encoding: 'utf8',
|
|
163
|
+
}).trim();
|
|
157
164
|
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
await resultsKv.put(resultKey, sc.encode(JSON.stringify(result)));
|
|
161
|
-
} catch (err) {
|
|
162
|
-
console.error(`[deploy-listener] Failed to write result: ${err.message}`);
|
|
163
|
-
}
|
|
165
|
+
console.log(`[deploy-listener] Success — now at ${result.sha}`);
|
|
164
166
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
} catch (err) {
|
|
168
|
+
result.status = 'failed';
|
|
169
|
+
result.errors.push(err.message);
|
|
170
|
+
result.log = (err.stdout || err.stderr || err.message).slice(-5000);
|
|
171
|
+
console.error(`[deploy-listener] Deploy FAILED: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
result.completedAt = new Date().toISOString();
|
|
175
|
+
result.durationSeconds = Math.round(
|
|
176
|
+
(new Date(result.completedAt) - new Date(result.startedAt)) / 1000
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Write final result to KV
|
|
167
180
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
node.lastDeploy = result.completedAt;
|
|
173
|
-
await nodesKv.put(NODE_ID, sc.encode(JSON.stringify(node)));
|
|
174
|
-
}
|
|
175
|
-
} catch {}
|
|
176
|
-
}
|
|
181
|
+
await resultsKv.put(resultKey, sc.encode(JSON.stringify(result)));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error(`[deploy-listener] Failed to write result: ${err.message}`);
|
|
184
|
+
}
|
|
177
185
|
|
|
178
|
-
|
|
186
|
+
// Update our deployVersion in the nodes registry
|
|
187
|
+
if (result.status === 'success' && nodesKv) {
|
|
188
|
+
try {
|
|
189
|
+
const existing = await nodesKv.get(NODE_ID);
|
|
190
|
+
if (existing && existing.value) {
|
|
191
|
+
const node = JSON.parse(sc.decode(existing.value));
|
|
192
|
+
node.deployVersion = result.sha;
|
|
193
|
+
node.lastDeploy = result.completedAt;
|
|
194
|
+
await nodesKv.put(NODE_ID, sc.encode(JSON.stringify(node)));
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
deploying = false;
|
|
200
|
+
}
|
|
179
201
|
}
|
|
180
202
|
|
|
181
203
|
// ── Auto-Catch-Up ────────────────────────────────────────────────────────
|
package/bin/mesh-deploy.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*
|
|
33
33
|
* ENVIRONMENT:
|
|
34
34
|
* OPENCLAW_DEPLOY_BRANCH — git branch (default: main)
|
|
35
|
-
* OPENCLAW_REPO_DIR — repo location (default: ~/openclaw
|
|
35
|
+
* OPENCLAW_REPO_DIR — repo location (default: ~/openclaw)
|
|
36
36
|
* OPENCLAW_NATS — NATS server URL (from env or openclaw.env)
|
|
37
37
|
*/
|
|
38
38
|
|
|
@@ -47,7 +47,15 @@ const crypto = require('crypto');
|
|
|
47
47
|
const IS_MAC = os.platform() === 'darwin';
|
|
48
48
|
const HOME = os.homedir();
|
|
49
49
|
const DEPLOY_BRANCH = process.env.OPENCLAW_DEPLOY_BRANCH || 'main';
|
|
50
|
-
const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(HOME, 'openclaw
|
|
50
|
+
const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(HOME, 'openclaw');
|
|
51
|
+
|
|
52
|
+
// KNOWN ISSUE: Two-directory problem
|
|
53
|
+
// ~/openclaw-node is the git repo (source of truth). mesh-deploy pulls into it,
|
|
54
|
+
// then copies files to ~/openclaw (the runtime location). These can drift if
|
|
55
|
+
// files are edited directly in ~/openclaw without back-porting to the repo.
|
|
56
|
+
// Resolution path: unify to a single directory. Either symlink ~/openclaw →
|
|
57
|
+
// ~/openclaw-node, or change REPO_DIR default + DIRS to point at the same tree.
|
|
58
|
+
// Until then, mesh-deploy.js is the only sanctioned way to propagate changes.
|
|
51
59
|
|
|
52
60
|
// Standard directory layout
|
|
53
61
|
const DIRS = {
|
|
@@ -884,7 +892,7 @@ async function main() {
|
|
|
884
892
|
|
|
885
893
|
if (!fs.existsSync(REPO_DIR)) {
|
|
886
894
|
fail(`Repo not found at ${REPO_DIR}`);
|
|
887
|
-
console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git ${REPO_DIR}`);
|
|
895
|
+
console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git "${REPO_DIR}"`);
|
|
888
896
|
process.exit(1);
|
|
889
897
|
}
|
|
890
898
|
|
|
@@ -29,7 +29,7 @@ const HEALTH_BUCKET = "MESH_NODE_HEALTH";
|
|
|
29
29
|
const KV_TTL_MS = 120_000; // entries expire after 2 minutes if node dies
|
|
30
30
|
|
|
31
31
|
const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
|
|
32
|
-
path.join(os.homedir(), 'openclaw
|
|
32
|
+
path.join(os.homedir(), 'openclaw');
|
|
33
33
|
|
|
34
34
|
const sc = StringCodec();
|
|
35
35
|
const IS_MAC = os.platform() === "darwin";
|