@cgh567/agent 2.4.2 → 2.4.3

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.
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  /**
3
- * helios-api.js — REST API for Helios daemon (port 9091)
3
+ * helios-api.js — REST API for Helios daemon
4
4
  *
5
5
  * Endpoints:
6
6
  * GET /api/health
@@ -48,8 +48,8 @@ const TRANSCRIPTS_DIR = path.join(__dirname, 'transcripts');
48
48
  const _transcriptStore = new TranscriptStore(TRANSCRIPTS_DIR);
49
49
  const CORS_HEADERS = {
50
50
  'Access-Control-Allow-Origin': '*',
51
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
52
- 'Access-Control-Allow-Headers': 'Content-Type, Accept',
51
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
52
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
53
53
  };
54
54
 
55
55
  // ── Logging ────────────────────────────────────────────────────────────────────────────────
@@ -61,6 +61,14 @@ function log(level, msg, extra) {
61
61
  else process.stdout.write(JSON.stringify(entry) + '\n');
62
62
  }
63
63
 
64
+ function mirrorHboStore(label, write) {
65
+ try {
66
+ if (typeof write === 'function') write();
67
+ } catch (err) {
68
+ log('warn', `hbo-core mirror failed: ${label}`, { error: err.message });
69
+ }
70
+ }
71
+
64
72
  // ── SSE Client Registry ───────────────────────────────────────────────────────
65
73
 
66
74
  const sseClients = new Set();
@@ -260,6 +268,7 @@ async function handleHealth(req, res, ctx) {
260
268
  const { tickCount, modules } = ctx;
261
269
  jsonResponse(res, 200, {
262
270
  status: 'ok',
271
+ port: ctx.actualBoundPort ?? ctx.apiPort ?? null,
263
272
  tick: tickCount ?? 0,
264
273
  modules: modules ?? [],
265
274
  timestamp: new Date().toISOString(),
@@ -374,13 +383,58 @@ async function handleCreateTask(req, res, ctx) {
374
383
  const cid = ctx.cid;
375
384
 
376
385
  const body = await parseBody(req);
377
- const { title, assigneeAgentId, priority, body: taskBody } = body;
386
+ const { title, assigneeAgentId, priority, body: taskBody, executionPolicy } = body;
378
387
 
379
388
  if (!title || typeof title !== 'string' || title.trim() === '') {
380
389
  jsonResponse(res, 400, { error: 'title is required and must be a non-empty string' });
381
390
  return;
382
391
  }
383
392
 
393
+ // P4-02: validate executionPolicy if provided
394
+ let validatedPolicyJson = null;
395
+ if (executionPolicy !== undefined) {
396
+ if (!executionPolicy || typeof executionPolicy !== 'object' || !Array.isArray(executionPolicy.stages)) {
397
+ jsonResponse(res, 400, { error: 'executionPolicy must have a stages array' });
398
+ return;
399
+ }
400
+ if (executionPolicy.stages.length === 0) {
401
+ jsonResponse(res, 400, { error: 'executionPolicy.stages must not be empty' });
402
+ return;
403
+ }
404
+ for (const stage of executionPolicy.stages) {
405
+ if (!stage.type || !['review', 'approval'].includes(stage.type)) {
406
+ jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval', got: ${stage.type}` });
407
+ return;
408
+ }
409
+ if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
410
+ jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
411
+ return;
412
+ }
413
+ }
414
+ // M-1 fix: validate ALL participants in a single batched query instead of N sequential queries
415
+ const allParticipantIds = [...new Set(
416
+ executionPolicy.stages.flatMap(s => s.participants.map(p => String(p)))
417
+ )];
418
+ const agentCheckResult = await mgQuery(
419
+ `MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
420
+ { cid, pids: allParticipantIds }
421
+ );
422
+ const foundIds = new Set(
423
+ (parseRows(agentCheckResult)[0]?.[0] ?? parseRows(agentCheckResult)[0]?.['found'] ?? [])
424
+ );
425
+ const missingIds = allParticipantIds.filter(id => !foundIds.has(id));
426
+ if (missingIds.length > 0) {
427
+ jsonResponse(res, 400, { error: `participants not found as BusinessAgents in this company: ${missingIds.join(', ')}` });
428
+ return;
429
+ }
430
+ validatedPolicyJson = JSON.stringify(executionPolicy);
431
+ }
432
+
433
+ // P4-03: initial executionState — 'idle' when policy exists, null string otherwise
434
+ const initialStateJson = validatedPolicyJson
435
+ ? JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] })
436
+ : 'null';
437
+
384
438
  const taskId = `task:api:${crypto.randomUUID()}`;
385
439
  const resolvedPriority = Number.isFinite(Number(priority)) ? Number(priority) : 2;
386
440
 
@@ -394,6 +448,8 @@ async function handleCreateTask(req, res, ctx) {
394
448
  priority: toInteger($priority),
395
449
  assigneeAgentId: $assigneeAgentId,
396
450
  body: $body,
451
+ executionPolicyJson: $policyJson,
452
+ executionStateJson: $stateJson,
397
453
  createdAt: datetime()
398
454
  })`,
399
455
  {
@@ -403,11 +459,26 @@ async function handleCreateTask(req, res, ctx) {
403
459
  priority: resolvedPriority,
404
460
  assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
405
461
  body: taskBody ? String(taskBody) : null,
462
+ policyJson: validatedPolicyJson,
463
+ stateJson: initialStateJson,
406
464
  }
407
465
  );
408
466
 
467
+ mirrorHboStore('task.create', () => hboStore.createTask?.({
468
+ id: taskId,
469
+ companyId: cid,
470
+ title: String(title).trim(),
471
+ status: 'todo',
472
+ priority: resolvedPriority,
473
+ assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
474
+ body: taskBody ? String(taskBody) : null,
475
+ executionPolicyJson: validatedPolicyJson,
476
+ executionStateJson: initialStateJson,
477
+ createdAt: Date.now(),
478
+ }));
479
+
409
480
  broadcast({ type: 'task.created', taskId, status: 'todo', companyId: cid });
410
- jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId } });
481
+ jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId, executionPolicyJson: validatedPolicyJson } });
411
482
  } catch (err) {
412
483
  jsonResponse(res, 500, { error: `Create failed: ${err.message}` });
413
484
  }
@@ -422,7 +493,8 @@ async function handleUpdateTask(req, res, ctx, taskId) {
422
493
  const body = await parseBody(req);
423
494
  const { status, priority } = body;
424
495
 
425
- const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated']);
496
+ // P4-01: 'in_review' added allows execution policy gate to hold a task for review
497
+ const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated', 'in_review']);
426
498
  if (status !== undefined && !VALID_STATUSES.has(status)) {
427
499
  jsonResponse(res, 400, { error: `Invalid status. Allowed: ${[...VALID_STATUSES].join(', ')}` });
428
500
  return;
@@ -438,6 +510,259 @@ async function handleUpdateTask(req, res, ctx, taskId) {
438
510
  }
439
511
 
440
512
  try {
513
+ // ─────────────────────────────────────────────────────────────────────────
514
+ // EXECUTION POLICY STATE MACHINE
515
+ //
516
+ // The state machine has three actors:
517
+ // A) Original executor sets status='done' and task has an active policy
518
+ // → INTERCEPT: redirect to in_review, assign to current stage reviewer
519
+ // B) Reviewer (currently assigned) sets status='done' to approve
520
+ // → ADVANCE: move to next stage, or if last stage, allow done
521
+ // C) Reviewer sets any non-done status to reject/request changes
522
+ // → CHANGES_REQUESTED: return task to original executor
523
+ //
524
+ // C-1 fix: The intercept (actor A) uses TWO queries, but the second (SET) query
525
+ // re-checks the WHERE condition with the expected policyJson — if policy changed
526
+ // between reads, the second query returns 0 rows and we return 409 (optimistic
527
+ // concurrency control). This correctly closes the TOCTOU window.
528
+ // ─────────────────────────────────────────────────────────────────────────
529
+
530
+ if (status === 'done') {
531
+ // Step 1: Read task's current state (fast metadata read)
532
+ const taskReadResult = await mgQuery(
533
+ `MATCH (t:Task {id: $id, companyId: $cid})
534
+ RETURN t.status AS taskStatus,
535
+ t.assigneeAgentId AS assigneeAgentId,
536
+ t.executionPolicyJson AS policyJson,
537
+ t.executionStateJson AS stateJson`,
538
+ { id: taskId, cid }
539
+ );
540
+ const taskReadRows = parseRows(taskReadResult);
541
+
542
+ if (taskReadRows.length > 0) {
543
+ const taskRow = taskReadRows[0];
544
+ const taskStatus = taskRow[0] ?? taskRow['taskStatus'] ?? null;
545
+ const returnAssignee = taskRow[1] ?? taskRow['assigneeAgentId'] ?? null;
546
+ const policyJson = taskRow[2] ?? taskRow['policyJson'] ?? null;
547
+ const stateJsonRaw = taskRow[3] ?? taskRow['stateJson'] ?? 'null';
548
+
549
+ let policy, state;
550
+ try { policy = policyJson ? JSON.parse(policyJson) : null; } catch (_) { policy = null; }
551
+ try { state = JSON.parse(stateJsonRaw); } catch (_) { state = null; }
552
+ if (state === null && stateJsonRaw === 'null') state = null; // explicit null string
553
+
554
+ // ── ACTOR B: Reviewer approving (task is currently in_review) ───────
555
+ if (taskStatus === 'in_review' && policy && state) {
556
+ const currentStageIdx = typeof state.currentStageIndex === 'number' ? state.currentStageIndex : 0;
557
+ const stages = Array.isArray(policy.stages) ? policy.stages : [];
558
+ const nextStageIdx = currentStageIdx + 1;
559
+
560
+ if (nextStageIdx < stages.length) {
561
+ // Advance to next stage (multi-stage full advancement — C-4 full)
562
+ const nextStage = stages[nextStageIdx];
563
+ const nextParticipant = nextStage?.participants?.[0] ?? null;
564
+ if (nextParticipant) {
565
+ const newState = JSON.stringify({
566
+ status: 'pending',
567
+ currentStageIndex: nextStageIdx,
568
+ currentParticipant: nextParticipant,
569
+ returnAssignee: state.returnAssignee,
570
+ completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
571
+ });
572
+ // Atomic: re-check we're still in_review before advancing
573
+ const advanceResult = await mgQuery(
574
+ `MATCH (t:Task {id: $id, companyId: $cid})
575
+ WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
576
+ SET t.executionStateJson = $stateJson,
577
+ t.assigneeAgentId = $nextReviewer,
578
+ t.updatedAt = datetime()
579
+ RETURN t.id`,
580
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, nextReviewer: nextParticipant }
581
+ );
582
+ if (parseRows(advanceResult).length === 0) {
583
+ jsonResponse(res, 409, { error: 'Concurrent update conflict' });
584
+ return;
585
+ }
586
+ mgQuery(
587
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
588
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
589
+ WITH a, existing WHERE existing IS NULL
590
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
591
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
592
+ { agentId: nextParticipant, cid, sigId: `ars:advance:${taskId}:${crypto.randomUUID()}` }
593
+ ).catch(e => log('warn', `P4 advance AgentReadySignal failed: ${e.message}`));
594
+ mirrorHboStore('task.update.advance_stage', () => hboStore.updateTask?.(taskId, cid, { executionStateJson: newState, assigneeAgentId: nextParticipant }));
595
+ broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
596
+ jsonResponse(res, 200, { updated: true, taskId, advanced: true, nextStage: nextStageIdx, nextReviewer: nextParticipant });
597
+ return;
598
+ }
599
+ }
600
+
601
+ // All stages passed (or last stage reviewer approved) — mark completed and allow done
602
+ const completedState = JSON.stringify({
603
+ ...state,
604
+ status: 'completed',
605
+ completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
606
+ });
607
+ // Atomic: re-check still in_review before marking completed
608
+ const completeResult = await mgQuery(
609
+ `MATCH (t:Task {id: $id, companyId: $cid})
610
+ WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
611
+ SET t.status = 'done',
612
+ t.executionStateJson = $stateJson,
613
+ t.updatedAt = datetime()
614
+ RETURN t.id`,
615
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: completedState }
616
+ );
617
+ if (parseRows(completeResult).length === 0) {
618
+ jsonResponse(res, 409, { error: 'Concurrent update conflict' });
619
+ return;
620
+ }
621
+ mirrorHboStore('task.update.approved', () => hboStore.updateTask?.(taskId, cid, { status: 'done', executionStateJson: completedState }));
622
+ broadcast({ type: 'task.updated', taskId, status: 'done', companyId: cid });
623
+ jsonResponse(res, 200, { updated: true, taskId, approved: true, status: 'done' });
624
+ return;
625
+ }
626
+
627
+ // ── ACTOR A: Original executor marking done with active policy ───────
628
+ if (taskStatus !== 'in_review' && policy) {
629
+ // Check if state is already completed — if so, allow done through
630
+ const alreadyCompleted = state && typeof state === 'object' && state.status === 'completed';
631
+ if (!alreadyCompleted) {
632
+ const firstStage = Array.isArray(policy.stages) && policy.stages.length > 0 ? policy.stages[0] : null;
633
+ const firstParticipant = firstStage?.participants?.[0] ?? null;
634
+
635
+ // C-2 fix: explicit guard — if no valid participant, log and fall through to done
636
+ if (!firstParticipant) {
637
+ log('warn', `P4: task ${taskId} has executionPolicy but no valid first participant — allowing done through`);
638
+ // fall through to standard path below
639
+ } else {
640
+ const newState = JSON.stringify({
641
+ status: 'pending',
642
+ currentStageIndex: 0,
643
+ currentParticipant: firstParticipant,
644
+ returnAssignee, // captured returnAssignee = original executor
645
+ completedStageIds: [],
646
+ });
647
+ // C-1 fix: ATOMIC check+set — WHERE re-checks policyJson so a concurrent
648
+ // policy change causes 0 rows returned → 409 (optimistic concurrency control)
649
+ const atomicResult = await mgQuery(
650
+ `MATCH (t:Task {id: $id, companyId: $cid})
651
+ WHERE t.executionPolicyJson = $expectedPolicy
652
+ AND t.status <> 'in_review'
653
+ AND (t.executionStateJson IS NULL OR NOT t.executionStateJson CONTAINS '"status":"completed"')
654
+ SET t.status = 'in_review',
655
+ t.executionStateJson = $stateJson,
656
+ t.assigneeAgentId = $reviewer,
657
+ t.updatedAt = datetime()
658
+ RETURN t.id AS id`,
659
+ { id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, reviewer: firstParticipant }
660
+ );
661
+ if (parseRows(atomicResult).length === 0) {
662
+ // Policy changed or state changed between reads — 409
663
+ jsonResponse(res, 409, { error: 'Concurrent update conflict — task state changed by another request' });
664
+ return;
665
+ }
666
+ mgQuery(
667
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
668
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
669
+ WITH a, existing WHERE existing IS NULL
670
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
671
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
672
+ { agentId: firstParticipant, cid, sigId: `ars:review:${taskId}:${crypto.randomUUID()}` }
673
+ ).catch(e => log('warn', `P4-04 AgentReadySignal emit failed: ${e.message}`));
674
+ mirrorHboStore('task.update.in_review', () => hboStore.updateTask?.(taskId, cid, { status: 'in_review', executionStateJson: newState, assigneeAgentId: firstParticipant }));
675
+ broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
676
+ jsonResponse(res, 200, { updated: true, taskId, intercepted: true, status: 'in_review', reviewer: firstParticipant });
677
+ return;
678
+ }
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ // ── ACTOR C: Reviewer rejecting / requesting changes ───────────────────
685
+ // C-3 fix: WHERE t.status = 'in_review' added to the SET query (prevents TOCTOU
686
+ // overwriting a task that was concurrently moved out of in_review by another request).
687
+ // FIX-H1: Check stage type to differentiate review vs approval semantics.
688
+ // FIX-M2: Explicit return for state=null case — no fall-through.
689
+ if (status !== undefined && status !== 'done') {
690
+ // Single atomic read+write: check in_review AND apply in one round-trip
691
+ // by embedding the WHERE in the SET query.
692
+ const reviewCheckResult = await mgQuery(
693
+ `MATCH (t:Task {id: $id, companyId: $cid})
694
+ WHERE t.status = 'in_review' AND t.executionStateJson IS NOT NULL
695
+ RETURN t.executionStateJson AS stateJson, t.executionPolicyJson AS policyJson`,
696
+ { id: taskId, cid }
697
+ );
698
+ const reviewRows = parseRows(reviewCheckResult);
699
+ if (reviewRows.length > 0) {
700
+ const reviewRow = reviewRows[0];
701
+ const stateJsonStr = reviewRow[0] ?? reviewRow['stateJson'] ?? 'null';
702
+ const policyJsonStr = reviewRow[1] ?? reviewRow['policyJson'] ?? null;
703
+ let state, policy;
704
+ try { state = JSON.parse(stateJsonStr); } catch (_) { state = null; }
705
+ try { policy = policyJsonStr ? JSON.parse(policyJsonStr) : null; } catch (_) { policy = null; }
706
+
707
+ // M2 fix: if state is null (corrupt JSON), block the standard path on in_review tasks
708
+ if (!state) {
709
+ jsonResponse(res, 409, { error: 'Task is in_review but execution state is corrupt — cannot apply status change' });
710
+ return;
711
+ }
712
+
713
+ if (state.returnAssignee) {
714
+ const returnAssignee = state.returnAssignee;
715
+ const changesState = JSON.stringify({ ...state, status: 'changes_requested' });
716
+ // C-3 fix: WHERE t.status = 'in_review' guards against concurrent status change
717
+ const changesResult = await mgQuery(
718
+ `MATCH (t:Task {id: $id, companyId: $cid})
719
+ WHERE t.status = 'in_review'
720
+ SET t.status = 'in_progress',
721
+ t.assigneeAgentId = $returnAssignee,
722
+ t.executionStateJson = $stateJson,
723
+ t.updatedAt = datetime()
724
+ RETURN t.id`,
725
+ { id: taskId, cid, returnAssignee, stateJson: changesState }
726
+ );
727
+ if (parseRows(changesResult).length === 0) {
728
+ // Concurrent write changed task out of in_review — fall through to standard path
729
+ // (the task is no longer in_review so standard update is correct)
730
+ } else {
731
+ mgQuery(
732
+ `MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
733
+ OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
734
+ WITH a, existing WHERE existing IS NULL
735
+ CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
736
+ status: 'pending', claimedBy: null, createdAt: datetime()})`,
737
+ { agentId: returnAssignee, cid, sigId: `ars:changes:${taskId}:${crypto.randomUUID()}` }
738
+ ).catch(e => log('warn', `P4-05 AgentReadySignal emit failed: ${e.message}`));
739
+ mirrorHboStore('task.update.changes_requested', () => hboStore.updateTask?.(taskId, cid, { status: 'in_progress', executionStateJson: changesState, assigneeAgentId: returnAssignee }));
740
+ broadcast({ type: 'task.updated', taskId, status: 'in_progress', companyId: cid });
741
+ jsonResponse(res, 200, { updated: true, taskId, changesRequested: true, returnAssignee });
742
+ return;
743
+ }
744
+ } else {
745
+ // returnAssignee is null — escalation path (no valid return point)
746
+ // M2 fix: explicit return after escalation to prevent fall-through
747
+ const escalateResult = await mgQuery(
748
+ `MATCH (t:Task {id: $id, companyId: $cid})
749
+ WHERE t.status = 'in_review'
750
+ SET t.status = 'blocked', t.blockedReason = 'escalation_chain_exhausted', t.updatedAt = datetime()
751
+ RETURN t.id`,
752
+ { id: taskId, cid }
753
+ ).catch(() => null);
754
+ if (escalateResult && parseRows(escalateResult).length > 0) {
755
+ mirrorHboStore('task.escalation', () => hboStore.updateTask?.(taskId, cid, { status: 'blocked', blockedReason: 'escalation_chain_exhausted' }));
756
+ broadcast({ type: 'task.blocked', taskId, reason: 'escalation_chain_exhausted', companyId: cid });
757
+ jsonResponse(res, 200, { updated: true, taskId, escalated: true });
758
+ return;
759
+ }
760
+ // If escalate returned 0 rows, task was concurrently moved out of in_review — fall through
761
+ }
762
+ }
763
+ }
764
+
765
+ // Standard update path (no policy intercept applies)
441
766
  const setClauses = [];
442
767
  const params = { id: taskId, cid };
443
768
  if (status !== undefined) { setClauses.push('t.status = $status'); params.status = status; }
@@ -454,6 +779,11 @@ async function handleUpdateTask(req, res, ctx, taskId) {
454
779
  return;
455
780
  }
456
781
 
782
+ const storeUpdate = {};
783
+ if (status !== undefined) storeUpdate.status = status;
784
+ if (priority !== undefined) storeUpdate.priority = Number(priority);
785
+ mirrorHboStore('task.update', () => hboStore.updateTask?.(taskId, cid, storeUpdate));
786
+
457
787
  broadcast({ type: 'task.updated', taskId, ...(status !== undefined ? { status } : {}), companyId: cid });
458
788
  jsonResponse(res, 200, { updated: true, taskId });
459
789
  } catch (err) {
@@ -461,6 +791,76 @@ async function handleUpdateTask(req, res, ctx, taskId) {
461
791
  }
462
792
  }
463
793
 
794
+ // P4-07: PATCH /api/tasks/:id/policy — set or update executionPolicy on an existing task
795
+ async function handlePatchTaskPolicy(req, res, ctx, taskId) {
796
+ const { mgQuery } = ctx;
797
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
798
+ if (!taskId) { jsonResponse(res, 400, { error: 'Missing task ID' }); return; }
799
+ const cid = ctx.cid;
800
+
801
+ const body = await parseBody(req);
802
+ const { policy } = body;
803
+
804
+ if (!policy || typeof policy !== 'object' || !Array.isArray(policy.stages)) {
805
+ jsonResponse(res, 400, { error: 'policy must have a stages array' });
806
+ return;
807
+ }
808
+ if (policy.stages.length === 0) {
809
+ jsonResponse(res, 400, { error: 'policy.stages must not be empty' });
810
+ return;
811
+ }
812
+ for (const stage of policy.stages) {
813
+ if (!stage.type || !['review', 'approval'].includes(stage.type)) {
814
+ jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval'` });
815
+ return;
816
+ }
817
+ if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
818
+ jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
819
+ return;
820
+ }
821
+ }
822
+ // M-1 fix: validate ALL participants in a single batched query
823
+ const allPolicyPids = [...new Set(policy.stages.flatMap(s => s.participants.map(p => String(p))))];
824
+ const policyAgentCheck = await mgQuery(
825
+ `MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
826
+ { cid, pids: allPolicyPids }
827
+ );
828
+ const foundPolicyAgents = new Set(
829
+ (parseRows(policyAgentCheck)[0]?.[0] ?? parseRows(policyAgentCheck)[0]?.['found'] ?? [])
830
+ );
831
+ const missingPolicyAgents = allPolicyPids.filter(id => !foundPolicyAgents.has(id));
832
+ if (missingPolicyAgents.length > 0) {
833
+ jsonResponse(res, 400, { error: `participants not found as BusinessAgents: ${missingPolicyAgents.join(', ')}` });
834
+ return;
835
+ }
836
+
837
+ const policyJson = JSON.stringify(policy);
838
+ // H-5 fix: initial executionStateJson for the policy — ensures state machine starts correctly.
839
+ // Without this, existing tasks with executionStateJson='null' would have returnAssignee=null
840
+ // when P4-04 fires, causing immediate escalation instead of returning to the original executor.
841
+ const initialStateJson = JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] });
842
+ try {
843
+ const result = await mgQuery(
844
+ `MATCH (t:Task {id: $id, companyId: $cid})
845
+ SET t.executionPolicyJson = $policyJson,
846
+ t.executionStateJson = $stateJson,
847
+ t.updatedAt = datetime()
848
+ RETURN t.id`,
849
+ { id: taskId, cid, policyJson, stateJson: initialStateJson }
850
+ );
851
+ if (parseRows(result).length === 0) {
852
+ jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
853
+ return;
854
+ }
855
+ // P4-08: SQLite write-through for policy and initial state
856
+ mirrorHboStore('task.policy', () => hboStore.updateTask?.(taskId, cid, { executionPolicyJson: policyJson, executionStateJson: initialStateJson }));
857
+ broadcast({ type: 'task.policy.updated', taskId, companyId: cid });
858
+ jsonResponse(res, 200, { updated: true, taskId, executionPolicyJson: policyJson });
859
+ } catch (err) {
860
+ jsonResponse(res, 500, { error: `Policy update failed: ${err.message}` });
861
+ }
862
+ }
863
+
464
864
  async function handleCancelTask(req, res, ctx, taskId) {
465
865
  const { mgQuery } = ctx;
466
866
  if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
@@ -481,6 +881,7 @@ async function handleCancelTask(req, res, ctx, taskId) {
481
881
  jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
482
882
  return;
483
883
  }
884
+ mirrorHboStore('task.cancel', () => hboStore.updateTask?.(taskId, cid, { status: 'cancelled', cancelReason: 'api_cancel' }));
484
885
  broadcast({ type: 'task.cancelled', taskId, companyId: cid });
485
886
  jsonResponse(res, 200, { cancelled: true, taskId });
486
887
  } catch (err) {
@@ -581,11 +982,12 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
581
982
  RETURN a.id`,
582
983
  { id: approvalId, cid, answer }
583
984
  );
584
- const rows = parseRows(approveResult);
585
- if (rows.length === 0) {
586
- jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
587
- return;
588
- }
985
+ const rows = parseRows(approveResult);
986
+ if (rows.length === 0) {
987
+ jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
988
+ return;
989
+ }
990
+ mirrorHboStore('approval.approve', () => hboStore.updateApproval?.(approvalId, cid, { status: 'approved', answer, approvedAt: Date.now() }));
589
991
 
590
992
  // Synchronously update linked Strategy if this is a strategy_proposal
591
993
  try {
@@ -714,6 +1116,7 @@ async function handleRejectApproval(req, res, ctx, approvalId) {
714
1116
  jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
715
1117
  return;
716
1118
  }
1119
+ mirrorHboStore('approval.reject', () => hboStore.updateApproval?.(approvalId, cid, { status: 'rejected', rejectReason: reason, rejectedAt: Date.now() }));
717
1120
  broadcast({ type: 'approval.rejected', approvalId, reason, companyId: cid });
718
1121
  jsonResponse(res, 200, { rejected: true, approvalId, reason });
719
1122
  } catch (err) {
@@ -2222,6 +2625,18 @@ async function handleCreateStrategy(req, res, ctx) {
2222
2625
  { id: approvalId, cid, approvalTitle: `Strategy: ${title}`, plan, proposedBy, strategyId }
2223
2626
  );
2224
2627
 
2628
+ mirrorHboStore('approval.create', () => hboStore.createApproval?.({
2629
+ id: approvalId,
2630
+ companyId: cid,
2631
+ type: 'strategy_proposal',
2632
+ title: `Strategy: ${title}`,
2633
+ description: plan,
2634
+ requestedBy: proposedBy,
2635
+ strategyId,
2636
+ status: 'pending',
2637
+ createdAt: Date.now(),
2638
+ }));
2639
+
2225
2640
  // Link goal to strategy
2226
2641
  await mgQuery(
2227
2642
  `MATCH (g:CompanyGoal {id: $goalId}), (s:Strategy {id: $strategyId})
@@ -2305,13 +2720,13 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2305
2720
  const result = await mgQuery(
2306
2721
  `MATCH (rt:RoutineTrigger {publicId: $publicId, companyId: $cid})-[:TRIGGERS]->(r:Routine)
2307
2722
  RETURN rt.id AS triggerId, rt.routineId AS routineId, r.title AS routineTitle,
2308
- r.variables AS variables, r.companyId AS companyId`,
2723
+ r.variables AS variables, r.companyId AS companyId, rt.secret AS secret`,
2309
2724
  { publicId, cid: ctx.cid }
2310
2725
  );
2311
2726
  const rows = parseRows(result);
2312
2727
  if (rows.length === 0) return jsonResponse(res, 404, { error: 'Trigger not found' });
2313
2728
 
2314
- const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId'];
2729
+ const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId', 'secret'];
2315
2730
  const trigger = rowToObj(rows[0], keys);
2316
2731
 
2317
2732
  // S-06: Verify HMAC signature if trigger has a secret configured
@@ -2356,8 +2771,8 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2356
2771
  }
2357
2772
  );
2358
2773
 
2359
- broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: cid });
2360
- return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
2774
+ broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: trigger.companyId });
2775
+ return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
2361
2776
  } catch (err) {
2362
2777
  return jsonResponse(res, 500, { error: err.message });
2363
2778
  }
@@ -2365,6 +2780,124 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
2365
2780
 
2366
2781
  // ── Routine Revisions & Runs ──────────────────────────────────────────────────
2367
2782
 
2783
+ // P5-01: GET /api/routines — list all routines for a company
2784
+ // SQL parity: falls back to hboStore.getRoutinesByCompany when Memgraph unavailable
2785
+ async function handleGetRoutines(req, res, ctx) {
2786
+ const { mgQuery } = ctx;
2787
+ const cid = ctx.cid;
2788
+ const url = new URL(req.url, 'http://localhost');
2789
+ const statusFilter = url.searchParams.get('status') || '';
2790
+
2791
+ if (mgQuery) {
2792
+ try {
2793
+ let cypher = `MATCH (r:Routine {companyId: $cid})`;
2794
+ const params = { cid };
2795
+ if (statusFilter) { cypher += ` WHERE r.status = $status`; params.status = statusFilter; }
2796
+ cypher += ` RETURN r.id, r.name, r.cronExpr, r.agentId, r.status, r.concurrencyPolicy,
2797
+ r.catchUpPolicy, r.catchUpCap, r.nextRunAt, r.lastRunAt, r.createdAt
2798
+ ORDER BY r.createdAt DESC`;
2799
+ const result = await mgQuery(cypher, params);
2800
+ const keys = ['id','name','cronExpr','agentId','status','concurrencyPolicy','catchUpPolicy','catchUpCap','nextRunAt','lastRunAt','createdAt'];
2801
+ const routines = parseRows(result).map(r => rowToObj(r, keys));
2802
+ return jsonResponse(res, 200, { routines, count: routines.length });
2803
+ } catch (e) {
2804
+ log('warn', `handleGetRoutines Memgraph failed, falling back to SQLite: ${e.message}`);
2805
+ }
2806
+ }
2807
+ // SQLite fallback
2808
+ try {
2809
+ const rows = hboStore.getRoutinesByCompany ? hboStore.getRoutinesByCompany(cid, statusFilter || undefined) : [];
2810
+ const routines = (rows || []).map(r => ({
2811
+ id: r.id, name: r.name ?? null, cronExpr: r.cron_expr ?? r.cronExpr ?? null,
2812
+ agentId: r.agent_id ?? r.agentId ?? null, status: r.status ?? 'active',
2813
+ concurrencyPolicy: r.concurrency_policy ?? r.concurrencyPolicy ?? 'skip_if_active',
2814
+ catchUpPolicy: r.catch_up_policy ?? r.catchUpPolicy ?? 'skip_missed',
2815
+ catchUpCap: r.catch_up_cap ?? r.catchUpCap ?? 0,
2816
+ nextRunAt: r.next_run_at ?? r.nextRunAt ?? null,
2817
+ lastRunAt: r.last_run_at ?? r.lastRunAt ?? null,
2818
+ createdAt: r.created_at ? new Date(r.created_at).toISOString() : null,
2819
+ }));
2820
+ return jsonResponse(res, 200, { routines, count: routines.length, _source: 'sqlite' });
2821
+ } catch (storeErr) {
2822
+ return jsonResponse(res, 503, { error: `Routines unavailable: ${storeErr.message}` });
2823
+ }
2824
+ }
2825
+
2826
+ // P5-02: POST /api/routines — create a new routine
2827
+ async function handleCreateRoutine(req, res, ctx) {
2828
+ const { mgQuery } = ctx;
2829
+ if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
2830
+ const cid = ctx.cid;
2831
+ if (!cid || cid === 'default-company') { jsonResponse(res, 400, { error: 'companyId is required' }); return; }
2832
+
2833
+ const body = await parseBody(req);
2834
+ const { name, agentId, cronExpr, concurrencyPolicy, catchUpPolicy, catchUpCap } = body;
2835
+
2836
+ if (!name || typeof name !== 'string' || !name.trim()) {
2837
+ jsonResponse(res, 400, { error: 'name is required' });
2838
+ return;
2839
+ }
2840
+ if (!agentId || typeof agentId !== 'string') {
2841
+ jsonResponse(res, 400, { error: 'agentId is required' });
2842
+ return;
2843
+ }
2844
+ if (!cronExpr || typeof cronExpr !== 'string') {
2845
+ jsonResponse(res, 400, { error: 'cronExpr is required' });
2846
+ return;
2847
+ }
2848
+
2849
+ // Compute nextRunAt using croner (confirmed available in daemon)
2850
+ let nextRunAt;
2851
+ try {
2852
+ const { Cron } = require('croner');
2853
+ const cron = new Cron(cronExpr);
2854
+ const next = cron.nextRun();
2855
+ cron.stop();
2856
+ nextRunAt = next ? next.toISOString().replace(/\.\d{3}Z$/, '+00:00') : null;
2857
+ } catch (cronErr) {
2858
+ log('warn', `P5-02: cronExpr could not be parsed: ${cronErr.message} — using +1h fallback`);
2859
+ nextRunAt = null; // Memgraph will use datetime() + duration("PT1H") fallback
2860
+ }
2861
+
2862
+ const resolvedPolicy = ['skip_if_active', 'coalesce_if_active', 'allow_concurrent'].includes(concurrencyPolicy)
2863
+ ? concurrencyPolicy : 'skip_if_active';
2864
+ const resolvedCatchUp = ['skip_missed', 'enqueue_missed_with_cap'].includes(catchUpPolicy)
2865
+ ? catchUpPolicy : 'skip_missed';
2866
+ const resolvedCap = Number.isFinite(Number(catchUpCap)) ? Math.max(0, Number(catchUpCap)) : 0;
2867
+ const routineId = `routine:${cid}:${crypto.randomUUID()}`;
2868
+
2869
+ try {
2870
+ const cypher = nextRunAt
2871
+ ? `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
2872
+ cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
2873
+ catchUpCap:toInteger($cap), nextRunAt:datetime($nextRun), createdAt:datetime() })`
2874
+ : `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
2875
+ cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
2876
+ catchUpCap:toInteger($cap), nextRunAt:datetime() + duration("PT1H"), createdAt:datetime() })`;
2877
+ const params = {
2878
+ id: routineId, cid, agentId: String(agentId), name: String(name).trim(),
2879
+ cron: cronExpr, policy: resolvedPolicy, catchUp: resolvedCatchUp, cap: resolvedCap,
2880
+ ...(nextRunAt ? { nextRun: nextRunAt } : {}),
2881
+ };
2882
+ await mgQuery(cypher, params);
2883
+
2884
+ // P5-SQL: SQLite write-through (fire-and-forget)
2885
+ try {
2886
+ hboStore.upsertRoutine?.({
2887
+ id: routineId, company_id: cid, agent_id: String(agentId), name: String(name).trim(),
2888
+ cron_expr: cronExpr, status: 'active', concurrency_policy: resolvedPolicy,
2889
+ catch_up_policy: resolvedCatchUp, catch_up_cap: resolvedCap,
2890
+ next_run_at: nextRunAt, created_at: Date.now(),
2891
+ });
2892
+ } catch (storeErr) { log('error', 'hboStore.upsertRoutine failed', { err: storeErr.message }); }
2893
+
2894
+ broadcast({ type: 'routine.created', routineId, companyId: cid });
2895
+ jsonResponse(res, 201, { routine: { id: routineId, name: String(name).trim(), agentId, cronExpr, status: 'active', concurrencyPolicy: resolvedPolicy, catchUpCap: resolvedCap } });
2896
+ } catch (err) {
2897
+ jsonResponse(res, 500, { error: `Create routine failed: ${err.message}` });
2898
+ }
2899
+ }
2900
+
2368
2901
  async function handlePatchRoutine(req, res, ctx, routineId) {
2369
2902
  const { mgQuery } = ctx;
2370
2903
  const body = await parseBody(req);
@@ -2500,7 +3033,7 @@ async function dispatchToAdapter(mgQuery, ctx, taskId, agentId) {
2500
3033
  HELIOS_AGENT_ID: agentId,
2501
3034
  HELIOS_RUN_ID: runId,
2502
3035
  HELIOS_TASK_ID: taskId,
2503
- HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9091}`,
3036
+ HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9093}`,
2504
3037
  };
2505
3038
 
2506
3039
  broadcast({ type: 'run.started', runId, taskId, agentId, adapterType, companyId: cid });
@@ -2799,6 +3332,7 @@ const tasksRoute = require('./routes/tasks')({
2799
3332
  handleGetTaskComments,
2800
3333
  handlePostTaskComment,
2801
3334
  handlePatchTask: handleUpdateTask, // GAP-20 fix: was wired to duplicate (no companyId); now uses correct handleUpdateTask
3335
+ handlePatchTaskPolicy, // P4-07: PATCH /api/tasks/:id/policy
2802
3336
  });
2803
3337
 
2804
3338
  const approvalsRoute = require('./routes/approvals')({
@@ -2845,6 +3379,8 @@ const strategyRoute = require('./routes/strategy')({
2845
3379
  });
2846
3380
 
2847
3381
  const routinesRoute = require('./routes/routines')({
3382
+ handleGetRoutines,
3383
+ handleCreateRoutine,
2848
3384
  handleGetRoutineTriggers,
2849
3385
  handleCreateRoutineTrigger,
2850
3386
  handleFireWebhookTrigger,
@@ -2931,6 +3467,10 @@ async function route(req, res, ctx) {
2931
3467
  // It can never elevate to a company this daemon doesn't own.
2932
3468
  const qcid = parsedUrl.searchParams.get('companyId');
2933
3469
  const primaryCid = ctx.companies?.[0]?.id ?? 'default-company';
3470
+ if (method === 'GET' && pathname === '/api/agents' && (!qcid || !qcid.trim())) {
3471
+ jsonResponse(res, 400, { error: 'companyId required' });
3472
+ return;
3473
+ }
2934
3474
  // qcid will be validated against allowedCompanyIds before being used;
2935
3475
  // if it doesn't match, the 403 below catches it. This is safe.
2936
3476
  // Group C: Create per-request context clone so concurrent requests can't corrupt each other's cid.
@@ -3654,7 +4194,7 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3654
4194
  * Start the REST API server.
3655
4195
  *
3656
4196
  * @param {Function} mgQuery - Memgraph query function: (cypher, params) => Promise<rows>
3657
- * @param {object} config - Daemon config (uses config.apiPort ?? 9091)
4197
+ * @param {object} config - Daemon config (uses config.apiPort ?? 9093)
3658
4198
  * @param {object} state - Optional shared state for health endpoint
3659
4199
  * @returns {{ server, updateTick, setDraining }}
3660
4200
  */
