wogiflow 2.4.4 → 2.5.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.
@@ -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
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
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: phaseOrder[member.role] ?? 3
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
  };