@cgh567/agent 2.4.1 → 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/bin/helios +0 -0
- package/bin/helios-rpc-node-wrapper.cjs +0 -0
- package/bin/helios-rpc-wrapper.sh +0 -0
- package/daemon/adapters/helios-rpc-adapter.js +47 -25
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/config/com.familiar.helios-daemon.plist +5 -0
- package/daemon/config/helios-daemon.service +4 -0
- package/daemon/context-enrichment.js +59 -21
- 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 +723 -57
- package/daemon/helios-company-daemon.js +616 -134
- package/daemon/lib/harada/cascade-judge.js +12 -50
- package/daemon/lib/harada/mandala.js +20 -0
- package/daemon/lib/harada/pillar-dispatcher.js +1 -1
- package/daemon/lib/harada/project-factory.js +7 -2
- package/daemon/lib/hbo-bridge.js +32 -13
- package/daemon/lib/hed-engine.js +10 -292
- package/daemon/lib/helios-hitl-host.js +15 -2
- package/daemon/lib/hitl-interaction-service.js +0 -0
- package/daemon/lib/memgraph-verify.js +38 -33
- package/daemon/lib/project-drift-detector.js +7 -17
- package/daemon/lib/project-semantic-updater.js +1 -14
- package/daemon/lib/task-completion-processor.js +11 -0
- package/daemon/lib/wizard-engine.js +57 -6
- package/daemon/routes/channels.js +10 -5
- package/daemon/routes/harada-map.js +11 -48
- package/daemon/routes/hbo.js +342 -75
- package/daemon/routes/hitl.js +0 -0
- package/daemon/routes/project.js +194 -62
- package/daemon/routes/routines.js +14 -0
- package/daemon/routes/tasks.js +15 -1
- package/daemon/routes/wizard.js +11 -4
- 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-hitl.js +0 -0
- package/daemon/schema-migrations-proj.js +131 -0
- package/extensions/001-tool-output-cap.ts +0 -0
- package/extensions/context-compaction.ts +45 -26
- package/extensions/cortex/activation-bridge.ts +5 -0
- package/extensions/cortex/learn.ts +26 -0
- package/extensions/cortex/wal-replay.ts +91 -0
- package/extensions/email/backfill.ts +0 -0
- package/extensions/helios-governance/analysis/ambiguity.ts +0 -0
- package/extensions/helios-governance/analysis/compliance.ts +0 -0
- package/extensions/helios-governance/analysis/long-task-detector.ts +0 -0
- package/extensions/helios-governance/analysis/output-contract.ts +0 -0
- package/extensions/helios-governance/analysis/patterns.ts +0 -0
- package/extensions/helios-governance/analysis/preflight.ts +0 -0
- package/extensions/helios-governance/analysis/recurring-violations.ts +0 -0
- package/extensions/helios-governance/analysis/task-classification.ts +0 -0
- package/extensions/helios-governance/analysis/task-intent.ts +0 -0
- package/extensions/helios-governance/gates/high-impact.ts +1 -1
- package/extensions/helios-governance/handlers/_jiti-require.ts +15 -8
- package/extensions/helios-governance/handlers/proxy-test-detector.ts +0 -0
- package/extensions/hema-dispatch-v3/graph-memory.ts +10 -0
- package/extensions/hema-dispatch-v3/index.ts +72 -47
- package/extensions/lib/elo-engine.js +0 -0
- package/extensions/lib/elo-engine.test.js +0 -0
- package/extensions/memgraph-autostart.ts +13 -0
- package/extensions/neuroplastic-eval.ts +0 -0
- package/extensions/shadow-loop/index.ts +0 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +8 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -0
- package/lib/brain-v2-budget.js +0 -0
- package/lib/brain-v2-circuit-breaker.js +0 -0
- package/lib/brain-v2.js +0 -0
- package/lib/broker/adaptive-throttle.js +0 -0
- package/lib/broker/batch-coalescer.js +0 -0
- package/lib/broker/bulkhead.js +0 -0
- package/lib/broker/channel-registry.js +0 -0
- package/lib/broker/circuit-breaker.js +0 -0
- package/lib/broker/evidence-cache.js +0 -0
- package/lib/broker/health-monitor.js +0 -0
- package/lib/broker/mage-queue.js +0 -0
- package/lib/broker/priority-queue.js +0 -0
- package/lib/broker/server.js.bak-error2-fix +0 -0
- package/lib/broker/session-registry.js +0 -0
- package/lib/broker/singleton-timers.js +0 -0
- package/lib/broker/types.d.ts +0 -0
- package/lib/broker/vegas-limit.js +0 -0
- package/lib/compression/dist/ccr-store.js +74 -0
- package/lib/compression/dist/content-router.js +115 -0
- package/lib/compression/dist/pipeline.js +113 -0
- package/lib/compression/dist/server.js +265 -0
- package/lib/compression/dist/smart-crusher.js +251 -0
- package/lib/context-budget.ts +0 -0
- package/lib/context-firewall.js +0 -0
- package/lib/crm/integration/triage-bridge.js +0 -0
- package/lib/email-utils.ts +0 -0
- package/lib/eval/__tests__/preflight-checker.test.ts +0 -0
- package/lib/eval/__tests__/task-instruction-parser.test.ts +0 -0
- package/lib/eval/__tests__/verifier-runner.test.ts +0 -0
- package/lib/eval/index.ts +0 -0
- package/lib/eval/preflight-checker.ts +0 -0
- package/lib/eval/task-domain-classifier.ts +0 -0
- package/lib/eval/task-instruction-parser.ts +0 -0
- package/lib/eval/verifier-runner.ts +0 -0
- package/lib/event-bus.d.ts +0 -0
- package/lib/event-bus.mts +1 -1
- package/lib/governance-context-selector.ts +0 -0
- package/lib/graph/generate-extension-embeddings.js +0 -0
- package/lib/graph/generate-static-embeddings.js +0 -0
- package/lib/graph/lib/utils.js +1 -1
- package/lib/graph-audit.d.ts +0 -0
- 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/mesh-circuit-breaker.js +0 -0
- package/lib/mission-loop/lesson-extractor.ts +0 -0
- package/lib/mission-loop/mental-model-scorer.ts +0 -0
- package/lib/mission-loop/occ-detector.ts +0 -0
- package/lib/mission-loop/query-variants.ts +0 -0
- package/lib/mission-loop/verifier-check.ts +0 -0
- package/lib/skill-reference-builder.ts +0 -0
- package/lib/telemetry/token-breakdown.ts +0 -0
- package/lib/tool-compressor.ts +0 -0
- package/lib/triage-core/classifier.ts +3 -2
- package/lib/triage-core/graph/schema.cypher +10 -0
- package/lib/triage-core/legal-routing.ts +0 -0
- package/lib/triage-core/mental-model/dunbar-classifier.ts +0 -0
- package/lib/triage-core/mental-model/enrich-all.ts +0 -0
- package/lib/triage-core/mental-model/identity-resolver.ts +0 -0
- package/lib/triage-core/mental-model/key-facts.ts +1 -2
- package/lib/triage-core/mental-model/model-assembler.ts +0 -0
- package/lib/triage-core/orchestrator.ts +4 -11
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -0
- package/package.json +18 -8
- package/skills/helios-business-operator/services/signals/upwork-signals.js +0 -0
- package/skills/talisman-ceo/SKILL.md +23 -25
- package/skills/talisman-comms/SKILL.md +5 -5
- package/skills/talisman-engineering/SKILL.md +5 -5
- package/skills/talisman-finance/SKILL.md +10 -8
- package/skills/talisman-marketing/SKILL.md +10 -10
- package/skills/talisman-sales/SKILL.md +12 -15
- package/skills/talisman-support/SKILL.md +5 -5
- package/agents/business/talisman-ceo.md +0 -183
- package/agents/business/talisman-comms.md +0 -257
- package/agents/business/talisman-cto.md +0 -153
- package/agents/business/talisman-finance.md +0 -246
- package/agents/business/talisman-marketing.md +0 -240
- package/agents/business/talisman-sales.md +0 -242
- package/agents/business/talisman-support.md +0 -236
- package/daemon/lib/approval-expiry.js +0 -162
- package/daemon/lib/blast-radius-analyzer.js +0 -75
- package/daemon/lib/domain-bootstrap-orchestrator.js +0 -267
- package/daemon/lib/forensic-log.js +0 -113
- package/daemon/lib/goal-research-pipeline.js +0 -644
- package/daemon/lib/harada/cascade-research-dispatcher.js +0 -261
- package/daemon/lib/headroom-middleware.js +0 -167
- package/daemon/lib/headroom-proxy-manager.js +0 -623
- package/daemon/lib/mental-model-cache.js +0 -96
- package/daemon/lib/project-factory.js +0 -47
- package/daemon/lib/session-log-reader.js +0 -93
- package/daemon/routes/hed.js +0 -133
- package/lib/graph/learning/headroom-learn-bridge.js +0 -215
- package/skills/helios-bookkeeping/SKILL.md +0 -321
- package/skills/helios-briefer/SKILL.md +0 -44
- package/skills/helios-client-relations/SKILL.md +0 -322
- package/skills/helios-personal-triager/SKILL.md +0 -45
- package/skills/helios-recruitment/SKILL.md +0 -317
- package/skills/helios-relationship-nudger/SKILL.md +0 -77
- package/skills/helios-researcher/SKILL.md +0 -44
- package/skills/helios-scheduler/SKILL.md +0 -58
- package/skills/helios-tax-analyst/SKILL.md +0 -280
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
|
|
@@ -42,13 +42,14 @@ const fs = require('fs');
|
|
|
42
42
|
const path = require('path');
|
|
43
43
|
const crypto = require('crypto');
|
|
44
44
|
const { TranscriptStore } = require('./transcript-store');
|
|
45
|
+
const hboStore = require('../lib/hbo-core-store');
|
|
45
46
|
|
|
46
47
|
const TRANSCRIPTS_DIR = path.join(__dirname, 'transcripts');
|
|
47
48
|
const _transcriptStore = new TranscriptStore(TRANSCRIPTS_DIR);
|
|
48
49
|
const CORS_HEADERS = {
|
|
49
50
|
'Access-Control-Allow-Origin': '*',
|
|
50
|
-
'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
|
|
51
|
-
'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',
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
// ── Logging ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -60,6 +61,14 @@ function log(level, msg, extra) {
|
|
|
60
61
|
else process.stdout.write(JSON.stringify(entry) + '\n');
|
|
61
62
|
}
|
|
62
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
|
+
|
|
63
72
|
// ── SSE Client Registry ───────────────────────────────────────────────────────
|
|
64
73
|
|
|
65
74
|
const sseClients = new Set();
|
|
@@ -259,6 +268,7 @@ async function handleHealth(req, res, ctx) {
|
|
|
259
268
|
const { tickCount, modules } = ctx;
|
|
260
269
|
jsonResponse(res, 200, {
|
|
261
270
|
status: 'ok',
|
|
271
|
+
port: ctx.actualBoundPort ?? ctx.apiPort ?? null,
|
|
262
272
|
tick: tickCount ?? 0,
|
|
263
273
|
modules: modules ?? [],
|
|
264
274
|
timestamp: new Date().toISOString(),
|
|
@@ -280,6 +290,26 @@ async function handleGetTasks(req, res, ctx) {
|
|
|
280
290
|
let cypher;
|
|
281
291
|
const params = { limit, cid: ctx.cid };
|
|
282
292
|
|
|
293
|
+
// SQLite-first task list read (P2-9)
|
|
294
|
+
try {
|
|
295
|
+
const _storeTasks = hboStore.getTasksByCompanyStatus
|
|
296
|
+
? (q.status
|
|
297
|
+
? hboStore.getTasksByCompanyStatus(ctx.cid, q.status)
|
|
298
|
+
: (() => { try { return require('../lib/hbo-core-store').getTasksByCompanyStatus(ctx.cid, ['todo','in_progress','done','andon_paused','help_pending','cancelled']); } catch(_) { return null; } })()
|
|
299
|
+
)
|
|
300
|
+
: null;
|
|
301
|
+
if (_storeTasks) {
|
|
302
|
+
let tasks = _storeTasks;
|
|
303
|
+
if (q.agentId) tasks = tasks.filter(t => t.assigneeAgentId === q.agentId);
|
|
304
|
+
tasks = tasks
|
|
305
|
+
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
|
|
306
|
+
.slice(0, limit)
|
|
307
|
+
.map(t => ({ id: t.id, title: t.title, status: t.status, priority: t.priority, assignee: t.assigneeAgentId, body: t.body ?? null }));
|
|
308
|
+
jsonResponse(res, 200, { tasks, count: tasks.length });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
} catch (_) {}
|
|
312
|
+
|
|
283
313
|
if (q.status && q.agentId) {
|
|
284
314
|
cypher = `MATCH (t:Task {companyId: $cid, status: $status, assigneeAgentId: $agentId})
|
|
285
315
|
RETURN t.id AS id, t.title AS title, t.status AS status,
|
|
@@ -353,13 +383,58 @@ async function handleCreateTask(req, res, ctx) {
|
|
|
353
383
|
const cid = ctx.cid;
|
|
354
384
|
|
|
355
385
|
const body = await parseBody(req);
|
|
356
|
-
const { title, assigneeAgentId, priority, body: taskBody } = body;
|
|
386
|
+
const { title, assigneeAgentId, priority, body: taskBody, executionPolicy } = body;
|
|
357
387
|
|
|
358
388
|
if (!title || typeof title !== 'string' || title.trim() === '') {
|
|
359
389
|
jsonResponse(res, 400, { error: 'title is required and must be a non-empty string' });
|
|
360
390
|
return;
|
|
361
391
|
}
|
|
362
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
|
+
|
|
363
438
|
const taskId = `task:api:${crypto.randomUUID()}`;
|
|
364
439
|
const resolvedPriority = Number.isFinite(Number(priority)) ? Number(priority) : 2;
|
|
365
440
|
|
|
@@ -373,6 +448,8 @@ async function handleCreateTask(req, res, ctx) {
|
|
|
373
448
|
priority: toInteger($priority),
|
|
374
449
|
assigneeAgentId: $assigneeAgentId,
|
|
375
450
|
body: $body,
|
|
451
|
+
executionPolicyJson: $policyJson,
|
|
452
|
+
executionStateJson: $stateJson,
|
|
376
453
|
createdAt: datetime()
|
|
377
454
|
})`,
|
|
378
455
|
{
|
|
@@ -382,11 +459,26 @@ async function handleCreateTask(req, res, ctx) {
|
|
|
382
459
|
priority: resolvedPriority,
|
|
383
460
|
assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
|
|
384
461
|
body: taskBody ? String(taskBody) : null,
|
|
462
|
+
policyJson: validatedPolicyJson,
|
|
463
|
+
stateJson: initialStateJson,
|
|
385
464
|
}
|
|
386
465
|
);
|
|
387
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
|
+
|
|
388
480
|
broadcast({ type: 'task.created', taskId, status: 'todo', companyId: cid });
|
|
389
|
-
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 } });
|
|
390
482
|
} catch (err) {
|
|
391
483
|
jsonResponse(res, 500, { error: `Create failed: ${err.message}` });
|
|
392
484
|
}
|
|
@@ -401,7 +493,8 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
401
493
|
const body = await parseBody(req);
|
|
402
494
|
const { status, priority } = body;
|
|
403
495
|
|
|
404
|
-
|
|
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']);
|
|
405
498
|
if (status !== undefined && !VALID_STATUSES.has(status)) {
|
|
406
499
|
jsonResponse(res, 400, { error: `Invalid status. Allowed: ${[...VALID_STATUSES].join(', ')}` });
|
|
407
500
|
return;
|
|
@@ -417,6 +510,259 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
417
510
|
}
|
|
418
511
|
|
|
419
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)
|
|
420
766
|
const setClauses = [];
|
|
421
767
|
const params = { id: taskId, cid };
|
|
422
768
|
if (status !== undefined) { setClauses.push('t.status = $status'); params.status = status; }
|
|
@@ -433,6 +779,11 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
433
779
|
return;
|
|
434
780
|
}
|
|
435
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
|
+
|
|
436
787
|
broadcast({ type: 'task.updated', taskId, ...(status !== undefined ? { status } : {}), companyId: cid });
|
|
437
788
|
jsonResponse(res, 200, { updated: true, taskId });
|
|
438
789
|
} catch (err) {
|
|
@@ -440,6 +791,76 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
440
791
|
}
|
|
441
792
|
}
|
|
442
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
|
+
|
|
443
864
|
async function handleCancelTask(req, res, ctx, taskId) {
|
|
444
865
|
const { mgQuery } = ctx;
|
|
445
866
|
if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
|
|
@@ -460,6 +881,7 @@ async function handleCancelTask(req, res, ctx, taskId) {
|
|
|
460
881
|
jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
|
|
461
882
|
return;
|
|
462
883
|
}
|
|
884
|
+
mirrorHboStore('task.cancel', () => hboStore.updateTask?.(taskId, cid, { status: 'cancelled', cancelReason: 'api_cancel' }));
|
|
463
885
|
broadcast({ type: 'task.cancelled', taskId, companyId: cid });
|
|
464
886
|
jsonResponse(res, 200, { cancelled: true, taskId });
|
|
465
887
|
} catch (err) {
|
|
@@ -503,6 +925,23 @@ async function handleGetApprovals(req, res, ctx) {
|
|
|
503
925
|
let cypher;
|
|
504
926
|
const params = { limit, cid: ctx.cid };
|
|
505
927
|
|
|
928
|
+
// SQLite-first approval list read (P2-9)
|
|
929
|
+
try {
|
|
930
|
+
const _storeApprovals = hboStore.getApprovalsByCompanyStatus
|
|
931
|
+
? (q.status
|
|
932
|
+
? hboStore.getApprovalsByCompanyStatus(ctx.cid, q.status)
|
|
933
|
+
: hboStore.getApprovalsByCompanyStatus(ctx.cid, ['pending','approved','rejected','expired'])
|
|
934
|
+
)
|
|
935
|
+
: null;
|
|
936
|
+
if (_storeApprovals) {
|
|
937
|
+
const approvals = _storeApprovals
|
|
938
|
+
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
|
|
939
|
+
.slice(0, limit);
|
|
940
|
+
jsonResponse(res, 200, { approvals, count: approvals.length });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
} catch (_) {}
|
|
944
|
+
|
|
506
945
|
if (q.status) {
|
|
507
946
|
cypher = `MATCH (a:Approval {companyId: $cid, status: $status})${returnClause}`;
|
|
508
947
|
params.status = q.status;
|
|
@@ -543,11 +982,12 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
|
|
|
543
982
|
RETURN a.id`,
|
|
544
983
|
{ id: approvalId, cid, answer }
|
|
545
984
|
);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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() }));
|
|
551
991
|
|
|
552
992
|
// Synchronously update linked Strategy if this is a strategy_proposal
|
|
553
993
|
try {
|
|
@@ -676,6 +1116,7 @@ async function handleRejectApproval(req, res, ctx, approvalId) {
|
|
|
676
1116
|
jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
|
|
677
1117
|
return;
|
|
678
1118
|
}
|
|
1119
|
+
mirrorHboStore('approval.reject', () => hboStore.updateApproval?.(approvalId, cid, { status: 'rejected', rejectReason: reason, rejectedAt: Date.now() }));
|
|
679
1120
|
broadcast({ type: 'approval.rejected', approvalId, reason, companyId: cid });
|
|
680
1121
|
jsonResponse(res, 200, { rejected: true, approvalId, reason });
|
|
681
1122
|
} catch (err) {
|
|
@@ -2184,6 +2625,18 @@ async function handleCreateStrategy(req, res, ctx) {
|
|
|
2184
2625
|
{ id: approvalId, cid, approvalTitle: `Strategy: ${title}`, plan, proposedBy, strategyId }
|
|
2185
2626
|
);
|
|
2186
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
|
+
|
|
2187
2640
|
// Link goal to strategy
|
|
2188
2641
|
await mgQuery(
|
|
2189
2642
|
`MATCH (g:CompanyGoal {id: $goalId}), (s:Strategy {id: $strategyId})
|
|
@@ -2267,13 +2720,13 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
|
|
|
2267
2720
|
const result = await mgQuery(
|
|
2268
2721
|
`MATCH (rt:RoutineTrigger {publicId: $publicId, companyId: $cid})-[:TRIGGERS]->(r:Routine)
|
|
2269
2722
|
RETURN rt.id AS triggerId, rt.routineId AS routineId, r.title AS routineTitle,
|
|
2270
|
-
r.variables AS variables, r.companyId AS companyId`,
|
|
2723
|
+
r.variables AS variables, r.companyId AS companyId, rt.secret AS secret`,
|
|
2271
2724
|
{ publicId, cid: ctx.cid }
|
|
2272
2725
|
);
|
|
2273
2726
|
const rows = parseRows(result);
|
|
2274
2727
|
if (rows.length === 0) return jsonResponse(res, 404, { error: 'Trigger not found' });
|
|
2275
2728
|
|
|
2276
|
-
const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId'];
|
|
2729
|
+
const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId', 'secret'];
|
|
2277
2730
|
const trigger = rowToObj(rows[0], keys);
|
|
2278
2731
|
|
|
2279
2732
|
// S-06: Verify HMAC signature if trigger has a secret configured
|
|
@@ -2318,8 +2771,8 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
|
|
|
2318
2771
|
}
|
|
2319
2772
|
);
|
|
2320
2773
|
|
|
2321
|
-
|
|
2322
|
-
|
|
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 });
|
|
2323
2776
|
} catch (err) {
|
|
2324
2777
|
return jsonResponse(res, 500, { error: err.message });
|
|
2325
2778
|
}
|
|
@@ -2327,6 +2780,124 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
|
|
|
2327
2780
|
|
|
2328
2781
|
// ── Routine Revisions & Runs ──────────────────────────────────────────────────
|
|
2329
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
|
+
|
|
2330
2901
|
async function handlePatchRoutine(req, res, ctx, routineId) {
|
|
2331
2902
|
const { mgQuery } = ctx;
|
|
2332
2903
|
const body = await parseBody(req);
|
|
@@ -2462,7 +3033,7 @@ async function dispatchToAdapter(mgQuery, ctx, taskId, agentId) {
|
|
|
2462
3033
|
HELIOS_AGENT_ID: agentId,
|
|
2463
3034
|
HELIOS_RUN_ID: runId,
|
|
2464
3035
|
HELIOS_TASK_ID: taskId,
|
|
2465
|
-
HELIOS_API_URL: `http://localhost:${ctx.apiPort ||
|
|
3036
|
+
HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9093}`,
|
|
2466
3037
|
};
|
|
2467
3038
|
|
|
2468
3039
|
broadcast({ type: 'run.started', runId, taskId, agentId, adapterType, companyId: cid });
|
|
@@ -2761,6 +3332,7 @@ const tasksRoute = require('./routes/tasks')({
|
|
|
2761
3332
|
handleGetTaskComments,
|
|
2762
3333
|
handlePostTaskComment,
|
|
2763
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
|
|
2764
3336
|
});
|
|
2765
3337
|
|
|
2766
3338
|
const approvalsRoute = require('./routes/approvals')({
|
|
@@ -2807,6 +3379,8 @@ const strategyRoute = require('./routes/strategy')({
|
|
|
2807
3379
|
});
|
|
2808
3380
|
|
|
2809
3381
|
const routinesRoute = require('./routes/routines')({
|
|
3382
|
+
handleGetRoutines,
|
|
3383
|
+
handleCreateRoutine,
|
|
2810
3384
|
handleGetRoutineTriggers,
|
|
2811
3385
|
handleCreateRoutineTrigger,
|
|
2812
3386
|
handleFireWebhookTrigger,
|
|
@@ -2848,6 +3422,10 @@ let interpretationRoute = () => false;
|
|
|
2848
3422
|
let hedRoute = () => false;
|
|
2849
3423
|
// D4: Department intelligence page handler — initialized lazily inside startApi()
|
|
2850
3424
|
let handleGetDepartmentPage = null;
|
|
3425
|
+
// Domain-specific routes — initialized lazily inside startApi()
|
|
3426
|
+
let hitlRoute = null;
|
|
3427
|
+
let channelsRoute = null;
|
|
3428
|
+
let emailInfraRoute = null;
|
|
2851
3429
|
const { handleOKRRoutes } = require('./routes/okrs');
|
|
2852
3430
|
const { handleSenseiRoutes } = require('./routes/sensei');
|
|
2853
3431
|
let suggestionsRoute, suggestionsCron;
|
|
@@ -2889,6 +3467,10 @@ async function route(req, res, ctx) {
|
|
|
2889
3467
|
// It can never elevate to a company this daemon doesn't own.
|
|
2890
3468
|
const qcid = parsedUrl.searchParams.get('companyId');
|
|
2891
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
|
+
}
|
|
2892
3474
|
// qcid will be validated against allowedCompanyIds before being used;
|
|
2893
3475
|
// if it doesn't match, the 403 below catches it. This is safe.
|
|
2894
3476
|
// Group C: Create per-request context clone so concurrent requests can't corrupt each other's cid.
|
|
@@ -2967,6 +3549,14 @@ async function route(req, res, ctx) {
|
|
|
2967
3549
|
return;
|
|
2968
3550
|
}
|
|
2969
3551
|
const result = ctx.daemon.registerCompany(String(companyId));
|
|
3552
|
+
// Also update ctx.companies so the tenant isolation check accepts this company
|
|
3553
|
+
// on subsequent requests (the 403 guard checks ctx.companies, not _modulesByCompany).
|
|
3554
|
+
// This is safe: ctx.companies is an array that only grows — no entries are removed.
|
|
3555
|
+
if (!ctx.companies) ctx.companies = [];
|
|
3556
|
+
const cidStr = String(companyId);
|
|
3557
|
+
if (!ctx.companies.find(c => String(c.id) === cidStr)) {
|
|
3558
|
+
try { ctx.companies.push({ id: cidStr, name: cidStr }); } catch (_) { /* frozen array — ignore */ }
|
|
3559
|
+
}
|
|
2970
3560
|
jsonResponse(res, 200, result);
|
|
2971
3561
|
} catch (err) {
|
|
2972
3562
|
jsonResponse(res, 500, { error: `registerCompany failed: ${err.message}` });
|
|
@@ -2974,6 +3564,51 @@ async function route(req, res, ctx) {
|
|
|
2974
3564
|
return;
|
|
2975
3565
|
}
|
|
2976
3566
|
|
|
3567
|
+
// GET /api/v1/context-brief — build and return the agent context brief.
|
|
3568
|
+
//
|
|
3569
|
+
// Used by harbor tests (test_context_propagation.py) and desktop context panel.
|
|
3570
|
+
// Calls buildContextBrief() from context-enrichment.js — the same function the
|
|
3571
|
+
// daemon calls before every agent dispatch.
|
|
3572
|
+
//
|
|
3573
|
+
// Query params:
|
|
3574
|
+
// agentId (required) — the BusinessAgent id
|
|
3575
|
+
// companyId (optional) — defaults to primary company; checked against allowedCompanyIds
|
|
3576
|
+
// nocache (optional) — if set, bypasses the 5-minute context cache
|
|
3577
|
+
//
|
|
3578
|
+
// Primary path: GET /api/v1/context-brief?agentId=X&companyId=Y
|
|
3579
|
+
// → buildContextBrief(mgQuery, agentId, '', companyId)
|
|
3580
|
+
// → { brief: string, agentId, companyId }
|
|
3581
|
+
if (method === 'GET' && pathname === '/api/v1/context-brief') {
|
|
3582
|
+
try {
|
|
3583
|
+
const agentId = parsedUrl.searchParams.get('agentId');
|
|
3584
|
+
const explicitCid = parsedUrl.searchParams.get('companyId');
|
|
3585
|
+
if (!agentId) {
|
|
3586
|
+
jsonResponse(res, 400, { error: 'agentId required' });
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
if (!explicitCid) {
|
|
3590
|
+
jsonResponse(res, 400, { error: 'companyId required' });
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
const cid = resolvedCid;
|
|
3594
|
+
const { mgQuery } = ctx;
|
|
3595
|
+
if (!mgQuery) {
|
|
3596
|
+
jsonResponse(res, 503, { error: 'Memgraph not available' });
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
const { buildContextBrief, invalidateContextCache } = require('./context-enrichment');
|
|
3600
|
+
// Bust 5-minute context cache when nocache=1 is set (used by tests after seeding)
|
|
3601
|
+
if (parsedUrl.searchParams.get('nocache')) {
|
|
3602
|
+
try { invalidateContextCache(cid); } catch (_) {}
|
|
3603
|
+
}
|
|
3604
|
+
const brief = await buildContextBrief(mgQuery, agentId, '', cid, null);
|
|
3605
|
+
jsonResponse(res, 200, { brief: brief || '', agentId, companyId: cid });
|
|
3606
|
+
} catch (err) {
|
|
3607
|
+
jsonResponse(res, 500, { error: `context-brief failed: ${err.message}` });
|
|
3608
|
+
}
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
|
|
2977
3612
|
// GET /api/headroom/health — proxy to Headroom compression proxy health endpoint
|
|
2978
3613
|
// Used by HeliosInfraService (helios-desktop) to show proxy status in Ground Control.
|
|
2979
3614
|
if (method === 'GET' && pathname === '/api/headroom/health') {
|
|
@@ -3163,18 +3798,49 @@ async function route(req, res, ctx) {
|
|
|
3163
3798
|
const baseUrl = HeadroomProxyManager.getInstance().getBaseUrl();
|
|
3164
3799
|
if (!baseUrl) return _origEnd(chunk, encoding, callback);
|
|
3165
3800
|
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3801
|
+
// Direct HTTP POST /headroom/compress — no npm package required.
|
|
3802
|
+
// Same pattern as context-compaction.ts and headroom-middleware.js.
|
|
3803
|
+
const _payload = JSON.stringify({
|
|
3804
|
+
messages: [{
|
|
3805
|
+
role: 'user',
|
|
3806
|
+
content: [{
|
|
3807
|
+
type: 'tool_result',
|
|
3808
|
+
tool_use_id: 'hbo_get',
|
|
3809
|
+
content: body,
|
|
3810
|
+
}],
|
|
3811
|
+
}],
|
|
3812
|
+
});
|
|
3813
|
+
const _url = new URL(baseUrl);
|
|
3814
|
+
const result = await new Promise((resolve, reject) => {
|
|
3815
|
+
const http = require('http');
|
|
3816
|
+
const req = http.request(
|
|
3817
|
+
{
|
|
3818
|
+
hostname: _url.hostname,
|
|
3819
|
+
port: parseInt(_url.port || '8787', 10),
|
|
3820
|
+
path: '/headroom/compress',
|
|
3821
|
+
method: 'POST',
|
|
3822
|
+
headers: {
|
|
3823
|
+
'Content-Type': 'application/json',
|
|
3824
|
+
'Content-Length': Buffer.byteLength(_payload),
|
|
3825
|
+
},
|
|
3826
|
+
},
|
|
3827
|
+
(res) => {
|
|
3828
|
+
let buf = '';
|
|
3829
|
+
res.on('data', (c) => { buf += c; });
|
|
3830
|
+
res.on('end', () => { try { resolve(JSON.parse(buf)); } catch { reject(new Error('bad json')); } });
|
|
3831
|
+
res.on('error', reject);
|
|
3832
|
+
}
|
|
3833
|
+
);
|
|
3834
|
+
req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
3835
|
+
req.on('error', reject);
|
|
3836
|
+
req.write(_payload);
|
|
3837
|
+
req.end();
|
|
3838
|
+
});
|
|
3171
3839
|
|
|
3172
3840
|
let compressed = body;
|
|
3173
3841
|
try {
|
|
3174
|
-
const c = result?.messages?.[0]?.content;
|
|
3175
|
-
|
|
3176
|
-
: Array.isArray(c) ? (c[0]?.text ?? c[0]?.content ?? body) : body;
|
|
3177
|
-
compressed = text;
|
|
3842
|
+
const c = result?.messages?.[0]?.content?.[0]?.content;
|
|
3843
|
+
if (c) compressed = c;
|
|
3178
3844
|
} catch (_) {}
|
|
3179
3845
|
|
|
3180
3846
|
if (result?.ccrHashes?.length) {
|
|
@@ -3495,41 +4161,27 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
|
|
|
3495
4161
|
}
|
|
3496
4162
|
}
|
|
3497
4163
|
|
|
3498
|
-
// Cache miss or stale —
|
|
4164
|
+
// Cache miss or stale — return 202 immediately and generate async
|
|
3499
4165
|
if (!handleGetDepartmentPage) {
|
|
3500
4166
|
jsonResponse(res, 503, { error: 'Department page generator not initialized' });
|
|
3501
4167
|
return;
|
|
3502
4168
|
}
|
|
3503
4169
|
|
|
3504
|
-
|
|
4170
|
+
// D5: Return 202 immediately so the frontend can show a loading state.
|
|
4171
|
+
// Generation runs in setImmediate (next event-loop tick) and broadcasts
|
|
4172
|
+
// department:page:ready when complete so the client can refetch.
|
|
4173
|
+
jsonResponse(res, 202, { ok: true, generating: true, department, companyId: cid });
|
|
3505
4174
|
|
|
3506
|
-
|
|
3507
|
-
// When null, query the DepartmentPage node for its status/error so the desktop
|
|
3508
|
-
// can show a specific message (e.g. "AWS credentials required") instead of the
|
|
3509
|
-
// generic "no intelligence page available" empty state.
|
|
3510
|
-
if (!narrative) {
|
|
3511
|
-
let failureError = null;
|
|
4175
|
+
setImmediate(async () => {
|
|
3512
4176
|
try {
|
|
3513
|
-
const
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
{ cid, dept: department }
|
|
3517
|
-
).catch(() => null);
|
|
3518
|
-
const failRow = failedNode?.rows?.[0];
|
|
3519
|
-
if (failRow) {
|
|
3520
|
-
failureError = Array.isArray(failRow) ? failRow[0] : failRow.error;
|
|
4177
|
+
const narrative = await handleGetDepartmentPage.generatePage(cid, department);
|
|
4178
|
+
if (narrative) {
|
|
4179
|
+
broadcast({ type: 'department:page:ready', companyId: cid, department });
|
|
3521
4180
|
}
|
|
3522
|
-
} catch (
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
error: failureError || 'Generation failed — check daemon logs',
|
|
3527
|
-
cached: false,
|
|
3528
|
-
});
|
|
3529
|
-
return;
|
|
3530
|
-
}
|
|
3531
|
-
|
|
3532
|
-
jsonResponse(res, 200, { department, narrative, generatedAt: new Date().toISOString(), cached: false });
|
|
4181
|
+
} catch (_genErr) {
|
|
4182
|
+
console.warn('[dept-page] async generation error:', _genErr && _genErr.message);
|
|
4183
|
+
}
|
|
4184
|
+
});
|
|
3533
4185
|
|
|
3534
4186
|
} catch (e) {
|
|
3535
4187
|
jsonResponse(res, 500, { error: 'Department page generation failed: ' + (e.message || 'unknown error') });
|
|
@@ -3542,7 +4194,7 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
|
|
|
3542
4194
|
* Start the REST API server.
|
|
3543
4195
|
*
|
|
3544
4196
|
* @param {Function} mgQuery - Memgraph query function: (cypher, params) => Promise<rows>
|
|
3545
|
-
* @param {object} config - Daemon config (uses config.apiPort ??
|
|
4197
|
+
* @param {object} config - Daemon config (uses config.apiPort ?? 9093)
|
|
3546
4198
|
* @param {object} state - Optional shared state for health endpoint
|
|
3547
4199
|
* @returns {{ server, updateTick, setDraining }}
|
|
3548
4200
|
*/
|
|
@@ -3550,6 +4202,16 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
|
|
|
3550
4202
|
|
|
3551
4203
|
async function handleGetTaskDetail(req, res, ctx, taskId) {
|
|
3552
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
|
+
}
|
|
3553
4215
|
try {
|
|
3554
4216
|
const result = await ctx.mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid });
|
|
3555
4217
|
if (result.rows && result.rows.length > 0) {
|
|
@@ -3730,6 +4392,7 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3730
4392
|
invalidateContextCache,
|
|
3731
4393
|
semanticUpdater: container?.cradle?.projectSemanticUpdater ?? null,
|
|
3732
4394
|
driftDetector: container?.cradle?.projectDriftDetector ?? null,
|
|
4395
|
+
hboStore,
|
|
3733
4396
|
});
|
|
3734
4397
|
// Dept Catchball pitch — shares same semanticUpdater from container
|
|
3735
4398
|
deptRoute = require('./routes/dept')({
|
|
@@ -3740,7 +4403,7 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3740
4403
|
} catch (e) {
|
|
3741
4404
|
// container.js not yet built — projectRoute falls back to () => false
|
|
3742
4405
|
const { invalidateContextCache } = require('./context-enrichment');
|
|
3743
|
-
projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache });
|
|
4406
|
+
projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache, hboStore });
|
|
3744
4407
|
deptRoute = require('./routes/dept')({ mgQuery, broadcast: state.broadcast ?? (() => {}) });
|
|
3745
4408
|
}
|
|
3746
4409
|
|
|
@@ -3753,12 +4416,12 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3753
4416
|
}
|
|
3754
4417
|
|
|
3755
4418
|
// Domain-specific routes: HITL, channel setup, email infrastructure
|
|
3756
|
-
|
|
4419
|
+
hitlRoute = null; channelsRoute = null; emailInfraRoute = null;
|
|
3757
4420
|
try { hitlRoute = require('./routes/hitl')({ mgQuery }); } catch(e) { log('warn', `hitl route unavailable: ${e.message}`); }
|
|
3758
4421
|
try { channelsRoute = require('./routes/channels')({ mgQuery }); } catch(e) { log('warn', `channels route unavailable: ${e.message}`); }
|
|
3759
4422
|
try { emailInfraRoute = require('./routes/email-infrastructure')({ mgQuery }); } catch(e) { log('warn', `emailInfra route unavailable: ${e.message}`); }
|
|
3760
4423
|
|
|
3761
|
-
const port = config.apiPort ??
|
|
4424
|
+
const port = config.apiPort ?? 9093;
|
|
3762
4425
|
|
|
3763
4426
|
// Security: shared-secret token for WebSocket upgrade.
|
|
3764
4427
|
// Mirrors GHSA-3c6j-hq33-3jv4 (forged exec lifecycle events via unauthenticated WS).
|
|
@@ -3770,6 +4433,8 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3770
4433
|
mgQuery,
|
|
3771
4434
|
companies: state.companies ?? [],
|
|
3772
4435
|
cid: state.companies?.[0]?.id ?? 'default-company', // default — overridden per-request in route()
|
|
4436
|
+
apiPort: port,
|
|
4437
|
+
actualBoundPort: null,
|
|
3773
4438
|
tickCount: 0,
|
|
3774
4439
|
draining: false,
|
|
3775
4440
|
apiToken: config.apiToken || null, // null = no auth (backward compat)
|
|
@@ -3888,11 +4553,12 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3888
4553
|
});
|
|
3889
4554
|
|
|
3890
4555
|
server.listen(port, config.apiHost ?? '127.0.0.1', () => {
|
|
4556
|
+
ctx.actualBoundPort = server.address()?.port ?? port;
|
|
3891
4557
|
process.stdout.write(JSON.stringify({
|
|
3892
4558
|
ts: new Date().toISOString(),
|
|
3893
4559
|
level: 'info',
|
|
3894
4560
|
module: 'helios-api',
|
|
3895
|
-
msg: `REST API listening on http://127.0.0.1:${
|
|
4561
|
+
msg: `REST API listening on http://127.0.0.1:${ctx.actualBoundPort}`,
|
|
3896
4562
|
}) + '\n');
|
|
3897
4563
|
|
|
3898
4564
|
// Write daemon.lock — the ground-truth discovery file for Helios Desktop.
|