wogiflow 2.4.4 → 2.5.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/.claude/commands/wogi-audit.md +26 -0
- package/.claude/commands/wogi-review.md +29 -0
- package/.claude/docs/explore-agents.md +8 -2
- package/lib/workspace-channel-server.js +364 -0
- package/lib/workspace-routing.js +301 -4
- package/lib/workspace.js +379 -43
- package/package.json +1 -1
- package/scripts/flow-schema-drift.js +837 -0
package/lib/workspace-routing.js
CHANGED
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const crypto = require('node:crypto');
|
|
14
|
+
const http = require('node:http');
|
|
15
|
+
|
|
16
|
+
let _workspaceMessages;
|
|
17
|
+
function getWorkspaceMessages() {
|
|
18
|
+
if (!_workspaceMessages) {
|
|
19
|
+
_workspaceMessages = require('./workspace-messages');
|
|
20
|
+
}
|
|
21
|
+
return _workspaceMessages;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Shared phase ordering — used by both getExecutionOrder() and dispatchCrossRepoPlan()
|
|
25
|
+
const PHASE_ORDER = { library: 0, contract: 0, provider: 1, both: 1, consumer: 2, standalone: 3, verify: 4 };
|
|
14
26
|
|
|
15
27
|
// ============================================================
|
|
16
28
|
// Routing Keywords (Criterion 1)
|
|
@@ -384,7 +396,14 @@ Be specific about file names, line numbers, and error messages.`,
|
|
|
384
396
|
*/
|
|
385
397
|
function decomposeToRepoTasks(workspaceRoot, workspaceTask, plan) {
|
|
386
398
|
const configPath = path.join(workspaceRoot, 'wogi-workspace.json');
|
|
387
|
-
|
|
399
|
+
let config;
|
|
400
|
+
try {
|
|
401
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
402
|
+
config = JSON.parse(raw);
|
|
403
|
+
if (!config || typeof config !== 'object') return [];
|
|
404
|
+
} catch (_err) {
|
|
405
|
+
return []; // Graceful fallback — config missing or malformed
|
|
406
|
+
}
|
|
388
407
|
const createdTasks = [];
|
|
389
408
|
|
|
390
409
|
for (const step of plan.executionOrder) {
|
|
@@ -442,7 +461,6 @@ function decomposeToRepoTasks(workspaceRoot, workspaceTask, plan) {
|
|
|
442
461
|
*/
|
|
443
462
|
function getExecutionOrder(manifest, targetRepos) {
|
|
444
463
|
const order = [];
|
|
445
|
-
const phaseOrder = { library: 0, provider: 1, both: 1, consumer: 2, standalone: 3 };
|
|
446
464
|
|
|
447
465
|
for (const name of targetRepos) {
|
|
448
466
|
const member = manifest.members[name];
|
|
@@ -451,7 +469,7 @@ function getExecutionOrder(manifest, targetRepos) {
|
|
|
451
469
|
name,
|
|
452
470
|
role: member.role,
|
|
453
471
|
phase: member.role,
|
|
454
|
-
order:
|
|
472
|
+
order: PHASE_ORDER[member.role] ?? 3
|
|
455
473
|
});
|
|
456
474
|
}
|
|
457
475
|
|
|
@@ -461,6 +479,277 @@ function getExecutionOrder(manifest, targetRepos) {
|
|
|
461
479
|
return order;
|
|
462
480
|
}
|
|
463
481
|
|
|
482
|
+
// ============================================================
|
|
483
|
+
// Channel-Based Dispatch (wf-d4b98f60)
|
|
484
|
+
// ============================================================
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Send an HTTP POST to a worker's channel server.
|
|
488
|
+
*
|
|
489
|
+
* @param {string} host — hostname (default '127.0.0.1')
|
|
490
|
+
* @param {number} port — worker's channel port
|
|
491
|
+
* @param {string} message — message body to send
|
|
492
|
+
* @param {Object} [opts] — options
|
|
493
|
+
* @param {string} [opts.from] — sender identifier
|
|
494
|
+
* @param {number} [opts.timeout] — request timeout in ms (default 5000)
|
|
495
|
+
* @returns {Promise<{ ok: boolean, status: number, body: string }>}
|
|
496
|
+
*/
|
|
497
|
+
function httpPost(host, port, message, opts = {}) {
|
|
498
|
+
const timeout = opts.timeout ?? 5000;
|
|
499
|
+
const from = opts.from ?? 'workspace-manager';
|
|
500
|
+
const buf = Buffer.from(message, 'utf-8');
|
|
501
|
+
|
|
502
|
+
return new Promise((resolve) => {
|
|
503
|
+
const req = http.request({
|
|
504
|
+
hostname: host,
|
|
505
|
+
port,
|
|
506
|
+
path: '/',
|
|
507
|
+
method: 'POST',
|
|
508
|
+
headers: {
|
|
509
|
+
'Content-Type': 'text/plain',
|
|
510
|
+
'Content-Length': buf.byteLength,
|
|
511
|
+
'X-Wogi-From': from
|
|
512
|
+
},
|
|
513
|
+
timeout
|
|
514
|
+
}, (res) => {
|
|
515
|
+
const chunks = [];
|
|
516
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
517
|
+
res.on('end', () => resolve({ ok: res.statusCode === 200, status: res.statusCode, body: Buffer.concat(chunks).toString('utf-8') }));
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
req.on('error', (err) => resolve({ ok: false, status: 0, body: err.message }));
|
|
521
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, status: 0, body: 'timeout' }); });
|
|
522
|
+
req.write(buf);
|
|
523
|
+
req.end();
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Check if a worker's channel server is running.
|
|
529
|
+
*
|
|
530
|
+
* @param {number} port
|
|
531
|
+
* @returns {Promise<{ up: boolean, repo: string }>}
|
|
532
|
+
*/
|
|
533
|
+
function checkWorkerHealth(port) {
|
|
534
|
+
return new Promise((resolve) => {
|
|
535
|
+
const req = http.request({
|
|
536
|
+
hostname: '127.0.0.1',
|
|
537
|
+
port,
|
|
538
|
+
path: '/health',
|
|
539
|
+
method: 'GET',
|
|
540
|
+
timeout: 3000
|
|
541
|
+
}, (res) => {
|
|
542
|
+
let body = '';
|
|
543
|
+
res.on('data', chunk => { body += chunk; });
|
|
544
|
+
res.on('end', () => {
|
|
545
|
+
try {
|
|
546
|
+
const data = JSON.parse(body);
|
|
547
|
+
resolve({ up: data.status === 'ok', repo: data.repo || 'unknown' });
|
|
548
|
+
} catch (_err) {
|
|
549
|
+
resolve({ up: false, repo: 'unknown' });
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
req.on('error', () => resolve({ up: false, repo: 'unknown' }));
|
|
555
|
+
req.on('timeout', () => { req.destroy(); resolve({ up: false, repo: 'unknown' }); });
|
|
556
|
+
req.end();
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Dispatch a task to a single worker via its channel.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} workspaceRoot
|
|
564
|
+
* @param {string} repoName — target repo
|
|
565
|
+
* @param {string} taskId — task ID to start
|
|
566
|
+
* @returns {Promise<{ ok: boolean, message: string }>}
|
|
567
|
+
*/
|
|
568
|
+
async function dispatchToChannel(workspaceRoot, repoName, taskId) {
|
|
569
|
+
// Validate taskId format to prevent injection into channel body
|
|
570
|
+
if (!/^wf-[0-9a-f]{8}$/i.test(taskId)) {
|
|
571
|
+
return { ok: false, message: `Invalid task ID format: "${taskId}" — expected wf-XXXXXXXX` };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const configPath = path.join(workspaceRoot, 'wogi-workspace.json');
|
|
575
|
+
let config;
|
|
576
|
+
try {
|
|
577
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
578
|
+
config = JSON.parse(raw);
|
|
579
|
+
if (!config || typeof config !== 'object') throw new Error('Invalid config format');
|
|
580
|
+
} catch (err) {
|
|
581
|
+
return { ok: false, message: `Cannot read workspace config: ${err.message}` };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const channelConfig = config.channels?.members?.[repoName];
|
|
585
|
+
if (!channelConfig) {
|
|
586
|
+
return { ok: false, message: `No channel config for repo "${repoName}"` };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const port = channelConfig.port;
|
|
590
|
+
|
|
591
|
+
// Health check first
|
|
592
|
+
const health = await checkWorkerHealth(port);
|
|
593
|
+
if (!health.up) {
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
message: `Worker "${repoName}" is not running on port ${port}. Start it with: cd ${repoName}/ && flow workspace start`
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Dispatch the task
|
|
601
|
+
const result = await httpPost('127.0.0.1', port, `/wogi-start ${taskId}`);
|
|
602
|
+
if (result.ok) {
|
|
603
|
+
return { ok: true, message: `Dispatched /wogi-start ${taskId} to ${repoName} (port ${port})` };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return { ok: false, message: `Dispatch failed: HTTP ${result.status} — ${result.body}` };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Dispatch a cross-repo execution plan to workers.
|
|
611
|
+
* Respects phase ordering: library → provider → consumer.
|
|
612
|
+
* Within a phase, dispatches in parallel. Between phases, waits for completion.
|
|
613
|
+
*
|
|
614
|
+
* @param {string} workspaceRoot
|
|
615
|
+
* @param {Array} createdTasks — from decomposeToRepoTasks()
|
|
616
|
+
* @param {Object} [opts]
|
|
617
|
+
* @param {boolean} [opts.parallel] — dispatch all at once ignoring phases (default false)
|
|
618
|
+
* @returns {Promise<{ dispatched: Array, failed: Array }>}
|
|
619
|
+
*/
|
|
620
|
+
async function dispatchCrossRepoPlan(workspaceRoot, createdTasks, opts = {}) {
|
|
621
|
+
const dispatched = [];
|
|
622
|
+
const failed = [];
|
|
623
|
+
|
|
624
|
+
if (opts.parallel) {
|
|
625
|
+
// Dispatch all tasks at once
|
|
626
|
+
const results = await Promise.all(
|
|
627
|
+
createdTasks.map(ct =>
|
|
628
|
+
dispatchToChannel(workspaceRoot, ct.repo, ct.task.id)
|
|
629
|
+
.then(r => ({ ...r, repo: ct.repo, taskId: ct.task.id, phase: ct.phase }))
|
|
630
|
+
)
|
|
631
|
+
);
|
|
632
|
+
for (const r of results) {
|
|
633
|
+
(r.ok ? dispatched : failed).push(r);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
// Group by phase order: library(0) → provider(1) → consumer(2) → standalone(3)
|
|
637
|
+
const grouped = {};
|
|
638
|
+
for (const ct of createdTasks) {
|
|
639
|
+
const order = PHASE_ORDER[ct.phase] ?? 3;
|
|
640
|
+
if (!grouped[order]) grouped[order] = [];
|
|
641
|
+
grouped[order].push(ct);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Execute phases in order
|
|
645
|
+
const sortedPhases = Object.keys(grouped).map(Number).sort((a, b) => a - b);
|
|
646
|
+
for (const phase of sortedPhases) {
|
|
647
|
+
const tasks = grouped[phase];
|
|
648
|
+
const results = await Promise.all(
|
|
649
|
+
tasks.map(ct =>
|
|
650
|
+
dispatchToChannel(workspaceRoot, ct.repo, ct.task.id)
|
|
651
|
+
.then(r => ({ ...r, repo: ct.repo, taskId: ct.task.id, phase: ct.phase }))
|
|
652
|
+
)
|
|
653
|
+
);
|
|
654
|
+
for (const r of results) {
|
|
655
|
+
(r.ok ? dispatched : failed).push(r);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Wait for this phase to complete before starting the next
|
|
659
|
+
const phaseTaskIds = results.filter(r => r.ok).map(r => r.taskId);
|
|
660
|
+
if (phaseTaskIds.length > 0 && phase !== sortedPhases[sortedPhases.length - 1]) {
|
|
661
|
+
const completion = await waitForCompletion(workspaceRoot, phaseTaskIds, {
|
|
662
|
+
timeoutMs: opts.phaseTimeoutMs ?? 15 * 60 * 1000 // 15 min per phase
|
|
663
|
+
});
|
|
664
|
+
if (completion.timedOut) {
|
|
665
|
+
// Abort further phases — provider didn't finish
|
|
666
|
+
failed.push({ ok: false, message: `Phase timed out waiting for: ${completion.pending.join(', ')}`, phase });
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return { dispatched, failed };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ============================================================
|
|
677
|
+
// Completion Monitoring (wf-d4b98f60)
|
|
678
|
+
// ============================================================
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Wait for workspace tasks to complete by polling the message bus.
|
|
682
|
+
*
|
|
683
|
+
* @param {string} workspaceRoot
|
|
684
|
+
* @param {string[]} taskIds — task IDs to wait for
|
|
685
|
+
* @param {Object} [opts]
|
|
686
|
+
* @param {number} [opts.pollIntervalMs] — poll interval (default 5000)
|
|
687
|
+
* @param {number} [opts.timeoutMs] — max wait time (default 1800000 = 30min)
|
|
688
|
+
* @returns {Promise<{ completed: string[], pending: string[], timedOut: boolean }>}
|
|
689
|
+
*/
|
|
690
|
+
async function waitForCompletion(workspaceRoot, taskIds, opts = {}) {
|
|
691
|
+
const pollInterval = opts.pollIntervalMs ?? 5000;
|
|
692
|
+
const timeout = opts.timeoutMs ?? 30 * 60 * 1000;
|
|
693
|
+
const startTime = Date.now();
|
|
694
|
+
const startIso = new Date(startTime).toISOString();
|
|
695
|
+
const completed = new Set();
|
|
696
|
+
const taskIdSet = new Set(taskIds);
|
|
697
|
+
|
|
698
|
+
let readMessages, updateMessageStatus;
|
|
699
|
+
try {
|
|
700
|
+
const bus = getWorkspaceMessages();
|
|
701
|
+
readMessages = bus.readMessages;
|
|
702
|
+
updateMessageStatus = bus.updateMessageStatus;
|
|
703
|
+
} catch (_err) {
|
|
704
|
+
return { completed: [], pending: [...taskIds], timedOut: false, error: 'Cannot load workspace-messages module' };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
while (completed.size < taskIds.length) {
|
|
708
|
+
if (Date.now() - startTime > timeout) {
|
|
709
|
+
return {
|
|
710
|
+
completed: [...completed],
|
|
711
|
+
pending: taskIds.filter(id => !completed.has(id)),
|
|
712
|
+
timedOut: true
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Read task-complete messages created AFTER we started waiting
|
|
717
|
+
try {
|
|
718
|
+
const messages = readMessages(workspaceRoot, { type: 'task-complete', status: 'pending' });
|
|
719
|
+
for (const msg of messages) {
|
|
720
|
+
// Only consider messages created after we started waiting
|
|
721
|
+
if (msg.timestamp && msg.timestamp < startIso) continue;
|
|
722
|
+
|
|
723
|
+
// Exact match on structured taskId field, or fallback to subject
|
|
724
|
+
const msgTaskId = msg.taskId || msg.subject;
|
|
725
|
+
if (msgTaskId && taskIdSet.has(msgTaskId) && !completed.has(msgTaskId)) {
|
|
726
|
+
completed.add(msgTaskId);
|
|
727
|
+
// Mark message as acknowledged so it's not re-processed
|
|
728
|
+
try {
|
|
729
|
+
if (updateMessageStatus) {
|
|
730
|
+
updateMessageStatus(workspaceRoot, msg.id, 'acknowledged');
|
|
731
|
+
}
|
|
732
|
+
} catch (_err) {
|
|
733
|
+
// Non-critical
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} catch (_err) {
|
|
738
|
+
// Non-critical — retry on next poll
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (completed.size < taskIds.length) {
|
|
742
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
completed: [...completed],
|
|
748
|
+
pending: [],
|
|
749
|
+
timedOut: false
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
464
753
|
// ============================================================
|
|
465
754
|
// Exports
|
|
466
755
|
// ============================================================
|
|
@@ -481,5 +770,13 @@ module.exports = {
|
|
|
481
770
|
decomposeToRepoTasks,
|
|
482
771
|
|
|
483
772
|
// Ordering
|
|
484
|
-
getExecutionOrder
|
|
773
|
+
getExecutionOrder,
|
|
774
|
+
|
|
775
|
+
// Channel dispatch
|
|
776
|
+
dispatchToChannel,
|
|
777
|
+
dispatchCrossRepoPlan,
|
|
778
|
+
checkWorkerHealth,
|
|
779
|
+
|
|
780
|
+
// Completion monitoring
|
|
781
|
+
waitForCompletion
|
|
485
782
|
};
|