@flame0510/project-aether 1.1.9
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/README.md +150 -0
- package/agent-templates/README.md +37 -0
- package/agent-templates/argus/AGENTS.md +52 -0
- package/agent-templates/argus/IDENTITY.md +7 -0
- package/agent-templates/argus/MEMORY.md +9 -0
- package/agent-templates/argus/SOUL.md +24 -0
- package/agent-templates/argus/TOOLS.md +8 -0
- package/agent-templates/atlas/AGENTS.md +47 -0
- package/agent-templates/atlas/HEARTBEAT.md +10 -0
- package/agent-templates/atlas/IDENTITY.md +6 -0
- package/agent-templates/atlas/MEMORY.md +9 -0
- package/agent-templates/atlas/SOUL.md +21 -0
- package/agent-templates/atlas/TOOLS.md +4 -0
- package/agent-templates/base-image/.trigger +0 -0
- package/agent-templates/base-image/Dockerfile +90 -0
- package/agent-templates/base-image/entrypoint.sh +37 -0
- package/agent-templates/custom/AGENTS.md +29 -0
- package/agent-templates/custom/BOOTSTRAP.md +56 -0
- package/agent-templates/custom/MEMORY.md +8 -0
- package/agent-templates/prometheus/AGENTS.md +37 -0
- package/agent-templates/prometheus/IDENTITY.md +6 -0
- package/agent-templates/prometheus/MEMORY.md +9 -0
- package/agent-templates/prometheus/SOUL.md +21 -0
- package/agent-templates/prometheus/TOOLS.md +4 -0
- package/app/agents/ChannelManager.tsx +465 -0
- package/app/agents/ImageDownloadBanner.tsx +159 -0
- package/app/agents/PageClient.tsx +1285 -0
- package/app/agents/create/PageClient.tsx +437 -0
- package/app/agents/create/page.tsx +126 -0
- package/app/agents/loading.tsx +14 -0
- package/app/agents/page.tsx +5 -0
- package/app/api/agents/[id]/backup/route.ts +169 -0
- package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
- package/app/api/agents/[id]/channels/route.ts +14 -0
- package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
- package/app/api/agents/[id]/lifecycle/route.ts +94 -0
- package/app/api/agents/[id]/recreate/route.ts +199 -0
- package/app/api/agents/[id]/restart/route.ts +33 -0
- package/app/api/agents/[id]/restore/route.ts +81 -0
- package/app/api/agents/[id]/route.ts +280 -0
- package/app/api/agents/channels-summary/route.ts +39 -0
- package/app/api/agents/config-json/route.ts +58 -0
- package/app/api/agents/create/route.ts +488 -0
- package/app/api/agents/download-image/route.ts +32 -0
- package/app/api/agents/image-status/route.ts +126 -0
- package/app/api/agents/route.ts +213 -0
- package/app/api/agents/token/route.ts +94 -0
- package/app/api/agents-active/route.ts +190 -0
- package/app/api/agents-config/route.ts +629 -0
- package/app/api/alerts/smoke/route.ts +26 -0
- package/app/api/aliases/add/route.ts +28 -0
- package/app/api/aliases/remove/route.ts +27 -0
- package/app/api/assistant/route.ts +204 -0
- package/app/api/auth/check/route.ts +38 -0
- package/app/api/auth/login/route.ts +85 -0
- package/app/api/auth/logout/route.ts +7 -0
- package/app/api/auth/status/route.ts +33 -0
- package/app/api/config/env/route.ts +132 -0
- package/app/api/config/restart/route.ts +26 -0
- package/app/api/containers/route.ts +75 -0
- package/app/api/cost-override/route.ts +46 -0
- package/app/api/costs/route.ts +84 -0
- package/app/api/credentials/[id]/reveal/route.ts +21 -0
- package/app/api/credentials/[id]/route.ts +52 -0
- package/app/api/credentials/[id]/sync/route.ts +58 -0
- package/app/api/credentials/detect/route.ts +246 -0
- package/app/api/credentials/route.ts +33 -0
- package/app/api/crons/[id]/route.ts +69 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/crons/runs/route.ts +31 -0
- package/app/api/debug/route.ts +11 -0
- package/app/api/envcheck/route.ts +18 -0
- package/app/api/events/route.ts +24 -0
- package/app/api/gateway/agent/route.ts +154 -0
- package/app/api/gateway/provider/balance/route.ts +38 -0
- package/app/api/gateway/provider/keys.ts +38 -0
- package/app/api/gateway/provider/oauth/route.ts +304 -0
- package/app/api/gateway/provider/route.ts +209 -0
- package/app/api/gateway/route.ts +223 -0
- package/app/api/gateway/sync.ts +189 -0
- package/app/api/lineage/route.ts +54 -0
- package/app/api/memory-context/route.ts +15 -0
- package/app/api/metrics/route.ts +35 -0
- package/app/api/models/route.ts +77 -0
- package/app/api/plugins/route.js +37 -0
- package/app/api/provider/upstream.ts +142 -0
- package/app/api/provider/v1/chat/completions/route.ts +146 -0
- package/app/api/provider/v1/models/route.ts +167 -0
- package/app/api/provider-usage/route.ts +629 -0
- package/app/api/session/route.ts +39 -0
- package/app/api/sessions/[id]/route.ts +38 -0
- package/app/api/sessions/route.ts +23 -0
- package/app/api/setup/agent-image/route.ts +102 -0
- package/app/api/setup/password/route.ts +111 -0
- package/app/api/setup/restart/route.ts +76 -0
- package/app/api/skills/delete/route.js +105 -0
- package/app/api/skills/promote/route.js +129 -0
- package/app/api/skills/route.js +203 -0
- package/app/api/skills/save/route.js +142 -0
- package/app/api/stats/route.ts +34 -0
- package/app/api/stats-since/route.ts +20 -0
- package/app/api/stream/route.ts +70 -0
- package/app/api/system-health/route.ts +233 -0
- package/app/api/tool-calls/route.ts +24 -0
- package/app/api/tools-config/route.ts +163 -0
- package/app/api/update-check/route.ts +127 -0
- package/app/api/version/route.ts +25 -0
- package/app/api/wizard/complete/route.ts +32 -0
- package/app/api/wizard/reset/route.ts +28 -0
- package/app/api/wizard/status/route.ts +28 -0
- package/app/api/workspace/route.ts +392 -0
- package/app/api/workspace/stream/route.ts +136 -0
- package/app/components/AppShell.tsx +33 -0
- package/app/components/AuthGuard.tsx +91 -0
- package/app/components/CostBreakdown.tsx +37 -0
- package/app/components/DashboardHeader.tsx +73 -0
- package/app/components/DashboardLayout.tsx +76 -0
- package/app/components/DashboardToolbar.tsx +61 -0
- package/app/components/FloatingActions.tsx +33 -0
- package/app/components/Icons.tsx +78 -0
- package/app/components/LineageGraphPage.tsx +142 -0
- package/app/components/LiveFeed.tsx +48 -0
- package/app/components/MobileBottomNav.tsx +84 -0
- package/app/components/ModelPickerModal.tsx +94 -0
- package/app/components/PasswordInput.tsx +86 -0
- package/app/components/PulseChat.tsx +362 -0
- package/app/components/Rev4aLoader.tsx +48 -0
- package/app/components/SessionDrawer.tsx +263 -0
- package/app/components/SessionTopology.tsx +289 -0
- package/app/components/Sidebar.tsx +294 -0
- package/app/components/Skeleton.tsx +133 -0
- package/app/components/SystemCockpit.tsx +169 -0
- package/app/components/VersionBanner.tsx +105 -0
- package/app/components/WizardButton.tsx +30 -0
- package/app/components/ui/Badge.tsx +30 -0
- package/app/components/ui/Button.tsx +59 -0
- package/app/components/ui/ConfirmModal.tsx +46 -0
- package/app/components/ui/FilterBar.tsx +33 -0
- package/app/components/ui/Input.tsx +25 -0
- package/app/components/ui/ItemList.tsx +83 -0
- package/app/components/ui/LoadingSpinner.tsx +30 -0
- package/app/components/ui/Metric.tsx +22 -0
- package/app/components/ui/Modal.tsx +42 -0
- package/app/components/ui/Page.tsx +18 -0
- package/app/components/ui/Pill.tsx +6 -0
- package/app/components/ui/PropertyList.tsx +24 -0
- package/app/components/ui/Select.tsx +26 -0
- package/app/components/ui/StatusCard.tsx +32 -0
- package/app/components/ui/Surface.tsx +14 -0
- package/app/components/ui/Tabs.tsx +55 -0
- package/app/components/ui/TemplateOption.tsx +59 -0
- package/app/components/ui/Toast.tsx +44 -0
- package/app/components/ui/index.ts +19 -0
- package/app/components/ui/tokens.ts +18 -0
- package/app/config/PageClient.tsx +293 -0
- package/app/config/loading.tsx +10 -0
- package/app/config/page.tsx +7 -0
- package/app/containers/ContainersClient.tsx +129 -0
- package/app/containers/loading.tsx +21 -0
- package/app/containers/page.tsx +5 -0
- package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
- package/app/containers/terminal/[id]/page.tsx +6 -0
- package/app/credentials/PageClient.tsx +598 -0
- package/app/credentials/loading.tsx +10 -0
- package/app/credentials/page.tsx +7 -0
- package/app/crons/PageClient.tsx +538 -0
- package/app/crons/loading.tsx +10 -0
- package/app/crons/page.tsx +5 -0
- package/app/design-system.ts +57 -0
- package/app/gateway/PageClient.tsx +1048 -0
- package/app/gateway/loading.tsx +24 -0
- package/app/gateway/page.tsx +5 -0
- package/app/globals.css +1474 -0
- package/app/layout.tsx +45 -0
- package/app/lib/models-context.tsx +41 -0
- package/app/lineage/loading.tsx +10 -0
- package/app/lineage/page.tsx +13 -0
- package/app/loading.tsx +16 -0
- package/app/login/page.tsx +160 -0
- package/app/memory/MemoryContextPageClient.tsx +196 -0
- package/app/memory/loading.tsx +10 -0
- package/app/memory/page.tsx +8 -0
- package/app/page.tsx +31 -0
- package/app/plugins/PageClient.tsx +296 -0
- package/app/plugins/loading.tsx +10 -0
- package/app/plugins/page.tsx +5 -0
- package/app/setup/PageClient.tsx +227 -0
- package/app/setup/loading.tsx +21 -0
- package/app/setup/page.tsx +7 -0
- package/app/skills/PageClient.tsx +499 -0
- package/app/skills/loading.tsx +10 -0
- package/app/skills/page.tsx +5 -0
- package/app/tools/ToolsPageClient.tsx +308 -0
- package/app/tools/loading.tsx +10 -0
- package/app/tools/page.tsx +62 -0
- package/app/tools/tools-catalog.ts +141 -0
- package/app/wizard/PageClient.tsx +485 -0
- package/app/wizard/icons.tsx +89 -0
- package/app/wizard/loading.tsx +24 -0
- package/app/wizard/page.tsx +7 -0
- package/app/wizard/storage.ts +33 -0
- package/app/wizard/useWizard.ts +101 -0
- package/app/workspace/WorkspaceClient.tsx +633 -0
- package/app/workspace/loading.tsx +36 -0
- package/app/workspace/page.tsx +8 -0
- package/bin/docker-entrypoint.sh +21 -0
- package/bin/postinstall.js +20 -0
- package/bin/rev4a.js +696 -0
- package/daemon.js +783 -0
- package/docs/ARCHITECTURE.md +557 -0
- package/docs/CONTAINER-TERMINAL.md +280 -0
- package/docs/DESIGN-SYSTEM.md +99 -0
- package/docs/FRONTEND-ARCHITECTURE.md +119 -0
- package/docs/REV4A.md +504 -0
- package/docs/dev/API-REFERENCE.md +1835 -0
- package/docs/dev/DATABASE.md +224 -0
- package/docs/dev/GATEWAY.md +300 -0
- package/docs/dev/PROVIDERS.md +229 -0
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
- package/docs/dev/WORKSPACE.md +199 -0
- package/docs/rag/DATA-FRESHNESS.md +109 -0
- package/docs/rag/GLOSSARY.md +125 -0
- package/docs/rag/REV4A-OVERVIEW.md +135 -0
- package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
- package/lib/agent-setup.ts +130 -0
- package/lib/alerts.ts +115 -0
- package/lib/apiFetch.ts +7 -0
- package/lib/auth.ts +28 -0
- package/lib/billing.ts +100 -0
- package/lib/buildAgentImage.ts +346 -0
- package/lib/channelManager.ts +386 -0
- package/lib/container.ts +60 -0
- package/lib/credentials/db.ts +86 -0
- package/lib/credentials/delivery.ts +448 -0
- package/lib/credentials/detect.ts +149 -0
- package/lib/credentials/hash.ts +9 -0
- package/lib/credentials/providers.ts +110 -0
- package/lib/credentials/vault.ts +195 -0
- package/lib/db-bootstrap.d.ts +3 -0
- package/lib/db-bootstrap.mjs +144 -0
- package/lib/db.ts +25 -0
- package/lib/docker-utils.ts +60 -0
- package/lib/hooks/useDashboard.ts +264 -0
- package/lib/hooks/useRev4aTimezone.ts +28 -0
- package/lib/hooks/useScreenshot.ts +30 -0
- package/lib/memory-context.ts +385 -0
- package/lib/model-catalogue.ts +131 -0
- package/lib/model-pricing.ts +63 -0
- package/lib/openclaw-cron.ts +350 -0
- package/lib/patterns/ApiAdapter.ts +73 -0
- package/lib/patterns/ApiClient.ts +65 -0
- package/lib/patterns/EventBus.ts +113 -0
- package/lib/patterns/FilterStrategy.ts +79 -0
- package/lib/patterns/SessionFactory.ts +117 -0
- package/lib/patterns/sessionPresentation.ts +117 -0
- package/lib/provider-balance.ts +163 -0
- package/lib/requireAuth.tsx +38 -0
- package/lib/rev4a-auth.d.ts +3 -0
- package/lib/rev4a-auth.js +36 -0
- package/lib/rev4a-paths.ts +145 -0
- package/lib/timezone.ts +46 -0
- package/lib/types/index.ts +96 -0
- package/lib/utils/format.ts +82 -0
- package/lineage.js +56 -0
- package/model-pricing.json +482 -0
- package/models.config.json +844 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +7 -0
- package/package.json +82 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.svg +58 -0
- package/public/icon-192-maskable.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512-maskable.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +38 -0
- package/public/rev4a-logo.png +0 -0
- package/public/rev4a-logo.svg +58 -0
- package/rev4a-rules/AGENTS.md +37 -0
- package/scripts/add-auth.cjs +185 -0
- package/scripts/backup.sh +66 -0
- package/scripts/check-providers.mjs +143 -0
- package/scripts/docker/traefik/docker-compose.yml +32 -0
- package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
- package/scripts/docker/traefik/traefik.yml +37 -0
- package/scripts/remove-auth-profile.js +56 -0
- package/scripts/restore.sh +82 -0
- package/terminal-ws-server.js +139 -0
- package/tsconfig.json +42 -0
package/daemon.js
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rev4a Daemon — SQLite event logger
|
|
4
|
+
* Polls OpenClaw sessions every 30s, writes events to SQLite.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const { spawnSync, execSync } = require('child_process');
|
|
12
|
+
const Database = require('better-sqlite3');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const DB_PATH = process.env.REV4A_DB || path.join(os.homedir(), '.config', 'rev4a', 'data', 'events.db');
|
|
16
|
+
const POLL_INTERVAL_MS = 30_000;
|
|
17
|
+
const POLL_INTERVAL_ACTIVE_MS = 15_000; // 15s when working sessions detected
|
|
18
|
+
|
|
19
|
+
// Cost rates per 1M tokens (separate in/out pricing)
|
|
20
|
+
const MODEL_PRICING = {
|
|
21
|
+
'claude-sonnet-4': { in: 3.00, out: 15.00 },
|
|
22
|
+
'claude-opus-4': { in: 15.00, out: 75.00 },
|
|
23
|
+
'gpt-5-mini': { in: 0.15, out: 0.60 },
|
|
24
|
+
'codex': { in: 3.00, out: 15.00 },
|
|
25
|
+
'gemini': { in: 0.075, out: 0.30 },
|
|
26
|
+
'flash': { in: 0.075, out: 0.30 },
|
|
27
|
+
'deepseek': { in: 0.55, out: 2.19 },
|
|
28
|
+
'default': { in: 3.00, out: 15.00 },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Alias diretti per modelli che non matchano per substring
|
|
32
|
+
const MODEL_ALIASES = {
|
|
33
|
+
'cheap': 'flash',
|
|
34
|
+
'fast': 'claude-sonnet-4',
|
|
35
|
+
'big': 'claude-opus-4',
|
|
36
|
+
'coder': 'codex',
|
|
37
|
+
'pro': 'gemini',
|
|
38
|
+
'reason': 'deepseek',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
function estimateCost(model, tokensIn, tokensOut) {
|
|
43
|
+
if (!tokensIn && !tokensOut) return 0;
|
|
44
|
+
const m = (model || '').toLowerCase();
|
|
45
|
+
// Check direct aliases first (e.g. 'cheap' → 'flash')
|
|
46
|
+
const aliasKey = Object.keys(MODEL_ALIASES).find(a => m === a || m.endsWith('/' + a));
|
|
47
|
+
const resolved = aliasKey ? MODEL_ALIASES[aliasKey] : null;
|
|
48
|
+
const key = resolved
|
|
49
|
+
|| Object.keys(MODEL_PRICING).find(k => k !== 'default' && m.includes(k))
|
|
50
|
+
|| 'default';
|
|
51
|
+
const p = MODEL_PRICING[key];
|
|
52
|
+
return ((tokensIn || 0) / 1_000_000 * p.in) + ((tokensOut || 0) / 1_000_000 * p.out);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── DB Setup ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
let db;
|
|
58
|
+
try {
|
|
59
|
+
db = new Database(DB_PATH);
|
|
60
|
+
db.pragma('journal_mode = WAL');
|
|
61
|
+
db.pragma('synchronous = NORMAL');
|
|
62
|
+
log('✅ Database connected, WAL mode active');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(`[FATAL] Cannot open DB at ${DB_PATH}: ${err.message}`);
|
|
65
|
+
console.error(`Verify: path exists, permissions on ${path.dirname(DB_PATH)}/, disk space`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
process.on('uncaughtException', (err) => {
|
|
70
|
+
console.error(`[UNCAUGHT] ${err.message}\n${err.stack}`);
|
|
71
|
+
console.error('Daemon will attempt restart via watchdog cron');
|
|
72
|
+
// NON uscire — il watchdog lo vede da healthcheck
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
db.exec(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS system_metrics (
|
|
77
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
78
|
+
ts INTEGER NOT NULL,
|
|
79
|
+
cpu_percent REAL,
|
|
80
|
+
ram_used_mb INTEGER,
|
|
81
|
+
ram_total_mb INTEGER,
|
|
82
|
+
disk_used_gb REAL,
|
|
83
|
+
disk_total_gb REAL,
|
|
84
|
+
load_avg_1m REAL
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_ts ON system_metrics(ts);
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
db.exec(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
91
|
+
session_id TEXT PRIMARY KEY,
|
|
92
|
+
parent_id TEXT,
|
|
93
|
+
label TEXT,
|
|
94
|
+
model TEXT,
|
|
95
|
+
tokens_in INTEGER DEFAULT 0,
|
|
96
|
+
tokens_out INTEGER DEFAULT 0,
|
|
97
|
+
cost_usd REAL DEFAULT 0,
|
|
98
|
+
status TEXT DEFAULT 'idle',
|
|
99
|
+
task_preview TEXT,
|
|
100
|
+
started_at INTEGER,
|
|
101
|
+
ended_at INTEGER,
|
|
102
|
+
updated_at INTEGER,
|
|
103
|
+
trello_card_url TEXT
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
107
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
108
|
+
ts INTEGER NOT NULL,
|
|
109
|
+
session_id TEXT NOT NULL,
|
|
110
|
+
type TEXT NOT NULL,
|
|
111
|
+
data TEXT
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at);
|
|
116
|
+
`);
|
|
117
|
+
|
|
118
|
+
// Add trello_card_url column if it doesn't exist (migration)
|
|
119
|
+
try {
|
|
120
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN trello_card_url TEXT`);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Column already exists — ignore
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Fix 1 — Add ended_at column (migration safe)
|
|
126
|
+
try {
|
|
127
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN ended_at INTEGER`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// Column already exists — ignore
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Prepared statements
|
|
133
|
+
const upsertSession = db.prepare(`
|
|
134
|
+
INSERT INTO sessions (session_id, parent_id, label, model, tokens_in, tokens_out, cost_usd, status, task_preview, started_at, ended_at, updated_at, trello_card_url)
|
|
135
|
+
VALUES (@session_id, @parent_id, @label, @model, @tokens_in, @tokens_out, @cost_usd, @status, @task_preview, @started_at, @ended_at, @updated_at, @trello_card_url)
|
|
136
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
137
|
+
model = excluded.model,
|
|
138
|
+
tokens_in = excluded.tokens_in,
|
|
139
|
+
tokens_out = excluded.tokens_out,
|
|
140
|
+
cost_usd = excluded.cost_usd,
|
|
141
|
+
status = excluded.status,
|
|
142
|
+
-- Only update updated_at if tokens actually changed (real activity)
|
|
143
|
+
updated_at = CASE
|
|
144
|
+
WHEN excluded.tokens_in != sessions.tokens_in OR excluded.tokens_out != sessions.tokens_out
|
|
145
|
+
OR excluded.status != sessions.status
|
|
146
|
+
THEN excluded.updated_at
|
|
147
|
+
ELSE sessions.updated_at
|
|
148
|
+
END,
|
|
149
|
+
ended_at = CASE
|
|
150
|
+
WHEN excluded.ended_at IS NOT NULL THEN excluded.ended_at
|
|
151
|
+
ELSE sessions.ended_at
|
|
152
|
+
END,
|
|
153
|
+
trello_card_url = COALESCE(excluded.trello_card_url, sessions.trello_card_url),
|
|
154
|
+
-- Update parent_id only if the incoming one is better (not null and not equal to main default)
|
|
155
|
+
parent_id = CASE
|
|
156
|
+
WHEN excluded.parent_id IS NOT NULL AND excluded.parent_id != sessions.parent_id
|
|
157
|
+
AND (sessions.parent_id IS NULL OR sessions.parent_id LIKE '%:main')
|
|
158
|
+
THEN excluded.parent_id
|
|
159
|
+
ELSE sessions.parent_id
|
|
160
|
+
END,
|
|
161
|
+
-- Update label only if the current one is the raw key (not a declared name)
|
|
162
|
+
label = CASE
|
|
163
|
+
WHEN sessions.label IS NULL OR sessions.label LIKE 'agent:%'
|
|
164
|
+
THEN excluded.label
|
|
165
|
+
ELSE sessions.label
|
|
166
|
+
END
|
|
167
|
+
`);
|
|
168
|
+
|
|
169
|
+
const insertEvent = db.prepare(`
|
|
170
|
+
INSERT INTO events (ts, session_id, type, data) VALUES (@ts, @session_id, @type, @data)
|
|
171
|
+
`);
|
|
172
|
+
|
|
173
|
+
const getSession = db.prepare(`SELECT * FROM sessions WHERE session_id = ?`);
|
|
174
|
+
|
|
175
|
+
// Track known sessions across polls
|
|
176
|
+
const knownSessions = new Map(); // session_id -> { status, tokens_in, tokens_out }
|
|
177
|
+
let pollCount = 0;
|
|
178
|
+
|
|
179
|
+
// ── System Metrics ───────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
const insertMetric = db.prepare(`
|
|
182
|
+
INSERT INTO system_metrics (ts, cpu_percent, ram_used_mb, ram_total_mb, disk_used_gb, disk_total_gb, load_avg_1m)
|
|
183
|
+
VALUES (@ts, @cpu_percent, @ram_used_mb, @ram_total_mb, @disk_used_gb, @disk_total_gb, @load_avg_1m)
|
|
184
|
+
`);
|
|
185
|
+
const pruneMetrics = db.prepare(`DELETE FROM system_metrics WHERE ts < ?`);
|
|
186
|
+
const getLastTwoMetrics = db.prepare(`SELECT cpu_percent, ram_used_mb, ram_total_mb FROM system_metrics ORDER BY ts DESC LIMIT 2`);
|
|
187
|
+
|
|
188
|
+
// Anomaly cooldown: track last anomaly alert timestamps
|
|
189
|
+
const lastAnomalyAlert = { cpu: 0, ram: 0 };
|
|
190
|
+
const ANOMALY_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
191
|
+
|
|
192
|
+
function getCpuPercent() {
|
|
193
|
+
// Container-accurate CPU: prefer cgroup v2 usage delta over host cumulative ticks.
|
|
194
|
+
const nowWallUsec = Number(process.hrtime.bigint() / 1000n);
|
|
195
|
+
const cgroupUsageUsec = readCgroupUsageUsec();
|
|
196
|
+
|
|
197
|
+
if (cgroupUsageUsec !== null) {
|
|
198
|
+
if (lastCpuSample && lastCpuSample.cgroupUsageUsec !== null) {
|
|
199
|
+
const deltaUsageUsec = cgroupUsageUsec - lastCpuSample.cgroupUsageUsec;
|
|
200
|
+
const deltaWallUsec = nowWallUsec - lastCpuSample.wallUsec;
|
|
201
|
+
if (deltaUsageUsec >= 0 && deltaWallUsec > 0) {
|
|
202
|
+
const limitCores = getCpuLimitCores();
|
|
203
|
+
const raw = (deltaUsageUsec / deltaWallUsec) / limitCores * 100;
|
|
204
|
+
const clamped = Math.max(0, Math.min(100, raw));
|
|
205
|
+
lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec, host: null };
|
|
206
|
+
return Number(clamped.toFixed(2));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec, host: null };
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const host = readHostCpuSample();
|
|
214
|
+
if (lastCpuSample && lastCpuSample.host) {
|
|
215
|
+
const deltaTotal = host.total - lastCpuSample.host.total;
|
|
216
|
+
const deltaIdle = host.idle - lastCpuSample.host.idle;
|
|
217
|
+
if (deltaTotal > 0) {
|
|
218
|
+
const busy = Math.max(0, deltaTotal - deltaIdle);
|
|
219
|
+
const raw = (busy / deltaTotal) * 100;
|
|
220
|
+
const clamped = Math.max(0, Math.min(100, raw));
|
|
221
|
+
lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec: null, host };
|
|
222
|
+
return Number(clamped.toFixed(2));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
lastCpuSample = { wallUsec: nowWallUsec, cgroupUsageUsec: null, host };
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const CGROUP_CPU_STAT = '/sys/fs/cgroup/cpu.stat';
|
|
230
|
+
const CGROUP_CPU_MAX = '/sys/fs/cgroup/cpu.max';
|
|
231
|
+
const CGROUP_CPUSET_EFFECTIVE = '/sys/fs/cgroup/cpuset.cpus.effective';
|
|
232
|
+
let lastCpuSample = null;
|
|
233
|
+
|
|
234
|
+
function readText(filePath) {
|
|
235
|
+
try {
|
|
236
|
+
return fs.readFileSync(filePath, 'utf8').trim();
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseCpuListCount(value) {
|
|
243
|
+
if (!value) return 0;
|
|
244
|
+
let count = 0;
|
|
245
|
+
for (const token of value.split(',')) {
|
|
246
|
+
const part = token.trim();
|
|
247
|
+
if (!part) continue;
|
|
248
|
+
if (part.includes('-')) {
|
|
249
|
+
const [startRaw, endRaw] = part.split('-');
|
|
250
|
+
const start = Number(startRaw);
|
|
251
|
+
const end = Number(endRaw);
|
|
252
|
+
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
|
253
|
+
count += (end - start + 1);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
const cpu = Number(part);
|
|
257
|
+
if (Number.isFinite(cpu)) count += 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return count;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getCpuLimitCores() {
|
|
264
|
+
const cpuMax = readText(CGROUP_CPU_MAX);
|
|
265
|
+
if (cpuMax) {
|
|
266
|
+
const [quotaRaw, periodRaw] = cpuMax.split(/\s+/);
|
|
267
|
+
if (quotaRaw && quotaRaw !== 'max') {
|
|
268
|
+
const quota = Number(quotaRaw);
|
|
269
|
+
const period = Number(periodRaw);
|
|
270
|
+
if (Number.isFinite(quota) && Number.isFinite(period) && quota > 0 && period > 0) {
|
|
271
|
+
return Math.max(quota / period, 0.001);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const cpusetCount = parseCpuListCount(readText(CGROUP_CPUSET_EFFECTIVE));
|
|
277
|
+
if (cpusetCount > 0) return cpusetCount;
|
|
278
|
+
|
|
279
|
+
return os.cpus().length || 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readCgroupUsageUsec() {
|
|
283
|
+
const stat = readText(CGROUP_CPU_STAT);
|
|
284
|
+
if (!stat) return null;
|
|
285
|
+
const line = stat.split('\n').find((x) => x.startsWith('usage_usec '));
|
|
286
|
+
if (!line) return null;
|
|
287
|
+
const usage = Number(line.split(/\s+/)[1]);
|
|
288
|
+
if (!Number.isFinite(usage)) return null;
|
|
289
|
+
return usage;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function readHostCpuSample() {
|
|
293
|
+
const cpus = os.cpus();
|
|
294
|
+
let idle = 0;
|
|
295
|
+
let total = 0;
|
|
296
|
+
for (const cpu of cpus) {
|
|
297
|
+
idle += cpu.times.idle;
|
|
298
|
+
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
|
|
299
|
+
}
|
|
300
|
+
return { idle, total };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getDiskStats() {
|
|
304
|
+
try {
|
|
305
|
+
const result = spawnSync('df', ['-BG', '/data', '--output=used,size'], {
|
|
306
|
+
encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL',
|
|
307
|
+
});
|
|
308
|
+
if (result.status !== 0 || !result.stdout) return { used: 0, total: 0 };
|
|
309
|
+
const lines = result.stdout.trim().split('\n');
|
|
310
|
+
// lines[0] = header, lines[1] = data
|
|
311
|
+
if (lines.length < 2) return { used: 0, total: 0 };
|
|
312
|
+
const parts = lines[1].trim().split(/\s+/);
|
|
313
|
+
const used = parseFloat(parts[0]) || 0; // already in GB (BG flag strips G)
|
|
314
|
+
const total = parseFloat(parts[1]) || 0;
|
|
315
|
+
return { used, total };
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return { used: 0, total: 0 };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function collectSystemMetrics() {
|
|
322
|
+
const now = Math.floor(Date.now() / 1000);
|
|
323
|
+
const cpu_percent = getCpuPercent();
|
|
324
|
+
const ram_total = os.totalmem();
|
|
325
|
+
const ram_free = os.freemem();
|
|
326
|
+
const ram_used_mb = Math.round((ram_total - ram_free) / 1024 / 1024);
|
|
327
|
+
const ram_total_mb = Math.round(ram_total / 1024 / 1024);
|
|
328
|
+
const disk = getDiskStats();
|
|
329
|
+
const load_avg_1m = parseFloat(os.loadavg()[0].toFixed(2));
|
|
330
|
+
|
|
331
|
+
insertMetric.run({
|
|
332
|
+
ts: now,
|
|
333
|
+
cpu_percent,
|
|
334
|
+
ram_used_mb,
|
|
335
|
+
ram_total_mb,
|
|
336
|
+
disk_used_gb: disk.used,
|
|
337
|
+
disk_total_gb: disk.total,
|
|
338
|
+
load_avg_1m,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Prune old data (keep 30 days)
|
|
342
|
+
pruneMetrics.run(now - 30 * 24 * 3600);
|
|
343
|
+
|
|
344
|
+
// Anomaly detection — check last 2 consecutive samples
|
|
345
|
+
const recent = getLastTwoMetrics.all();
|
|
346
|
+
if (recent.length === 2) {
|
|
347
|
+
const nowMs = Date.now();
|
|
348
|
+
// CPU >85% for 2 consecutive samples
|
|
349
|
+
if (recent[0].cpu_percent > 85 && recent[1].cpu_percent > 85) {
|
|
350
|
+
if (nowMs - lastAnomalyAlert.cpu > ANOMALY_COOLDOWN_MS) {
|
|
351
|
+
lastAnomalyAlert.cpu = nowMs;
|
|
352
|
+
insertEvent.run({
|
|
353
|
+
ts: Date.now(),
|
|
354
|
+
session_id: 'system',
|
|
355
|
+
type: 'system_anomaly',
|
|
356
|
+
data: JSON.stringify({
|
|
357
|
+
metric: 'cpu',
|
|
358
|
+
values: [recent[1].cpu_percent, recent[0].cpu_percent],
|
|
359
|
+
threshold: 85,
|
|
360
|
+
message: `CPU alta: ${recent[0].cpu_percent}% per 2 campioni consecutivi`,
|
|
361
|
+
}),
|
|
362
|
+
});
|
|
363
|
+
log(`[ANOMALY] CPU alta: ${recent[0].cpu_percent}%`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// RAM >90% for 2 consecutive samples
|
|
367
|
+
const ram0pct = recent[0].ram_total_mb > 0 ? Math.round(recent[0].ram_used_mb / recent[0].ram_total_mb * 100) : 0;
|
|
368
|
+
const ram1pct = recent[1].ram_total_mb > 0 ? Math.round(recent[1].ram_used_mb / recent[1].ram_total_mb * 100) : 0;
|
|
369
|
+
if (ram0pct > 90 && ram1pct > 90) {
|
|
370
|
+
if (nowMs - lastAnomalyAlert.ram > ANOMALY_COOLDOWN_MS) {
|
|
371
|
+
lastAnomalyAlert.ram = nowMs;
|
|
372
|
+
insertEvent.run({
|
|
373
|
+
ts: Date.now(),
|
|
374
|
+
session_id: 'system',
|
|
375
|
+
type: 'system_anomaly',
|
|
376
|
+
data: JSON.stringify({
|
|
377
|
+
metric: 'ram',
|
|
378
|
+
values: [ram1pct, ram0pct],
|
|
379
|
+
threshold: 90,
|
|
380
|
+
message: `RAM alta: ${ram0pct}% per 2 campioni consecutivi`,
|
|
381
|
+
}),
|
|
382
|
+
});
|
|
383
|
+
log(`[ANOMALY] RAM alta: ${ram0pct}%`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
log(`[METRICS] CPU:${cpu_percent}% RAM:${ram_used_mb}/${ram_total_mb}MB Disk:${disk.used}/${disk.total}GB Load:${load_avg_1m}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
function log(msg) {
|
|
394
|
+
console.log(`[${new Date().toISOString()}] ${msg}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function extractTrelloUrl(text) {
|
|
398
|
+
if (!text) return null;
|
|
399
|
+
const m = text.match(/https:\/\/trello\.com\/c\/[a-zA-Z0-9]+/);
|
|
400
|
+
return m ? m[0] : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseSessionKey(key) { // e.g. "agent:website:subagent:abc123" or "agent:website:main"
|
|
404
|
+
if (!key) return { label: key, parent_id: null };
|
|
405
|
+
const parts = key.split(':');
|
|
406
|
+
// agent:<agentId>:main → root session
|
|
407
|
+
// agent:<agentId>:subagent:<uuid> → child session
|
|
408
|
+
if (parts.length >= 3 && parts[2] === 'main') {
|
|
409
|
+
return { label: key, parent_id: null };
|
|
410
|
+
}
|
|
411
|
+
if (parts.length >= 4 && parts[2] === 'subagent') {
|
|
412
|
+
// parent is agent:<agentId>:main
|
|
413
|
+
const parent = `${parts[0]}:${parts[1]}:main`;
|
|
414
|
+
return { label: key, parent_id: parent };
|
|
415
|
+
}
|
|
416
|
+
return { label: key, parent_id: null };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function inferStatus(session, prevSnapshot) {
|
|
420
|
+
// Strong failure signals from OpenClaw win immediately.
|
|
421
|
+
if (session.abortedLastRun || session.error || session.result === 'error' || session.status === 'error') return 'error';
|
|
422
|
+
|
|
423
|
+
const ageMs = session.ageMs || 0;
|
|
424
|
+
const tokensIn = session.inputTokens || 0;
|
|
425
|
+
const tokensOut = session.outputTokens || 0;
|
|
426
|
+
|
|
427
|
+
// If tokens changed since last poll → session is actively processing
|
|
428
|
+
if (prevSnapshot) {
|
|
429
|
+
const tokenDelta = (tokensIn + tokensOut) - (prevSnapshot.tokens_in + prevSnapshot.tokens_out);
|
|
430
|
+
if (tokenDelta > 0) return 'working';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Updated very recently (< 3 min) and fresh → still active
|
|
434
|
+
if (ageMs < 180_000 && session.totalTokensFresh) return 'working';
|
|
435
|
+
|
|
436
|
+
// Updated in last 3–30 min without token change → idle (waiting/paused)
|
|
437
|
+
if (ageMs < 1_800_000) return 'idle';
|
|
438
|
+
|
|
439
|
+
// Not updated for > 30 min → completed
|
|
440
|
+
return 'completed';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getOrphanResolution(session, snap, ageMs) {
|
|
444
|
+
const tokens = (session.inputTokens || 0) + (session.outputTokens || 0);
|
|
445
|
+
const kind = String(session.kind || '');
|
|
446
|
+
const hadFailureHints = Boolean(session.abortedLastRun || session.error || session.result === 'error');
|
|
447
|
+
if (hadFailureHints) return 'error';
|
|
448
|
+
if (tokens === 0 && kind === 'spawn-child') return 'error';
|
|
449
|
+
if (snap?.status === 'working' && tokens === 0 && ageMs >= 2 * 60 * 1000) return 'error';
|
|
450
|
+
return 'completed';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function orphanEventType(status) {
|
|
454
|
+
return status === 'error' ? 'fail' : 'complete';
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Main Poll Loop ───────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
let openclawMissing = false;
|
|
460
|
+
|
|
461
|
+
function pollSessions() {
|
|
462
|
+
// If openclaw is not installed, skip polling entirely
|
|
463
|
+
if (!openclawMissing) {
|
|
464
|
+
try {
|
|
465
|
+
execSync('which openclaw', { stdio: 'ignore', timeout: 3000 });
|
|
466
|
+
} catch {
|
|
467
|
+
openclawMissing = true;
|
|
468
|
+
log('OpenClaw not found — daemon idle (no sessions to poll)');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
log('Polling OpenClaw sessions...');
|
|
476
|
+
|
|
477
|
+
let output;
|
|
478
|
+
{
|
|
479
|
+
const result = spawnSync(
|
|
480
|
+
'openclaw', ['sessions', '--json', '--all-agents'],
|
|
481
|
+
{
|
|
482
|
+
encoding: 'utf8',
|
|
483
|
+
timeout: 25_000,
|
|
484
|
+
killSignal: 'SIGKILL',
|
|
485
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
486
|
+
env: { ...process.env },
|
|
487
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
if (result.error || result.status !== 0) {
|
|
491
|
+
log(`WARN: openclaw sessions failed: ${result.error?.message || '(exit ' + result.status + ')'}`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
output = result.stdout;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let data;
|
|
498
|
+
try {
|
|
499
|
+
// Strip any trailing non-JSON lines (e.g. stray log output from openclaw on stdout)
|
|
500
|
+
// Find the last closing brace of the top-level JSON object
|
|
501
|
+
const jsonEnd = output.lastIndexOf('}');
|
|
502
|
+
const cleanOutput = jsonEnd !== -1 ? output.slice(0, jsonEnd + 1) : output;
|
|
503
|
+
data = JSON.parse(cleanOutput);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
log(`ERROR: Failed to parse JSON: ${err.message}`);
|
|
506
|
+
log(`ERROR: Raw output (first 500 chars): ${output.slice(0, 500)}`);
|
|
507
|
+
return; // graceful: skip this poll cycle, retry next interval
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const sessions = data.sessions || [];
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
let newCount = 0;
|
|
513
|
+
let updatedCount = 0;
|
|
514
|
+
|
|
515
|
+
const batchInsertEvents = db.transaction((evts) => {
|
|
516
|
+
for (const evt of evts) insertEvent.run(evt);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const pendingEvents = [];
|
|
520
|
+
|
|
521
|
+
// Load declared lineage (takes priority over key parsing)
|
|
522
|
+
let declaredLineage = {}; // child_id → parent_id
|
|
523
|
+
let declaredNames = {}; // child_id → agent_name
|
|
524
|
+
try {
|
|
525
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS lineage (
|
|
526
|
+
child_id TEXT PRIMARY KEY,
|
|
527
|
+
parent_id TEXT NOT NULL,
|
|
528
|
+
agent_name TEXT,
|
|
529
|
+
declared_at INTEGER NOT NULL
|
|
530
|
+
)`).run();
|
|
531
|
+
try { db.prepare('ALTER TABLE lineage ADD COLUMN agent_name TEXT').run(); } catch(e) {}
|
|
532
|
+
const rows = db.prepare('SELECT child_id, parent_id, agent_name FROM lineage').all();
|
|
533
|
+
for (const r of rows) {
|
|
534
|
+
declaredLineage[r.child_id] = r.parent_id;
|
|
535
|
+
if (r.agent_name) declaredNames[r.child_id] = r.agent_name;
|
|
536
|
+
}
|
|
537
|
+
} catch (e) { /* table not yet created, skip */ }
|
|
538
|
+
|
|
539
|
+
for (const s of sessions) {
|
|
540
|
+
const session_id = s.key;
|
|
541
|
+
|
|
542
|
+
// Skip Telegram channel/group sessions (multi-user).
|
|
543
|
+
// Keep Telegram direct sessions (agent:ops:telegram:argus:direct:...) as root nodes
|
|
544
|
+
// since they are the parent of all sub-agents spawned via Telegram.
|
|
545
|
+
if (session_id.includes(':telegram:') && !session_id.includes(':direct:')) continue;
|
|
546
|
+
const { label, parent_id: inferredParent } = parseSessionKey(s.key);
|
|
547
|
+
// Use parentSessionKey if present, otherwise declared lineage, otherwise inferred (always null now)
|
|
548
|
+
const parent_id = s.parentSessionKey || declaredLineage[session_id] || inferredParent;
|
|
549
|
+
const model = s.model || null;
|
|
550
|
+
const tokens_in = s.inputTokens || 0;
|
|
551
|
+
const tokens_out = s.outputTokens || 0;
|
|
552
|
+
const cost_usd = estimateCost(model, tokens_in, tokens_out);
|
|
553
|
+
const started_at = s.updatedAt ? s.updatedAt - (s.ageMs || 0) : now;
|
|
554
|
+
const updated_at = s.updatedAt || now;
|
|
555
|
+
|
|
556
|
+
const isNew = !knownSessions.has(session_id);
|
|
557
|
+
const prevSnapshot = knownSessions.get(session_id);
|
|
558
|
+
|
|
559
|
+
const task_preview_val = s.task || s.task_preview || s.label || null;
|
|
560
|
+
const trello_card_url = extractTrelloUrl(task_preview_val);
|
|
561
|
+
// Use declared name if available, otherwise label from key
|
|
562
|
+
const effectiveLabel = declaredNames[session_id] || label;
|
|
563
|
+
|
|
564
|
+
const status = inferStatus(s, prevSnapshot);
|
|
565
|
+
|
|
566
|
+
// Fix 1: write ended_at when session transitions to completed/error
|
|
567
|
+
let ended_at = null;
|
|
568
|
+
if (status === 'completed' || status === 'error') {
|
|
569
|
+
// Preserve existing ended_at if already set; otherwise set it now
|
|
570
|
+
const existing = getSession.get(session_id);
|
|
571
|
+
ended_at = (existing && existing.ended_at) ? existing.ended_at : now;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
upsertSession.run({
|
|
575
|
+
session_id,
|
|
576
|
+
parent_id,
|
|
577
|
+
label: effectiveLabel,
|
|
578
|
+
model,
|
|
579
|
+
tokens_in,
|
|
580
|
+
tokens_out,
|
|
581
|
+
cost_usd,
|
|
582
|
+
status,
|
|
583
|
+
task_preview: task_preview_val,
|
|
584
|
+
started_at,
|
|
585
|
+
ended_at,
|
|
586
|
+
updated_at,
|
|
587
|
+
trello_card_url,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
if (isNew) {
|
|
591
|
+
newCount++;
|
|
592
|
+
pendingEvents.push({
|
|
593
|
+
ts: now,
|
|
594
|
+
session_id,
|
|
595
|
+
type: 'spawn',
|
|
596
|
+
data: JSON.stringify({ model, parent_id, tokens: tokens_in + tokens_out }),
|
|
597
|
+
});
|
|
598
|
+
} else if (prevSnapshot && prevSnapshot.status !== 'completed' && status === 'completed') {
|
|
599
|
+
pendingEvents.push({
|
|
600
|
+
ts: now,
|
|
601
|
+
session_id,
|
|
602
|
+
type: 'complete',
|
|
603
|
+
data: JSON.stringify({ cost_usd, tokens: tokens_in + tokens_out }),
|
|
604
|
+
});
|
|
605
|
+
} else if (prevSnapshot && prevSnapshot.status !== 'error' && status === 'error') {
|
|
606
|
+
pendingEvents.push({
|
|
607
|
+
ts: now,
|
|
608
|
+
session_id,
|
|
609
|
+
type: 'fail',
|
|
610
|
+
data: JSON.stringify({ cost_usd, tokens: tokens_in + tokens_out, reason: 'openclaw_error_signal' }),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
knownSessions.set(session_id, {
|
|
615
|
+
status,
|
|
616
|
+
tokens_in,
|
|
617
|
+
tokens_out,
|
|
618
|
+
updatedAt: Date.now(),
|
|
619
|
+
kind: s.kind,
|
|
620
|
+
abortedLastRun: s.abortedLastRun,
|
|
621
|
+
error: s.error,
|
|
622
|
+
result: s.result,
|
|
623
|
+
});
|
|
624
|
+
updatedCount++;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (pendingEvents.length > 0) batchInsertEvents(pendingEvents);
|
|
628
|
+
|
|
629
|
+
// Auto-close orphan sessions: sessions that were working/idle but disappeared
|
|
630
|
+
// from openclaw sessions for > 5 minutes (10+ poll cycles) → mark as completed/error.
|
|
631
|
+
// Grace period protects against transient poll gaps or partial openclaw output.
|
|
632
|
+
const currentIds = new Set(sessions.filter(s => !s.key.includes(':telegram:')).map(s => s.key));
|
|
633
|
+
const fiveMinAgo = now - 5 * 60 * 1000;
|
|
634
|
+
if (currentIds.size > 0) {
|
|
635
|
+
const orphanCandidates = db.prepare(`
|
|
636
|
+
SELECT session_id, status, updated_at, tokens_in, tokens_out, ended_at
|
|
637
|
+
FROM sessions
|
|
638
|
+
WHERE status IN ('working','idle')
|
|
639
|
+
AND updated_at < ?
|
|
640
|
+
AND session_id NOT IN (${Array.from(currentIds).map(() => '?').join(',')})
|
|
641
|
+
`).all(fiveMinAgo, ...currentIds);
|
|
642
|
+
const orphanEvents = [];
|
|
643
|
+
let orphanCount = 0;
|
|
644
|
+
for (const row of orphanCandidates) {
|
|
645
|
+
const snap = knownSessions.get(row.session_id);
|
|
646
|
+
const resolved = getOrphanResolution({
|
|
647
|
+
inputTokens: row.tokens_in,
|
|
648
|
+
outputTokens: row.tokens_out,
|
|
649
|
+
kind: snap?.kind,
|
|
650
|
+
abortedLastRun: snap?.abortedLastRun,
|
|
651
|
+
error: snap?.error,
|
|
652
|
+
result: snap?.result,
|
|
653
|
+
}, snap, now - Number(row.updated_at || 0));
|
|
654
|
+
const ended_at = row.ended_at || now;
|
|
655
|
+
db.prepare(`UPDATE sessions SET status=?, ended_at=?, updated_at=? WHERE session_id = ?`).run(resolved, ended_at, now, row.session_id);
|
|
656
|
+
orphanEvents.push({
|
|
657
|
+
ts: now,
|
|
658
|
+
session_id: row.session_id,
|
|
659
|
+
type: orphanEventType(resolved),
|
|
660
|
+
data: JSON.stringify({ reason: 'orphan_close', prior_status: row.status, resolved_status: resolved }),
|
|
661
|
+
});
|
|
662
|
+
orphanCount++;
|
|
663
|
+
}
|
|
664
|
+
if (orphanEvents.length > 0) batchInsertEvents(orphanEvents);
|
|
665
|
+
if (orphanCount > 0) log(`Orphan cleanup: ${orphanCount} zombie sessions auto-closed (${orphanEvents.filter(e => e.type === 'fail').length} errors)`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── Timeout Detection ────────────────────────────────────────────────────
|
|
669
|
+
// Find sub-agents that were 'working' in the last snapshot but are no longer
|
|
670
|
+
// present in the current output and have been missing for more than 10 minutes.
|
|
671
|
+
// Questi vengono marcati come 'timeout' nella tabella events.
|
|
672
|
+
const TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000; // 10 minuti
|
|
673
|
+
for (const [session_id, snap] of knownSessions) {
|
|
674
|
+
// Only sub-agents (contain 'subagent' in the key)
|
|
675
|
+
if (!session_id.includes('subagent')) continue;
|
|
676
|
+
// Solo se erano working nell'ultimo poll
|
|
677
|
+
if (snap.status !== 'working') continue;
|
|
678
|
+
// Only if not present in the current poll
|
|
679
|
+
if (currentIds.has(session_id)) continue;
|
|
680
|
+
// Only if missing long enough
|
|
681
|
+
const missingFor = now - (snap.updatedAt || now);
|
|
682
|
+
if (missingFor < TIMEOUT_THRESHOLD_MS) continue;
|
|
683
|
+
// Prevent logging the same timeout multiple times (check if already logged)
|
|
684
|
+
const alreadyLogged = db.prepare(
|
|
685
|
+
`SELECT id FROM events WHERE session_id = ? AND type = 'spawn_timeout' LIMIT 1`
|
|
686
|
+
).get(session_id);
|
|
687
|
+
if (alreadyLogged) continue;
|
|
688
|
+
|
|
689
|
+
// Insert timeout event
|
|
690
|
+
insertEvent.run({
|
|
691
|
+
ts: now,
|
|
692
|
+
session_id,
|
|
693
|
+
type: 'spawn_timeout',
|
|
694
|
+
data: JSON.stringify({
|
|
695
|
+
missing_for_ms: missingFor,
|
|
696
|
+
last_status: snap.status,
|
|
697
|
+
message: `Subagente missing dopo ${Math.round(missingFor / 60000)}min senza completare`,
|
|
698
|
+
}),
|
|
699
|
+
});
|
|
700
|
+
log(`[TIMEOUT] ${session_id} missing da ${Math.round(missingFor / 60000)}min`);
|
|
701
|
+
|
|
702
|
+
// Notify Michele via openclaw message (only if openclaw is available)
|
|
703
|
+
try {
|
|
704
|
+
if (!openclawMissing) {
|
|
705
|
+
execSync('which openclaw', { stdio: 'ignore', timeout: 3000 });
|
|
706
|
+
}
|
|
707
|
+
spawnSync('openclaw', [
|
|
708
|
+
'message', 'send',
|
|
709
|
+
'--account', 'ops',
|
|
710
|
+
'--target', '297086793',
|
|
711
|
+
'--text', `⚠️ Rev4a: agent timeout\n\`${session_id.slice(-36)}\`\nMissing for ${Math.round(missingFor / 60000)} min senza completare.`,
|
|
712
|
+
], { encoding: 'utf8', timeout: 10_000, killSignal: 'SIGKILL' });
|
|
713
|
+
} catch (e) {
|
|
714
|
+
log(`[TIMEOUT] Telegram notification failed: ${e.message}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Collect system metrics after each poll
|
|
719
|
+
try { collectSystemMetrics(); } catch (e) { log(`[METRICS ERROR] ${e.message}`); }
|
|
720
|
+
|
|
721
|
+
// Force names and parents declared via lineage — overrides any previous label (including "Sub-agente")
|
|
722
|
+
const updateLabel = db.prepare('UPDATE sessions SET label = ?, parent_id = ? WHERE session_id = ?');
|
|
723
|
+
const applyLineage = db.transaction(() => {
|
|
724
|
+
for (const [child_id, agent_name] of Object.entries(declaredNames)) {
|
|
725
|
+
const parent_id = declaredLineage[child_id] || null;
|
|
726
|
+
updateLabel.run(agent_name, parent_id, child_id);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
applyLineage();
|
|
730
|
+
|
|
731
|
+
log(`Poll done: ${sessions.length} sessions (${newCount} new, ${updatedCount} updated, ${pendingEvents.length} events written)`);
|
|
732
|
+
|
|
733
|
+
// Force WAL checkpoint so the VPS reads updated data from the main file
|
|
734
|
+
db.pragma('wal_checkpoint(PASSIVE)');
|
|
735
|
+
|
|
736
|
+
// Retention policy: delete cron sessions older than 7 days
|
|
737
|
+
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
|
738
|
+
const r1 = db.prepare(`DELETE FROM sessions WHERE session_id LIKE '%:cron:%' AND status = 'completed' AND updated_at < ?`).run(sevenDaysAgo);
|
|
739
|
+
const r2 = db.prepare(`DELETE FROM events WHERE session_id LIKE '%:cron:%' AND ts < ?`).run(sevenDaysAgo);
|
|
740
|
+
if (r1.changes > 0 || r2.changes > 0) {
|
|
741
|
+
log(`Retention cleanup: ${r1.changes} cron sessions, ${r2.changes} cron events deleted`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Prune knownSessions — delete completed sessions da >30 giorni
|
|
745
|
+
const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
|
746
|
+
for (const [id, snap] of knownSessions) {
|
|
747
|
+
if (snap.status === 'completed' && snap.updatedAt && snap.updatedAt < cutoff) {
|
|
748
|
+
knownSessions.delete(id);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// WAL checkpoint FULL ogni 10 poll
|
|
753
|
+
pollCount = (pollCount || 0) + 1;
|
|
754
|
+
if (pollCount % 10 === 0) {
|
|
755
|
+
db.pragma('wal_checkpoint(FULL)');
|
|
756
|
+
log(`WAL checkpoint FULL executed (poll #${pollCount})`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ── Start ────────────────────────────────────────────────────────────────────
|
|
761
|
+
|
|
762
|
+
log('Rev4a Daemon starting...');
|
|
763
|
+
log(`DB: ${DB_PATH}`);
|
|
764
|
+
log(`Poll interval: ${POLL_INTERVAL_MS / 1000}s`);
|
|
765
|
+
|
|
766
|
+
// Run immediately, then on interval
|
|
767
|
+
pollSessions();
|
|
768
|
+
const timer = setInterval(pollSessions, POLL_INTERVAL_MS);
|
|
769
|
+
|
|
770
|
+
// Graceful shutdown
|
|
771
|
+
process.on('SIGTERM', () => {
|
|
772
|
+
log('SIGTERM received, shutting down...');
|
|
773
|
+
clearInterval(timer);
|
|
774
|
+
db.close();
|
|
775
|
+
process.exit(0);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
process.on('SIGINT', () => {
|
|
779
|
+
log('SIGINT received, shutting down...');
|
|
780
|
+
clearInterval(timer);
|
|
781
|
+
db.close();
|
|
782
|
+
process.exit(0);
|
|
783
|
+
});
|