@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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * extensions/cortex/wal-replay.ts
3
+ *
4
+ * Auto-replay cortex WAL entries when Memgraph becomes available.
5
+ * Called by learn.ts on the memgraph:available event (P4-2).
6
+ *
7
+ * Reads cortex-write-journal.jsonl, replays each entry via rawWrite,
8
+ * removes successfully replayed entries atomically (temp file + rename).
9
+ *
10
+ * Uses extensions/lib/helios-root (same module as learn.ts) to ensure
11
+ * the journal path resolves identically in both files.
12
+ */
13
+ import * as fs from 'fs';
14
+
15
+ // Re-use the same path derivation as learn.ts.
16
+ // MUST use '../lib/helios-root' (extensions/lib/helios-root), NOT
17
+ // '../../lib/helios-root' (lib/helios-root) — different fallback strategies.
18
+ const { heliosPath } = require('../lib/helios-root');
19
+ const WRITE_JOURNAL_PATH: string = heliosPath('sessions', 'cortex-write-journal.jsonl');
20
+
21
+ interface WalEntry {
22
+ cypher: string;
23
+ params: Record<string, unknown>;
24
+ timestamp?: number;
25
+ }
26
+
27
+ function readJournalEntries(): WalEntry[] {
28
+ if (!fs.existsSync(WRITE_JOURNAL_PATH)) return [];
29
+ try {
30
+ const lines = fs.readFileSync(WRITE_JOURNAL_PATH, 'utf8')
31
+ .split('\n')
32
+ .filter(Boolean);
33
+ return lines.map(line => {
34
+ try { return JSON.parse(line) as WalEntry; } catch { return null; }
35
+ }).filter((e): e is WalEntry => e !== null && typeof e.cypher === 'string');
36
+ } catch (e) {
37
+ process.stderr.write(`[cortex-wal-replay] failed to read journal: ${String(e)}\n`);
38
+ return [];
39
+ }
40
+ }
41
+
42
+ function writeJournalEntries(entries: WalEntry[]): void {
43
+ const tmp = WRITE_JOURNAL_PATH + '.tmp';
44
+ try {
45
+ fs.writeFileSync(tmp, entries.map(e => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : ''), 'utf8');
46
+ fs.renameSync(tmp, WRITE_JOURNAL_PATH);
47
+ } catch (e) {
48
+ process.stderr.write(`[cortex-wal-replay] failed to write journal: ${String(e)}\n`);
49
+ try { fs.unlinkSync(tmp); } catch {}
50
+ }
51
+ }
52
+
53
+ export async function replayCortexWal(): Promise<void> {
54
+ const entries = readJournalEntries();
55
+ if (entries.length === 0) return;
56
+
57
+ let rawWrite: ((cypher: string, params: Record<string, unknown>) => Promise<unknown>) | null = null;
58
+ try {
59
+ const mg = require('../../lib/safe-memgraph');
60
+ rawWrite = mg.rawWrite ?? mg.safeWrite ?? null;
61
+ } catch (e) {
62
+ process.stderr.write(`[cortex-wal-replay] failed to load safe-memgraph: ${String(e)}\n`);
63
+ return;
64
+ }
65
+
66
+ if (!rawWrite) {
67
+ process.stderr.write('[cortex-wal-replay] rawWrite not available — skipping replay\n');
68
+ return;
69
+ }
70
+
71
+ const remaining: WalEntry[] = [];
72
+ let replayed = 0;
73
+ let failed = 0;
74
+
75
+ for (const entry of entries) {
76
+ try {
77
+ await rawWrite(entry.cypher, entry.params ?? {});
78
+ replayed++;
79
+ } catch (e) {
80
+ failed++;
81
+ remaining.push(entry);
82
+ process.stderr.write(`[cortex-wal-replay] entry replay failed (kept in journal): ${String(e)}\n`);
83
+ }
84
+ }
85
+
86
+ writeJournalEntries(remaining);
87
+
88
+ process.stderr.write(
89
+ `[cortex-wal-replay] Replayed ${replayed} / ${entries.length} entries. ${failed} failed (left in journal).\n`
90
+ );
91
+ }
@@ -682,15 +682,20 @@ export default function hemaDispatchV3(pi: any): void {
682
682
  } catch (_) { process.stderr.write(`[hema:catch] fail-open: graceful degradation: ${(_ as Error)?.message || _}\n`); } // HeliosRuntime may not be initialized — fail-open
