godcrm/pes-core/core/workflows.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

496 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// PES Agentic Workflows — The Pet as Trigger & Face
// ============================================================
// Зверь = лицо и триггер, под ним = настоящие workflow.
//
// Built-in workflows:
// triage — scan CRM for new/urgent items
// daily_summary — aggregate today's events + interactions
// task_cleanup — archive stale notes/reminders
// health_check — monitor PES vitals, flag anomalies
// crm_digest — summarize CRM space activity
//
// Level gates:
// L8 — triage, health_check, daily_summary (read-only + analytics)
// L10 — crm_digest (CRM analytics)
// L15 — task_cleanup, custom workflows (automation)
// ============================================================
const WORKFLOW_DEFS = {
// ── L8: Basic workflows ──────────────────────────────
triage: {
name: 'Triage',
description: 'Сканирует CRM на новые/срочные записи',
levelReq: 8,
cooldownMs: 10 * 60 * 1000, // 10 min
schedule: null, // manual only
async run(ctx) {
const { crm, store } = ctx;
if (!crm) return { ok: false, text: 'CRM не подключена' };
const tables = await crm.getTables();
if (!tables.length) return { ok: false, text: 'Нет таблиц в CRM' };
const results = [];
let totalNew = 0;
for (const table of tables.slice(0, 8)) {
try {
const rows = await crm.getRows(table.id, { limit: 5 });
if (!rows.length) continue;
// Check for recently created/updated rows
const recent = rows.filter(r => {
const created = r.created_at || r.data?.created_at;
if (!created) return false;
const age = Date.now() - new Date(created).getTime();
return age < 24 * 60 * 60 * 1000; // last 24h
});
if (recent.length > 0) {
totalNew += recent.length;
results.push({
table: table.name || table.display_name,
count: recent.length,
sample: recent[0].data || recent[0],
});
}
} catch (_) {}
}
if (totalNew === 0) {
return { ok: true, text: '✅ Всё спокойно — нет новых записей за 24ч', data: { totalNew: 0 } };
}
let text = `📋 Triage: ${totalNew} новых записей за 24ч\n\n`;
for (const r of results) {
text += `📁 ${r.table}: ${r.count} новых\n`;
const sample = r.sample;
const preview = Object.entries(sample)
.filter(([k, v]) => v != null && v !== '' && !k.startsWith('_') && k !== 'id')
.slice(0, 3)
.map(([k, v]) => ` ${k}: ${String(v).slice(0, 60)}`)
.join('\n');
if (preview) text += preview + '\n';
text += '\n';
}
return { ok: true, text, data: { totalNew, tables: results.length } };
},
},
health_check: {
name: 'Health Check',
description: 'Проверяет виталы PES и флагует аномалии',
levelReq: 8,
cooldownMs: 5 * 60 * 1000,
schedule: '*/30 * * * *', // every 30 min
async run(ctx) {
const { store } = ctx;
const stats = store.getStats();
if (!stats) return { ok: false, text: 'Нет данных' };
const alerts = [];
const report = [];
// Vitals check
if (stats.mood < 0.2) alerts.push('⚠️ Настроение критически низкое');
if (stats.energy < 0.15) alerts.push('⚠️ Энергия почти на нуле');
if (stats.hunger > 0.85) alerts.push('🍖 Очень голоден!');
if (stats.loneliness > 0.8) alerts.push('💔 Очень одинок');
// Interaction check
const recent = store.getRecentInteractions(10);
const lastInteraction = recent[0];
if (lastInteraction) {
const age = Date.now() - new Date(lastInteraction.timestamp).getTime();
if (age > 6 * 60 * 60 * 1000) { // 6 hours no interaction
alerts.push('⏰ Нет взаимодействий > 6 часов');
}
}
// Pending reminders check
try {
const pending = store.getPendingReminders();
if (pending.length > 0) {
alerts.push(`📌 ${pending.length} просроченных напоминаний`);
}
} catch (_) {}
// Build report
report.push(`🐾 ${stats.level?.toFixed?.(1) || stats.level} lvl, ${stats.xp} XP`);
report.push(`💚 mood: ${Math.round(stats.mood * 100)}%`);
report.push(`⚡ energy: ${Math.round(stats.energy * 100)}%`);
report.push(`🍖 hunger: ${Math.round(stats.hunger * 100)}%`);
report.push(`🧠 interactions: ${stats.interactions_total}`);
const hasAlerts = alerts.length > 0;
let text = hasAlerts
? `🚨 Health Check — ${alerts.length} предупреждений:\n\n${alerts.join('\n')}\n\n${report.join('\n')}`
: `✅ Health Check — всё в норме\n\n${report.join('\n')}`;
return { ok: true, text, data: { alerts: alerts.length, vitals: { mood: stats.mood, energy: stats.energy, hunger: stats.hunger } } };
},
},
// ── L12: Analytics workflows ─────────────────────────
daily_summary: {
name: 'Daily Summary',
description: 'Итоги дня: взаимодействия, XP, эмоции, заметки',
levelReq: 8,
cooldownMs: 60 * 60 * 1000, // 1 hour
schedule: '0 21 * * *', // every day at 21:00
async run(ctx) {
const { store } = ctx;
const stats = store.getStats();
if (!stats) return { ok: false, text: 'Нет данных' };
// Today's interactions
const allRecent = store.getRecentInteractions(100);
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayInteractions = allRecent.filter(i => new Date(i.timestamp) >= todayStart);
// XP earned today
let todayXP = 0;
try {
const xpRows = store.db.prepare(`
SELECT SUM(amount) as total FROM xp_log
WHERE timestamp >= ?
`).get(todayStart.toISOString());
todayXP = xpRows?.total || 0;
} catch (_) {}
// Notes created today
let todayNotes = 0;
try {
const notesRow = store.db.prepare(`
SELECT COUNT(*) as c FROM owner_notes
WHERE is_deleted = 0 AND created_at >= ?
`).get(todayStart.toISOString());
todayNotes = notesRow?.c || 0;
} catch (_) {}
// Emotion distribution today
const emotionCounts = {};
for (const i of todayInteractions) {
const em = i.emotion_after || 'unknown';
emotionCounts[em] = (emotionCounts[em] || 0) + 1;
}
const topEmotions = Object.entries(emotionCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([e, c]) => `${e} (${c})`);
// Unique actors
const actors = new Set(todayInteractions.map(i => i.actor).filter(Boolean));
let text = `📊 Итоги дня:\n\n`;
text += `🐾 Взаимодействий: ${todayInteractions.length}\n`;
text += `⭐ XP заработано: +${todayXP}\n`;
text += `📝 Заметок создано: ${todayNotes}\n`;
text += `👥 Собеседников: ${actors.size}\n`;
if (topEmotions.length) {
text += `😊 Эмоции: ${topEmotions.join(', ')}\n`;
}
text += `\n📈 Уровень: ${stats.level?.toFixed?.(1) || stats.level}`;
text += `\n💚 Настроение: ${Math.round(stats.mood * 100)}%`;
return { ok: true, text, data: { interactions: todayInteractions.length, xp: todayXP, notes: todayNotes } };
},
},
crm_digest: {
name: 'CRM Digest',
description: 'Обзор активности в CRM пространстве',
levelReq: 10,
cooldownMs: 30 * 60 * 1000,
schedule: null,
async run(ctx) {
const { crm } = ctx;
if (!crm) return { ok: false, text: 'CRM не подключена' };
const tables = await crm.getTables();
if (!tables.length) return { ok: false, text: 'Нет таблиц' };
let text = `📊 CRM Digest — ${tables.length} таблиц\n\n`;
let totalRows = 0;
for (const table of tables.slice(0, 12)) {
try {
const rows = await crm.getRows(table.id, { limit: 1 });
const name = table.name || table.display_name || `#${table.id}`;
// Get actual count via a COUNT query approximation
const allRows = await crm.getRows(table.id, { limit: 100 });
const count = allRows.length;
totalRows += count;
text += `📁 ${name}: ${count}${count >= 100 ? '+' : ''} записей\n`;
} catch (_) {}
}
text += `\n📈 Всего: ~${totalRows} записей в ${tables.length} таблицах`;
return { ok: true, text, data: { tables: tables.length, totalRows } };
},
},
// ── L20: Automation workflows ────────────────────────
task_cleanup: {
name: 'Task Cleanup',
description: 'Архивирует старые заметки и просроченные напоминания',
levelReq: 15,
cooldownMs: 60 * 60 * 1000,
schedule: '0 3 * * *', // 3 AM daily
async run(ctx) {
const { store } = ctx;
let cleaned = 0;
// Archive delivered reminders older than 7 days
try {
const old = store.db.prepare(`
SELECT id FROM reminders
WHERE delivered = 1 AND delivered_at < datetime('now', '-7 days')
`).all();
if (old.length > 0) {
store.db.prepare(`
DELETE FROM reminders
WHERE delivered = 1 AND delivered_at < datetime('now', '-7 days')
`).run();
cleaned += old.length;
}
} catch (_) {}
// Count stale notes (>30 days, unpinned)
let staleNotes = 0;
try {
const stale = store.db.prepare(`
SELECT COUNT(*) as c FROM owner_notes
WHERE is_deleted = 0 AND pinned = 0 AND created_at < datetime('now', '-30 days')
`).get();
staleNotes = stale?.c || 0;
} catch (_) {}
let text = `🧹 Cleanup:\n`;
text += ` 🗑 Удалено доставленных напоминаний: ${cleaned}\n`;
if (staleNotes > 0) {
text += ` 📝 Старых заметок (>30д): ${staleNotes} — можно архивировать\n`;
}
text += cleaned === 0 && staleNotes === 0 ? '\n✅ Всё чисто!' : '';
return { ok: true, text, data: { cleaned, staleNotes } };
},
},
};
// ── Workflow Engine ──────────────────────────────────────
export class WorkflowEngine {
constructor({ store, crm, pes }) {
this.store = store;
this.crm = crm;
this.pes = pes;
this._lastRun = {}; // { workflowId: timestamp }
this._scheduledTimers = [];
this._firedThisMinute = new Set();
this._onAutoRun = null;
this.ensureTable();
}
// ── List available workflows for current level ────────
list(currentLevel) {
return Object.entries(WORKFLOW_DEFS)
.filter(([_, def]) => def.levelReq <= Math.floor(currentLevel))
.map(([id, def]) => ({
id,
name: def.name,
description: def.description,
levelReq: def.levelReq,
schedule: def.schedule || 'manual',
lastRun: this._lastRun[id] || null,
}));
}
// ── Run a workflow by ID ──────────────────────────────
async run(workflowId, currentLevel) {
const def = WORKFLOW_DEFS[workflowId];
if (!def) return { ok: false, text: `❌ Workflow "${workflowId}" не найден` };
if (def.levelReq > Math.floor(currentLevel)) {
return { ok: false, text: `🔒 ${def.name} разблокируется на уровне ${def.levelReq}` };
}
// Cooldown check
const lastRun = this._lastRun[workflowId];
if (lastRun && (Date.now() - lastRun) < def.cooldownMs) {
const waitSec = Math.ceil((def.cooldownMs - (Date.now() - lastRun)) / 1000);
return { ok: false, text: `${def.name} — cooldown ${waitSec}с` };
}
// Execute
const startMs = Date.now();
try {
const ctx = { store: this.store, crm: this.crm, pes: this.pes };
const result = await def.run(ctx);
this._lastRun[workflowId] = Date.now();
// Log to DB
this._logRun(workflowId, result.ok, Date.now() - startMs, result.data);
// Trigger PES event
if (this.pes) {
this.pes.event({
type: 'workflow_completed',
from: 'system',
data: { workflow: workflowId, success: result.ok },
});
}
return result;
} catch (err) {
this._logRun(workflowId, false, Date.now() - startMs, { error: err.message });
return { ok: false, text: `${def.name} ошибка: ${err.message}` };
}
}
// ── Schedule-based auto-run (accurate time-based) ─────
startScheduler(currentLevel) {
this.stopScheduler();
this._schedulerLevel = currentLevel;
// Check every 60 seconds if any scheduled workflow should fire
this._schedulerTimer = setInterval(() => {
this._tickScheduler();
}, 60 * 1000);
// Run first tick immediately
this._tickScheduler();
console.log('[WORKFLOW] Scheduler started, level', Math.floor(currentLevel));
}
_tickScheduler() {
const now = new Date();
const currentMinute = now.getMinutes();
const currentHour = now.getHours();
// Clear fired set each tick (fires every 60s, so previous minute's keys are stale)
this._firedThisMinute.clear();
for (const [id, def] of Object.entries(WORKFLOW_DEFS)) {
if (!def.schedule) continue;
const parsed = this._parseCron(def.schedule);
if (!parsed) continue;
// Check if current time matches the cron pattern
if (!this._cronMatches(parsed, currentMinute, currentHour)) continue;
// Prevent double-fire within same minute
const key = `${id}_${currentHour}_${currentMinute}`;
if (this._firedThisMinute.has(key)) continue;
// Level check
let level = this._schedulerLevel;
try {
const stats = this.store.getStats();
if (stats) level = stats.level;
} catch (_) {}
if (def.levelReq > Math.floor(level)) continue;
this._firedThisMinute.add(key);
console.log(`[WORKFLOW] Auto-running ${id} (matched ${def.schedule})`);
this.run(id, level).then(result => {
// Notify via onAutoRun callback if set
if (this._onAutoRun && result.ok) {
this._onAutoRun(id, result);
}
}).catch(err => {
console.error(`[WORKFLOW] Scheduled ${id} error:`, err.message);
});
}
}
// Set callback for auto-run notifications (telegram sends message)
onAutoRun(callback) {
this._onAutoRun = callback;
}
stopScheduler() {
if (this._schedulerTimer) clearInterval(this._schedulerTimer);
this._schedulerTimer = null;
this._scheduledTimers = [];
}
// ── Get run history ───────────────────────────────────
getHistory(limit = 10) {
try {
return this.store.db.prepare(`
SELECT * FROM workflow_runs ORDER BY id DESC LIMIT ?
`).all(limit);
} catch (_) {
return [];
}
}
// ── Internal: log run to DB ───────────────────────────
_logRun(workflowId, success, durationMs, data) {
try {
this.store.db.prepare(`
INSERT INTO workflow_runs (workflow_id, success, duration_ms, result_data)
VALUES (?, ?, ?, ?)
`).run(workflowId, success ? 1 : 0, durationMs, data ? JSON.stringify(data) : null);
} catch (_) {
// Table might not exist yet on first run
}
}
// ── Internal: parse cron expression ──────────────────
_parseCron(schedule) {
// Supports: "*/N * * * *", "M H * * *"
const parts = schedule.trim().split(/\s+/);
if (parts.length < 5) return null;
const [minPart, hourPart] = parts;
return { minPart, hourPart };
}
_cronMatches(parsed, currentMin, currentHour) {
const { minPart, hourPart } = parsed;
// Check minute
if (minPart.startsWith('*/')) {
const interval = parseInt(minPart.slice(2));
if (isNaN(interval) || currentMin % interval !== 0) return false;
} else if (minPart !== '*') {
if (parseInt(minPart) !== currentMin) return false;
}
// Check hour
if (hourPart.startsWith('*/')) {
const interval = parseInt(hourPart.slice(2));
if (isNaN(interval) || currentHour % interval !== 0) return false;
} else if (hourPart !== '*') {
if (parseInt(hourPart) !== currentHour) return false;
}
return true;
}
// ── Ensure workflow_runs table exists ───────────────
ensureTable() {
try {
this.store.db.prepare(`
CREATE TABLE IF NOT EXISTS workflow_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id TEXT NOT NULL,
success INTEGER DEFAULT 1,
duration_ms INTEGER DEFAULT 0,
result_data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
} catch (_) {}
}
}
export { WORKFLOW_DEFS };
export default WorkflowEngine;