@@ -3662,6 +4202,16 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
3662
4202
 
3663
4203
  async function handleGetTaskDetail(req, res, ctx, taskId) {
3664
4204
  const cid = ctx.cid;
4205
+ // H-2 fix: SQLite fallback when Memgraph is unavailable
4206
+ if (!ctx.mgQuery) {
4207
+ try {
4208
+ const t = hboStore.getTask ? hboStore.getTask(taskId, cid) : null;
4209
+ if (!t) return jsonResponse(res, 404, { error: 'Task not found' });
4210
+ return jsonResponse(res, 200, { task: t, _source: 'sqlite' });
4211
+ } catch (storeErr) {
4212
+ return jsonResponse(res, 503, { error: 'Task unavailable: Memgraph not connected', task: null });
4213
+ }
4214
+ }
3665
4215
  try {
3666
4216
  const result = await ctx.mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid });
3667
4217
  if (result.rows && result.rows.length > 0) {
@@ -3842,6 +4392,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3842
4392
  invalidateContextCache,
3843
4393
  semanticUpdater: container?.cradle?.projectSemanticUpdater ?? null,
3844
4394
  driftDetector: container?.cradle?.projectDriftDetector ?? null,
4395
+ hboStore,
3845
4396
  });
3846
4397
  // Dept Catchball pitch — shares same semanticUpdater from container
