@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.
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/daemon-manager.js +1 -1
- package/daemon/db/email-infrastructure-migrate.js +192 -0
- package/daemon/db/hbo-core-migrate.js +189 -0
- package/daemon/helios-api.js +574 -20
- package/daemon/helios-company-daemon.js +103 -13
- package/daemon/lib/hbo-bridge.js +1 -1
- package/daemon/lib/hed-engine.js +25 -0
- package/daemon/lib/task-completion-processor.js +11 -0
- package/daemon/lib/wizard-engine.js +57 -6
- package/daemon/routes/hbo.js +253 -47
- package/daemon/routes/project.js +190 -59
- package/daemon/routes/routines.js +14 -0
- package/daemon/routes/tasks.js +15 -1
- package/daemon/schema-apply.js +174 -0
- package/daemon/schema-definitions.js +423 -0
- package/daemon/schema-migrations-hbo.js +10 -0
- package/daemon/schema-migrations-hed.js +18 -0
- package/daemon/schema-migrations-proj.js +131 -0
- package/extensions/cortex/wal-replay.ts +91 -0
- package/extensions/hema-dispatch-v3/index.ts +13 -7
- package/extensions/warm-tick/warm-tick-maintenance.ts +8 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -0
- package/lib/event-bus.mts +1 -1
- package/lib/graph-availability.js +62 -0
- package/lib/hbo-core-store.compiled.js +834 -0
- package/lib/hbo-core-store.js +124 -0
- package/lib/hbo-core-store.ts +908 -0
- package/lib/triage-core/classifier.ts +3 -2
- package/lib/triage-core/graph/schema.cypher +10 -0
- package/lib/triage-core/mental-model/key-facts.ts +1 -2
- package/lib/triage-core/orchestrator.ts +4 -11
- package/package.json +9 -5
package/daemon/helios-api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/**
|
|
3
|
-
* helios-api.js — REST API for Helios daemon
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
2360
|
-
|
|
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 ||
|
|
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 ??
|
|
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 ??
|
|
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:${
|
|
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.
|