@cgh567/agent 2.4.2 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/daemon/adapters/tui_wakeup.js +8 -0
- package/daemon/daemon-manager.js +1 -1
- package/daemon/db/email-infrastructure-migrate.js +192 -0
- package/daemon/db/hbo-core-migrate.js +189 -0
- package/daemon/helios-api.js +574 -20
- package/daemon/helios-company-daemon.js +103 -13
- package/daemon/lib/hbo-bridge.js +1 -1
- package/daemon/lib/hed-engine.js +25 -0
- package/daemon/lib/task-completion-processor.js +11 -0
- package/daemon/lib/wizard-engine.js +57 -6
- package/daemon/routes/hbo.js +253 -47
- package/daemon/routes/project.js +190 -59
- package/daemon/routes/routines.js +14 -0
- package/daemon/routes/tasks.js +15 -1
- package/daemon/schema-apply.js +174 -0
- package/daemon/schema-definitions.js +423 -0
- package/daemon/schema-migrations-hbo.js +10 -0
- package/daemon/schema-migrations-hed.js +18 -0
- package/daemon/schema-migrations-proj.js +131 -0
- package/extensions/cortex/wal-replay.ts +91 -0
- package/extensions/hema-dispatch-v3/index.ts +13 -7
- package/extensions/warm-tick/warm-tick-maintenance.ts +8 -0
- package/lib/__tests__/hbo-core-store.test.js +238 -0
- package/lib/event-bus.mts +1 -1
- package/lib/graph-availability.js +62 -0
- package/lib/hbo-core-store.compiled.js +834 -0
- package/lib/hbo-core-store.js +124 -0
- package/lib/hbo-core-store.ts +908 -0
- package/lib/triage-core/classifier.ts +3 -2
- package/lib/triage-core/graph/schema.cypher +10 -0
- package/lib/triage-core/mental-model/key-facts.ts +1 -2
- package/lib/triage-core/orchestrator.ts +4 -11
- package/package.json +9 -5
package/daemon/routes/project.js
CHANGED
|
@@ -107,35 +107,63 @@ function startSseKeepAlive(res) {
|
|
|
107
107
|
|
|
108
108
|
// ── Route factory ─────────────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
|
-
module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateContextCache, semanticUpdater, driftDetector } = {}) {
|
|
110
|
+
module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateContextCache, semanticUpdater, driftDetector, hboStore } = {}) {
|
|
111
111
|
|
|
112
112
|
// ── POST /api/project ─────────────────────────────────────────────────────
|
|
113
113
|
async function handleCreate(req, res, ctx) {
|
|
114
114
|
const body = await readBody(req);
|
|
115
115
|
if (!assertBody(body, res, req)) return;
|
|
116
116
|
// D3: ctx.cid is authoritative - prevents cross-company project injection
|
|
117
|
-
|
|
117
|
+
// P3-02: pillarId and goalId are now optional (defaults to null) so desktop
|
|
118
|
+
// users without Harada pillars can still create projects.
|
|
119
|
+
const { name } = body;
|
|
120
|
+
const pillarId = body.pillarId ? String(body.pillarId) : null;
|
|
121
|
+
const goalId = body.goalId ? String(body.goalId) : null;
|
|
118
122
|
const companyId = ctx?.cid || body?.companyId || '';
|
|
119
|
-
if (!companyId || !
|
|
120
|
-
return jsonErr(res, 400, 'companyId
|
|
123
|
+
if (!companyId || !name) {
|
|
124
|
+
return jsonErr(res, 400, 'companyId and name are required', req);
|
|
121
125
|
}
|
|
122
126
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
// projId is deterministic: use caller-supplied id (H-3: preserves desktop ID for reconciliation),
|
|
128
|
+
// then pillarId-slug, then UUID slug (P3-02: pillarId optional)
|
|
129
|
+
const slug = pillarId
|
|
130
|
+
? pillarId.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40)
|
|
131
|
+
: randomUUID().slice(0, 8);
|
|
132
|
+
const projId = body.id ? String(body.id) : `proj:${companyId}:${slug}`;
|
|
125
133
|
const docId = `pdoc:${projId}:main`;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
p.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
if (mgQuery) {
|
|
135
|
+
await mgQuery(
|
|
136
|
+
`MERGE (p:HeliosProject {id: $projId})
|
|
137
|
+
ON CREATE SET p.companyId=$cid, p.goalId=$goalId, p.pillarId=$pillarId,
|
|
138
|
+
p.name=$name, p.status='planning', p.phase='planning', p.createdAt=datetime()`,
|
|
139
|
+
{ projId, cid: companyId, goalId, pillarId, name }
|
|
140
|
+
);
|
|
141
|
+
// H-1 fix: SQLite write-through happens HERE (after first Memgraph write succeeds)
|
|
142
|
+
// so it executes even if the second Memgraph write (ProjectDocument) fails
|
|
143
|
+
if (hboStore?.upsertProject) {
|
|
144
|
+
try {
|
|
145
|
+
hboStore.upsertProject({ id: projId, companyId, name, pillarId, goalId, status: 'planning', phase: 'planning', createdAt: new Date().toISOString(), updated_at: Date.now() });
|
|
146
|
+
} catch (storeErr) {
|
|
147
|
+
process.stderr.write(`[project] hboStore.upsertProject failed (non-fatal): ${storeErr.message}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await mgQuery(
|
|
151
|
+
`MERGE (d:ProjectDocument {id: $docId})
|
|
152
|
+
ON CREATE SET d.projectId=$projId, d.purpose='', d.approach='',
|
|
153
|
+
d.successCriteria='[]', d.intentAnchor='', d.exclusions='[]',
|
|
154
|
+
d.content='', d.version=toInteger(1), d.updatedAt=datetime()`,
|
|
155
|
+
{ docId, projId }
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
// Memgraph unavailable — write to SQLite only (C-2 / SQL-parity invariant)
|
|
159
|
+
if (hboStore?.upsertProject) {
|
|
160
|
+
try {
|
|
161
|
+
hboStore.upsertProject({ id: projId, companyId, name, pillarId, goalId, status: 'planning', phase: 'planning', createdAt: new Date().toISOString(), updated_at: Date.now() });
|
|
162
|
+
} catch (storeErr) {
|
|
163
|
+
process.stderr.write(`[project] hboStore.upsertProject (offline) failed (non-fatal): ${storeErr.message}\n`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
139
167
|
jsonOk(res, { ok: true, projectId: projId }, req, 201);
|
|
140
168
|
} catch (e) {
|
|
141
169
|
jsonErr(res, 500, safeMsg(e), req);
|
|
@@ -148,41 +176,84 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
148
176
|
const cid = url.searchParams.get('companyId') || ctx?.cid || '';
|
|
149
177
|
const pillar = url.searchParams.get('pillarId') || '';
|
|
150
178
|
const status = url.searchParams.get('status') || '';
|
|
179
|
+
// P3-SQL: try Memgraph first; fall back to SQLite when unavailable
|
|
180
|
+
if (mgQuery) {
|
|
181
|
+
try {
|
|
182
|
+
let cypher = `MATCH (p:HeliosProject)`;
|
|
183
|
+
const params = {};
|
|
184
|
+
const where = [];
|
|
185
|
+
if (cid) { where.push('p.companyId = $cid'); params.cid = cid; }
|
|
186
|
+
if (pillar) { where.push('p.pillarId = $pillar'); params.pillar = pillar; }
|
|
187
|
+
if (status) { where.push('p.status = $status'); params.status = status; }
|
|
188
|
+
if (where.length) cypher += ` WHERE ${where.join(' AND ')}`;
|
|
189
|
+
cypher += ` RETURN p.id, p.name, p.phase, p.status, p.pillarId, p.goalId, p.createdAt ORDER BY p.createdAt DESC LIMIT 100`;
|
|
190
|
+
const result = await mgQuery(cypher, params);
|
|
191
|
+
const keys = ['id','name','phase','status','pillarId','goalId','createdAt'];
|
|
192
|
+
const projects = parseRows(result).map(r => rowToObj(r, keys));
|
|
193
|
+
return jsonOk(res, { projects, count: projects.length }, req);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
process.stderr.write(`[project] handleList Memgraph failed, falling back to SQLite: ${e.message}\n`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// SQLite fallback
|
|
151
199
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
cypher += ` RETURN p.id, p.name, p.phase, p.status, p.pillarId, p.goalId, p.createdAt ORDER BY p.createdAt DESC LIMIT 100`;
|
|
160
|
-
const result = await mgQuery(cypher, params);
|
|
161
|
-
const keys = ['id','name','phase','status','pillarId','goalId','createdAt'];
|
|
162
|
-
const projects = parseRows(result).map(r => rowToObj(r, keys));
|
|
163
|
-
jsonOk(res, { projects, count: projects.length }, req);
|
|
164
|
-
} catch (e) {
|
|
165
|
-
jsonErr(res, 500, safeMsg(e), req);
|
|
200
|
+
const all = hboStore?.getProjectsByCompany ? hboStore.getProjectsByCompany(cid) : [];
|
|
201
|
+
let projects = all || [];
|
|
202
|
+
if (pillar) projects = projects.filter(p => p.pillarId === pillar);
|
|
203
|
+
if (status) projects = projects.filter(p => p.status === status);
|
|
204
|
+
return jsonOk(res, { projects, count: projects.length, _source: 'sqlite' }, req);
|
|
205
|
+
} catch (storeErr) {
|
|
206
|
+
return jsonErr(res, 503, `Projects unavailable: Memgraph not connected and SQLite fallback failed: ${storeErr.message}`, req);
|
|
166
207
|
}
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
// ── GET /api/project/:id ──────────────────────────────────────────────────
|
|
170
211
|
async function handleGet(req, res, ctx, projectId) {
|
|
212
|
+
// P3-SQL: Memgraph-first, SQLite fallback
|
|
213
|
+
if (mgQuery) {
|
|
214
|
+
try {
|
|
215
|
+
const projRes = await mgQuery(
|
|
216
|
+
`MATCH (p:HeliosProject {id: $id, companyId: $cid})
|
|
217
|
+
OPTIONAL MATCH (d:ProjectDocument {projectId: p.id})
|
|
218
|
+
RETURN p.id, p.name, p.phase, p.status, p.pillarId, p.goalId, p.createdAt,
|
|
219
|
+
d.purpose, d.approach, d.successCriteria, d.intentAnchor, d.exclusions, d.version, d.updatedAt, d.content`,
|
|
220
|
+
{ id: projectId, cid: ctx.cid || ctx?.cid || '' }
|
|
221
|
+
);
|
|
222
|
+
const row = parseRows(projRes)[0];
|
|
223
|
+
if (!row) return jsonErr(res, 404, 'Project not found', req);
|
|
224
|
+
const keys = ['id','name','phase','status','pillarId','goalId','createdAt','purpose','approach','successCriteria','intentAnchor','exclusions','version','updatedAt','content'];
|
|
225
|
+
return jsonOk(res, rowToObj(row, keys), req);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
process.stderr.write(`[project] handleGet Memgraph failed, falling back to SQLite: ${e.message}\n`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// SQLite fallback
|
|
171
231
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
232
|
+
const p = hboStore?.getProject ? hboStore.getProject(projectId, ctx?.cid || '') : null;
|
|
233
|
+
if (!p) return jsonErr(res, 404, 'Project not found', req);
|
|
234
|
+
// M-2 fix: normalize response shape to match Memgraph path exactly
|
|
235
|
+
const doc = hboStore?.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
|
|
236
|
+
const createdAtMs = typeof p.created_at === 'number' ? p.created_at : (p.createdAt ? new Date(p.createdAt).getTime() : null);
|
|
237
|
+
return jsonOk(res, {
|
|
238
|
+
id: p.id,
|
|
239
|
+
name: p.name ?? null,
|
|
240
|
+
phase: p.phase ?? 'planning',
|
|
241
|
+
status: p.status ?? 'planning',
|
|
242
|
+
pillarId: p.pillarId ?? p.pillar_id ?? null,
|
|
243
|
+
goalId: p.goalId ?? p.goal_id ?? null,
|
|
244
|
+
createdAt: createdAtMs ? new Date(createdAtMs).toISOString() : null,
|
|
245
|
+
purpose: doc?.purpose ?? null,
|
|
246
|
+
approach: doc?.approach ?? null,
|
|
247
|
+
successCriteria: doc?.success_criteria ?? doc?.successCriteria ?? '[]',
|
|
248
|
+
intentAnchor: doc?.intent_anchor ?? doc?.intentAnchor ?? null,
|
|
249
|
+
exclusions: doc?.exclusions ?? '[]',
|
|
250
|
+
version: doc?.version ?? 1,
|
|
251
|
+
updatedAt: doc?.updated_at ? new Date(doc.updated_at).toISOString() : null,
|
|
252
|
+
content: doc?.content ?? null,
|
|
253
|
+
_source: 'sqlite',
|
|
254
|
+
}, req);
|
|
255
|
+
} catch (storeErr) {
|
|
256
|
+
return jsonErr(res, 503, `Project unavailable: ${storeErr.message}`, req);
|
|
186
257
|
}
|
|
187
258
|
}
|
|
188
259
|
|
|
@@ -202,6 +273,26 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
202
273
|
const docId = `pdoc:${projectId}:main`;
|
|
203
274
|
const companyId = bodyCompanyId || ctx?.cid || projectId.split(':')[1] || 'unknown';
|
|
204
275
|
|
|
276
|
+
// C-2 fix: SQLite-only path when Memgraph unavailable
|
|
277
|
+
if (!mgQuery) {
|
|
278
|
+
if (hboStore?.upsertProjectDocument) {
|
|
279
|
+
try {
|
|
280
|
+
const existing = hboStore.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
|
|
281
|
+
const update: Record<string, any> = {
|
|
282
|
+
id: docId, project_id: projectId, company_id: companyId,
|
|
283
|
+
...(existing ? { purpose: existing.purpose, approach: existing.approach, intent_anchor: existing.intent_anchor, success_criteria: existing.success_criteria, exclusions: existing.exclusions, content: existing.content, version: existing.version ?? 1 } : {}),
|
|
284
|
+
[section === 'intentAnchor' ? 'intent_anchor' : section === 'successCriteria' ? 'success_criteria' : section]: String(value),
|
|
285
|
+
version: ((existing?.version ?? 0) + 1),
|
|
286
|
+
updated_at: Date.now(),
|
|
287
|
+
};
|
|
288
|
+
hboStore.upsertProjectDocument(update);
|
|
289
|
+
} catch (storeErr) {
|
|
290
|
+
process.stderr.write(`[project] hboStore.upsertProjectDocument (offline) failed: ${storeErr.message}\n`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return jsonOk(res, { ok: true, section, _source: 'sqlite' }, req, 200);
|
|
294
|
+
}
|
|
295
|
+
|
|
205
296
|
// ── content section: persist block tree + back-extract semantic fields ──
|
|
206
297
|
// When the editor saves the full BlockNote block array, we:
|
|
207
298
|
// 1. Store it as d.content (JSON string) — source of truth for the editor
|
|
@@ -260,6 +351,12 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
260
351
|
{ docId, content: String(value), purpose, approach, intentAnchor }
|
|
261
352
|
);
|
|
262
353
|
if (invalidateContextCache) invalidateContextCache(projectId);
|
|
354
|
+
// C-2: SQLite write-through for content section
|
|
355
|
+
if (hboStore?.upsertProjectDocument) {
|
|
356
|
+
try {
|
|
357
|
+
hboStore.upsertProjectDocument({ id: docId, project_id: projectId, company_id: companyId, content: String(value), purpose, approach, intent_anchor: intentAnchor, updated_at: Date.now() });
|
|
358
|
+
} catch (storeErr) { process.stderr.write(`[project] content write-through failed: ${storeErr.message}\n`); }
|
|
359
|
+
}
|
|
263
360
|
return jsonOk(res, { ok: true, section: 'content' }, req, 200);
|
|
264
361
|
}
|
|
265
362
|
|
|
@@ -296,6 +393,21 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
296
393
|
// proposalId: internal LLM analysis task ID
|
|
297
394
|
jsonOk(res, { ok: true, pduId, proposalId, message: 'Analyzing edit — check back shortly' }, req, 202);
|
|
298
395
|
|
|
396
|
+
// C-2: SQLite write-through for proposal/semantic edit sections
|
|
397
|
+
if (hboStore?.upsertProjectDocument) {
|
|
398
|
+
try {
|
|
399
|
+
const existing = hboStore.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
|
|
400
|
+
const colKey = section === 'intentAnchor' ? 'intent_anchor' : section === 'successCriteria' ? 'success_criteria' : section;
|
|
401
|
+
hboStore.upsertProjectDocument({
|
|
402
|
+
id: docId, project_id: projectId, company_id: companyId,
|
|
403
|
+
...(existing ? { purpose: existing.purpose, approach: existing.approach, intent_anchor: existing.intent_anchor, success_criteria: existing.success_criteria, exclusions: existing.exclusions, content: existing.content, version: existing.version ?? 1 } : {}),
|
|
404
|
+
[colKey]: String(value),
|
|
405
|
+
version: ((existing?.version ?? 0) + 1),
|
|
406
|
+
updated_at: Date.now(),
|
|
407
|
+
});
|
|
408
|
+
} catch (storeErr) { process.stderr.write(`[project] proposal write-through failed: ${storeErr.message}\n`); }
|
|
409
|
+
}
|
|
410
|
+
|
|
299
411
|
// Trigger semantic analysis asynchronously (non-blocking)
|
|
300
412
|
if (semanticUpdater) {
|
|
301
413
|
setImmediate(async () => {
|
|
@@ -480,21 +592,34 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
480
592
|
|
|
481
593
|
// ── GET /api/project/:id/state ────────────────────────────────────────────
|
|
482
594
|
async function handleGetState(req, res, ctx, projectId) {
|
|
595
|
+
// C-1 fix: guard against Memgraph unavailability — return zero-state from SQLite
|
|
596
|
+
if (!mgQuery) {
|
|
597
|
+
try {
|
|
598
|
+
const p = hboStore?.getProject ? hboStore.getProject(projectId, ctx?.cid || '') : null;
|
|
599
|
+
const currentPhase = p?.phase ?? p?.status ?? 'planning';
|
|
600
|
+
return jsonOk(res, { totalCells: 0, closedCells: 0, activeTasks: 0, openDriftSignals: 0, currentPhase, hillProgress: 0, _source: 'sqlite' }, req);
|
|
601
|
+
} catch (storeErr) {
|
|
602
|
+
return jsonOk(res, { totalCells: 0, closedCells: 0, activeTasks: 0, openDriftSignals: 0, currentPhase: 'planning', hillProgress: 0, _source: 'sqlite' }, req);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
483
605
|
try {
|
|
484
606
|
const [cellRes, taskRes, driftRes, docRes] = await Promise.all([
|
|
485
607
|
mgQuery(
|
|
608
|
+
// P3-05: OPTIONAL MATCH so projects without GoalPillars return hillProgress:0 not an error
|
|
609
|
+
// H-6 fix: WHERE gp IS NOT NULL prevents null pillarId matching unrelated ActionCells
|
|
486
610
|
`MATCH (p:HeliosProject {id: $id})
|
|
487
|
-
MATCH (gp:GoalPillar {id: p.pillarId})
|
|
488
|
-
MATCH (cell:ActionCell
|
|
611
|
+
OPTIONAL MATCH (gp:GoalPillar {id: p.pillarId})
|
|
612
|
+
OPTIONAL MATCH (cell:ActionCell) WHERE cell.pillarId = gp.id AND gp IS NOT NULL
|
|
613
|
+
RETURN count(cell) AS total,
|
|
489
614
|
count(CASE WHEN cell.status='closed' THEN 1 END) AS closed`,
|
|
490
|
-
{ id: projectId
|
|
615
|
+
{ id: projectId }
|
|
491
616
|
).catch(() => null),
|
|
492
617
|
mgQuery(
|
|
493
618
|
`MATCH (p:HeliosProject {id: $id})
|
|
494
619
|
MATCH (t:Task {companyId: p.companyId}) WHERE t.task_metadata CONTAINS p.pillarId
|
|
495
620
|
AND t.status IN ['todo','in_progress']
|
|
496
621
|
RETURN count(t) AS active`,
|
|
497
|
-
{ id: projectId
|
|
622
|
+
{ id: projectId }
|
|
498
623
|
).catch(() => null),
|
|
499
624
|
mgQuery(
|
|
500
625
|
`MATCH (sig:AnomalySignal) WHERE sig.id STARTS WITH $prefix AND sig.status='open'
|
|
@@ -503,7 +628,7 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
503
628
|
).catch(() => null),
|
|
504
629
|
mgQuery(
|
|
505
630
|
`MATCH (p:HeliosProject {id: $id}) RETURN p.phase`,
|
|
506
|
-
{ id: projectId
|
|
631
|
+
{ id: projectId }
|
|
507
632
|
).catch(() => null),
|
|
508
633
|
]);
|
|
509
634
|
|
|
@@ -512,16 +637,22 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
|
|
|
512
637
|
const driftRow = parseRows(driftRes)[0];
|
|
513
638
|
const docRow = parseRows(docRes)[0];
|
|
514
639
|
|
|
515
|
-
const totalCells
|
|
516
|
-
const closedCells
|
|
517
|
-
const activeTasks
|
|
518
|
-
const openDriftSignals = Number(driftRow?.[0] ?? driftRow?.['open']
|
|
519
|
-
const currentPhase
|
|
520
|
-
const hillProgress
|
|
640
|
+
const totalCells = Number(cellRow?.[0] ?? cellRow?.['total'] ?? 0);
|
|
641
|
+
const closedCells = Number(cellRow?.[1] ?? cellRow?.['closed'] ?? 0);
|
|
642
|
+
const activeTasks = Number(taskRow?.[0] ?? taskRow?.['active'] ?? 0);
|
|
643
|
+
const openDriftSignals = Number(driftRow?.[0] ?? driftRow?.['open'] ?? 0);
|
|
644
|
+
const currentPhase = docRow?.[0] ?? docRow?.['phase'] ?? 'planning';
|
|
645
|
+
const hillProgress = totalCells > 0 ? Math.round((closedCells / totalCells) * 100) : 0;
|
|
521
646
|
|
|
522
647
|
jsonOk(res, { totalCells, closedCells, activeTasks, openDriftSignals, currentPhase, hillProgress }, req);
|
|
523
648
|
} catch (e) {
|
|
524
|
-
|
|
649
|
+
// Memgraph query failed mid-flight — degrade to SQLite zero-state
|
|
650
|
+
try {
|
|
651
|
+
const p = hboStore?.getProject ? hboStore.getProject(projectId, ctx?.cid || '') : null;
|
|
652
|
+
jsonOk(res, { totalCells: 0, closedCells: 0, activeTasks: 0, openDriftSignals: 0, currentPhase: p?.phase ?? 'planning', hillProgress: 0, _source: 'sqlite' }, req);
|
|
653
|
+
} catch (_) {
|
|
654
|
+
jsonErr(res, 500, safeMsg(e), req);
|
|
655
|
+
}
|
|
525
656
|
}
|
|
526
657
|
}
|
|
527
658
|
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
module.exports = function createRoutinesRouter(handlers) {
|
|
16
16
|
const {
|
|
17
|
+
handleGetRoutines,
|
|
18
|
+
handleCreateRoutine,
|
|
17
19
|
handleGetRoutineTriggers,
|
|
18
20
|
handleCreateRoutineTrigger,
|
|
19
21
|
handleFireWebhookTrigger,
|
|
@@ -30,6 +32,18 @@ module.exports = function createRoutinesRouter(handlers) {
|
|
|
30
32
|
return true;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// P5-01: GET /api/routines — list routines for a company (must be before /:id routes)
|
|
36
|
+
if (method === 'GET' && pathname === '/api/routines') {
|
|
37
|
+
await handleGetRoutines(req, res, ctx);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// P5-02: POST /api/routines — create a new routine
|
|
42
|
+
if (method === 'POST' && pathname === '/api/routines') {
|
|
43
|
+
await handleCreateRoutine(req, res, ctx);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
// PATCH /api/routines/:id (edit routine → creates revision)
|
|
34
48
|
const patchRoutineMatch = pathname.match(/^\/api\/routines\/([^/]+)$/);
|
|
35
49
|
if (method === 'PATCH' && patchRoutineMatch) {
|
package/daemon/routes/tasks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// NOTE: Task status validation is handled in handleUpdateTask (helios-api.js VALID_STATUSES).
|
|
4
|
+
// The canonical valid statuses include: todo, in_progress, done, failed, cancelled, blocked, escalated, in_review.
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* routes/tasks.js — Task-related route dispatch
|
|
@@ -26,6 +27,7 @@ module.exports = function createTasksRouter(handlers) {
|
|
|
26
27
|
handleGetTaskComments,
|
|
27
28
|
handlePostTaskComment,
|
|
28
29
|
handlePatchTask,
|
|
30
|
+
handlePatchTaskPolicy, // P4-07: PATCH /api/tasks/:id/policy
|
|
29
31
|
} = handlers;
|
|
30
32
|
|
|
31
33
|
return async function tasksRoute(req, res, ctx, pathname, method) {
|
|
@@ -89,6 +91,18 @@ module.exports = function createTasksRouter(handlers) {
|
|
|
89
91
|
return true;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
// PATCH /api/tasks/:id/policy — P4-07: set executionPolicy on a task
|
|
95
|
+
const policyMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/policy$/);
|
|
96
|
+
if (method === 'PATCH' && policyMatch) {
|
|
97
|
+
if (handlePatchTaskPolicy) {
|
|
98
|
+
await handlePatchTaskPolicy(req, res, ctx, decodeURIComponent(policyMatch[1]));
|
|
99
|
+
} else {
|
|
100
|
+
res.writeHead(501, { 'Content-Type': 'application/json' });
|
|
101
|
+
res.end(JSON.stringify({ error: 'Policy endpoint not configured' }));
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
return false;
|
|
93
107
|
};
|
|
94
108
|
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* schema-apply.js — Converges the Memgraph graph to the canonical schema.
|
|
4
|
+
*
|
|
5
|
+
* Uses Memgraph MAGE's schema.assert() procedure to atomically create missing
|
|
6
|
+
* indexes and uniqueness constraints, keep existing correct ones, and optionally
|
|
7
|
+
* drop anything that doesn't belong (controlled by dropExisting flag).
|
|
8
|
+
*
|
|
9
|
+
* Composite indexes (multi-property) are applied via separate Cypher statements
|
|
10
|
+
* because schema.assert() only handles single-property label indexes.
|
|
11
|
+
*
|
|
12
|
+
* Called once during daemon startup, after Memgraph connection is established
|
|
13
|
+
* and before any tick processing begins.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* const { applySchema } = require('./schema-apply');
|
|
17
|
+
* await applySchema(mgQuery); // safe for rolling deployments
|
|
18
|
+
* await applySchema(mgQuery, true); // drop_existing — use on fresh installs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { INDEXES, COMPOSITE_INDEXES, UNIQUE_CONSTRAINTS } = require('./schema-definitions');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {Function} mgQuery async (cypher, params?) => { rows, keys }
|
|
25
|
+
* @param {boolean} [dropExisting=false]
|
|
26
|
+
* false = safe for live deployments — adds missing, keeps existing, never drops.
|
|
27
|
+
* true = converge exactly — drops indexes/constraints not in the definition.
|
|
28
|
+
* Use on fresh installs or after intentional schema cleanup.
|
|
29
|
+
*/
|
|
30
|
+
async function applySchema(mgQuery, dropExisting = false) {
|
|
31
|
+
const log = (msg) => console.log('[schema-apply]', msg);
|
|
32
|
+
const warn = (msg) => console.warn('[schema-apply] WARN:', msg);
|
|
33
|
+
|
|
34
|
+
// ── Step 1: schema.assert() for single-property indexes + unique constraints
|
|
35
|
+
log(`Applying schema via schema.assert() (drop_existing=${dropExisting})…`);
|
|
36
|
+
|
|
37
|
+
let assertResult;
|
|
38
|
+
try {
|
|
39
|
+
assertResult = await mgQuery(
|
|
40
|
+
`CALL schema.assert($indices, $unique, {}, $drop)
|
|
41
|
+
YIELD action, label, key, unique
|
|
42
|
+
RETURN action, label, key, unique`,
|
|
43
|
+
{
|
|
44
|
+
indices: INDEXES,
|
|
45
|
+
unique: UNIQUE_CONSTRAINTS,
|
|
46
|
+
drop: dropExisting,
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// schema.assert() is a MAGE procedure — if MAGE is not installed or the
|
|
51
|
+
// version doesn't include the schema module, fall back gracefully to the
|
|
52
|
+
// per-statement legacy approach.
|
|
53
|
+
warn(`schema.assert() unavailable: ${err.message}`);
|
|
54
|
+
warn('Falling back to per-statement legacy migration.');
|
|
55
|
+
return _legacyFallback(mgQuery);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Parse and log results ────────────────────────────────────────────────
|
|
59
|
+
const rows = assertResult?.rows ?? [];
|
|
60
|
+
const counts = { Created: 0, Kept: 0, Dropped: 0 };
|
|
61
|
+
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
// Memgraph returns rows as arrays or objects depending on driver config
|
|
64
|
+
const action = Array.isArray(row) ? row[0] : row.action;
|
|
65
|
+
const label = Array.isArray(row) ? row[1] : row.label;
|
|
66
|
+
const key = Array.isArray(row) ? row[2] : row.key;
|
|
67
|
+
const unique = Array.isArray(row) ? row[3] : row.unique;
|
|
68
|
+
|
|
69
|
+
const kind = unique ? 'constraint' : 'index';
|
|
70
|
+
if (action === 'Created') {
|
|
71
|
+
log(` ✓ Created ${kind}: :${label}(${key})`);
|
|
72
|
+
} else if (action === 'Dropped') {
|
|
73
|
+
log(` ✗ Dropped ${kind}: :${label}(${key})`);
|
|
74
|
+
}
|
|
75
|
+
// Kept entries are silent — too noisy otherwise
|
|
76
|
+
if (counts[action] !== undefined) counts[action]++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
log(`schema.assert() complete — Created: ${counts.Created}, Kept: ${counts.Kept}, Dropped: ${counts.Dropped}`);
|
|
80
|
+
|
|
81
|
+
// ── Step 2: Composite indexes ─────────────────────────────────────────────
|
|
82
|
+
// schema.assert() does not support composite indexes, so we create them
|
|
83
|
+
// individually. Each uses CREATE INDEX ON :Label(propA, propB) with a
|
|
84
|
+
// try/catch to silently skip "already exists" errors.
|
|
85
|
+
let compositeCreated = 0;
|
|
86
|
+
let compositeSkipped = 0;
|
|
87
|
+
|
|
88
|
+
for (const { label, props } of COMPOSITE_INDEXES) {
|
|
89
|
+
const propList = props.join(', ');
|
|
90
|
+
const cypher = `CREATE INDEX ON :${label}(${propList})`;
|
|
91
|
+
try {
|
|
92
|
+
await mgQuery(cypher);
|
|
93
|
+
log(` ✓ Created composite index: :${label}(${propList})`);
|
|
94
|
+
compositeCreated++;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// "already exists" is the expected no-op case
|
|
97
|
+
const msg = err.message ?? '';
|
|
98
|
+
if (/already exists/i.test(msg) || /constraint.*exists/i.test(msg) || /index.*exists/i.test(msg)) {
|
|
99
|
+
compositeSkipped++;
|
|
100
|
+
} else {
|
|
101
|
+
warn(`Composite index :${label}(${propList}) failed: ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (compositeCreated > 0 || compositeSkipped > 0) {
|
|
107
|
+
log(`Composite indexes — Created: ${compositeCreated}, Already existed: ${compositeSkipped}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log('Schema application complete.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Legacy fallback ───────────────────────────────────────────────────────────
|
|
114
|
+
// Used when schema.assert() is unavailable (MAGE not installed or older version).
|
|
115
|
+
// Runs each constraint/index as individual CREATE statements with try/catch.
|
|
116
|
+
// This is the old seven-migration-file behaviour, condensed into one function.
|
|
117
|
+
|
|
118
|
+
async function _legacyFallback(mgQuery) {
|
|
119
|
+
const log = (msg) => console.log('[schema-apply/legacy]', msg);
|
|
120
|
+
const warn = (msg) => console.warn('[schema-apply/legacy] WARN:', msg);
|
|
121
|
+
|
|
122
|
+
log('Running legacy per-statement schema migration…');
|
|
123
|
+
|
|
124
|
+
let created = 0;
|
|
125
|
+
let skipped = 0;
|
|
126
|
+
let failed = 0;
|
|
127
|
+
|
|
128
|
+
async function exec(cypher) {
|
|
129
|
+
try {
|
|
130
|
+
await mgQuery(cypher);
|
|
131
|
+
created++;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const msg = err.message ?? '';
|
|
134
|
+
if (/already exists/i.test(msg) || /constraint.*exists/i.test(msg) || /index.*exists/i.test(msg)) {
|
|
135
|
+
skipped++;
|
|
136
|
+
} else {
|
|
137
|
+
warn(`${cypher} → ${msg}`);
|
|
138
|
+
failed++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Unique constraints
|
|
144
|
+
for (const [label, constraintGroups] of Object.entries(UNIQUE_CONSTRAINTS)) {
|
|
145
|
+
for (const props of constraintGroups) {
|
|
146
|
+
if (props.length === 1) {
|
|
147
|
+
await exec(`CREATE CONSTRAINT ON (n:${label}) ASSERT n.${props[0]} IS UNIQUE`);
|
|
148
|
+
} else {
|
|
149
|
+
// Composite unique — old syntax doesn't support this cleanly; use new syntax
|
|
150
|
+
await exec(`CREATE CONSTRAINT FOR (n:${label}) REQUIRE (${props.map(p => `n.${p}`).join(', ')}) IS UNIQUE`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Single-property indexes
|
|
156
|
+
for (const [label, props] of Object.entries(INDEXES)) {
|
|
157
|
+
for (const prop of props) {
|
|
158
|
+
if (prop === '') {
|
|
159
|
+
await exec(`CREATE INDEX ON :${label}`);
|
|
160
|
+
} else {
|
|
161
|
+
await exec(`CREATE INDEX ON :${label}(${prop})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Composite indexes
|
|
167
|
+
for (const { label, props } of COMPOSITE_INDEXES) {
|
|
168
|
+
await exec(`CREATE INDEX ON :${label}(${props.join(', ')})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
log(`Legacy migration complete — Created: ${created}, Skipped: ${skipped}, Failed: ${failed}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { applySchema };
|