@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.
@@ -20,12 +20,14 @@ if (!process.env.TZ) { process.env.TZ = 'UTC'; }
20
20
  */
21
21
 
22
22
  const path = require('path');
23
- const fs = require('fs');
24
- const os = require('os');
25
- const { performance } = require('perf_hooks');
26
- const { randomUUID } = require('crypto');
27
- const { buildContextBrief } = require('./context-enrichment');
28
- const hboStore = require('../lib/hbo-core-store');
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const { execFile } = require('child_process');
26
+ const { performance } = require('perf_hooks');
27
+ const { randomUUID } = require('crypto');
28
+ const { buildContextBrief } = require('./context-enrichment');
29
+ const hboStore = require('../lib/hbo-core-store');
30
+ const graphWal = require('../lib/graph-wal');
29
31
  const { runMigrations } = require('./schema-migrations');
30
32
  const { runHBOMigrations } = require('./schema-migrations-hbo');
31
33
  const { runHaradaMigrations } = require('./schema-migrations-harada');
@@ -727,7 +729,7 @@ async function executeQueueAction(item) {
727
729
  const cid = (item.payload && item.payload.companyId) || 'default';
728
730
 
729
731
  // SQLite-first write (P2-2)
730
- hboStore.createTask({
732
+ try { hboStore.createTask({
731
733
  id: taskId,
732
734
  companyId: cid,
733
735
  title,
@@ -739,7 +741,7 @@ async function executeQueueAction(item) {
739
741
  sourceChannel: item.channel,
740
742
  progressPropagated: false,
741
743
  createdAt: Date.now(),
742
- });
744
+ }); } catch (_storeErr) { /* non-fatal: SQLite store unavailable */ }
743
745
  // Non-blocking Memgraph projection (fire-and-forget)
744
746
  setImmediate(() => mg.safeWrite(`
745
747
  CREATE (t:Task {
@@ -1058,7 +1060,7 @@ class RoutineEvaluator {
1058
1060
  // milliseconds-and-Z suffix produced by toISOString(). No change needed.
1059
1061
  const now = new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
1060
1062
  dueRoutines = await this.mg(
1061
- `MATCH (r:Routine {companyId: $companyId}) WHERE r.status = 'active' AND r.nextRunAt <= datetime($now) RETURN r.id, r.name, r.cronExpr, r.agentId, r.companyId, r.concurrencyPolicy, r.timezone`,
1063
+ `MATCH (r:Routine {companyId: $companyId}) WHERE r.status = 'active' AND r.nextRunAt <= datetime($now) RETURN r.id, r.name, r.cronExpr, r.agentId, r.companyId, r.concurrencyPolicy, r.timezone, r.catchUpCap, r.catchUpPolicy`,
1062
1064
  { now, companyId: this.companyId }
1063
1065
  );
1064
1066
  } catch (err) {
@@ -1086,11 +1088,79 @@ class RoutineEvaluator {
1086
1088
  }
1087
1089
  }
1088
1090
 
1091
+ // P5-03: coalesce_if_active — skip creating a full run but queue one follow-up RoutineRun
1092
+ if (routine['r.concurrencyPolicy'] === 'coalesce_if_active') {
1093
+ const active = await this.mg(
1094
+ `MATCH (rr:RoutineRun {routineId: $rid}) WHERE rr.status IN ['queued', 'running'] RETURN count(rr) as cnt`,
1095
+ { rid: routineId }
1096
+ );
1097
+ const activeCount = active?.rows?.[0]?.[0] ?? 0;
1098
+ if (activeCount > 0) {
1099
+ // Check if a coalesced follow-up already exists (prevent duplicate queuing)
1100
+ const queuedFollowUp = await this.mg(
1101
+ `MATCH (rr:RoutineRun {routineId: $rid, status: 'queued_coalesced'}) RETURN count(rr) as cnt`,
1102
+ { rid: routineId }
1103
+ );
1104
+ if ((queuedFollowUp?.rows?.[0]?.[0] ?? 0) === 0) {
1105
+ const followUpRunId = requireRunId(`run:${routineId}:coalesced:${randomUUID()}`, 'RoutineEvaluator.coalesce');
1106
+ await this.mg(
1107
+ `MERGE (rr:RoutineRun {id: $runId}) SET rr.routineId = $routineId, rr.status = 'queued_coalesced', rr.companyId = $companyId, rr.queuedAt = datetime()`,
1108
+ { runId: followUpRunId, routineId, companyId: this.companyId }
1109
+ ).catch(e => log('warn', `RoutineEvaluator: coalesce follow-up failed: ${e.message}`));
1110
+ }
1111
+ log('debug', `Coalescing routine ${routine['r.name']} — queued follow-up`);
1112
+ continue;
1113
+ }
1114
+ }
1115
+
1089
1116
  const routineAgentId = routine['r.agentId'];
1090
1117
  if (!routineAgentId) {
1091
1118
  log('warn', `RoutineEvaluator: routine ${routineId} has no agentId — skipping task creation`);
1092
1119
  continue;
1093
1120
  }
1121
+
1122
+ // P5-04: catchUpCap — enqueue missed windows up to cap
1123
+ // This fires BEFORE the normal single-task creation to batch missed runs first.
1124
+ const catchUpPolicy = routine['r.catchUpPolicy'];
1125
+ const catchUpCap = parseInt(routine['r.catchUpCap'] ?? '0', 10) || 0;
1126
+ if (catchUpPolicy === 'enqueue_missed_with_cap' && catchUpCap > 0) {
1127
+ try {
1128
+ const { Cron } = require('croner');
1129
+ const cron = new Cron(routine['r.cronExpr'], { timezone: routine['r.timezone'] });
1130
+ // Count missed windows: how many times cron fired between lastRunAt and now
1131
+ // Simple approximation: count backward from now until we hit lastRunAt or cap
1132
+ let missedCount = 0;
1133
+ const prev = cron.previousRun ? cron.previousRun() : null;
1134
+ // Since we don't have a full missed-window iterator here, use a conservative
1135
+ // estimate: check if at least one missed window exists and enqueue up to cap
1136
+ // by repeatedly calling previousRun. Max cap iterations.
1137
+ let checkDate = prev;
1138
+ const lastRunStr = routine['r.lastRunAt'];
1139
+ const lastRunMs = lastRunStr ? Date.parse(lastRunStr) : 0;
1140
+ while (checkDate && missedCount < catchUpCap) {
1141
+ if (checkDate.getTime() <= lastRunMs) break;
1142
+ missedCount++;
1143
+ checkDate = cron.previousRun ? cron.previousRun() : null;
1144
+ }
1145
+ cron.stop();
1146
+ for (let i = 0; i < missedCount; i++) {
1147
+ const catchUpTaskId = `task:routine:${routineId}:catchup:${i}:${randomUUID()}`;
1148
+ const catchUpRunId = requireRunId(`run:${routineId}:catchup:${i}:${randomUUID()}`, 'RoutineEvaluator.catchup');
1149
+ await this.mg(
1150
+ `MERGE (t:Task {id: $taskId}) SET t.title = $title, t.status = 'todo', t.assigneeAgentId = $agentId, t.companyId = $companyId, t.originKind = 'routine_catchup', t.progressPropagated = false, t.createdAt = datetime()`,
1151
+ { taskId: catchUpTaskId, title: `Routine catch-up: ${routine['r.name']}`, agentId: routineAgentId, companyId: this.companyId }
1152
+ ).catch(e => log('warn', `RoutineEvaluator: catch-up task create failed: ${e.message}`));
1153
+ await this.mg(
1154
+ `MERGE (rr:RoutineRun {id: $runId}) SET rr.routineId = $routineId, rr.status = 'queued', rr.linkedTaskId = $taskId, rr.companyId = $companyId, rr.queuedAt = datetime()`,
1155
+ { runId: catchUpRunId, routineId, taskId: catchUpTaskId, companyId: this.companyId }
1156
+ ).catch(e => log('warn', `RoutineEvaluator: catch-up run create failed: ${e.message}`));
1157
+ }
1158
+ if (missedCount > 0) log('info', `RoutineEvaluator: catch-up ${missedCount} tasks for ${routine['r.name']}`);
1159
+ } catch (catchUpErr) {
1160
+ log('warn', `RoutineEvaluator: catchUpCap logic failed for ${routineId}: ${catchUpErr.message}`);
1161
+ }
1162
+ }
1163
+
1094
1164
  const taskId = `task:routine:${routineId}:${randomUUID()}`;
1095
1165
  await criticalOp(
1096
1166
  () => this.mg(
@@ -4330,10 +4400,30 @@ class HeliosCompanyDaemon {
4330
4400
  // CRITICAL-3 fix: wait for Memgraph to be reachable before running migrations.
4331
4401
  // Without this, PM2 can restart the daemon before Memgraph is up post-OOM,
4332
4402
  // and all migrations + MAGE backfill silently fail, leaving the graph in a partial state.
4333
- await this._waitForMemgraph();
4334
- await this._connectMemgraph();
4335
-
4336
- // ── TZ sanity check ─────────────────────────────────────────────────────────
4403
+ await this._waitForMemgraph();
4404
+ await this._connectMemgraph();
4405
+
4406
+ try {
4407
+ const replay = await graphWal.replayPending(this._mgQueryAsync.bind(this));
4408
+ log('info', 'WAL replay complete', replay);
4409
+ } catch (err) {
4410
+ log('error', 'WAL replay failed', { error: err.message });
4411
+ }
4412
+
4413
+ const migrateScript = path.join(DAEMON_DIR, 'db', 'hbo-core-migrate.js');
4414
+ execFile(process.execPath, ['--experimental-sqlite', migrateScript], {
4415
+ cwd: HELIOS_ROOT,
4416
+ timeout: 60_000,
4417
+ env: process.env,
4418
+ }, (err, stdout, stderr) => {
4419
+ if (err) {
4420
+ log('warn', 'hbo-core reconcile failed', { error: err.message, stderr: String(stderr || '').slice(0, 1000) });
4421
+ return;
4422
+ }
4423
+ log('info', 'hbo-core reconcile complete', { stdout: String(stdout || '').slice(0, 1000) });
4424
+ });
4425
+
4426
+ // ── TZ sanity check ─────────────────────────────────────────────────────────
4337
4427
  if (!process.env.TZ || process.env.TZ !== 'UTC') {
4338
4428
  log('warn', `[startup] TZ=${process.env.TZ} — recommend TZ=UTC for consistent datetime comparisons. See AGENTS.md.`);
4339
4429
  }
@@ -737,7 +737,7 @@ class HBOBridge {
737
737
  // Find active goals (CompanyGoal OR BusinessGoal) with no child BusinessTasks
738
738
  // and no existing decomposition task (checked by deterministic MERGE on t.id).
739
739
  // CompanyGoal: created by seed-company.js at startup
740
- // BusinessGoal: created via POST /api/hbo/goals or business-goal-service.js
740
+ // BusinessGoal: created via POST /api/hbo/goals or the wizard's write-through goal path
741
741
  const undecomposed = await this._mg(
742
742
  `MATCH (g)
743
743
  WHERE (g:CompanyGoal OR g:BusinessGoal)
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ /**
3
+ * daemon/lib/hed-engine.js — Helios Event Dispatch engine.
4
+ * Manages HED wave advancement and event routing.
5
+ */
6
+
7
+ class HEDEngine {
8
+ constructor(mgQuery) {
9
+ this._mg = mgQuery;
10
+ }
11
+
12
+ async checkWaveAdvancement(companyId) {
13
+ // Wave advancement check — no-op when Memgraph is unavailable
14
+ try {
15
+ await this._mg(
16
+ `MATCH (w:HEDWave {companyId: $cid, status: 'pending'}) RETURN w.id LIMIT 1`,
17
+ { cid: companyId }
18
+ );
19
+ } catch (_) {
20
+ // Fail-open: Memgraph unavailable
21
+ }
22
+ }
23
+ }
24
+
25
+ module.exports = { HEDEngine };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const logger = require('./logger');
3
+ const hboStore = require('../../lib/hbo-core-store');
3
4
  const { MandalaManager } = require('./harada/mandala');
4
5
  /**
5
6
  * daemon/lib/task-completion-processor.js — Task Completion → Graph Persistence Pipeline
@@ -259,6 +260,16 @@ class TaskCompletionProcessor {
259
260
  SET t.resultSummary = $summary, t.hasFullResult = true`,
260
261
  { taskId, summary: output.slice(0, 2000) }
261
262
  );
263
+
264
+ try {
265
+ hboStore.updateTask?.(taskId, companyId || '', {
266
+ status: 'done',
267
+ resultSummary: output.slice(0, 2000),
268
+ hasFullResult: true,
269
+ });
270
+ } catch (err) {
271
+ logger.warn(`[completion-processor] hbo-core mirror failed for ${taskId}: ${err.message}`);
272
+ }
262
273
  }
263
274
 
264
275
  // ── 2. Update goal progress ───────────────────────────────────────────────────
@@ -26,13 +26,65 @@ const { randomUUID } = require('crypto');
26
26
  const { EventEmitter } = require('events');
27
27
  const { SIGNAL_KEY } = require('./signal-key');
28
28
 
29
- const { initDefaultGoal } = require('../../skills/helios-business-operator/lib/business-goal-service.js');
30
29
  const { CompanyBeliefService } = require('./company-belief-service');
30
+ const hboStore = require('../../lib/hbo-core-store');
31
31
 
32
32
  const HELIOS_ROOT = path.resolve(__dirname, '..', '..');
33
33
  const COMPANIES_DIR = path.join(__dirname, '..', 'companies');
34
34
  const HELIOS_RPC_BIN = path.join(HELIOS_ROOT, 'bin', 'helios-rpc.js');
35
35
 
36
+ async function initWizardBusinessGoal(mgQuery, companyId, goalTitle) {
37
+ if (!goalTitle || !goalTitle.trim()) return null;
38
+
39
+ const existing = await mgQuery(
40
+ `MATCH (g:BusinessGoal {level: 'company', companyId: $companyId})
41
+ RETURN g.id AS id, g.title AS title LIMIT toInteger(1)`,
42
+ { companyId }
43
+ );
44
+ const existingRows = existing?.rows || [];
45
+ if (existingRows.length > 0) return { created: false };
46
+
47
+ const goalId = `bg:${randomUUID()}`;
48
+ const title = goalTitle.trim();
49
+ await mgQuery(
50
+ `CREATE (g:BusinessGoal {
51
+ id: $id,
52
+ companyId: $companyId,
53
+ title: $title,
54
+ description: $description,
55
+ level: 'company',
56
+ status: 'active',
57
+ parentId: null,
58
+ parentGoalId: null,
59
+ createdAt: datetime(),
60
+ updatedAt: datetime()
61
+ })`,
62
+ {
63
+ id: goalId,
64
+ companyId,
65
+ title,
66
+ description: 'Top-level company objective',
67
+ }
68
+ );
69
+
70
+ try {
71
+ hboStore.createGoal?.({
72
+ id: goalId,
73
+ companyId,
74
+ title,
75
+ description: 'Top-level company objective',
76
+ level: 'company',
77
+ status: 'active',
78
+ parentId: null,
79
+ createdAt: Date.now(),
80
+ });
81
+ } catch (storeErr) {
82
+ process.stderr.write(`[wizard-engine] goal mirror failed: ${storeErr.message}\n`);
83
+ }
84
+
85
+ return { created: true, goalId };
86
+ }
87
+
36
88
  // ── Default agent template — the 7 core HBO roles every company gets ─────────
37
89
  //
38
90
  // Skills use 'helios-business-operator' so the helios_rpc adapter runs the
@@ -916,13 +968,12 @@ class WizardEngine extends EventEmitter {
916
968
 
917
969
  await seedToMemgraph(this._mgQuery, config);
918
970
 
919
- // AR-M33 FIX: Wire initDefaultGoal now that company is seeded into Memgraph.
920
- // initDefaultGoal creates a BusinessGoal node (distinct from CompanyGoal) scoped
921
- // to the company and links the wizard's stated goal text as the top-level objective.
971
+ // Create a daemon-visible BusinessGoal after CompanyGoal seeding.
972
+ // Uses this wizard's mgQuery path and mirrors to hbo-core.db.
922
973
  // Previously this function had zero call sites — it was dead code.
923
974
  if (goal && goal.trim()) {
924
975
  try {
925
- await initDefaultGoal(companyId, goal.trim());
976
+ await initWizardBusinessGoal(this._mgQuery, companyId, goal.trim());
926
977
  this._emit('wizard:seed_company', {
927
978
  message: `Default BusinessGoal initialized for company "${companyId}"`,
928
979
  companyId,
@@ -930,7 +981,7 @@ class WizardEngine extends EventEmitter {
930
981
  } catch (goalErr) {
931
982
  // Non-fatal: CompanyGoal already seeded; BusinessGoal hierarchy is supplemental.
932
983
  this._emit('wizard:seed_company', {
933
- message: `initDefaultGoal note: ${goalErr.message}`,
984
+ message: `BusinessGoal initialization note: ${goalErr.message}`,
934
985
  companyId,
935
986
  });
936
987
  }
@@ -32,7 +32,7 @@ function getCorsHeaders(req) {
32
32
  const allowedOrigin = origin && HBO_ALLOWED_ORIGINS.has(origin) ? origin : 'http://localhost:9093';
33
33
  return {
34
34
  'Access-Control-Allow-Origin': allowedOrigin,
35
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
35
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
36
36
  'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
37
37
  'Vary': 'Origin',
38
38
  };
@@ -431,28 +431,37 @@ async function handleTransitionBusinessTask(req, res, ctx, taskId) {
431
431
  const taskService = loadHBOService('lib/business-task-service.js');
432
432
  if (taskService && typeof taskService.transition === 'function') {
433
433
  try {
434
- const task = await Promise.race([
435
- taskService.transition(taskId, status, cid),
436
- new Promise((_, reject) =>
437
- setTimeout(() => reject(new Error('task-service timeout — falling back to direct')), 5000)
438
- ),
439
- ]);
440
- jsonOk(res, { task });
441
- return;
442
- } catch (_svcErr) {
443
- // service failed for any reason — fall through to direct Memgraph path below
444
- }
445
- }
446
-
447
- // Fallback: update directly via ctx.mgQuery
448
- const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
449
- await mgQuery(
450
- `MATCH (bt:BusinessTask {id: $id, companyId: $cid})
451
- SET bt.status = $status, bt.updatedAt = datetime($now)`,
452
- { id: taskId, cid, status, now }
453
- );
454
- _bc({ type: 'task.updated', taskId, companyId: cid, status });
455
- jsonOk(res, { updated: true, taskId, status });
434
+ const task = await Promise.race([
435
+ taskService.transition(taskId, status, cid),
436
+ new Promise((_, reject) =>
437
+ setTimeout(() => reject(new Error('task-service timeout — falling back to direct')), 5000)
438
+ ),
439
+ ]);
440
+ if (task !== null && task !== undefined) {
441
+ jsonOk(res, { task });
442
+ return;
443
+ }
444
+ // Service did not find a task for this company; fall through to the
445
+ // direct path so we can return a clear 404 instead of a silent success.
446
+ } catch (_svcErr) {
447
+ // service failed for any reason — fall through to direct Memgraph path below
448
+ }
449
+ }
450
+
451
+ // Fallback: update directly via ctx.mgQuery
452
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
453
+ const result = await mgQuery(
454
+ `MATCH (bt:BusinessTask {id: $id, companyId: $cid})
455
+ SET bt.status = $status, bt.updatedAt = datetime($now)
456
+ RETURN bt.id AS id`,
457
+ { id: taskId, cid, status, now }
458
+ );
459
+ if (parseRows(result).length === 0) {
460
+ jsonErr(res, 404, `BusinessTask ${taskId} not found for company ${cid}`);
461
+ return;
462
+ }
463
+ _bc({ type: 'task.updated', taskId, companyId: cid, status });
464
+ jsonOk(res, { updated: true, taskId, status });
456
465
  } catch (e) {
457
466
  jsonErr(res, 500, `Transition BusinessTask failed: ${safeErrMsg(e)}`);
458
467
  }
@@ -462,34 +471,199 @@ async function handleTransitionBusinessTask(req, res, ctx, taskId) {
462
471
 
463
472
  async function handleGetBusinessGoals(req, res, ctx) {
464
473
  const { mgQuery, cid } = ctx;
465
- if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
474
+ // SQL-P1: SQLite fallback when Memgraph is unavailable — not all users have Memgraph.
475
+ // Try Memgraph first; on failure (no connection or query error), fall through to SQLite.
476
+ if (mgQuery) {
477
+ try {
478
+ // Get all BusinessGoal nodes
479
+ // BUG-12 fix: BusinessGoal relationships are stored as (child)-[:CHILD_OF]->(parent).
480
+ const result = await mgQuery(
481
+ `MATCH (bg:BusinessGoal {companyId: $cid})
482
+ OPTIONAL MATCH (child:BusinessGoal)-[:CHILD_OF]->(bg)
483
+ WITH bg, [x IN collect(child.id) WHERE x IS NOT NULL] AS childIds
484
+ RETURN bg.id AS id, bg.title AS title, bg.level AS level,
485
+ bg.ownerAgentId AS ownerAgentId, bg.status AS status,
486
+ bg.progress AS progress, childIds
487
+ ORDER BY bg.level ASC`,
488
+ { cid }
489
+ );
490
+ const rows = parseRows(result);
491
+ const keys = ['id', 'title', 'level', 'ownerAgentId', 'status', 'progress', 'childIds'];
492
+ const goals = rows.map(r => {
493
+ const goal = rowToObj(r, keys);
494
+ if (goal.level !== null && goal.level !== undefined) goal.level = String(goal.level);
495
+ return goal;
496
+ });
497
+ const rootGoals = goals.filter(g => !g.level || String(g.level) === 'company');
498
+ return jsonOk(res, { goals, rootGoals, count: goals.length });
499
+ } catch (e) {
500
+ // Memgraph query failed — fall through to SQLite fallback below
501
+ process.stderr.write(`[hbo] handleGetBusinessGoals Memgraph failed, falling back to SQLite: ${e.message}\n`);
502
+ }
503
+ }
504
+ // SQLite fallback (Memgraph unavailable or query failed)
466
505
  try {
467
- // Get all BusinessGoal nodes
468
- // BUG-12 fix: BusinessGoal relationships are stored as (child)-[:CHILD_OF]->(parent).
469
- // The old query used (bg)-[:PARENT_OF]->(child) which matched zero edges.
470
- // Migration note: run `MATCH (bg:BusinessGoal)-[:PARENT_OF]->(c:BusinessGoal)
471
- // MERGE (c)-[:CHILD_OF]->(bg) DELETE relationship` if old edges exist.
472
- const result = await mgQuery(
473
- `MATCH (bg:BusinessGoal {companyId: $cid})
474
- OPTIONAL MATCH (child:BusinessGoal)-[:CHILD_OF]->(bg)
475
- WITH bg, [x IN collect(child.id) WHERE x IS NOT NULL] AS childIds
476
- RETURN bg.id AS id, bg.title AS title, bg.level AS level,
477
- bg.ownerAgentId AS ownerAgentId, bg.status AS status,
478
- bg.progress AS progress, childIds
479
- ORDER BY bg.level ASC`,
480
- { cid }
481
- );
482
- const rows = parseRows(result);
483
- const keys = ['id', 'title', 'level', 'ownerAgentId', 'status', 'progress', 'childIds'];
484
- const goals = rows.map(r => rowToObj(r, keys));
485
- // Build hierarchy tree
486
- const rootGoals = goals.filter(g => !g.level || g.level === 0 || g.level === 'company');
487
- jsonOk(res, { goals, rootGoals, count: goals.length });
488
- } catch (e) {
489
- jsonErr(res, 500, `BusinessGoal query failed: ${safeErrMsg(e)}`);
506
+ const storeGoals = hboStore.getGoalsByCompany ? hboStore.getGoalsByCompany(cid) : [];
507
+ const goals = (storeGoals || []).map(g => ({
508
+ id: g.id ?? g.id,
509
+ title: g.title ?? null,
510
+ level: g.level ? String(g.level) : null,
511
+ ownerAgentId: g.ownerAgentId ?? null,
512
+ status: g.status ?? null,
513
+ progress: g.progress ?? null,
514
+ childIds: g.childIds ?? [],
515
+ }));
516
+ const rootGoals = goals.filter(g => !g.level || g.level === 'company');
517
+ return jsonOk(res, { goals, rootGoals, count: goals.length, _source: 'sqlite' });
518
+ } catch (storeErr) {
519
+ return jsonErr(res, 503, `Goals unavailable: Memgraph not connected and SQLite fallback failed: ${storeErr.message}`);
520
+ }
521
+ }
522
+
523
+ async function handleGetBusinessGoal(req, res, ctx, goalId) {
524
+ const { mgQuery, cid } = ctx;
525
+ if (!goalId) { jsonErr(res, 400, 'Missing goal ID'); return; }
526
+ // SQL-P2: SQLite fallback when Memgraph unavailable.
527
+ if (mgQuery) {
528
+ try {
529
+ const result = await mgQuery(
530
+ `MATCH (g:BusinessGoal {id: $id, companyId: $cid})
531
+ RETURN g.id AS id, g.title AS title, g.description AS description,
532
+ g.level AS level, g.status AS status, g.parentId AS parentId,
533
+ g.ownerAgentId AS ownerAgentId, g.progress AS progress`,
534
+ { id: goalId, cid }
535
+ );
536
+ const rows = parseRows(result);
537
+ if (rows.length === 0) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
538
+ const keys = ['id', 'title', 'description', 'level', 'status', 'parentId', 'ownerAgentId', 'progress'];
539
+ return jsonOk(res, { goal: rowToObj(rows[0], keys) });
540
+ } catch (e) {
541
+ process.stderr.write(`[hbo] handleGetBusinessGoal Memgraph failed, falling back to SQLite: ${e.message}\n`);
542
+ }
543
+ }
544
+ // SQLite fallback
545
+ try {
546
+ const g = hboStore.getGoal ? hboStore.getGoal(goalId, cid) : null;
547
+ if (!g) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
548
+ return jsonOk(res, { goal: g, _source: 'sqlite' });
549
+ } catch (storeErr) {
550
+ return jsonErr(res, 503, `Goal unavailable: ${storeErr.message}`);
490
551
  }
491
552
  }
492
553
 
554
+ async function handleCreateBusinessGoal(req, res, ctx) {
555
+ const { mgQuery, cid } = ctx;
556
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
557
+ // H-4 fix: reject if cid is missing or is the fallback sentinel — prevents silent writes to 'default-company'
558
+ if (!cid || cid === 'default-company') { jsonErr(res, 400, 'companyId is required'); return; }
559
+ try {
560
+ const body = await readBody(req);
561
+ if (!assertValidBody(body, res)) return;
562
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
563
+ if (!title) { jsonErr(res, 400, 'title is required'); return; }
564
+
565
+ const goalId = body.id ? String(body.id) : `bg:${randomUUID()}`;
566
+ const description = body.description !== undefined ? String(body.description) : null;
567
+ const level = body.level !== undefined ? String(body.level) : 'company';
568
+ const parentId = body.parentId || body.parentGoalId ? String(body.parentId || body.parentGoalId) : null;
569
+
570
+ await mgQuery(
571
+ `CREATE (g:BusinessGoal {
572
+ id: $id, companyId: $cid, title: $title, description: $desc,
573
+ level: $level, status: 'active', parentId: $parentId,
574
+ parentGoalId: $parentId, createdAt: datetime(), updatedAt: datetime()
575
+ })`,
576
+ { id: goalId, cid, title, desc: description, level, parentId }
577
+ );
578
+ try {
579
+ hboStore.createGoal?.({ id: goalId, companyId: cid, title, description, level, status: 'active', parentId, createdAt: Date.now() });
580
+ } catch (storeErr) {
581
+ process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`);
582
+ }
583
+ try { ctx._bc?.({ type: 'goal.created', companyId: cid, goalId }); } catch (_) {}
584
+ jsonOk(res, { goal: { id: goalId, companyId: cid, title, description, level, status: 'active', parentId } }, 201);
585
+ } catch (e) {
586
+ jsonErr(res, 500, `Create BusinessGoal failed: ${safeErrMsg(e)}`);
587
+ }
588
+ }
589
+
590
+ async function handleUpdateBusinessGoal(req, res, ctx, goalId) {
591
+ const { mgQuery, cid } = ctx;
592
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
593
+ if (!goalId) { jsonErr(res, 400, 'Missing goal ID'); return; }
594
+ try {
595
+ const body = await readBody(req);
596
+ if (!assertValidBody(body, res)) return;
597
+ const title = body.title !== undefined ? String(body.title).trim() : null;
598
+ const description = body.description !== undefined ? String(body.description) : null;
599
+ const level = body.level !== undefined ? String(body.level) : null;
600
+ const status = body.status !== undefined ? String(body.status) : null;
601
+ const parentId = body.parentId !== undefined ? (body.parentId ? String(body.parentId) : null) : undefined;
602
+
603
+ if (title === '' || (title === null && description === null && level === null && status === null && parentId === undefined)) {
604
+ jsonErr(res, 400, 'At least one of title, description, level, status, parentId must be provided');
605
+ return;
606
+ }
607
+
608
+ const result = await mgQuery(
609
+ `MATCH (g:BusinessGoal {id: $id, companyId: $cid})
610
+ SET g.title = COALESCE($title, g.title),
611
+ g.description = CASE WHEN $hasDescription THEN $description ELSE g.description END,
612
+ g.level = COALESCE($level, g.level),
613
+ g.status = COALESCE($status, g.status),
614
+ g.parentId = CASE WHEN $hasParentId THEN $parentId ELSE g.parentId END,
615
+ g.parentGoalId = CASE WHEN $hasParentId THEN $parentId ELSE g.parentGoalId END,
616
+ g.updatedAt = datetime()
617
+ RETURN g.id AS id, g.title AS title, g.description AS description,
618
+ g.level AS level, g.status AS status, g.parentId AS parentId`,
619
+ {
620
+ id: goalId, cid, title, description, level, status,
621
+ parentId: parentId === undefined ? null : parentId,
622
+ hasDescription: body.description !== undefined,
623
+ hasParentId: parentId !== undefined,
624
+ }
625
+ );
626
+ const rows = parseRows(result);
627
+ if (rows.length === 0) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
628
+ const goal = rowToObj(rows[0], ['id', 'title', 'description', 'level', 'status', 'parentId']);
629
+ // H-3 fix: only pass fields that were explicitly provided in the PATCH body to updateGoal.
630
+ // rowToObj fills missing Memgraph fields with null — passing null for e.g. title would
631
+ // overwrite the existing SQLite title with null on a status-only update.
632
+ const storeUpdate: Record<string, any> = { id: goalId };
633
+ if (title !== null) storeUpdate.title = goal.title;
634
+ if (description !== null) storeUpdate.description = goal.description;
635
+ if (level !== null) storeUpdate.level = goal.level;
636
+ if (status !== null) storeUpdate.status = goal.status;
637
+ if (parentId !== undefined) storeUpdate.parentId = goal.parentId;
638
+ try { hboStore.updateGoal?.(goalId, cid, storeUpdate); } catch (storeErr) { process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`); }
639
+ try { ctx._bc?.({ type: 'goal.updated', companyId: cid, goalId }); } catch (_) {}
640
+ jsonOk(res, { goal });
641
+ } catch (e) {
642
+ jsonErr(res, 500, `Update BusinessGoal failed: ${safeErrMsg(e)}`);
643
+ }
644
+ }
645
+
646
+ async function handleDeleteBusinessGoal(req, res, ctx, goalId) {
647
+ const { mgQuery, cid } = ctx;
648
+ if (!mgQuery) { jsonErr(res, 503, 'Memgraph not connected'); return; }
649
+ if (!goalId) { jsonErr(res, 400, 'Missing goal ID'); return; }
650
+ try {
651
+ const result = await mgQuery(
652
+ `MATCH (g:BusinessGoal {id: $id, companyId: $cid})
653
+ WITH g, g.id AS deletedId
654
+ DETACH DELETE g
655
+ RETURN deletedId AS id`,
656
+ { id: goalId, cid }
657
+ );
658
+ if (parseRows(result).length === 0) { jsonErr(res, 404, `BusinessGoal ${goalId} not found`); return; }
659
+ try { hboStore.deleteGoal?.(goalId, cid); } catch (storeErr) { process.stderr.write(`[hbo] goal mirror failed: ${storeErr.message}\n`); }
660
+ try { ctx._bc?.({ type: 'goal.deleted', companyId: cid, goalId }); } catch (_) {}
661
+ jsonOk(res, { deleted: true, goalId });
662
+ } catch (e) {
663
+ jsonErr(res, 500, `Delete BusinessGoal failed: ${safeErrMsg(e)}`);
664
+ }
665
+ }
666
+
493
667
  // ── Warm signals handler ──────────────────────────────────────────────────────
494
668
 
495
669
  async function handleGetWarmSignals(req, res, ctx) {
@@ -2744,6 +2918,14 @@ module.exports = function createHBORouter(handlers) {
2744
2918
  return false;
2745
2919
  }
2746
2920
 
2921
+ // H-2 fix: Handle OPTIONS preflight for all HBO routes (including DELETE /api/hbo/goals/:id).
2922
+ // Must be BEFORE auth check — browsers never send credentials on preflight requests.
2923
+ if (method === 'OPTIONS') {
2924
+ res.writeHead(204, getCorsHeaders(req));
2925
+ res.end();
2926
+ return true;
2927
+ }
2928
+
2747
2929
  // Auth check for all HBO routes
2748
2930
  const authHeader = (req.headers || {})['authorization'];
2749
2931
  const expected = ctx.apiToken;
@@ -2834,6 +3016,30 @@ module.exports = function createHBORouter(handlers) {
2834
3016
  return true;
2835
3017
  }
2836
3018
 
3019
+ // GET /api/hbo/goals/:id — must be checked before POST/PATCH/DELETE goalMatch
3020
+ const goalIdOnlyMatch = pathname.match(/^\/api\/hbo\/goals\/([^/]+)$/);
3021
+ if (method === 'GET' && goalIdOnlyMatch) {
3022
+ await handleGetBusinessGoal(req, res, ctx, decodeURIComponent(goalIdOnlyMatch[1]));
3023
+ return true;
3024
+ }
3025
+
3026
+ // POST /api/hbo/goals
3027
+ if (method === 'POST' && pathname === '/api/hbo/goals') {
3028
+ await handleCreateBusinessGoal(req, res, ctx);
3029
+ return true;
3030
+ }
3031
+
3032
+ // PATCH/DELETE /api/hbo/goals/:id
3033
+ const goalMatch = pathname.match(/^\/api\/hbo\/goals\/(.+)$/);
3034
+ if (method === 'PATCH' && goalMatch) {
3035
+ await handleUpdateBusinessGoal(req, res, ctx, decodeURIComponent(goalMatch[1]));
3036
+ return true;
3037
+ }
3038
+ if (method === 'DELETE' && goalMatch) {
3039
+ await handleDeleteBusinessGoal(req, res, ctx, decodeURIComponent(goalMatch[1]));
3040
+ return true;
3041
+ }
3042
+
2837
3043
  // GET /api/hbo/triage-summary
2838
3044
  if (method === 'GET' && pathname === '/api/hbo/triage-summary') {
2839
3045
  await handleGetTriageSummary(req, res, ctx);