@cgh567/agent 2.4.2 → 2.4.4
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/agents/business/talisman-ceo.md +183 -0
- package/agents/business/talisman-comms.md +257 -0
- package/agents/business/talisman-cto.md +153 -0
- package/agents/business/talisman-finance.md +246 -0
- package/agents/business/talisman-marketing.md +240 -0
- package/agents/business/talisman-sales.md +242 -0
- package/agents/business/talisman-support.md +236 -0
- package/bin/helios-rpc.js +19 -0
- package/daemon/adapters/helios-rpc-adapter.js +5 -12
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/context-enrichment.js +27 -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 +863 -64
- package/daemon/helios-company-daemon.js +233 -33
- package/daemon/lib/blast-radius-analyzer.js +75 -0
- package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
- package/daemon/lib/forensic-log.js +113 -0
- package/daemon/lib/goal-research-pipeline.js +644 -0
- package/daemon/lib/harada/cascade-judge.js +84 -1
- package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
- package/daemon/lib/harada/pillar-dispatcher.js +23 -2
- package/daemon/lib/hbo-bridge.js +74 -6
- package/daemon/lib/headroom-middleware.js +129 -0
- package/daemon/lib/headroom-proxy-manager.js +309 -0
- package/daemon/lib/hed-engine.js +25 -0
- package/daemon/lib/intelligence/department-page-generator.js +46 -1
- package/daemon/lib/interpretation-engine.js +92 -0
- package/daemon/lib/mental-model-cache.js +96 -0
- package/daemon/lib/project-factory.js +47 -0
- package/daemon/lib/session-log-reader.js +93 -0
- package/daemon/lib/standard-work-bootstrap.js +87 -1
- package/daemon/lib/task-completion-processor.js +23 -0
- package/daemon/lib/wizard-engine.js +57 -6
- package/daemon/package.json +2 -1
- package/daemon/routes/agents.js +51 -6
- package/daemon/routes/channels.js +116 -2
- package/daemon/routes/crm.js +85 -0
- package/daemon/routes/dashboard.js +62 -16
- package/daemon/routes/dept.js +10 -1
- package/daemon/routes/email-triage.js +19 -10
- package/daemon/routes/hbo.js +618 -58
- package/daemon/routes/hed.js +133 -0
- package/daemon/routes/inbox.js +397 -8
- package/daemon/routes/project.js +580 -66
- 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 +433 -0
- package/daemon/schema-migrations-hbo.js +20 -0
- package/daemon/schema-migrations-hed.js +18 -0
- package/daemon/schema-migrations-proj.js +153 -0
- package/extensions/__tests__/codebase-index.test.ts +73 -0
- package/extensions/__tests__/extension-command-registration.test.ts +35 -0
- package/extensions/__tests__/git-push-guard.test.ts +68 -0
- package/extensions/context-compaction.ts +104 -76
- package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
- package/extensions/cortex/wal-replay.ts +91 -0
- package/extensions/email/actions/draft-response.ts +21 -1
- package/extensions/email/auth/accounts.ts +5 -11
- package/extensions/email/auth/inbox-dog.ts +5 -2
- package/extensions/email/backfill.ts +20 -13
- package/extensions/email/providers/gmail.ts +164 -0
- package/extensions/email/providers/google-calendar.ts +34 -5
- package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
- package/extensions/helios-browser/backends/playwright.ts +3 -1
- package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
- package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
- package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
- package/extensions/hema-dispatch-v3/index.ts +46 -72
- package/extensions/interview/__tests__/server.test.ts +117 -0
- package/extensions/lib/helios-root.cjs +46 -0
- package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
- package/extensions/warm-tick/warm-tick-maintenance.ts +164 -0
- package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
- package/lib/__tests__/crash-fixes.test.ts +49 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -0
- package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
- package/lib/broker/__tests__/jit-subscription.test.js +44 -1
- package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
- package/lib/compression/__tests__/ccr-store.test.js +138 -0
- package/lib/compression/__tests__/pipeline.test.js +280 -0
- package/lib/compression/__tests__/smart-crusher.test.js +242 -0
- package/lib/compression/dist/server.js +34 -1
- package/lib/compression/dist/start-server.js +77 -0
- package/lib/event-bus.mts +1 -1
- package/lib/graph/learning/headroom-learn-bridge.js +175 -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 +979 -0
- package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
- package/lib/skill-sync.js +6 -1
- package/lib/startup-integrity.js +9 -2
- package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
- package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
- package/lib/triage-core/__tests__/classifier.test.ts +45 -7
- package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
- package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
- package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
- package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
- package/lib/triage-core/__tests__/signals.test.ts +357 -0
- package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
- package/lib/triage-core/backfill-cost-estimator.ts +91 -0
- package/lib/triage-core/backfill-orchestrator.ts +119 -0
- package/lib/triage-core/classifier.ts +41 -8
- package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
- package/lib/triage-core/cos/response-debt.ts +2 -2
- package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
- package/lib/triage-core/graph/batch-persistence.ts +66 -2
- package/lib/triage-core/graph/betweenness-worker.js +75 -0
- package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
- package/lib/triage-core/graph/persistence.ts +1 -1
- package/lib/triage-core/graph/schema-v2.ts +2 -0
- package/lib/triage-core/graph/schema.cypher +11 -0
- package/lib/triage-core/graph/triage-query.ts +1 -1
- package/lib/triage-core/learning.ts +15 -20
- package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
- package/lib/triage-core/mental-model/cos-integration.ts +1 -1
- package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
- package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
- package/lib/triage-core/mental-model/key-facts.ts +1 -2
- package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
- package/lib/triage-core/mental-model/model-assembler.ts +16 -3
- package/lib/triage-core/orchestrator.ts +8 -15
- package/lib/triage-core/scheduled-sends.ts +39 -2
- package/lib/triage-core/signals/comms-style.ts +1 -1
- package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
- package/lib/triage-core/signals/favee-type.ts +6 -1
- package/lib/triage-core/signals/goal-relevance.ts +31 -2
- package/lib/triage-core/signals/personal-importance.ts +1 -1
- package/lib/triage-core/signals/referral-chain.ts +0 -1
- package/lib/triage-core/signals/relationship-decay.ts +4 -0
- package/lib/triage-core/signals/relationship-health.ts +6 -1
- package/lib/triage-core/signals/trajectory-signal.ts +38 -3
- package/lib/triage-core/tournament-runner.js +11 -1
- package/lib/triage-core/triage-llm-factory.ts +110 -0
- package/lib/triage-core/triage-local-llm.ts +145 -0
- package/lib/triage-core/triage-sql-store.ts +337 -0
- package/lib/triage-core/types.ts +2 -2
- package/lib/unified-graph.atomic.test.ts +52 -0
- package/lib/unified-graph.failure-categories.test.ts +55 -0
- package/package.json +18 -7
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/skills/helios-bookkeeping/SKILL.md +321 -0
- package/skills/helios-briefer/SKILL.md +44 -0
- package/skills/helios-client-relations/SKILL.md +322 -0
- package/skills/helios-personal-triager/SKILL.md +45 -0
- package/skills/helios-recruitment/SKILL.md +317 -0
- package/skills/helios-relationship-nudger/SKILL.md +77 -0
- package/skills/helios-researcher/SKILL.md +44 -0
- package/skills/helios-scheduler/SKILL.md +58 -0
- package/skills/helios-tax-analyst/SKILL.md +280 -0
- package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
package/daemon/helios-api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/**
|
|
3
|
-
* helios-api.js — REST API for Helios daemon
|
|
3
|
+
* helios-api.js — REST API for Helios daemon
|
|
4
4
|
*
|
|
5
5
|
* Endpoints:
|
|
6
6
|
* GET /api/health
|
|
@@ -48,8 +48,8 @@ const TRANSCRIPTS_DIR = path.join(__dirname, 'transcripts');
|
|
|
48
48
|
const _transcriptStore = new TranscriptStore(TRANSCRIPTS_DIR);
|
|
49
49
|
const CORS_HEADERS = {
|
|
50
50
|
'Access-Control-Allow-Origin': '*',
|
|
51
|
-
'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
|
|
52
|
-
'Access-Control-Allow-Headers': 'Content-Type, Accept',
|
|
51
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
52
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
// ── Logging ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -61,6 +61,14 @@ function log(level, msg, extra) {
|
|
|
61
61
|
else process.stdout.write(JSON.stringify(entry) + '\n');
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function mirrorHboStore(label, write) {
|
|
65
|
+
try {
|
|
66
|
+
if (typeof write === 'function') write();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
log('warn', `hbo-core mirror failed: ${label}`, { error: err.message });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
// ── SSE Client Registry ───────────────────────────────────────────────────────
|
|
65
73
|
|
|
66
74
|
const sseClients = new Set();
|
|
@@ -260,6 +268,7 @@ async function handleHealth(req, res, ctx) {
|
|
|
260
268
|
const { tickCount, modules } = ctx;
|
|
261
269
|
jsonResponse(res, 200, {
|
|
262
270
|
status: 'ok',
|
|
271
|
+
port: ctx.actualBoundPort ?? ctx.apiPort ?? null,
|
|
263
272
|
tick: tickCount ?? 0,
|
|
264
273
|
modules: modules ?? [],
|
|
265
274
|
timestamp: new Date().toISOString(),
|
|
@@ -374,13 +383,60 @@ async function handleCreateTask(req, res, ctx) {
|
|
|
374
383
|
const cid = ctx.cid;
|
|
375
384
|
|
|
376
385
|
const body = await parseBody(req);
|
|
377
|
-
const { title, assigneeAgentId, priority, body: taskBody
|
|
386
|
+
const { title, assigneeAgentId, priority, body: taskBody, executionPolicy,
|
|
387
|
+
// Phase 2.5-B additions: sourceType, sourceId, originKind, projectId for email-to-task
|
|
388
|
+
sourceType, sourceId, originKind, projectId } = body;
|
|
378
389
|
|
|
379
390
|
if (!title || typeof title !== 'string' || title.trim() === '') {
|
|
380
391
|
jsonResponse(res, 400, { error: 'title is required and must be a non-empty string' });
|
|
381
392
|
return;
|
|
382
393
|
}
|
|
383
394
|
|
|
395
|
+
// P4-02: validate executionPolicy if provided
|
|
396
|
+
let validatedPolicyJson = null;
|
|
397
|
+
if (executionPolicy !== undefined) {
|
|
398
|
+
if (!executionPolicy || typeof executionPolicy !== 'object' || !Array.isArray(executionPolicy.stages)) {
|
|
399
|
+
jsonResponse(res, 400, { error: 'executionPolicy must have a stages array' });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (executionPolicy.stages.length === 0) {
|
|
403
|
+
jsonResponse(res, 400, { error: 'executionPolicy.stages must not be empty' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (const stage of executionPolicy.stages) {
|
|
407
|
+
if (!stage.type || !['review', 'approval'].includes(stage.type)) {
|
|
408
|
+
jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval', got: ${stage.type}` });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
|
|
412
|
+
jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// M-1 fix: validate ALL participants in a single batched query instead of N sequential queries
|
|
417
|
+
const allParticipantIds = [...new Set(
|
|
418
|
+
executionPolicy.stages.flatMap(s => s.participants.map(p => String(p)))
|
|
419
|
+
)];
|
|
420
|
+
const agentCheckResult = await mgQuery(
|
|
421
|
+
`MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
|
|
422
|
+
{ cid, pids: allParticipantIds }
|
|
423
|
+
);
|
|
424
|
+
const foundIds = new Set(
|
|
425
|
+
(parseRows(agentCheckResult)[0]?.[0] ?? parseRows(agentCheckResult)[0]?.['found'] ?? [])
|
|
426
|
+
);
|
|
427
|
+
const missingIds = allParticipantIds.filter(id => !foundIds.has(id));
|
|
428
|
+
if (missingIds.length > 0) {
|
|
429
|
+
jsonResponse(res, 400, { error: `participants not found as BusinessAgents in this company: ${missingIds.join(', ')}` });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
validatedPolicyJson = JSON.stringify(executionPolicy);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// P4-03: initial executionState — 'idle' when policy exists, null string otherwise
|
|
436
|
+
const initialStateJson = validatedPolicyJson
|
|
437
|
+
? JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] })
|
|
438
|
+
: 'null';
|
|
439
|
+
|
|
384
440
|
const taskId = `task:api:${crypto.randomUUID()}`;
|
|
385
441
|
const resolvedPriority = Number.isFinite(Number(priority)) ? Number(priority) : 2;
|
|
386
442
|
|
|
@@ -394,6 +450,12 @@ async function handleCreateTask(req, res, ctx) {
|
|
|
394
450
|
priority: toInteger($priority),
|
|
395
451
|
assigneeAgentId: $assigneeAgentId,
|
|
396
452
|
body: $body,
|
|
453
|
+
executionPolicyJson: $policyJson,
|
|
454
|
+
executionStateJson: $stateJson,
|
|
455
|
+
sourceType: $sourceType,
|
|
456
|
+
sourceId: $sourceId,
|
|
457
|
+
originKind: $originKind,
|
|
458
|
+
projectId: $projectId,
|
|
397
459
|
createdAt: datetime()
|
|
398
460
|
})`,
|
|
399
461
|
{
|
|
@@ -403,11 +465,55 @@ async function handleCreateTask(req, res, ctx) {
|
|
|
403
465
|
priority: resolvedPriority,
|
|
404
466
|
assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
|
|
405
467
|
body: taskBody ? String(taskBody) : null,
|
|
468
|
+
policyJson: validatedPolicyJson,
|
|
469
|
+
stateJson: initialStateJson,
|
|
470
|
+
sourceType: sourceType ? String(sourceType) : null,
|
|
471
|
+
sourceId: sourceId ? String(sourceId) : null,
|
|
472
|
+
originKind: originKind ? String(originKind) : null,
|
|
473
|
+
projectId: projectId ? String(projectId) : null,
|
|
406
474
|
}
|
|
407
475
|
);
|
|
408
476
|
|
|
477
|
+
// Phase 2.5-B: OI-P3 fix — write BELONGS_TO_PROJECT edge when projectId is provided
|
|
478
|
+
if (projectId) {
|
|
479
|
+
try {
|
|
480
|
+
await mgQuery(
|
|
481
|
+
`MATCH (t:Task {id: $taskId}), (p:HeliosProject {id: $projectId, companyId: $cid})
|
|
482
|
+
MERGE (t)-[:BELONGS_TO_PROJECT]->(p)`,
|
|
483
|
+
{ taskId, projectId: String(projectId), cid }
|
|
484
|
+
);
|
|
485
|
+
} catch (_edgeErr) {
|
|
486
|
+
// Edge write is best-effort — task is already created; log but don't fail
|
|
487
|
+
log('warn', 'BELONGS_TO_PROJECT edge write failed (OI-P3)', { taskId, projectId });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
mirrorHboStore('task.create', () => hboStore.createTask?.({
|
|
492
|
+
id: taskId,
|
|
493
|
+
companyId: cid,
|
|
494
|
+
title: String(title).trim(),
|
|
495
|
+
status: 'todo',
|
|
496
|
+
priority: resolvedPriority,
|
|
497
|
+
assigneeAgentId: assigneeAgentId ? String(assigneeAgentId) : null,
|
|
498
|
+
body: taskBody ? String(taskBody) : null,
|
|
499
|
+
executionPolicyJson: validatedPolicyJson,
|
|
500
|
+
executionStateJson: initialStateJson,
|
|
501
|
+
createdAt: Date.now(),
|
|
502
|
+
}));
|
|
503
|
+
|
|
409
504
|
broadcast({ type: 'task.created', taskId, status: 'todo', companyId: cid });
|
|
410
|
-
jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId } });
|
|
505
|
+
jsonResponse(res, 201, { task: { id: taskId, title, status: 'todo', priority: resolvedPriority, assigneeAgentId, executionPolicyJson: validatedPolicyJson } });
|
|
506
|
+
|
|
507
|
+
// P_EXPLAIN-01: fire-and-forget interpretation explanation background job
|
|
508
|
+
const { rawWrite: _interpRawWrite } = require('../lib/safe-memgraph.js');
|
|
509
|
+
setImmediate(() => {
|
|
510
|
+
try {
|
|
511
|
+
const { createInterpretationExplanation } = require('./lib/interpretation-engine.js');
|
|
512
|
+
createInterpretationExplanation(taskId, cid, _interpRawWrite).catch(e =>
|
|
513
|
+
log('warn', 'P_EXPLAIN-01 failed', { e: e.message })
|
|
514
|
+
);
|
|
515
|
+
} catch (_) { /* interpretation is non-blocking */ }
|
|
516
|
+
});
|
|
411
517
|
} catch (err) {
|
|
412
518
|
jsonResponse(res, 500, { error: `Create failed: ${err.message}` });
|
|
413
519
|
}
|
|
@@ -420,9 +526,12 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
420
526
|
const cid = ctx.cid;
|
|
421
527
|
|
|
422
528
|
const body = await parseBody(req);
|
|
423
|
-
|
|
529
|
+
// P6-06a: workspacePath added — desktop sets this after acquiring a SessionWorktree
|
|
530
|
+
// for isolated execution; daemon reads it into adapterContext.workspacePath
|
|
531
|
+
const { status, priority, workspacePath, blockedReason, clearBlocked, hillPosition } = body;
|
|
424
532
|
|
|
425
|
-
|
|
533
|
+
// P4-01: 'in_review' added — allows execution policy gate to hold a task for review
|
|
534
|
+
const VALID_STATUSES = new Set(['todo', 'in_progress', 'done', 'failed', 'cancelled', 'blocked', 'escalated', 'in_review']);
|
|
426
535
|
if (status !== undefined && !VALID_STATUSES.has(status)) {
|
|
427
536
|
jsonResponse(res, 400, { error: `Invalid status. Allowed: ${[...VALID_STATUSES].join(', ')}` });
|
|
428
537
|
return;
|
|
@@ -432,16 +541,280 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
432
541
|
return;
|
|
433
542
|
}
|
|
434
543
|
|
|
435
|
-
if (status === undefined && priority === undefined) {
|
|
436
|
-
jsonResponse(res, 400, { error: 'Provide at least one field to update: status, priority' });
|
|
544
|
+
if (status === undefined && priority === undefined && workspacePath === undefined && blockedReason === undefined && clearBlocked === undefined && hillPosition === undefined) {
|
|
545
|
+
jsonResponse(res, 400, { error: 'Provide at least one field to update: status, priority, workspacePath, blockedReason, clearBlocked, hillPosition' });
|
|
437
546
|
return;
|
|
438
547
|
}
|
|
439
548
|
|
|
440
549
|
try {
|
|
550
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
551
|
+
// EXECUTION POLICY STATE MACHINE
|
|
552
|
+
//
|
|
553
|
+
// The state machine has three actors:
|
|
554
|
+
// A) Original executor sets status='done' and task has an active policy
|
|
555
|
+
// → INTERCEPT: redirect to in_review, assign to current stage reviewer
|
|
556
|
+
// B) Reviewer (currently assigned) sets status='done' to approve
|
|
557
|
+
// → ADVANCE: move to next stage, or if last stage, allow done
|
|
558
|
+
// C) Reviewer sets any non-done status to reject/request changes
|
|
559
|
+
// → CHANGES_REQUESTED: return task to original executor
|
|
560
|
+
//
|
|
561
|
+
// C-1 fix: The intercept (actor A) uses TWO queries, but the second (SET) query
|
|
562
|
+
// re-checks the WHERE condition with the expected policyJson — if policy changed
|
|
563
|
+
// between reads, the second query returns 0 rows and we return 409 (optimistic
|
|
564
|
+
// concurrency control). This correctly closes the TOCTOU window.
|
|
565
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
if (status === 'done') {
|
|
568
|
+
// Step 1: Read task's current state (fast metadata read)
|
|
569
|
+
const taskReadResult = await mgQuery(
|
|
570
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
571
|
+
RETURN t.status AS taskStatus,
|
|
572
|
+
t.assigneeAgentId AS assigneeAgentId,
|
|
573
|
+
t.executionPolicyJson AS policyJson,
|
|
574
|
+
t.executionStateJson AS stateJson`,
|
|
575
|
+
{ id: taskId, cid }
|
|
576
|
+
);
|
|
577
|
+
const taskReadRows = parseRows(taskReadResult);
|
|
578
|
+
|
|
579
|
+
if (taskReadRows.length > 0) {
|
|
580
|
+
const taskRow = taskReadRows[0];
|
|
581
|
+
const taskStatus = taskRow[0] ?? taskRow['taskStatus'] ?? null;
|
|
582
|
+
const returnAssignee = taskRow[1] ?? taskRow['assigneeAgentId'] ?? null;
|
|
583
|
+
const policyJson = taskRow[2] ?? taskRow['policyJson'] ?? null;
|
|
584
|
+
const stateJsonRaw = taskRow[3] ?? taskRow['stateJson'] ?? 'null';
|
|
585
|
+
|
|
586
|
+
let policy, state;
|
|
587
|
+
try { policy = policyJson ? JSON.parse(policyJson) : null; } catch (_) { policy = null; }
|
|
588
|
+
try { state = JSON.parse(stateJsonRaw); } catch (_) { state = null; }
|
|
589
|
+
if (state === null && stateJsonRaw === 'null') state = null; // explicit null string
|
|
590
|
+
|
|
591
|
+
// ── ACTOR B: Reviewer approving (task is currently in_review) ───────
|
|
592
|
+
if (taskStatus === 'in_review' && policy && state) {
|
|
593
|
+
const currentStageIdx = typeof state.currentStageIndex === 'number' ? state.currentStageIndex : 0;
|
|
594
|
+
const stages = Array.isArray(policy.stages) ? policy.stages : [];
|
|
595
|
+
const nextStageIdx = currentStageIdx + 1;
|
|
596
|
+
|
|
597
|
+
if (nextStageIdx < stages.length) {
|
|
598
|
+
// Advance to next stage (multi-stage full advancement — C-4 full)
|
|
599
|
+
const nextStage = stages[nextStageIdx];
|
|
600
|
+
const nextParticipant = nextStage?.participants?.[0] ?? null;
|
|
601
|
+
if (nextParticipant) {
|
|
602
|
+
const newState = JSON.stringify({
|
|
603
|
+
status: 'pending',
|
|
604
|
+
currentStageIndex: nextStageIdx,
|
|
605
|
+
currentParticipant: nextParticipant,
|
|
606
|
+
returnAssignee: state.returnAssignee,
|
|
607
|
+
completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
|
|
608
|
+
});
|
|
609
|
+
// Atomic: re-check we're still in_review before advancing
|
|
610
|
+
const advanceResult = await mgQuery(
|
|
611
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
612
|
+
WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
|
|
613
|
+
SET t.executionStateJson = $stateJson,
|
|
614
|
+
t.assigneeAgentId = $nextReviewer,
|
|
615
|
+
t.updatedAt = datetime()
|
|
616
|
+
RETURN t.id`,
|
|
617
|
+
{ id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, nextReviewer: nextParticipant }
|
|
618
|
+
);
|
|
619
|
+
if (parseRows(advanceResult).length === 0) {
|
|
620
|
+
jsonResponse(res, 409, { error: 'Concurrent update conflict' });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
mgQuery(
|
|
624
|
+
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
|
|
625
|
+
OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
|
|
626
|
+
WITH a, existing WHERE existing IS NULL
|
|
627
|
+
CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
|
|
628
|
+
status: 'pending', claimedBy: null, createdAt: datetime()})`,
|
|
629
|
+
{ agentId: nextParticipant, cid, sigId: `ars:advance:${taskId}:${crypto.randomUUID()}` }
|
|
630
|
+
).catch(e => log('warn', `P4 advance AgentReadySignal failed: ${e.message}`));
|
|
631
|
+
mirrorHboStore('task.update.advance_stage', () => hboStore.updateTask?.(taskId, cid, { executionStateJson: newState, assigneeAgentId: nextParticipant }));
|
|
632
|
+
broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
|
|
633
|
+
jsonResponse(res, 200, { updated: true, taskId, advanced: true, nextStage: nextStageIdx, nextReviewer: nextParticipant });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// All stages passed (or last stage reviewer approved) — mark completed and allow done
|
|
639
|
+
const completedState = JSON.stringify({
|
|
640
|
+
...state,
|
|
641
|
+
status: 'completed',
|
|
642
|
+
completedStageIds: [...(state.completedStageIds ?? []), `stage:${currentStageIdx}`],
|
|
643
|
+
});
|
|
644
|
+
// Atomic: re-check still in_review before marking completed
|
|
645
|
+
const completeResult = await mgQuery(
|
|
646
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
647
|
+
WHERE t.status = 'in_review' AND t.executionPolicyJson = $expectedPolicy
|
|
648
|
+
SET t.status = 'done',
|
|
649
|
+
t.executionStateJson = $stateJson,
|
|
650
|
+
t.updatedAt = datetime()
|
|
651
|
+
RETURN t.id`,
|
|
652
|
+
{ id: taskId, cid, expectedPolicy: policyJson, stateJson: completedState }
|
|
653
|
+
);
|
|
654
|
+
if (parseRows(completeResult).length === 0) {
|
|
655
|
+
jsonResponse(res, 409, { error: 'Concurrent update conflict' });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
mirrorHboStore('task.update.approved', () => hboStore.updateTask?.(taskId, cid, { status: 'done', executionStateJson: completedState }));
|
|
659
|
+
broadcast({ type: 'task.updated', taskId, status: 'done', companyId: cid });
|
|
660
|
+
jsonResponse(res, 200, { updated: true, taskId, approved: true, status: 'done' });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── ACTOR A: Original executor marking done with active policy ───────
|
|
665
|
+
if (taskStatus !== 'in_review' && policy) {
|
|
666
|
+
// Check if state is already completed — if so, allow done through
|
|
667
|
+
const alreadyCompleted = state && typeof state === 'object' && state.status === 'completed';
|
|
668
|
+
if (!alreadyCompleted) {
|
|
669
|
+
const firstStage = Array.isArray(policy.stages) && policy.stages.length > 0 ? policy.stages[0] : null;
|
|
670
|
+
const firstParticipant = firstStage?.participants?.[0] ?? null;
|
|
671
|
+
|
|
672
|
+
// C-2 fix: explicit guard — if no valid participant, log and fall through to done
|
|
673
|
+
if (!firstParticipant) {
|
|
674
|
+
log('warn', `P4: task ${taskId} has executionPolicy but no valid first participant — allowing done through`);
|
|
675
|
+
// fall through to standard path below
|
|
676
|
+
} else {
|
|
677
|
+
const newState = JSON.stringify({
|
|
678
|
+
status: 'pending',
|
|
679
|
+
currentStageIndex: 0,
|
|
680
|
+
currentParticipant: firstParticipant,
|
|
681
|
+
returnAssignee, // captured returnAssignee = original executor
|
|
682
|
+
completedStageIds: [],
|
|
683
|
+
});
|
|
684
|
+
// C-1 fix: ATOMIC check+set — WHERE re-checks policyJson so a concurrent
|
|
685
|
+
// policy change causes 0 rows returned → 409 (optimistic concurrency control)
|
|
686
|
+
const atomicResult = await mgQuery(
|
|
687
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
688
|
+
WHERE t.executionPolicyJson = $expectedPolicy
|
|
689
|
+
AND t.status <> 'in_review'
|
|
690
|
+
AND (t.executionStateJson IS NULL OR NOT t.executionStateJson CONTAINS '"status":"completed"')
|
|
691
|
+
SET t.status = 'in_review',
|
|
692
|
+
t.executionStateJson = $stateJson,
|
|
693
|
+
t.assigneeAgentId = $reviewer,
|
|
694
|
+
t.updatedAt = datetime()
|
|
695
|
+
RETURN t.id AS id`,
|
|
696
|
+
{ id: taskId, cid, expectedPolicy: policyJson, stateJson: newState, reviewer: firstParticipant }
|
|
697
|
+
);
|
|
698
|
+
if (parseRows(atomicResult).length === 0) {
|
|
699
|
+
// Policy changed or state changed between reads — 409
|
|
700
|
+
jsonResponse(res, 409, { error: 'Concurrent update conflict — task state changed by another request' });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
mgQuery(
|
|
704
|
+
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
|
|
705
|
+
OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
|
|
706
|
+
WITH a, existing WHERE existing IS NULL
|
|
707
|
+
CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
|
|
708
|
+
status: 'pending', claimedBy: null, createdAt: datetime()})`,
|
|
709
|
+
{ agentId: firstParticipant, cid, sigId: `ars:review:${taskId}:${crypto.randomUUID()}` }
|
|
710
|
+
).catch(e => log('warn', `P4-04 AgentReadySignal emit failed: ${e.message}`));
|
|
711
|
+
mirrorHboStore('task.update.in_review', () => hboStore.updateTask?.(taskId, cid, { status: 'in_review', executionStateJson: newState, assigneeAgentId: firstParticipant }));
|
|
712
|
+
broadcast({ type: 'task.updated', taskId, status: 'in_review', companyId: cid });
|
|
713
|
+
jsonResponse(res, 200, { updated: true, taskId, intercepted: true, status: 'in_review', reviewer: firstParticipant });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── ACTOR C: Reviewer rejecting / requesting changes ───────────────────
|
|
722
|
+
// C-3 fix: WHERE t.status = 'in_review' added to the SET query (prevents TOCTOU
|
|
723
|
+
// overwriting a task that was concurrently moved out of in_review by another request).
|
|
724
|
+
// FIX-H1: Check stage type to differentiate review vs approval semantics.
|
|
725
|
+
// FIX-M2: Explicit return for state=null case — no fall-through.
|
|
726
|
+
if (status !== undefined && status !== 'done') {
|
|
727
|
+
// Single atomic read+write: check in_review AND apply in one round-trip
|
|
728
|
+
// by embedding the WHERE in the SET query.
|
|
729
|
+
const reviewCheckResult = await mgQuery(
|
|
730
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
731
|
+
WHERE t.status = 'in_review' AND t.executionStateJson IS NOT NULL
|
|
732
|
+
RETURN t.executionStateJson AS stateJson, t.executionPolicyJson AS policyJson`,
|
|
733
|
+
{ id: taskId, cid }
|
|
734
|
+
);
|
|
735
|
+
const reviewRows = parseRows(reviewCheckResult);
|
|
736
|
+
if (reviewRows.length > 0) {
|
|
737
|
+
const reviewRow = reviewRows[0];
|
|
738
|
+
const stateJsonStr = reviewRow[0] ?? reviewRow['stateJson'] ?? 'null';
|
|
739
|
+
const policyJsonStr = reviewRow[1] ?? reviewRow['policyJson'] ?? null;
|
|
740
|
+
let state, policy;
|
|
741
|
+
try { state = JSON.parse(stateJsonStr); } catch (_) { state = null; }
|
|
742
|
+
try { policy = policyJsonStr ? JSON.parse(policyJsonStr) : null; } catch (_) { policy = null; }
|
|
743
|
+
|
|
744
|
+
// M2 fix: if state is null (corrupt JSON), block the standard path on in_review tasks
|
|
745
|
+
if (!state) {
|
|
746
|
+
jsonResponse(res, 409, { error: 'Task is in_review but execution state is corrupt — cannot apply status change' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (state.returnAssignee) {
|
|
751
|
+
const returnAssignee = state.returnAssignee;
|
|
752
|
+
const changesState = JSON.stringify({ ...state, status: 'changes_requested' });
|
|
753
|
+
// C-3 fix: WHERE t.status = 'in_review' guards against concurrent status change
|
|
754
|
+
const changesResult = await mgQuery(
|
|
755
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
756
|
+
WHERE t.status = 'in_review'
|
|
757
|
+
SET t.status = 'in_progress',
|
|
758
|
+
t.assigneeAgentId = $returnAssignee,
|
|
759
|
+
t.executionStateJson = $stateJson,
|
|
760
|
+
t.updatedAt = datetime()
|
|
761
|
+
RETURN t.id`,
|
|
762
|
+
{ id: taskId, cid, returnAssignee, stateJson: changesState }
|
|
763
|
+
);
|
|
764
|
+
if (parseRows(changesResult).length === 0) {
|
|
765
|
+
// Concurrent write changed task out of in_review — fall through to standard path
|
|
766
|
+
// (the task is no longer in_review so standard update is correct)
|
|
767
|
+
} else {
|
|
768
|
+
mgQuery(
|
|
769
|
+
`MATCH (a:BusinessAgent {id: $agentId, companyId: $cid, status: 'active'})
|
|
770
|
+
OPTIONAL MATCH (existing:AgentReadySignal {agentId: $agentId, companyId: $cid, status: 'pending'})
|
|
771
|
+
WITH a, existing WHERE existing IS NULL
|
|
772
|
+
CREATE (s:AgentReadySignal {id: $sigId, agentId: $agentId, companyId: $cid,
|
|
773
|
+
status: 'pending', claimedBy: null, createdAt: datetime()})`,
|
|
774
|
+
{ agentId: returnAssignee, cid, sigId: `ars:changes:${taskId}:${crypto.randomUUID()}` }
|
|
775
|
+
).catch(e => log('warn', `P4-05 AgentReadySignal emit failed: ${e.message}`));
|
|
776
|
+
mirrorHboStore('task.update.changes_requested', () => hboStore.updateTask?.(taskId, cid, { status: 'in_progress', executionStateJson: changesState, assigneeAgentId: returnAssignee }));
|
|
777
|
+
broadcast({ type: 'task.updated', taskId, status: 'in_progress', companyId: cid });
|
|
778
|
+
jsonResponse(res, 200, { updated: true, taskId, changesRequested: true, returnAssignee });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
} else {
|
|
782
|
+
// returnAssignee is null — escalation path (no valid return point)
|
|
783
|
+
// M2 fix: explicit return after escalation to prevent fall-through
|
|
784
|
+
const escalateResult = await mgQuery(
|
|
785
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
786
|
+
WHERE t.status = 'in_review'
|
|
787
|
+
SET t.status = 'blocked', t.blockedReason = 'escalation_chain_exhausted', t.updatedAt = datetime()
|
|
788
|
+
RETURN t.id`,
|
|
789
|
+
{ id: taskId, cid }
|
|
790
|
+
).catch(() => null);
|
|
791
|
+
if (escalateResult && parseRows(escalateResult).length > 0) {
|
|
792
|
+
mirrorHboStore('task.escalation', () => hboStore.updateTask?.(taskId, cid, { status: 'blocked', blockedReason: 'escalation_chain_exhausted' }));
|
|
793
|
+
broadcast({ type: 'task.blocked', taskId, reason: 'escalation_chain_exhausted', companyId: cid });
|
|
794
|
+
jsonResponse(res, 200, { updated: true, taskId, escalated: true });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// If escalate returned 0 rows, task was concurrently moved out of in_review — fall through
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Standard update path (no policy intercept applies)
|
|
441
803
|
const setClauses = [];
|
|
442
804
|
const params = { id: taskId, cid };
|
|
443
805
|
if (status !== undefined) { setClauses.push('t.status = $status'); params.status = status; }
|
|
444
806
|
if (priority !== undefined) { setClauses.push('t.priority = toInteger($priority)'); params.priority = Number(priority); }
|
|
807
|
+
// P6-06a: persist workspacePath on Task node so daemon can read it into adapterContext
|
|
808
|
+
if (workspacePath !== undefined) { setClauses.push('t.workspacePath = $workspacePath'); params.workspacePath = workspacePath ? String(workspacePath) : null; }
|
|
809
|
+
// P1-G: blockedReason + blockedAt — clearBlocked=true nulls both fields and resets status to todo.
|
|
810
|
+
// COALESCE preserves existing reason if not re-set; blockedAt only set once on initial block.
|
|
811
|
+
if (blockedReason !== undefined || clearBlocked !== undefined) {
|
|
812
|
+
params.clearBlocked = clearBlocked === true;
|
|
813
|
+
params.blockedReason = (blockedReason !== undefined && blockedReason !== null) ? String(blockedReason) : null;
|
|
814
|
+
setClauses.push('t.blockedReason = CASE WHEN $clearBlocked = true THEN null ELSE COALESCE($blockedReason, t.blockedReason) END');
|
|
815
|
+
setClauses.push('t.blockedAt = CASE WHEN $clearBlocked = true THEN null WHEN $blockedReason IS NOT NULL AND t.blockedAt IS NULL THEN datetime() ELSE t.blockedAt END');
|
|
816
|
+
setClauses.push('t.status = CASE WHEN $clearBlocked = true AND t.status = \'blocked\' THEN \'todo\' ELSE t.status END');
|
|
817
|
+
}
|
|
445
818
|
setClauses.push('t.updatedAt = datetime()');
|
|
446
819
|
|
|
447
820
|
// B-09: Single atomic check+update — no TOCTOU window between existence check and SET
|
|
@@ -454,6 +827,27 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
454
827
|
return;
|
|
455
828
|
}
|
|
456
829
|
|
|
830
|
+
// T4-02: HillChart write — track task work-cycle position for hill chart visualisation
|
|
831
|
+
if (hillPosition !== undefined && Number.isFinite(Number(hillPosition))) {
|
|
832
|
+
const hPos = Math.max(0, Math.min(1, Number(hillPosition)));
|
|
833
|
+
const hPhase = hPos < 0.5 ? 'uphill' : hPos < 1.0 ? 'downhill' : 'complete';
|
|
834
|
+
mgQuery(
|
|
835
|
+
`MERGE (h:HillChart {id: 'hill:' + $taskId})
|
|
836
|
+
SET h.taskId = $taskId, h.companyId = $cid,
|
|
837
|
+
h.position = $pos, h.phase = $phase, h.updatedAt = datetime()
|
|
838
|
+
WITH h
|
|
839
|
+
MATCH (t:Task {id: $taskId, companyId: $cid})
|
|
840
|
+
MERGE (t)-[:HAS_HILL_CHART]->(h)`,
|
|
841
|
+
{ taskId, cid, pos: hPos, phase: hPhase }
|
|
842
|
+
).catch(e => log('warn', `HillChart MERGE failed for task ${taskId}: ${e.message}`));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const storeUpdate = {};
|
|
846
|
+
if (status !== undefined) storeUpdate.status = status;
|
|
847
|
+
if (priority !== undefined) storeUpdate.priority = Number(priority);
|
|
848
|
+
if (workspacePath !== undefined) storeUpdate.workspacePath = workspacePath ? String(workspacePath) : null;
|
|
849
|
+
mirrorHboStore('task.update', () => hboStore.updateTask?.(taskId, cid, storeUpdate));
|
|
850
|
+
|
|
457
851
|
broadcast({ type: 'task.updated', taskId, ...(status !== undefined ? { status } : {}), companyId: cid });
|
|
458
852
|
jsonResponse(res, 200, { updated: true, taskId });
|
|
459
853
|
} catch (err) {
|
|
@@ -461,6 +855,76 @@ async function handleUpdateTask(req, res, ctx, taskId) {
|
|
|
461
855
|
}
|
|
462
856
|
}
|
|
463
857
|
|
|
858
|
+
// P4-07: PATCH /api/tasks/:id/policy — set or update executionPolicy on an existing task
|
|
859
|
+
async function handlePatchTaskPolicy(req, res, ctx, taskId) {
|
|
860
|
+
const { mgQuery } = ctx;
|
|
861
|
+
if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
|
|
862
|
+
if (!taskId) { jsonResponse(res, 400, { error: 'Missing task ID' }); return; }
|
|
863
|
+
const cid = ctx.cid;
|
|
864
|
+
|
|
865
|
+
const body = await parseBody(req);
|
|
866
|
+
const { policy } = body;
|
|
867
|
+
|
|
868
|
+
if (!policy || typeof policy !== 'object' || !Array.isArray(policy.stages)) {
|
|
869
|
+
jsonResponse(res, 400, { error: 'policy must have a stages array' });
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (policy.stages.length === 0) {
|
|
873
|
+
jsonResponse(res, 400, { error: 'policy.stages must not be empty' });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
for (const stage of policy.stages) {
|
|
877
|
+
if (!stage.type || !['review', 'approval'].includes(stage.type)) {
|
|
878
|
+
jsonResponse(res, 400, { error: `stage.type must be 'review' or 'approval'` });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (!Array.isArray(stage.participants) || stage.participants.length === 0) {
|
|
882
|
+
jsonResponse(res, 400, { error: 'each stage must have a non-empty participants array' });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// M-1 fix: validate ALL participants in a single batched query
|
|
887
|
+
const allPolicyPids = [...new Set(policy.stages.flatMap(s => s.participants.map(p => String(p))))];
|
|
888
|
+
const policyAgentCheck = await mgQuery(
|
|
889
|
+
`MATCH (a:BusinessAgent {companyId: $cid}) WHERE a.id IN $pids RETURN collect(a.id) AS found`,
|
|
890
|
+
{ cid, pids: allPolicyPids }
|
|
891
|
+
);
|
|
892
|
+
const foundPolicyAgents = new Set(
|
|
893
|
+
(parseRows(policyAgentCheck)[0]?.[0] ?? parseRows(policyAgentCheck)[0]?.['found'] ?? [])
|
|
894
|
+
);
|
|
895
|
+
const missingPolicyAgents = allPolicyPids.filter(id => !foundPolicyAgents.has(id));
|
|
896
|
+
if (missingPolicyAgents.length > 0) {
|
|
897
|
+
jsonResponse(res, 400, { error: `participants not found as BusinessAgents: ${missingPolicyAgents.join(', ')}` });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const policyJson = JSON.stringify(policy);
|
|
902
|
+
// H-5 fix: initial executionStateJson for the policy — ensures state machine starts correctly.
|
|
903
|
+
// Without this, existing tasks with executionStateJson='null' would have returnAssignee=null
|
|
904
|
+
// when P4-04 fires, causing immediate escalation instead of returning to the original executor.
|
|
905
|
+
const initialStateJson = JSON.stringify({ status: 'idle', currentStageIndex: null, currentParticipant: null, returnAssignee: null, completedStageIds: [] });
|
|
906
|
+
try {
|
|
907
|
+
const result = await mgQuery(
|
|
908
|
+
`MATCH (t:Task {id: $id, companyId: $cid})
|
|
909
|
+
SET t.executionPolicyJson = $policyJson,
|
|
910
|
+
t.executionStateJson = $stateJson,
|
|
911
|
+
t.updatedAt = datetime()
|
|
912
|
+
RETURN t.id`,
|
|
913
|
+
{ id: taskId, cid, policyJson, stateJson: initialStateJson }
|
|
914
|
+
);
|
|
915
|
+
if (parseRows(result).length === 0) {
|
|
916
|
+
jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// P4-08: SQLite write-through for policy and initial state
|
|
920
|
+
mirrorHboStore('task.policy', () => hboStore.updateTask?.(taskId, cid, { executionPolicyJson: policyJson, executionStateJson: initialStateJson }));
|
|
921
|
+
broadcast({ type: 'task.policy.updated', taskId, companyId: cid });
|
|
922
|
+
jsonResponse(res, 200, { updated: true, taskId, executionPolicyJson: policyJson });
|
|
923
|
+
} catch (err) {
|
|
924
|
+
jsonResponse(res, 500, { error: `Policy update failed: ${err.message}` });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
464
928
|
async function handleCancelTask(req, res, ctx, taskId) {
|
|
465
929
|
const { mgQuery } = ctx;
|
|
466
930
|
if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
|
|
@@ -481,6 +945,7 @@ async function handleCancelTask(req, res, ctx, taskId) {
|
|
|
481
945
|
jsonResponse(res, 404, { error: `Task not found: ${taskId}` });
|
|
482
946
|
return;
|
|
483
947
|
}
|
|
948
|
+
mirrorHboStore('task.cancel', () => hboStore.updateTask?.(taskId, cid, { status: 'cancelled', cancelReason: 'api_cancel' }));
|
|
484
949
|
broadcast({ type: 'task.cancelled', taskId, companyId: cid });
|
|
485
950
|
jsonResponse(res, 200, { cancelled: true, taskId });
|
|
486
951
|
} catch (err) {
|
|
@@ -506,6 +971,7 @@ async function handleGetApprovals(req, res, ctx) {
|
|
|
506
971
|
a.status AS status, a.requestedBy AS requestedBy,
|
|
507
972
|
a.agentId AS agentId, a.taskId AS taskId,
|
|
508
973
|
a.createdAt AS createdAt, a.followUpTaskCreated AS followUpTaskCreated,
|
|
974
|
+
a.followUpTaskId AS followUpTaskId,
|
|
509
975
|
a.expiresAt AS expiresAt,
|
|
510
976
|
a.kaizenProposalId AS kaizenProposalId,
|
|
511
977
|
a.description AS description,
|
|
@@ -553,7 +1019,7 @@ async function handleGetApprovals(req, res, ctx) {
|
|
|
553
1019
|
const rows = parseRows(result);
|
|
554
1020
|
const keys = [
|
|
555
1021
|
'id', 'type', 'title', 'status', 'requestedBy', 'agentId', 'taskId',
|
|
556
|
-
'createdAt', 'followUpTaskCreated', 'expiresAt', 'kaizenProposalId',
|
|
1022
|
+
'createdAt', 'followUpTaskCreated', 'followUpTaskId', 'expiresAt', 'kaizenProposalId',
|
|
557
1023
|
'description', 'payload', 'body', 'question', 'pillarId',
|
|
558
1024
|
'sourceAgent', 'resolvedAt', 'department', 'templateKey',
|
|
559
1025
|
'inferredAnswer', 'defaultAnswer',
|
|
@@ -581,11 +1047,12 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
|
|
|
581
1047
|
RETURN a.id`,
|
|
582
1048
|
{ id: approvalId, cid, answer }
|
|
583
1049
|
);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
1050
|
+
const rows = parseRows(approveResult);
|
|
1051
|
+
if (rows.length === 0) {
|
|
1052
|
+
jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
mirrorHboStore('approval.approve', () => hboStore.updateApproval?.(approvalId, cid, { status: 'approved', answer, approvedAt: Date.now() }));
|
|
589
1056
|
|
|
590
1057
|
// Synchronously update linked Strategy if this is a strategy_proposal
|
|
591
1058
|
try {
|
|
@@ -687,6 +1154,8 @@ async function handleApproveApproval(req, res, ctx, approvalId) {
|
|
|
687
1154
|
}
|
|
688
1155
|
|
|
689
1156
|
broadcast({ type: 'approval.approved', approvalId, companyId: cid });
|
|
1157
|
+
// H-01: record approval.approve in ActivityLogger
|
|
1158
|
+
ctx.activityLogger?.record({ action: 'approval.approve', actor: cid, entityId: approvalId, companyId: cid });
|
|
690
1159
|
jsonResponse(res, 200, { approved: true, approvalId });
|
|
691
1160
|
} catch (err) {
|
|
692
1161
|
jsonResponse(res, 500, { error: `Approve failed: ${err.message}` });
|
|
@@ -714,7 +1183,10 @@ async function handleRejectApproval(req, res, ctx, approvalId) {
|
|
|
714
1183
|
jsonResponse(res, 409, { error: `Approval not found or not in pending state: ${approvalId}` });
|
|
715
1184
|
return;
|
|
716
1185
|
}
|
|
1186
|
+
mirrorHboStore('approval.reject', () => hboStore.updateApproval?.(approvalId, cid, { status: 'rejected', rejectReason: reason, rejectedAt: Date.now() }));
|
|
717
1187
|
broadcast({ type: 'approval.rejected', approvalId, reason, companyId: cid });
|
|
1188
|
+
// H-01: record approval.reject in ActivityLogger
|
|
1189
|
+
ctx.activityLogger?.record({ action: 'approval.reject', actor: cid, entityId: approvalId, companyId: cid });
|
|
718
1190
|
jsonResponse(res, 200, { rejected: true, approvalId, reason });
|
|
719
1191
|
} catch (err) {
|
|
720
1192
|
jsonResponse(res, 500, { error: `Reject failed: ${err.message}` });
|
|
@@ -993,16 +1465,39 @@ async function handleGetAgents(req, res, ctx) {
|
|
|
993
1465
|
|
|
994
1466
|
async function handleGetAgent(req, res, ctx, agentId) {
|
|
995
1467
|
const { mgQuery } = ctx;
|
|
996
|
-
if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
|
|
997
1468
|
if (!agentId) { jsonResponse(res, 400, { error: 'Missing agent ID' }); return; }
|
|
998
1469
|
const cid = ctx.cid;
|
|
999
1470
|
|
|
1471
|
+
// P7-A1: SQL parity — fall back to SQLite when Memgraph unavailable
|
|
1472
|
+
if (!mgQuery) {
|
|
1473
|
+
try {
|
|
1474
|
+
const a = hboStore.getBusinessAgent ? hboStore.getBusinessAgent(agentId, cid) : null;
|
|
1475
|
+
if (!a) { jsonResponse(res, 404, { error: `Agent not found: ${agentId}` }); return; }
|
|
1476
|
+
// H-1 fix: also include recentTasks from SQLite so shape matches Memgraph path
|
|
1477
|
+
const sqliteTasks = hboStore.getTasksByCompanyStatus
|
|
1478
|
+
? hboStore.getTasksByCompanyStatus(cid, ['todo', 'in_progress', 'done']).filter(t => t.assigneeAgentId === agentId || t.assignee_agent_id === agentId).slice(0, 10)
|
|
1479
|
+
: [];
|
|
1480
|
+
a.recentTasks = sqliteTasks.map(t => ({ id: t.id, title: t.title ?? null, status: t.status ?? null, priority: t.priority ?? null }));
|
|
1481
|
+
return jsonResponse(res, 200, { agent: a, _source: 'sqlite' });
|
|
1482
|
+
} catch (storeErr) {
|
|
1483
|
+
return jsonResponse(res, 503, { error: 'Agent unavailable: Memgraph not connected', agent: null });
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1000
1487
|
try {
|
|
1001
1488
|
const [agentResult, tasksResult] = await Promise.all([
|
|
1002
1489
|
mgQuery(
|
|
1490
|
+
// P7-A1: extended RETURN to include all required fields for agent detail page
|
|
1003
1491
|
`MATCH (a:BusinessAgent {id: $id, companyId: $cid})
|
|
1004
|
-
RETURN a.id AS id, a.title AS title, a.status AS status,
|
|
1005
|
-
a.
|
|
1492
|
+
RETURN a.id AS id, a.title AS title, a.role AS role, a.status AS status,
|
|
1493
|
+
a.skill AS skill, a.adapter AS adapter,
|
|
1494
|
+
a.budgetMonthlyCents AS budgetMonthlyCents,
|
|
1495
|
+
a.heartbeatIntervalMs AS heartbeatIntervalMs,
|
|
1496
|
+
a.capabilities AS capabilities,
|
|
1497
|
+
a.defaultSkills AS defaultSkills,
|
|
1498
|
+
a.lastHeartbeatAt AS lastHeartbeatAt,
|
|
1499
|
+
a.pauseReason AS pauseReason,
|
|
1500
|
+
a.companyId AS companyId`,
|
|
1006
1501
|
{ id: agentId, cid }
|
|
1007
1502
|
),
|
|
1008
1503
|
mgQuery(
|
|
@@ -1019,7 +1514,9 @@ async function handleGetAgent(req, res, ctx, agentId) {
|
|
|
1019
1514
|
return;
|
|
1020
1515
|
}
|
|
1021
1516
|
|
|
1022
|
-
const agentKeys = ['id', 'title', 'status', '
|
|
1517
|
+
const agentKeys = ['id', 'title', 'role', 'status', 'skill', 'adapter',
|
|
1518
|
+
'budgetMonthlyCents', 'heartbeatIntervalMs', 'capabilities', 'defaultSkills',
|
|
1519
|
+
'lastHeartbeatAt', 'pauseReason', 'companyId'];
|
|
1023
1520
|
const taskKeys = ['id', 'title', 'status', 'priority'];
|
|
1024
1521
|
const agent = rowToObj(agentRows[0], agentKeys);
|
|
1025
1522
|
agent.recentTasks = parseRows(tasksResult).map(r => rowToObj(r, taskKeys));
|
|
@@ -1030,6 +1527,37 @@ async function handleGetAgent(req, res, ctx, agentId) {
|
|
|
1030
1527
|
}
|
|
1031
1528
|
}
|
|
1032
1529
|
|
|
1530
|
+
// P7-A2: GET /api/agents/:id/runs — HeartbeatRun history for an agent
|
|
1531
|
+
async function handleGetAgentRuns(req, res, ctx, agentId) {
|
|
1532
|
+
const { mgQuery } = ctx;
|
|
1533
|
+
if (!agentId) { jsonResponse(res, 400, { error: 'Missing agent ID' }); return; }
|
|
1534
|
+
const cid = ctx.cid;
|
|
1535
|
+
|
|
1536
|
+
// SQL parity: HeartbeatRun nodes are Memgraph-only (no SQLite mirror yet)
|
|
1537
|
+
if (!mgQuery) {
|
|
1538
|
+
return jsonResponse(res, 200, { runs: [], _source: 'sqlite' });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
try {
|
|
1542
|
+
const result = await mgQuery(
|
|
1543
|
+
// H-2 fix: removed h.runId (field doesn't exist on HeartbeatRun nodes — they use h.id)
|
|
1544
|
+
// M-4 fix: use toString() for consistent datetime ordering (matches all other ORDER BY in codebase)
|
|
1545
|
+
`MATCH (h:HeartbeatRun {agentId: $agentId, companyId: $cid})
|
|
1546
|
+
RETURN h.id AS id, h.agentId AS agentId, h.companyId AS companyId,
|
|
1547
|
+
h.status AS status, h.startedAt AS startedAt, h.endedAt AS endedAt,
|
|
1548
|
+
h.taskId AS taskId
|
|
1549
|
+
ORDER BY toString(h.startedAt) DESC LIMIT toInteger(50)`,
|
|
1550
|
+
{ agentId, cid }
|
|
1551
|
+
);
|
|
1552
|
+
const rows = parseRows(result);
|
|
1553
|
+
const keys = ['id', 'agentId', 'companyId', 'status', 'startedAt', 'endedAt', 'taskId'];
|
|
1554
|
+
const runs = rows.map(r => rowToObj(r, keys));
|
|
1555
|
+
return jsonResponse(res, 200, { runs, count: runs.length });
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
return jsonResponse(res, 500, { error: `Query failed: ${err.message}` });
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1033
1561
|
// ── Activity ──────────────────────────────────────────────────────────────────
|
|
1034
1562
|
|
|
1035
1563
|
async function handleGetActivity(req, res, ctx) {
|
|
@@ -1418,6 +1946,32 @@ async function handleReviseApproval(req, res, ctx, approvalId) {
|
|
|
1418
1946
|
return;
|
|
1419
1947
|
}
|
|
1420
1948
|
broadcast({ type: 'approval.revised', approvalId, companyId: cid });
|
|
1949
|
+
|
|
1950
|
+
// B-19 fix: create AgentReadySignal so the assigned agent wakes and processes the revision.
|
|
1951
|
+
// Without this, the agent that owns the approval's task never re-evaluates after revision.
|
|
1952
|
+
setImmediate(async () => {
|
|
1953
|
+
try {
|
|
1954
|
+
const taskRows = await mgQuery(
|
|
1955
|
+
`MATCH (a:Approval {id: $id, companyId: $cid})-[:FOR_TASK]->(t:Task)
|
|
1956
|
+
RETURN t.id AS taskId, t.assigneeAgentId AS agentId`,
|
|
1957
|
+
{ id: approvalId, cid }
|
|
1958
|
+
);
|
|
1959
|
+
const taskRow = parseRows(taskRows)[0];
|
|
1960
|
+
if (taskRow && taskRow.taskId && taskRow.agentId) {
|
|
1961
|
+
await mgQuery(
|
|
1962
|
+
`MERGE (s:AgentReadySignal {id: 'signal:revise:' + $taskId})
|
|
1963
|
+
ON CREATE SET s.agentId = $agentId, s.companyId = $cid, s.status = 'pending',
|
|
1964
|
+
s.origin = 'approval_revision', s.taskId = $taskId,
|
|
1965
|
+
s.approvalId = $approvalId, s.createdAt = localdatetime()
|
|
1966
|
+
ON MATCH SET s.status = 'pending', s.approvalId = $approvalId`,
|
|
1967
|
+
{ taskId: taskRow.taskId, agentId: taskRow.agentId, cid, approvalId }
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
} catch (sigErr) {
|
|
1971
|
+
log('warn', 'B-19: AgentReadySignal creation failed after revision', { approvalId, err: sigErr.message });
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1421
1975
|
jsonResponse(res, 200, { revised: true, approvalId });
|
|
1422
1976
|
} catch (err) {
|
|
1423
1977
|
jsonResponse(res, 500, { error: { code: 'UPDATE_FAILED', message: `Revise failed: ${err.message}` } });
|
|
@@ -2222,6 +2776,18 @@ async function handleCreateStrategy(req, res, ctx) {
|
|
|
2222
2776
|
{ id: approvalId, cid, approvalTitle: `Strategy: ${title}`, plan, proposedBy, strategyId }
|
|
2223
2777
|
);
|
|
2224
2778
|
|
|
2779
|
+
mirrorHboStore('approval.create', () => hboStore.createApproval?.({
|
|
2780
|
+
id: approvalId,
|
|
2781
|
+
companyId: cid,
|
|
2782
|
+
type: 'strategy_proposal',
|
|
2783
|
+
title: `Strategy: ${title}`,
|
|
2784
|
+
description: plan,
|
|
2785
|
+
requestedBy: proposedBy,
|
|
2786
|
+
strategyId,
|
|
2787
|
+
status: 'pending',
|
|
2788
|
+
createdAt: Date.now(),
|
|
2789
|
+
}));
|
|
2790
|
+
|
|
2225
2791
|
// Link goal to strategy
|
|
2226
2792
|
await mgQuery(
|
|
2227
2793
|
`MATCH (g:CompanyGoal {id: $goalId}), (s:Strategy {id: $strategyId})
|
|
@@ -2259,6 +2825,10 @@ async function handleGetRoutineTriggers(req, res, ctx, routineId) {
|
|
|
2259
2825
|
|
|
2260
2826
|
async function handleCreateRoutineTrigger(req, res, ctx, routineId) {
|
|
2261
2827
|
const { mgQuery } = ctx;
|
|
2828
|
+
// HIGH-2 fix: guard against Memgraph unavailability
|
|
2829
|
+
if (!mgQuery) { return jsonResponse(res, 503, { error: 'Memgraph not connected' }); }
|
|
2830
|
+
// CRIT-2 fix: declare cid so broadcast() does not throw ReferenceError
|
|
2831
|
+
const cid = ctx.cid;
|
|
2262
2832
|
const body = await parseBody(req);
|
|
2263
2833
|
const kind = body.kind;
|
|
2264
2834
|
if (!kind || !['schedule', 'webhook', 'api'].includes(kind)) {
|
|
@@ -2283,7 +2853,7 @@ async function handleCreateRoutineTrigger(req, res, ctx, routineId) {
|
|
|
2283
2853
|
{
|
|
2284
2854
|
triggerId,
|
|
2285
2855
|
routineId,
|
|
2286
|
-
cid
|
|
2856
|
+
cid,
|
|
2287
2857
|
kind,
|
|
2288
2858
|
publicId,
|
|
2289
2859
|
config: JSON.stringify(body.config || {}),
|
|
@@ -2305,13 +2875,13 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
|
|
|
2305
2875
|
const result = await mgQuery(
|
|
2306
2876
|
`MATCH (rt:RoutineTrigger {publicId: $publicId, companyId: $cid})-[:TRIGGERS]->(r:Routine)
|
|
2307
2877
|
RETURN rt.id AS triggerId, rt.routineId AS routineId, r.title AS routineTitle,
|
|
2308
|
-
r.variables AS variables, r.companyId AS companyId`,
|
|
2878
|
+
r.variables AS variables, r.companyId AS companyId, rt.secret AS secret`,
|
|
2309
2879
|
{ publicId, cid: ctx.cid }
|
|
2310
2880
|
);
|
|
2311
2881
|
const rows = parseRows(result);
|
|
2312
2882
|
if (rows.length === 0) return jsonResponse(res, 404, { error: 'Trigger not found' });
|
|
2313
2883
|
|
|
2314
|
-
const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId'];
|
|
2884
|
+
const keys = ['triggerId', 'routineId', 'routineTitle', 'variables', 'companyId', 'secret'];
|
|
2315
2885
|
const trigger = rowToObj(rows[0], keys);
|
|
2316
2886
|
|
|
2317
2887
|
// S-06: Verify HMAC signature if trigger has a secret configured
|
|
@@ -2356,8 +2926,8 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
|
|
|
2356
2926
|
}
|
|
2357
2927
|
);
|
|
2358
2928
|
|
|
2359
|
-
|
|
2360
|
-
|
|
2929
|
+
broadcast({ type: 'routine.fired', routineId: trigger.routineId, taskId, source: 'webhook', companyId: trigger.companyId });
|
|
2930
|
+
return jsonResponse(res, 200, { fired: true, taskId, routineId: trigger.routineId });
|
|
2361
2931
|
} catch (err) {
|
|
2362
2932
|
return jsonResponse(res, 500, { error: err.message });
|
|
2363
2933
|
}
|
|
@@ -2365,15 +2935,137 @@ async function handleFireWebhookTrigger(req, res, ctx, publicId) {
|
|
|
2365
2935
|
|
|
2366
2936
|
// ── Routine Revisions & Runs ──────────────────────────────────────────────────
|
|
2367
2937
|
|
|
2938
|
+
// P5-01: GET /api/routines — list all routines for a company
|
|
2939
|
+
// SQL parity: falls back to hboStore.getRoutinesByCompany when Memgraph unavailable
|
|
2940
|
+
async function handleGetRoutines(req, res, ctx) {
|
|
2941
|
+
const { mgQuery } = ctx;
|
|
2942
|
+
const cid = ctx.cid;
|
|
2943
|
+
const url = new URL(req.url, 'http://localhost');
|
|
2944
|
+
const statusFilter = url.searchParams.get('status') || '';
|
|
2945
|
+
|
|
2946
|
+
if (mgQuery) {
|
|
2947
|
+
try {
|
|
2948
|
+
let cypher = `MATCH (r:Routine {companyId: $cid})`;
|
|
2949
|
+
const params = { cid };
|
|
2950
|
+
if (statusFilter) { cypher += ` WHERE r.status = $status`; params.status = statusFilter; }
|
|
2951
|
+
cypher += ` RETURN r.id, r.name, r.cronExpr, r.agentId, r.status, r.concurrencyPolicy,
|
|
2952
|
+
r.catchUpPolicy, r.catchUpCap, r.nextRunAt, r.lastRunAt, r.createdAt
|
|
2953
|
+
ORDER BY r.createdAt DESC`;
|
|
2954
|
+
const result = await mgQuery(cypher, params);
|
|
2955
|
+
const keys = ['id','name','cronExpr','agentId','status','concurrencyPolicy','catchUpPolicy','catchUpCap','nextRunAt','lastRunAt','createdAt'];
|
|
2956
|
+
const routines = parseRows(result).map(r => rowToObj(r, keys));
|
|
2957
|
+
return jsonResponse(res, 200, { routines, count: routines.length });
|
|
2958
|
+
} catch (e) {
|
|
2959
|
+
log('warn', `handleGetRoutines Memgraph failed, falling back to SQLite: ${e.message}`);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
// SQLite fallback
|
|
2963
|
+
try {
|
|
2964
|
+
const rows = hboStore.getRoutinesByCompany ? hboStore.getRoutinesByCompany(cid, statusFilter || undefined) : [];
|
|
2965
|
+
const routines = (rows || []).map(r => ({
|
|
2966
|
+
id: r.id, name: r.name ?? null, cronExpr: r.cron_expr ?? r.cronExpr ?? null,
|
|
2967
|
+
agentId: r.agent_id ?? r.agentId ?? null, status: r.status ?? 'active',
|
|
2968
|
+
concurrencyPolicy: r.concurrency_policy ?? r.concurrencyPolicy ?? 'skip_if_active',
|
|
2969
|
+
catchUpPolicy: r.catch_up_policy ?? r.catchUpPolicy ?? 'skip_missed',
|
|
2970
|
+
catchUpCap: r.catch_up_cap ?? r.catchUpCap ?? 0,
|
|
2971
|
+
nextRunAt: r.next_run_at ?? r.nextRunAt ?? null,
|
|
2972
|
+
lastRunAt: r.last_run_at ?? r.lastRunAt ?? null,
|
|
2973
|
+
createdAt: r.created_at ? new Date(r.created_at).toISOString() : null,
|
|
2974
|
+
}));
|
|
2975
|
+
return jsonResponse(res, 200, { routines, count: routines.length, _source: 'sqlite' });
|
|
2976
|
+
} catch (storeErr) {
|
|
2977
|
+
return jsonResponse(res, 503, { error: `Routines unavailable: ${storeErr.message}` });
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// P5-02: POST /api/routines — create a new routine
|
|
2982
|
+
async function handleCreateRoutine(req, res, ctx) {
|
|
2983
|
+
const { mgQuery } = ctx;
|
|
2984
|
+
if (!mgQuery) { jsonResponse(res, 503, { error: 'Memgraph not connected' }); return; }
|
|
2985
|
+
const cid = ctx.cid;
|
|
2986
|
+
if (!cid || cid === 'default-company') { jsonResponse(res, 400, { error: 'companyId is required' }); return; }
|
|
2987
|
+
|
|
2988
|
+
const body = await parseBody(req);
|
|
2989
|
+
const { name, agentId, cronExpr, concurrencyPolicy, catchUpPolicy, catchUpCap } = body;
|
|
2990
|
+
|
|
2991
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
2992
|
+
jsonResponse(res, 400, { error: 'name is required' });
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
if (!agentId || typeof agentId !== 'string') {
|
|
2996
|
+
jsonResponse(res, 400, { error: 'agentId is required' });
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
if (!cronExpr || typeof cronExpr !== 'string') {
|
|
3000
|
+
jsonResponse(res, 400, { error: 'cronExpr is required' });
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
// Compute nextRunAt using croner (confirmed available in daemon)
|
|
3005
|
+
let nextRunAt;
|
|
3006
|
+
try {
|
|
3007
|
+
const { Cron } = require('croner');
|
|
3008
|
+
const cron = new Cron(cronExpr);
|
|
3009
|
+
const next = cron.nextRun();
|
|
3010
|
+
cron.stop();
|
|
3011
|
+
nextRunAt = next ? next.toISOString().replace(/\.\d{3}Z$/, '+00:00') : null;
|
|
3012
|
+
} catch (cronErr) {
|
|
3013
|
+
log('warn', `P5-02: cronExpr could not be parsed: ${cronErr.message} — using +1h fallback`);
|
|
3014
|
+
nextRunAt = null; // Memgraph will use datetime() + duration("PT1H") fallback
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
const resolvedPolicy = ['skip_if_active', 'coalesce_if_active', 'allow_concurrent'].includes(concurrencyPolicy)
|
|
3018
|
+
? concurrencyPolicy : 'skip_if_active';
|
|
3019
|
+
const resolvedCatchUp = ['skip_missed', 'enqueue_missed_with_cap'].includes(catchUpPolicy)
|
|
3020
|
+
? catchUpPolicy : 'skip_missed';
|
|
3021
|
+
const resolvedCap = Number.isFinite(Number(catchUpCap)) ? Math.max(0, Number(catchUpCap)) : 0;
|
|
3022
|
+
const routineId = `routine:${cid}:${crypto.randomUUID()}`;
|
|
3023
|
+
|
|
3024
|
+
try {
|
|
3025
|
+
const cypher = nextRunAt
|
|
3026
|
+
? `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
|
|
3027
|
+
cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
|
|
3028
|
+
catchUpCap:toInteger($cap), nextRunAt:datetime($nextRun), createdAt:datetime() })`
|
|
3029
|
+
: `CREATE (r:Routine { id:$id, companyId:$cid, agentId:$agentId, name:$name,
|
|
3030
|
+
cronExpr:$cron, status:'active', concurrencyPolicy:$policy, catchUpPolicy:$catchUp,
|
|
3031
|
+
catchUpCap:toInteger($cap), nextRunAt:datetime() + duration("PT1H"), createdAt:datetime() })`;
|
|
3032
|
+
const params = {
|
|
3033
|
+
id: routineId, cid, agentId: String(agentId), name: String(name).trim(),
|
|
3034
|
+
cron: cronExpr, policy: resolvedPolicy, catchUp: resolvedCatchUp, cap: resolvedCap,
|
|
3035
|
+
...(nextRunAt ? { nextRun: nextRunAt } : {}),
|
|
3036
|
+
};
|
|
3037
|
+
await mgQuery(cypher, params);
|
|
3038
|
+
|
|
3039
|
+
// P5-SQL: SQLite write-through (fire-and-forget)
|
|
3040
|
+
try {
|
|
3041
|
+
hboStore.upsertRoutine?.({
|
|
3042
|
+
id: routineId, company_id: cid, agent_id: String(agentId), name: String(name).trim(),
|
|
3043
|
+
cron_expr: cronExpr, status: 'active', concurrency_policy: resolvedPolicy,
|
|
3044
|
+
catch_up_policy: resolvedCatchUp, catch_up_cap: resolvedCap,
|
|
3045
|
+
next_run_at: nextRunAt, created_at: Date.now(),
|
|
3046
|
+
});
|
|
3047
|
+
} catch (storeErr) { log('error', 'hboStore.upsertRoutine failed', { err: storeErr.message }); }
|
|
3048
|
+
|
|
3049
|
+
broadcast({ type: 'routine.created', routineId, companyId: cid });
|
|
3050
|
+
jsonResponse(res, 201, { routine: { id: routineId, name: String(name).trim(), agentId, cronExpr, status: 'active', concurrencyPolicy: resolvedPolicy, catchUpCap: resolvedCap } });
|
|
3051
|
+
} catch (err) {
|
|
3052
|
+
jsonResponse(res, 500, { error: `Create routine failed: ${err.message}` });
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2368
3056
|
async function handlePatchRoutine(req, res, ctx, routineId) {
|
|
2369
3057
|
const { mgQuery } = ctx;
|
|
3058
|
+
// HIGH-2 fix: guard against Memgraph unavailability
|
|
3059
|
+
if (!mgQuery) { return jsonResponse(res, 503, { error: 'Memgraph not connected' }); }
|
|
3060
|
+
// CRIT-2 fix: declare cid so broadcast() does not throw ReferenceError
|
|
3061
|
+
const cid = ctx.cid;
|
|
2370
3062
|
const body = await parseBody(req);
|
|
2371
3063
|
|
|
2372
3064
|
try {
|
|
2373
|
-
// Get current routine
|
|
3065
|
+
// Get current routine — CRIT-5 fix: use r.name (not r.title) consistently
|
|
2374
3066
|
const existing = await mgQuery(
|
|
2375
|
-
`MATCH (r:Routine {id: $routineId, companyId: $cid}) RETURN r.
|
|
2376
|
-
{ routineId, cid
|
|
3067
|
+
`MATCH (r:Routine {id: $routineId, companyId: $cid}) RETURN r.name AS name, r.config AS config`,
|
|
3068
|
+
{ routineId, cid }
|
|
2377
3069
|
);
|
|
2378
3070
|
const rows = parseRows(existing);
|
|
2379
3071
|
if (rows.length === 0) return jsonResponse(res, 404, { error: 'Routine not found' });
|
|
@@ -2381,48 +3073,69 @@ async function handlePatchRoutine(req, res, ctx, routineId) {
|
|
|
2381
3073
|
// Count existing revisions to determine revision number
|
|
2382
3074
|
const revCount = await mgQuery(
|
|
2383
3075
|
`MATCH (rv:RoutineRevision {routineId: $routineId, companyId: $cid}) RETURN count(rv) AS cnt`,
|
|
2384
|
-
{ routineId, cid
|
|
3076
|
+
{ routineId, cid }
|
|
2385
3077
|
);
|
|
2386
3078
|
const revRows = parseRows(revCount);
|
|
2387
3079
|
const revisionNumber = (revRows[0]?.[0] || 0) + 1;
|
|
2388
3080
|
|
|
2389
|
-
// Create revision
|
|
2390
|
-
const revisionId = `revision:${crypto.randomUUID()}`;
|
|
2391
|
-
await mgQuery(
|
|
2392
|
-
`CREATE (rv:RoutineRevision {
|
|
2393
|
-
id: $revisionId,
|
|
2394
|
-
routineId: $routineId,
|
|
2395
|
-
companyId: $cid,
|
|
2396
|
-
revisionNumber: $revisionNumber,
|
|
2397
|
-
config: $config,
|
|
2398
|
-
createdAt: datetime()
|
|
2399
|
-
})`,
|
|
2400
|
-
{
|
|
2401
|
-
revisionId,
|
|
2402
|
-
routineId,
|
|
2403
|
-
cid: ctx.cid,
|
|
2404
|
-
revisionNumber,
|
|
2405
|
-
config: JSON.stringify(body)
|
|
2406
|
-
}
|
|
2407
|
-
);
|
|
2408
|
-
|
|
2409
3081
|
// Update routine with new values
|
|
3082
|
+
// CRIT-3 fix: handle body.status (for soft-delete via 'inactive' + other status changes)
|
|
3083
|
+
// CRIT-5 fix: use r.name not r.title
|
|
2410
3084
|
const sets = [];
|
|
2411
|
-
const params = { routineId, cid
|
|
2412
|
-
if (body.
|
|
2413
|
-
if (body.
|
|
3085
|
+
const params = { routineId, cid };
|
|
3086
|
+
if (body.name) { sets.push('r.name = $name'); params.name = String(body.name); }
|
|
3087
|
+
if (body.title) { sets.push('r.name = $name'); params.name = String(body.title); } // legacy alias
|
|
3088
|
+
if (body.status) { sets.push('r.status = $status'); params.status = String(body.status); }
|
|
3089
|
+
if (body.catchUpPolicy) { sets.push('r.catchUpPolicy = $catchUpPolicy'); params.catchUpPolicy = body.catchUpPolicy; }
|
|
2414
3090
|
if (body.concurrencyPolicy) { sets.push('r.concurrencyPolicy = $concurrencyPolicy'); params.concurrencyPolicy = body.concurrencyPolicy; }
|
|
2415
|
-
if (body.
|
|
3091
|
+
if (body.cronExpr) { sets.push('r.cronExpr = $cronExpr'); params.cronExpr = body.cronExpr; }
|
|
3092
|
+
if (body.config) { sets.push('r.config = $config'); params.config = JSON.stringify(body.config); }
|
|
3093
|
+
sets.push('r.updatedAt = datetime()');
|
|
3094
|
+
|
|
3095
|
+
// MED-3 fix: only create a revision if there are actual field changes (not on no-op patches)
|
|
3096
|
+
const hasChanges = sets.length > 1; // always has updatedAt, so check > 1
|
|
3097
|
+
if (hasChanges) {
|
|
3098
|
+
const revisionId = `revision:${crypto.randomUUID()}`;
|
|
3099
|
+
await mgQuery(
|
|
3100
|
+
`CREATE (rv:RoutineRevision {
|
|
3101
|
+
id: $revisionId,
|
|
3102
|
+
routineId: $routineId,
|
|
3103
|
+
companyId: $cid,
|
|
3104
|
+
revisionNumber: $revisionNumber,
|
|
3105
|
+
config: $config,
|
|
3106
|
+
createdAt: datetime()
|
|
3107
|
+
})`,
|
|
3108
|
+
{
|
|
3109
|
+
revisionId,
|
|
3110
|
+
routineId,
|
|
3111
|
+
cid,
|
|
3112
|
+
revisionNumber,
|
|
3113
|
+
config: JSON.stringify(body)
|
|
3114
|
+
}
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
2416
3117
|
|
|
2417
|
-
if (sets.length >
|
|
3118
|
+
if (sets.length > 1) {
|
|
2418
3119
|
await mgQuery(
|
|
2419
3120
|
`MATCH (r:Routine {id: $routineId, companyId: $cid}) SET ${sets.join(', ')}`,
|
|
2420
3121
|
params
|
|
2421
3122
|
);
|
|
2422
3123
|
}
|
|
2423
3124
|
|
|
2424
|
-
|
|
2425
|
-
|
|
3125
|
+
// HIGH-3 fix: SQLite write-through after successful Memgraph update
|
|
3126
|
+
if (sets.length > 1 && hboStore?.updateRoutine) {
|
|
3127
|
+
const storeUpdate = {};
|
|
3128
|
+
if (body.name || body.title) storeUpdate.name = body.name ?? body.title;
|
|
3129
|
+
if (body.status) storeUpdate.status = body.status;
|
|
3130
|
+
if (body.catchUpPolicy) storeUpdate.catch_up_policy = body.catchUpPolicy;
|
|
3131
|
+
if (body.concurrencyPolicy) storeUpdate.concurrency_policy = body.concurrencyPolicy;
|
|
3132
|
+
if (body.cronExpr) storeUpdate.cron_expr = body.cronExpr;
|
|
3133
|
+
try { hboStore.updateRoutine(routineId, cid, storeUpdate); }
|
|
3134
|
+
catch (storeErr) { log('error', 'hboStore.updateRoutine failed', { err: storeErr.message, routineId }); }
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
broadcast({ type: 'routine.updated', routineId, revisionNumber: hasChanges ? revisionNumber : null, companyId: cid });
|
|
3138
|
+
return jsonResponse(res, 200, { updated: true, routineId, revisionNumber: hasChanges ? revisionNumber : null });
|
|
2426
3139
|
} catch (err) {
|
|
2427
3140
|
return jsonResponse(res, 500, { error: err.message });
|
|
2428
3141
|
}
|
|
@@ -2448,6 +3161,12 @@ async function handleGetRoutineRevisions(req, res, ctx, routineId) {
|
|
|
2448
3161
|
|
|
2449
3162
|
async function handleGetRoutineRuns(req, res, ctx, routineId) {
|
|
2450
3163
|
const { mgQuery } = ctx;
|
|
3164
|
+
const cid = ctx.cid;
|
|
3165
|
+
// MED-5 fix: SQL parity — fallback to SQLite when Memgraph unavailable
|
|
3166
|
+
if (!mgQuery) {
|
|
3167
|
+
// No RoutineRun SQLite table exists yet — return empty gracefully
|
|
3168
|
+
return jsonResponse(res, 200, { runs: [], _source: 'sqlite' });
|
|
3169
|
+
}
|
|
2451
3170
|
try {
|
|
2452
3171
|
const result = await mgQuery(
|
|
2453
3172
|
`MATCH (rr:RoutineRun {routineId: $routineId, companyId: $cid})
|
|
@@ -2455,7 +3174,7 @@ async function handleGetRoutineRuns(req, res, ctx, routineId) {
|
|
|
2455
3174
|
rr.startedAt AS startedAt, rr.completedAt AS completedAt
|
|
2456
3175
|
ORDER BY rr.startedAt DESC
|
|
2457
3176
|
LIMIT toInteger($lim)`,
|
|
2458
|
-
{ routineId, cid
|
|
3177
|
+
{ routineId, cid, lim: 50 }
|
|
2459
3178
|
);
|
|
2460
3179
|
const rows = parseRows(result);
|
|
2461
3180
|
const keys = ['id', 'status', 'taskId', 'startedAt', 'completedAt'];
|
|
@@ -2500,7 +3219,7 @@ async function dispatchToAdapter(mgQuery, ctx, taskId, agentId) {
|
|
|
2500
3219
|
HELIOS_AGENT_ID: agentId,
|
|
2501
3220
|
HELIOS_RUN_ID: runId,
|
|
2502
3221
|
HELIOS_TASK_ID: taskId,
|
|
2503
|
-
HELIOS_API_URL: `http://localhost:${ctx.apiPort ||
|
|
3222
|
+
HELIOS_API_URL: `http://localhost:${ctx.apiPort || 9093}`,
|
|
2504
3223
|
};
|
|
2505
3224
|
|
|
2506
3225
|
broadcast({ type: 'run.started', runId, taskId, agentId, adapterType, companyId: cid });
|
|
@@ -2799,6 +3518,7 @@ const tasksRoute = require('./routes/tasks')({
|
|
|
2799
3518
|
handleGetTaskComments,
|
|
2800
3519
|
handlePostTaskComment,
|
|
2801
3520
|
handlePatchTask: handleUpdateTask, // GAP-20 fix: was wired to duplicate (no companyId); now uses correct handleUpdateTask
|
|
3521
|
+
handlePatchTaskPolicy, // P4-07: PATCH /api/tasks/:id/policy
|
|
2802
3522
|
});
|
|
2803
3523
|
|
|
2804
3524
|
const approvalsRoute = require('./routes/approvals')({
|
|
@@ -2814,9 +3534,14 @@ const approvalsRoute = require('./routes/approvals')({
|
|
|
2814
3534
|
|
|
2815
3535
|
const agentsRoute = require('./routes/agents')({
|
|
2816
3536
|
handleGetAgents,
|
|
3537
|
+
handleGetAgent, // P7-A1: wire single-agent GET (was dead code — not passed before)
|
|
3538
|
+
handleGetAgentRuns, // P7-A2: HeartbeatRun history
|
|
2817
3539
|
handleSyncSkills,
|
|
2818
3540
|
handleApproveAgent,
|
|
2819
3541
|
handleTerminateAgent,
|
|
3542
|
+
handlePauseAgent, // Bug fix: was missing, caused TypeError in agents.js
|
|
3543
|
+
handleResumeAgent, // Bug fix: was missing
|
|
3544
|
+
handlePauseAll: null, // pause-all is handled in hbo.js; stub null to prevent TypeError
|
|
2820
3545
|
});
|
|
2821
3546
|
|
|
2822
3547
|
const skillsRoute = require('./routes/skills')({
|
|
@@ -2845,6 +3570,8 @@ const strategyRoute = require('./routes/strategy')({
|
|
|
2845
3570
|
});
|
|
2846
3571
|
|
|
2847
3572
|
const routinesRoute = require('./routes/routines')({
|
|
3573
|
+
handleGetRoutines,
|
|
3574
|
+
handleCreateRoutine,
|
|
2848
3575
|
handleGetRoutineTriggers,
|
|
2849
3576
|
handleCreateRoutineTrigger,
|
|
2850
3577
|
handleFireWebhookTrigger,
|
|
@@ -2869,7 +3596,7 @@ const inboxRoute = require('./routes/inbox')({ broadcast });
|
|
|
2869
3596
|
const queueRoute = require('./routes/queue')({ broadcast });
|
|
2870
3597
|
const { handleDraftsRoute } = require('./routes/drafts');
|
|
2871
3598
|
const goalsRoute = require('./routes/goals');
|
|
2872
|
-
|
|
3599
|
+
let hboRoute = require('./routes/hbo')({ broadcast });
|
|
2873
3600
|
const emailTriageRoute = require('./routes/email-triage')();
|
|
2874
3601
|
const andonRoute = require('./routes/andon')({ handleGetAndonBoard });
|
|
2875
3602
|
const wizardRoute = require('./routes/wizard')({});
|
|
@@ -2899,6 +3626,9 @@ let mandalaRoute;
|
|
|
2899
3626
|
try { mandalaRoute = require('./routes/mandala').mandalaRoute; } catch (_) { mandalaRoute = () => false; }
|
|
2900
3627
|
let personRoute;
|
|
2901
3628
|
try { personRoute = require('./routes/person')({ handleGetPersonProfile, handleGetPersonByEmail, handleGetPersonEpisodes }); } catch (_) { personRoute = () => false; }
|
|
3629
|
+
// P5-S5c: CRM dual-write route — accepts POST /api/crm/contacts from desktop crmSyncService
|
|
3630
|
+
let crmRoute;
|
|
3631
|
+
try { crmRoute = require('./routes/crm')({ parseBody, jsonResponse }); } catch (_) { crmRoute = () => false; }
|
|
2902
3632
|
|
|
2903
3633
|
|
|
2904
3634
|
// ── WS-nonce store (single-use, 60-second TTL) ───────────────────────────────
|
|
@@ -2931,6 +3661,10 @@ async function route(req, res, ctx) {
|
|
|
2931
3661
|
// It can never elevate to a company this daemon doesn't own.
|
|
2932
3662
|
const qcid = parsedUrl.searchParams.get('companyId');
|
|
2933
3663
|
const primaryCid = ctx.companies?.[0]?.id ?? 'default-company';
|
|
3664
|
+
if (method === 'GET' && pathname === '/api/agents' && (!qcid || !qcid.trim())) {
|
|
3665
|
+
jsonResponse(res, 400, { error: 'companyId required' });
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
2934
3668
|
// qcid will be validated against allowedCompanyIds before being used;
|
|
2935
3669
|
// if it doesn't match, the 403 below catches it. This is safe.
|
|
2936
3670
|
// Group C: Create per-request context clone so concurrent requests can't corrupt each other's cid.
|
|
@@ -2959,6 +3693,25 @@ async function route(req, res, ctx) {
|
|
|
2959
3693
|
return;
|
|
2960
3694
|
}
|
|
2961
3695
|
|
|
3696
|
+
// SP3: Read receipt tracking pixel — must be before auth middleware, no auth required
|
|
3697
|
+
if (req.method === 'GET' && /^\/t\/[a-zA-Z0-9_-]+\.png$/.test(req.url || '')) {
|
|
3698
|
+
const trackingId = (req.url || '').slice(3, -4); // strip leading /t/ and trailing .png
|
|
3699
|
+
const gif1x1 = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
|
3700
|
+
res.writeHead(200, {
|
|
3701
|
+
'Content-Type': 'image/gif',
|
|
3702
|
+
'Content-Length': gif1x1.length,
|
|
3703
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
3704
|
+
'Pragma': 'no-cache',
|
|
3705
|
+
});
|
|
3706
|
+
res.end(gif1x1);
|
|
3707
|
+
// Fire-and-forget: log open event to Memgraph
|
|
3708
|
+
try {
|
|
3709
|
+
const { rawWrite: rw } = require('../lib/safe-memgraph.js');
|
|
3710
|
+
rw(`MERGE (o:OpenEvent {trackingId: $id}) ON CREATE SET o.count = 1, o.firstOpenedAt = datetime() ON MATCH SET o.count = o.count + 1, o.lastOpenedAt = datetime()`, { id: trackingId }).catch(() => {});
|
|
3711
|
+
} catch {}
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
2962
3715
|
// ── Bearer token auth ────────────────────────────────────────────────────
|
|
2963
3716
|
// Public routes that skip auth
|
|
2964
3717
|
const PUBLIC_PATHS = new Set(['/api/health', '/api/auth/ws-nonce', '/login', '/signup', '/api/auth/signup', '/api/auth/login', '/api/auth/logout']);
|
|
@@ -3335,6 +4088,7 @@ async function route(req, res, ctx) {
|
|
|
3335
4088
|
if (await suggestionsRoute(req, res, reqCtx, pathname, method)) return;
|
|
3336
4089
|
if (await mandalaRoute(req, res, reqCtx, pathname, method)) return;
|
|
3337
4090
|
if (await personRoute(req, res, reqCtx, pathname, method)) return;
|
|
4091
|
+
if (await crmRoute(req, res, reqCtx, pathname, method)) return;
|
|
3338
4092
|
|
|
3339
4093
|
// ── Phase 3: SystemAim endpoints ─────────────────────────────────────────
|
|
3340
4094
|
if (pathname === '/api/system-aim') {
|
|
@@ -3611,11 +4365,26 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
|
|
|
3611
4365
|
if (narrativeStr && lastGenStr) {
|
|
3612
4366
|
const lastGen = new Date(String(lastGenStr)).getTime();
|
|
3613
4367
|
if (!isNaN(lastGen) && Date.now() - lastGen < CACHE_TTL_MS) {
|
|
3614
|
-
// Cache hit — return as-is
|
|
4368
|
+
// Cache hit — return as-is, also include tasks created from this page
|
|
3615
4369
|
let narrative;
|
|
3616
4370
|
try { narrative = JSON.parse(String(narrativeStr)); }
|
|
3617
4371
|
catch (_) { narrative = { governingThought: String(narrativeStr) }; }
|
|
3618
|
-
|
|
4372
|
+
|
|
4373
|
+
// P8E-01: Include tasks created from this department page's decisionsStructured
|
|
4374
|
+
const tasksResult = await mg(
|
|
4375
|
+
`MATCH (dp:DepartmentPage {companyId: $cid, department: $dept})
|
|
4376
|
+
WHERE dp.narrative IS NOT NULL
|
|
4377
|
+
OPTIONAL MATCH (dp)-[:CREATED_TASK]->(t:Task)
|
|
4378
|
+
RETURN collect({id: t.id, title: t.title, status: t.status, originKind: t.originKind}) AS tasks
|
|
4379
|
+
ORDER BY dp.lastGeneratedAt DESC LIMIT 1`,
|
|
4380
|
+
{ cid, dept: department }
|
|
4381
|
+
).catch(() => null);
|
|
4382
|
+
const tasksRow = tasksResult && tasksResult.rows ? tasksResult.rows[0] : null;
|
|
4383
|
+
const tasks = tasksRow
|
|
4384
|
+
? (Array.isArray(tasksRow) ? (tasksRow[0] || []) : (tasksRow.tasks || []))
|
|
4385
|
+
: [];
|
|
4386
|
+
|
|
4387
|
+
jsonResponse(res, 200, { department, narrative, generatedAt: lastGenStr, cached: true, tasks });
|
|
3619
4388
|
return;
|
|
3620
4389
|
}
|
|
3621
4390
|
}
|
|
@@ -3654,7 +4423,7 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
|
|
|
3654
4423
|
* Start the REST API server.
|
|
3655
4424
|
*
|
|
3656
4425
|
* @param {Function} mgQuery - Memgraph query function: (cypher, params) => Promise<rows>
|
|
3657
|
-
* @param {object} config - Daemon config (uses config.apiPort ??
|
|
4426
|
+
* @param {object} config - Daemon config (uses config.apiPort ?? 9093)
|
|
3658
4427
|
* @param {object} state - Optional shared state for health endpoint
|
|
3659
4428
|
* @returns {{ server, updateTick, setDraining }}
|
|
3660
4429
|
*/
|
|
@@ -3662,6 +4431,16 @@ async function handleGetDepartmentPageRoute(req, res, ctx, department) {
|
|
|
3662
4431
|
|
|
3663
4432
|
async function handleGetTaskDetail(req, res, ctx, taskId) {
|
|
3664
4433
|
const cid = ctx.cid;
|
|
4434
|
+
// H-2 fix: SQLite fallback when Memgraph is unavailable
|
|
4435
|
+
if (!ctx.mgQuery) {
|
|
4436
|
+
try {
|
|
4437
|
+
const t = hboStore.getTask ? hboStore.getTask(taskId, cid) : null;
|
|
4438
|
+
if (!t) return jsonResponse(res, 404, { error: 'Task not found' });
|
|
4439
|
+
return jsonResponse(res, 200, { task: t, _source: 'sqlite' });
|
|
4440
|
+
} catch (storeErr) {
|
|
4441
|
+
return jsonResponse(res, 503, { error: 'Task unavailable: Memgraph not connected', task: null });
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
3665
4444
|
try {
|
|
3666
4445
|
const result = await ctx.mgQuery('MATCH (t:Task {id: $id, companyId: $cid}) RETURN t', { id: taskId, cid });
|
|
3667
4446
|
if (result.rows && result.rows.length > 0) {
|
|
@@ -3842,6 +4621,7 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3842
4621
|
invalidateContextCache,
|
|
3843
4622
|
semanticUpdater: container?.cradle?.projectSemanticUpdater ?? null,
|
|
3844
4623
|
driftDetector: container?.cradle?.projectDriftDetector ?? null,
|
|
4624
|
+
hboStore,
|
|
3845
4625
|
});
|
|
3846
4626
|
// Dept Catchball pitch — shares same semanticUpdater from container
|
|
3847
4627
|
deptRoute = require('./routes/dept')({
|
|
@@ -3852,10 +4632,25 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3852
4632
|
} catch (e) {
|
|
3853
4633
|
// container.js not yet built — projectRoute falls back to () => false
|
|
3854
4634
|
const { invalidateContextCache } = require('./context-enrichment');
|
|
3855
|
-
projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache });
|
|
4635
|
+
projectRoute = require('./routes/project')({ mgQuery, broadcast: state.broadcast ?? (() => {}), invalidateContextCache, hboStore });
|
|
3856
4636
|
deptRoute = require('./routes/dept')({ mgQuery, broadcast: state.broadcast ?? (() => {}) });
|
|
3857
4637
|
}
|
|
3858
4638
|
|
|
4639
|
+
// Reinitialize hboRoute with triggerGoalDecompose hook so POST /api/hbo/goals immediately
|
|
4640
|
+
// fires tickGoalDecompose instead of waiting up to 150s for the next 5th-tick.
|
|
4641
|
+
// state.daemon._mods is the per-company module registry built by buildForCompany().
|
|
4642
|
+
if (state.daemon && typeof state.broadcast === 'function') {
|
|
4643
|
+
const _triggerFn = (cid) => {
|
|
4644
|
+
try {
|
|
4645
|
+
const mods = state.daemon._mods && state.daemon._mods.get ? state.daemon._mods.get(cid) : (state.daemon._mods?.[cid]);
|
|
4646
|
+
if (mods && mods.hboBridge && typeof mods.hboBridge.tickGoalDecompose === 'function') {
|
|
4647
|
+
mods.hboBridge.tickGoalDecompose({ fromGoalCreate: true }).catch(() => {});
|
|
4648
|
+
}
|
|
4649
|
+
} catch (_) {}
|
|
4650
|
+
};
|
|
4651
|
+
hboRoute = require('./routes/hbo')({ broadcast: state.broadcast, mgQuery, triggerGoalDecompose: _triggerFn });
|
|
4652
|
+
}
|
|
4653
|
+
|
|
3859
4654
|
// HED routes — requires mgQuery for HEDEngine
|
|
3860
4655
|
try {
|
|
3861
4656
|
const { createHedRoutes } = require('./routes/hed');
|
|
@@ -3870,7 +4665,7 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3870
4665
|
try { channelsRoute = require('./routes/channels')({ mgQuery }); } catch(e) { log('warn', `channels route unavailable: ${e.message}`); }
|
|
3871
4666
|
try { emailInfraRoute = require('./routes/email-infrastructure')({ mgQuery }); } catch(e) { log('warn', `emailInfra route unavailable: ${e.message}`); }
|
|
3872
4667
|
|
|
3873
|
-
const port = config.apiPort ??
|
|
4668
|
+
const port = config.apiPort ?? 9093;
|
|
3874
4669
|
|
|
3875
4670
|
// Security: shared-secret token for WebSocket upgrade.
|
|
3876
4671
|
// Mirrors GHSA-3c6j-hq33-3jv4 (forged exec lifecycle events via unauthenticated WS).
|
|
@@ -3882,10 +4677,13 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
3882
4677
|
mgQuery,
|
|
3883
4678
|
companies: state.companies ?? [],
|
|
3884
4679
|
cid: state.companies?.[0]?.id ?? 'default-company', // default — overridden per-request in route()
|
|
4680
|
+
apiPort: port,
|
|
4681
|
+
actualBoundPort: null,
|
|
3885
4682
|
tickCount: 0,
|
|
3886
4683
|
draining: false,
|
|
3887
4684
|
apiToken: config.apiToken || null, // null = no auth (backward compat)
|
|
3888
4685
|
daemon: state.daemon ?? null, // daemon instance ref — used by wizard route for onCompanyReady
|
|
4686
|
+
activityLogger: state.activityLogger ?? null, // H-01: for recording approval events
|
|
3889
4687
|
modules: [
|
|
3890
4688
|
'RoutineEvaluator', 'BudgetEnforcer', 'LivenessWatchdog',
|
|
3891
4689
|
'AgentDispatcher', 'ActivityLogger', 'TaskCompletionWatchdog',
|
|
@@ -4000,11 +4798,12 @@ function startApi(mgQuery, config = {}, state = {}) {
|
|
|
4000
4798
|
});
|
|
4001
4799
|
|
|
4002
4800
|
server.listen(port, config.apiHost ?? '127.0.0.1', () => {
|
|
4801
|
+
ctx.actualBoundPort = server.address()?.port ?? port;
|
|
4003
4802
|
process.stdout.write(JSON.stringify({
|
|
4004
4803
|
ts: new Date().toISOString(),
|
|
4005
4804
|
level: 'info',
|
|
4006
4805
|
module: 'helios-api',
|
|
4007
|
-
msg: `REST API listening on http://127.0.0.1:${
|
|
4806
|
+
msg: `REST API listening on http://127.0.0.1:${ctx.actualBoundPort}`,
|
|
4008
4807
|
}) + '\n');
|
|
4009
4808
|
|
|
4010
4809
|
// Write daemon.lock — the ground-truth discovery file for Helios Desktop.
|