3847
4398
  deptRoute = require('./routes/dept')({
@@ -3852,7 +4403,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3852
4403
  } catch (e) {
3853
4404
  // container.js not yet built — projectRoute falls back to () => false
3854
4405
  const { invalidateContextCache } = require('./context-enrichment');
3855
- projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache });
4406
+ projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache, hboStore });
3856
4407
  deptRoute = require('./routes/dept')({ mgQuery, broadcast: state.broadcast ?? (() => {}) });
3857
4408
  }
3858
4409
 
@@ -3870,7 +4421,7 @@ function startApi(mgQuery, config = {}, state = {}) {
3870
4421
  try { channelsRoute = require('./routes/channels')({ mgQuery }); } catch(e) { log('warn', `channels route unavailable: ${e.message}`); }
3871
4422
  try { emailInfraRoute = require('./routes/email-infrastructure')({ mgQuery }); } catch(e) { log('warn', `emailInfra route unavailable: ${e.message}`); }
3872
4423
 
3873
- const port = config.apiPort ?? 9091;
4424
+ const port = config.apiPort ?? 9093;
3874
4425
 
3875
4426
  // Security: shared-secret token for WebSocket upgrade.
3876
4427
  // Mirrors GHSA-3c6j-hq33-3jv4 (forged exec lifecycle events via unauthenticated WS).
@@ -3882,6 +4433,8 @@ function startApi(mgQuery, config = {}, state = {}) {
3882
4433
  mgQuery,
3883
4434
  companies: state.companies ?? [],
3884
4435
  cid: state.companies?.[0]?.id ?? 'default-company', // default — overridden per-request in route()
4436
+ apiPort: port,
4437
+ actualBoundPort: null,
3885
4438
  tickCount: 0,
3886
4439
  draining: false,
3887
4440
  apiToken: config.apiToken || null, // null = no auth (backward compat)
@@ -4000,11 +4553,12 @@ function startApi(mgQuery, config = {}, state = {}) {
4000
4553
  });
4001
4554
 
4002
4555
  server.listen(port, config.apiHost ?? '127.0.0.1', () => {
4556
+ ctx.actualBoundPort = server.address()?.port ?? port;
4003
4557
  process.stdout.write(JSON.stringify({
4004
4558
  ts: new Date().toISOString(),
4005
4559
  level: 'info',
4006
4560
  module: 'helios-api',
4007
- msg: `REST API listening on http://127.0.0.1:${port}`,
4561
+ msg: `REST API listening on http://127.0.0.1:${ctx.actualBoundPort}`,
4008
4562
  }) + '\n');
4009
4563
 
4010
4564
  // Write daemon.lock — the ground-truth discovery file for Helios Desktop.