683
683
 
684
684
  // ═══ TASK-13: Budget Pre-Admission Gate ═══
685
- // Reads ~/helios-agent/brainv2/budget-status.json (written by GraphSyncService).
685
+ // MT-02: Path resolves from HELIOS_ROOT env var (required). If unset, daemon logs a startup error.
686
+ // budget-status.json is written by GraphSyncService at $HELIOS_ROOT/brainv2/budget-status.json.
686
687
  // Fail-open: if file missing or unreadable, proceed normally.
687
688
  // blocked=true → hard block, return early with ⛔ message
688
689
  // warningActive → inject budget warning into agent context (soft signal)
689
690
  let _budgetStatus: { blocked?: boolean; warningActive?: boolean; policies?: any[] } = {};
690
691
  let _budgetWarning: string | null = null;
691
692
  try {
692
- const _budgetStatusPath = join(homedir(), 'helios-agent/brainv2/budget-status.json');
693
- if (fs.existsSync(_budgetStatusPath)) {
693
+ const _heliosRoot = process.env['HELIOS_ROOT'];
694
+ if (!_heliosRoot) {
695
+ process.stderr.write('[hema] HELIOS_ROOT not set — budget-status.json cannot be read. Set HELIOS_ROOT to the helios-agent repo root.\n');
696
+ }
697
+ const _budgetStatusPath = _heliosRoot ? join(_heliosRoot, 'brainv2/budget-status.json') : null;
698
+ if (_budgetStatusPath && fs.existsSync(_budgetStatusPath)) {
694
699
  _budgetStatus = JSON.parse(fs.readFileSync(_budgetStatusPath, 'utf8'));
695
700
  }
696
701
  } catch (_budgetErr) {
@@ -2676,9 +2681,10 @@ export default function hemaDispatchV3(pi: any): void {
2676
2681
  } catch (_) { process.stderr.write(`[hema:catch] fail-open: ${(_ as Error)?.message || _}\n`); }
2677
2682
 
2678
2683
  // Load hot memory — ensure file exists
2679
- const hotMemoryPath = join(homedir(), 'helios-agent', 'sessions', 'hot-memory.json');
2684
+ const _heliosRootForMem = process.env['HELIOS_ROOT'] ?? join(homedir(), 'helios-agent');
2685
+ const hotMemoryPath = join(_heliosRootForMem, 'sessions', 'hot-memory.json');
2680
2686
  if (!fs.existsSync(hotMemoryPath)) {
2681
- const sessDir = join(homedir(), 'helios-agent', 'sessions');
2687
+ const sessDir = join(_heliosRootForMem, 'sessions');
2682
2688
  if (!fs.existsSync(sessDir)) fs.mkdirSync(sessDir, { recursive: true });
2683
2689
  fs.writeFileSync(hotMemoryPath, JSON.stringify({ sessions: [] }, null, 2), 'utf8');
2684
2690
  }
@@ -2706,7 +2712,7 @@ export default function hemaDispatchV3(pi: any): void {
2706
2712
  const pendingIds = [..._pendingDispatchIds.keys()];
2707
2713
  try {
2708
2714
  const _walSessionId = di('sessionId') || process.pid;
2709
- const walPath = join(homedir(), `helios-agent/sessions/.pending-dispatches-${_walSessionId}.json`);
2715
+ const walPath = join(process.env['HELIOS_ROOT'] ?? join(homedir(), 'helios-agent'), `sessions/.pending-dispatches-${_walSessionId}.json`);
2710
2716
  fs.writeFileSync(walPath, JSON.stringify({ ids: pendingIds, timestamp: Date.now(), sessionId: di('sessionId') || 'unknown' }));
2711
2717
  process.stderr.write(`[hema] session_shutdown: wrote ${pendingIds.length} pending dispatch ID(s) to WAL\n`);
2712
2718
  } catch (e: any) {
@@ -3144,7 +3150,7 @@ export default function hemaDispatchV3(pi: any): void {
3144
3150
  return { systemPrompt: appendDynamic(event.systemPrompt, '\n' + evalContext) };
3145
3151
  }
3146
3152
 
3147
- const hotMemoryPath = join(homedir(), 'helios-agent', 'sessions', 'hot-memory.json');
3153
+ const hotMemoryPath = join(process.env['HELIOS_ROOT'] ?? join(homedir(), 'helios-agent'), 'sessions', 'hot-memory.json');
3148
3154
 
3149
3155
  let hotMemory: any = null;
3150
3156
  if (fs.existsSync(hotMemoryPath)) {
@@ -713,6 +713,14 @@ export async function runScheduledMaintenance(): Promise<void> {
713
713
  }
714
714
  }
715
715
 
716
+ // SEC-5: Daily cleanup of expired DraftAction PII nodes (30-day TTL)
717
+ try {
718
+ const { rawWrite: _rawWrite } = require('../../lib/safe-memgraph.js');
719
+ await _rawWrite('MATCH (da:DraftAction) WHERE da.expiresAt < datetime() DETACH DELETE da', {});
720
+ } catch (err: any) {
721
+ console.warn('[warm-tick] DraftAction TTL cleanup failed:', err?.message || String(err));
722
+ }
723
+
716
724
  // Daily: incremental label backfill (ensures critical graph labels are never empty)
717
725
  try {
718
726
  const { runIncrementalBackfill } = await import('./warm-tick-backfill.js');
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/__tests__/hbo-core-store.test.js
4
+ *
5
+ * Unit tests for hbo-core-store.ts (SQLite fallback store).
6
+ * Uses node:sqlite (Node.js v22+ built-in) — no native compilation required.
7
+ * Runs natively on Windows and macOS.
8
+ *
9
+ * Run: node --experimental-sqlite lib/__tests__/hbo-core-store.test.js
10
+ * Or: pnpm run test:hbo-store
11
+ *
12
+ * Invariants verified:
13
+ * - Memgraph is the primary data model; SQLite is the fallback
14
+ * - No hardcoded company IDs (process.pid suffix)
15
+ * - INSERT OR REPLACE is idempotent
16
+ * - company_id isolation prevents cross-tenant data bleed
17
+ * - company_id=undefined throws (never silently stores null)
18
+ * - updateTask/updateApproval upsert when record is missing
19
+ */
20
+
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const fs = require('fs');
24
+
25
+ // Unique per test run — no cross-run state pollution
26
+ const TEST_ROOT = path.join(os.tmpdir(), 'hbo-core-store-test-' + process.pid);
27
+ process.env.HELIOS_ROOT = TEST_ROOT;
28
+ fs.mkdirSync(path.join(TEST_ROOT, 'data'), { recursive: true });
29
+
30
+ // Must require AFTER setting HELIOS_ROOT
31
+ const store = require('../hbo-core-store');
32
+
33
+ // Unique company IDs — no hardcoded tenant names
34
+ const CID_A = 'test:hbo-store:co-a:' + process.pid;
35
+ const CID_B = 'test:hbo-store:co-b:' + process.pid;
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ const failures = [];
40
+
41
+ function assert(condition, message) {
42
+ if (!condition) {
43
+ const err = new Error('FAIL: ' + message);
44
+ failures.push(err);
45
+ failed++;
46
+ console.error(' ✗', message);
47
+ } else {
48
+ passed++;
49
+ console.log(' ✓', message);
50
+ }
51
+ }
52
+
53
+ function assertEqual(a, b, message) {
54
+ assert(a === b, `${message} (expected ${JSON.stringify(b)}, got ${JSON.stringify(a)})`);
55
+ }
56
+
57
+ function assertNull(a, message) {
58
+ assert(a === null, `${message} (expected null, got ${JSON.stringify(a)})`);
59
+ }
60
+
61
+ function assertThrows(fn, messagePart, label) {
62
+ try {
63
+ fn();
64
+ assert(false, `${label}: expected throw but did not throw`);
65
+ } catch (e) {
66
+ if (messagePart && !e.message.includes(messagePart)) {
67
+ assert(false, `${label}: threw but message "${e.message}" did not contain "${messagePart}"`);
68
+ } else {
69
+ assert(true, label);
70
+ }
71
+ }
72
+ }
73
+
74
+ // ── Task tests ────────────────────────────────────────────────────────────────
75
+
76
+ console.log('\n[Tasks]');
77
+
78
+ store.createTask({ id: 't-1', companyId: CID_A, title: 'Test Task', status: 'todo', priority: 1 });
79
+ const t1 = store.getTask('t-1', CID_A);
80
+ assert(t1 !== null, 'getTask returns record after createTask');
81
+ assertEqual(t1.title, 'Test Task', 'getTask.title correct');
82
+ assertEqual(t1.status, 'todo', 'getTask.status correct');
83
+
84
+ assertNull(store.getTask('t-1', CID_B), 'getTask returns null for wrong company (isolation)');
85
+
86
+ store.updateTask('t-1', CID_A, { status: 'in_progress' });
87
+ assertEqual(store.getTask('t-1', CID_A).status, 'in_progress', 'updateTask changes status');
88
+
89
+ // updateTask upsert: record not yet in SQLite → creates it
90
+ store.updateTask('t-upsert', CID_A, { title: 'Upserted', status: 'todo' });
91
+ const tu = store.getTask('t-upsert', CID_A);
92
+ assert(tu !== null, 'updateTask upserts when record is missing');
93
+ assertEqual(tu.status, 'todo', 'upserted task has correct status');
94
+
95
+ store.createTask({ id: 't-2', companyId: CID_A, title: 'Task 2', status: 'done' });
96
+ const todos = store.getTasksByCompanyStatus(CID_A, 'todo');
97
+ assert(todos.some(t => t.id === 't-upsert'), 'getTasksByCompanyStatus(todo) includes todo tasks');
98
+ assert(!todos.some(t => t.id === 't-2'), 'getTasksByCompanyStatus(todo) excludes done tasks');
99
+
100
+ store.updateTask('t-1', CID_A, { status: 'in_progress' });
101
+ const active = store.getTasksByCompanyStatus(CID_A, ['todo', 'in_progress']);
102
+ assert(active.some(t => t.id === 't-1'), 'multi-status filter includes in_progress');
103
+ assert(active.some(t => t.id === 't-upsert'), 'multi-status filter includes todo');
104
+ assert(!active.some(t => t.id === 't-2'), 'multi-status filter excludes done');
105
+
106
+ assertThrows(
107
+ () => store.createTask({ id: 't-nocompany', title: 'X' }),
108
+ 'company_id is required',
109
+ 'createTask throws when companyId is missing'
110
+ );
111
+
112
+ // Idempotency
113
+ store.createTask({ id: 't-1', companyId: CID_A, title: 'Updated Title', status: 'in_progress' });
114
+ const allTasks = store.getTasksByCompanyStatus(CID_A, ['todo', 'in_progress', 'done']);
115
+ assertEqual(allTasks.filter(t => t.id === 't-1').length, 1, 'createTask is idempotent (no duplicate rows)');
116
+ assertEqual(store.getTask('t-1', CID_A).title, 'Updated Title', 'second createTask updates the record');
117
+
118
+ store.deleteTask('t-2', CID_A);
119
+ assertNull(store.getTask('t-2', CID_A), 'deleteTask removes the record');
120
+
121
+ // ── Approval tests ────────────────────────────────────────────────────────────
122
+
123
+ console.log('\n[Approvals]');
124
+
125
+ store.createApproval({ id: 'ap-1', companyId: CID_A, type: 'budget_exceeded', status: 'pending', requestedBy: 'agent:ceo' });
126
+ const ap1 = store.getApproval('ap-1', CID_A);
127
+ assert(ap1 !== null, 'getApproval returns record after createApproval');
128
+ assertEqual(ap1.type, 'budget_exceeded', 'approval type correct');
129
+ assertNull(store.getApproval('ap-1', CID_B), 'approval isolated from other company');
130
+
131
+ store.updateApproval('ap-1', CID_A, { status: 'approved', followUpTaskCreated: false });
132
+ assertEqual(store.getApproval('ap-1', CID_A).status, 'approved', 'updateApproval changes status');
133
+
134
+ store.updateApproval('ap-upsert', CID_A, { status: 'pending', type: 'belief_confirmation' });
135
+ assert(store.getApproval('ap-upsert', CID_A) !== null, 'updateApproval upserts when missing');
136
+
137
+ const pendingApprovals = store.getApprovalsByCompanyStatus(CID_A, 'pending');
138
+ assert(pendingApprovals.some(a => a.id === 'ap-upsert'), 'getApprovalsByCompanyStatus returns pending');
139
+ assert(!pendingApprovals.some(a => a.id === 'ap-1'), 'getApprovalsByCompanyStatus excludes approved');
140
+
141
+ assertThrows(
142
+ () => store.createApproval({ id: 'ap-nocompany', type: 'x' }),
143
+ 'company_id is required',
144
+ 'createApproval throws when companyId is missing'
145
+ );
146
+
147
+ // ── BudgetPolicy tests ────────────────────────────────────────────────────────
148
+
149
+ console.log('\n[Goals]');
150
+
151
+ store.createGoal({
152
+ id: 'goal-1',
153
+ companyId: CID_A,
154
+ title: 'Increase retention',
155
+ description: 'Company-level goal',
156
+ level: 'company',
157
+ status: 'active',
158
+ });
159
+ const goal1 = store.getGoal('goal-1', CID_A);
160
+ assert(goal1 !== null, 'getGoal returns record after createGoal');
161
+ assertEqual(goal1.title, 'Increase retention', 'getGoal.title correct');
162
+ assertEqual(goal1.level, 'company', 'getGoal.level correct');
163
+ assertNull(store.getGoal('goal-1', CID_B), 'getGoal returns null for wrong company (isolation)');
164
+
165
+ store.updateGoal('goal-1', CID_A, { title: 'Increase net retention', status: 'paused' });
166
+ const updatedGoal = store.getGoal('goal-1', CID_A);
167
+ assertEqual(updatedGoal.title, 'Increase net retention', 'updateGoal changes title');
168
+ assertEqual(updatedGoal.status, 'paused', 'updateGoal changes status');
169
+
170
+ store.createGoal({
171
+ id: 'goal-child',
172
+ companyId: CID_A,
173
+ title: 'Improve onboarding',
174
+ level: 'department',
175
+ status: 'active',
176
+ parentId: 'goal-1',
177
+ });
178
+ const companyGoals = store.getGoalsByCompany(CID_A);
179
+ assert(companyGoals.some(g => g.id === 'goal-1'), 'getGoalsByCompany includes parent goal');
180
+ assert(companyGoals.some(g => g.id === 'goal-child' && g.parentId === 'goal-1'), 'getGoalsByCompany includes child goal with parentId');
181
+ assert(!store.getGoalsByCompany(CID_B).some(g => g.id === 'goal-1'), 'goals isolated from other company');
182
+
183
+ assertThrows(
184
+ () => store.createGoal({ id: 'goal-nocompany', title: 'X' }),
185
+ 'company_id is required',
186
+ 'createGoal throws when companyId is missing'
187
+ );
188
+
189
+ store.deleteGoal('goal-child', CID_A);
190
+ assertNull(store.getGoal('goal-child', CID_A), 'deleteGoal removes the record');
191
+
192
+ // ── BudgetPolicy tests ───────────────────────────────────────────────────────
193
+
194
+ console.log('\n[BudgetPolicy]');
195
+
196
+ store.upsertBudgetPolicy({ id: 'bp-1', companyId: CID_A, agentId: 'agent:sales', limitCents: 50000, warnPercent: 80, hardStopEnabled: true });
197
+ assert(store.getBudgetPolicy('bp-1', CID_A) !== null, 'getBudgetPolicy returns record after upsert');
198
+ assert(store.getBudgetPoliciesByCompany(CID_A).some(p => p.id === 'bp-1'), 'getBudgetPoliciesByCompany returns the policy');
199
+ assert(!store.getBudgetPoliciesByCompany(CID_B).some(p => p.id === 'bp-1'), 'policy isolated from other company');
200
+
201
+ // ── OKR tests ─────────────────────────────────────────────────────────────────
202
+
203
+ console.log('\n[OKRs]');
204
+
205
+ store.upsertOKR({ id: 'okr-1', companyId: CID_A, type: 'quarterly_okr', status: 'active', title: 'Q3 OKR' });
206
+ assert(store.getOKRsByCompanyType(CID_A, 'quarterly_okr', 'active').some(o => o.id === 'okr-1'), 'getOKRsByCompanyType returns active quarterly OKR');
207
+ assert(!store.getOKRsByCompanyType(CID_A, 'quarterly', 'active').some(o => o.id === 'okr-1'), "type='quarterly' does NOT match 'quarterly_okr'");
208
+ assert(!store.getOKRsByCompanyType(CID_B, 'quarterly_okr', 'active').some(o => o.id === 'okr-1'), 'OKR isolated from other company');
209
+
210
+ // ── Lead tests ────────────────────────────────────────────────────────────────
211
+
212
+ console.log('\n[Leads]');
213
+
214
+ store.upsertLead({ id: 'lead-1', companyId: CID_A, status: 'new', name: 'Alice', email: 'alice@example.com' });
215
+ assert(store.getLeadsByCompanyStatus(CID_A, ['new', 'stale']).some(l => l.id === 'lead-1'), 'getLeadsByCompanyStatus returns new lead');
216
+ assert(!store.getLeadsByCompanyStatus(CID_B, ['new']).some(l => l.id === 'lead-1'), 'lead isolated from other company');
217
+
218
+ // ── CostEvent tests ───────────────────────────────────────────────────────────
219
+
220
+ console.log('\n[CostEvents]');
221
+
222
+ const now = Date.now();
223
+ store.createCostEvent({ id: 'ce-1', companyId: CID_A, feature: 'llm', model: 'claude-sonnet', amountUsd: 0.05, createdAt: now });
224
+ assert(store.getCostEventsByCompanyRange(CID_A, now - 1000, now + 1000).some(e => e.id === 'ce-1'), 'getCostEventsByCompanyRange returns event in range');
225
+ assert(!store.getCostEventsByCompanyRange(CID_B, now - 1000, now + 1000).some(e => e.id === 'ce-1'), 'cost event isolated from other company');
226
+
227
+ // ── Summary ───────────────────────────────────────────────────────────────────
228
+
229
+ console.log('\n─────────────────────────────────────────');
230
+ console.log(`Results: ${passed} passed, ${failed} failed`);
231
+ if (failed > 0) {
232
+ console.error('\nFailures:');
233
+ failures.forEach(f => console.error(' -', f.message));
234
+ process.exit(1);
235
+ } else {
236
+ console.log('All tests passed.');
237
+ process.exit(0);
238
+ }
package/lib/event-bus.mts CHANGED
@@ -46,7 +46,7 @@ interface EventMap {
46
46
  senderHandle: string;
47
47
  subject: string;
48
48
  snippet: string;
49
- body: string;
49
+ // body intentionally excluded — PII (SEC-6)
50
50
  threadId: string;
51
51
  receivedAt: string;
52
52
  suggestedAction: string;
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/graph-availability.js
4
+ *
5
+ * Single source of truth for Memgraph availability state within a SINGLE
6
+ * Node.js process. Listeners are notified on state change only (not on
7
+ * redundant sets of the same value).
8
+ *
9
+ * ⚠️ PROCESS SCOPE WARNING:
10
+ * This module uses Node.js module-level state. Each OS process has its own
11
+ * module registry and therefore its own independent _available + _listeners.
12
+ * It is ONLY valid within a single process:
13
+ *
14
+ * ✅ Pi extension process — memgraph-autostart sets state, cortex/hema read it
15
+ * ✅ Eval container — same process, same state
16
+ * ❌ helios-company-daemon.js — runs in its own process; state here is NEVER
17
+ * set by memgraph-autostart (which runs in Pi's process). Do NOT use
18
+ * isMemgraphAvailable() in daemon code to gate Memgraph writes — use the
19
+ * try/catch + setImmediate fire-and-forget pattern instead.
20
+ * ❌ broker/server.js — forked process; has its own availability state
21
+ *
22
+ * Usage (within Pi's extension process only):
23
+ * const { isMemgraphAvailable, setMemgraphAvailable, onAvailabilityChange } = require('./graph-availability');
24
+ */
25
+
26
+ let _available = false;
27
+ let _listeners = [];
28
+
29
+ function setMemgraphAvailable(available) {
30
+ const v = !!available;
31
+ if (v === _available) return;
32
+ _available = v;
33
+ const snap = _listeners.slice();
34
+ for (const fn of snap) {
35
+ try { fn(_available); } catch (e) {
36
+ process.stderr.write('[graph-availability] listener error: ' + String(e) + '\n');
37
+ }
38
+ }
39
+ }
40
+
41
+ function isMemgraphAvailable() {
42
+ return _available;
43
+ }
44
+
45
+ function onAvailabilityChange(fn) {
46
+ _listeners.push(fn);
47
+ return function unsubscribe() {
48
+ _listeners = _listeners.filter((l) => l !== fn);
49
+ };
50
+ }
51
+
52
+ function _resetForTest() {
53
+ _available = false;
54
+ _listeners = [];
55
+ }
56
+
57
+ module.exports = {
58
+ setMemgraphAvailable,
59
+ isMemgraphAvailable,
60
+ onAvailabilityChange,
61
+ _resetForTest,
62
+ };