@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
|
@@ -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 {
|
|
26
|
-
const {
|
|
27
|
-
const {
|
|
28
|
-
const
|
|
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
|
-
|
|
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
|
}
|
package/daemon/lib/hbo-bridge.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
920
|
-
//
|
|
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
|
|
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: `
|
|
984
|
+
message: `BusinessGoal initialization note: ${goalErr.message}`,
|
|
934
985
|
companyId,
|
|
935
986
|
});
|
|
936
987
|
}
|
package/daemon/routes/hbo.js
CHANGED
|
@@ -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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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);
|