From c84d61561c446d5b0e4b66adccb07234ec0e81b4 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 22 Apr 2026 21:11:42 -0300 Subject: [PATCH 01/12] refactor(telegram): centralize notifications in routines, remove from skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Telegram reply() out of skill SKILL.md files into the routine .py callers via notify_telegram=True on run_skill(). This guarantees exactly one send per execution — the instruction is appended at the end of the prompt after all skill steps, so the agent cannot send it early. - runner.py: add notify_telegram param to run_skill() — reads chat_id from TELEGRAM_CHAT_ID env, appends explicit one-shot instruction - Skills cleaned: prod-end-of-day, prod-good-morning, pulse-faq-sync, pulse-daily (custom files gitignored, updated locally) - Routines updated: end_of_day, good_morning (custom routines gitignored) Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/prod-end-of-day/SKILL.md | 7 --- .claude/skills/prod-good-morning/SKILL.md | 7 --- .claude/skills/pulse-daily/SKILL.md | 7 --- .claude/skills/pulse-faq-sync/SKILL.md | 8 --- ADWs/routines/end_of_day.py | 2 +- ADWs/routines/good_morning.py | 2 +- ADWs/runner.py | 64 ++++++++++++++++++++++- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/.claude/skills/prod-end-of-day/SKILL.md b/.claude/skills/prod-end-of-day/SKILL.md index 88059d11..a20b80d5 100644 --- a/.claude/skills/prod-end-of-day/SKILL.md +++ b/.claude/skills/prod-end-of-day/SKILL.md @@ -93,10 +93,3 @@ Present a short summary: **Tomorrow:** {sentence about where to resume} ``` - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/prod-good-morning/SKILL.md b/.claude/skills/prod-good-morning/SKILL.md index e75bc47e..915390ac 100644 --- a/.claude/skills/prod-good-morning/SKILL.md +++ b/.claude/skills/prod-good-morning/SKILL.md @@ -64,10 +64,3 @@ Create the `workspace/daily-logs/` directory if it does not exist. ## Tone Keep the morning briefing conversational and brief. The user is starting their day — they don't need a wall of text. Punchy bullets, one clear recommendation, then move into action. - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/pulse-daily/SKILL.md b/.claude/skills/pulse-daily/SKILL.md index 4457a45c..0d461c40 100644 --- a/.claude/skills/pulse-daily/SKILL.md +++ b/.claude/skills/pulse-daily/SKILL.md @@ -93,10 +93,3 @@ Report saved to workspace/community/reports/daily/ - **Compare with average** — if previous reports exist in the directory, compare metrics - **Empty channels = OK** — if a channel had no activity, do not report as a problem - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/pulse-faq-sync/SKILL.md b/.claude/skills/pulse-faq-sync/SKILL.md index f1aefdb0..c484103c 100644 --- a/.claude/skills/pulse-faq-sync/SKILL.md +++ b/.claude/skills/pulse-faq-sync/SKILL.md @@ -203,11 +203,3 @@ The "Skipped" block is mandatory — it gives visibility on questions the commun - **Tags in comments** — keep HTML comment tags for easy searching - **Keep organized** — categories in logical order (installation -> config -> integrations -> product -> billing -> errors) - -### Notify via Telegram - -Upon completion, send **exactly ONE** Telegram message with the full summary: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (totals + alerts combined in one message) -- Do NOT split into multiple messages — combine summary and alerts into a single call -- If the routine had no updates, send anyway with "no updates" diff --git a/ADWs/routines/end_of_day.py b/ADWs/routines/end_of_day.py index 2fabe95e..4314c2e5 100644 --- a/ADWs/routines/end_of_day.py +++ b/ADWs/routines/end_of_day.py @@ -8,7 +8,7 @@ def main(): banner("🌙 End of Day", "Memória • Logs • Tarefas • Aprendizados | @clawdia") results = [] - results.append(run_skill("prod-end-of-day", log_name="end-of-day", timeout=600, agent="clawdia-assistant")) + results.append(run_skill("prod-end-of-day", log_name="end-of-day", timeout=600, agent="clawdia-assistant", notify_telegram=True)) summary(results, "End of Day") if __name__ == "__main__": diff --git a/ADWs/routines/good_morning.py b/ADWs/routines/good_morning.py index 5c97c088..e6dc28b4 100644 --- a/ADWs/routines/good_morning.py +++ b/ADWs/routines/good_morning.py @@ -8,7 +8,7 @@ def main(): banner("☀️ Good Morning", "Agenda • Emails • Tarefas | @clawdia") results = [] - results.append(run_skill("prod-good-morning", log_name="good-morning", timeout=600, agent="clawdia-assistant")) + results.append(run_skill("prod-good-morning", log_name="good-morning", timeout=600, agent="clawdia-assistant", notify_telegram=True)) summary(results, "Good Morning") if __name__ == "__main__": diff --git a/ADWs/runner.py b/ADWs/runner.py index 6d1533a7..c7b75fbe 100644 --- a/ADWs/runner.py +++ b/ADWs/runner.py @@ -291,9 +291,38 @@ def run_claude(prompt: str, log_name: str = "unnamed", timeout: int = 600, agent return {"success": False, "stdout": "", "stderr": str(e), "returncode": -3, "duration": duration} -def run_skill(skill_name: str, args: str = "", log_name: str = None, timeout: int = 600, agent: str = None) -> dict: - """Execute a skill via CLI, optionally with an agent.""" +def run_skill( + skill_name: str, + args: str = "", + log_name: str = None, + timeout: int = 600, + agent: str = None, + notify_telegram: bool | str = False, +) -> dict: + """Execute a skill via CLI, optionally with an agent. + + Args: + notify_telegram: Controls post-skill Telegram notification. + False (default) — no notification (skill must NOT call reply() either). + True — appends notification instruction; reads chat_id from + TELEGRAM_CHAT_ID env var. + "" — same as True but overrides the chat_id. + """ prompt = f"Execute the skill /{skill_name} {args}".strip() + if notify_telegram: + chat_id = ( + notify_telegram + if isinstance(notify_telegram, str) + else os.environ.get("TELEGRAM_CHAT_ID", "") + ) + if chat_id: + prompt += ( + f"\n\nAo concluir TODOS os passos acima, envie UMA única mensagem Telegram via:" + f'\nreply(chat_id="{chat_id}", text="...")' + f"\nFormato: emoji + nome da rotina + principais resultados em 2-3 linhas." + f"\nCRÍTICO: chame reply() EXATAMENTE UMA VEZ, somente aqui no final." + f" Não envie mensagens intermediárias nem de progresso." + ) return run_claude(prompt, log_name or skill_name, timeout, agent=agent) @@ -393,3 +422,34 @@ def summary(results: list, title: str = "Completed"): border_style="green" if failed == 0 else "yellow", padding=(0, 2) )) + + +def send_telegram(text: str, chat_id: str = None) -> bool: + """Send a Telegram message via bot API (no MCP dependency). + + Reads TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from environment. + Returns True if sent successfully, False otherwise. + """ + import urllib.request + import urllib.parse + + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + cid = chat_id or os.environ.get("TELEGRAM_CHAT_ID", "") + if not token or not cid: + console.print(" [warning]⚠ Telegram not configured (missing BOT_TOKEN or CHAT_ID)[/warning]") + return False + + try: + payload = urllib.parse.urlencode({"chat_id": cid, "text": text, "parse_mode": "HTML"}).encode() + url = f"https://api.telegram.org/bot{token}/sendMessage" + req = urllib.request.Request(url, data=payload, method="POST") + with urllib.request.urlopen(req, timeout=10) as resp: + ok = resp.status == 200 + if ok: + console.print(" [success]✓[/success] Telegram enviado") + else: + console.print(f" [warning]⚠ Telegram status {resp.status}[/warning]") + return ok + except Exception as e: + console.print(f" [warning]⚠ Telegram error: {e}[/warning]") + return False From 277adb0a900910e2919448a625efbc6d549626de Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Sat, 25 Apr 2026 07:53:56 -0300 Subject: [PATCH 02/12] fix(telegram): remove reply() from prod-review-todoist skill Telegram notification was still in the skill file, causing the agent to call reply() multiple times per run (3x observed on 25/04 at 06:51). Notification now controlled by notify_telegram=True in review_todoist.py (gitignored custom routine, already updated on disk). Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/prod-review-todoist/SKILL.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.claude/skills/prod-review-todoist/SKILL.md b/.claude/skills/prod-review-todoist/SKILL.md index 2180aab2..34a78c1d 100644 --- a/.claude/skills/prod-review-todoist/SKILL.md +++ b/.claude/skills/prod-review-todoist/SKILL.md @@ -124,10 +124,3 @@ If the user wants to see details of what changed, they ask. - **If unsure about the category**, use `[Operations]` as fallback - **Execute first, report after** — no intermediate report - -### Notify via Telegram - -Upon completion, send **exactly ONE** Telegram message — do NOT call `reply()` more than once per run: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines, all combined in a single message) -- If the routine had no updates, send anyway with "no updates" From cab896653a38d6546e5315f7d00ef1dec37c4441 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Sat, 25 Apr 2026 08:40:42 -0300 Subject: [PATCH 03/12] fix(chat-bridge): resolve agent file from WORKSPACE_ROOT, not session cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket threads pass workspace_path (e.g. workspace/personal/) as the session cwd. loadAgentFile was building the path relative to that cwd, so it looked for .claude/agents/ inside the ticket's folder instead of the workspace root — always failing for every agent. Fix: try WORKSPACE_ROOT/.claude/agents/{name}.md first, fall back to cwd for custom per-directory agents. Affected: kai-personal-assistant, flux-finance, and any ticket with a workspace_path set. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/terminal-server/src/chat-bridge.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dashboard/terminal-server/src/chat-bridge.js b/dashboard/terminal-server/src/chat-bridge.js index 64827bf6..caf138b1 100644 --- a/dashboard/terminal-server/src/chat-bridge.js +++ b/dashboard/terminal-server/src/chat-bridge.js @@ -48,7 +48,10 @@ const NEEDS_APPROVAL = new Set([ * Extracts YAML frontmatter for metadata and the body as the prompt. */ function loadAgentFile(agentName, cwd) { - const agentPath = path.join(cwd, '.claude', 'agents', `${agentName}.md`); + // Agent definitions live at the workspace root — cwd varies per ticket session. + const rootPath = path.join(WORKSPACE_ROOT, '.claude', 'agents', `${agentName}.md`); + const cwdPath = path.join(cwd, '.claude', 'agents', `${agentName}.md`); + const agentPath = fs.existsSync(rootPath) ? rootPath : cwdPath; if (!fs.existsSync(agentPath)) { console.warn(`[chat-bridge] Agent file not found: ${agentPath}`); return null; From cc27a4abe2a47441e27feb6c6b71292a50c0e67a Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Sun, 26 Apr 2026 20:14:05 -0300 Subject: [PATCH 04/12] fix(telegram): eliminate duplicate notifications from inline reply() template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: notify_telegram instruction showed reply(chat_id=..., text="...") as an inline code snippet, causing the agent to execute it immediately on reading the instruction AND again at the end — 2x per instruction. Financial Pulse was getting 4x because the skill's Step 8 also contained a reply() call instruction (2 instructions × 2 executions = 4 messages). Fixes: - runner.py: rewrite notify_telegram prompt to describe the action in plain text without an inline function-call template - fin-daily-pulse SKILL.md: remove Step 8 Telegram section — notification is handled by the routine caller via notify_telegram=True Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/fin-daily-pulse/SKILL.md | 8 +++----- ADWs/runner.py | 12 +++++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.claude/skills/fin-daily-pulse/SKILL.md b/.claude/skills/fin-daily-pulse/SKILL.md index 2f2a74af..1c98eba8 100644 --- a/.claude/skills/fin-daily-pulse/SKILL.md +++ b/.claude/skills/fin-daily-pulse/SKILL.md @@ -157,9 +157,9 @@ workspace/finance/reports/daily/[C] YYYY-MM-DD-financial-pulse.html Create the directory `workspace/finance/reports/daily/` if it does not exist. -## Step 8 — Confirm and notify (ONE Telegram message only) +## Step 8 — Confirm -Output the completion summary, then send **exactly one** Telegram message. Do NOT call `reply` more than once per run. +Output the completion summary in the terminal: ``` ## Financial Pulse generated @@ -171,6 +171,4 @@ Output the completion summary, then send **exactly one** Telegram message. Do NO **Alerts:** {N} attention points ``` -Call `reply` **once** with a short summary (do not send the full markdown above — send a compact version): - -- Format: `[emoji] Financial Pulse [date] | MRR: R$ X,XXX | Receita: R$ X,XXX | Churn: X% | [N] alertas` +Do NOT send a Telegram message here — the caller handles notifications. diff --git a/ADWs/runner.py b/ADWs/runner.py index c7b75fbe..30c06a45 100644 --- a/ADWs/runner.py +++ b/ADWs/runner.py @@ -317,11 +317,13 @@ def run_skill( ) if chat_id: prompt += ( - f"\n\nAo concluir TODOS os passos acima, envie UMA única mensagem Telegram via:" - f'\nreply(chat_id="{chat_id}", text="...")' - f"\nFormato: emoji + nome da rotina + principais resultados em 2-3 linhas." - f"\nCRÍTICO: chame reply() EXATAMENTE UMA VEZ, somente aqui no final." - f" Não envie mensagens intermediárias nem de progresso." + f"\n\n---\n" + f"NOTIFICAÇÃO TELEGRAM — executar SOMENTE após concluir TODOS os passos acima.\n" + f"Use a ferramenta Telegram reply com chat_id={chat_id} e um texto compacto:\n" + f" emoji + nome da rotina + data + principais resultados em 2-3 linhas.\n" + f"REGRA ABSOLUTA: chame a ferramenta reply UMA ÚNICA VEZ, no final de tudo.\n" + f"Nunca chame reply para progresso, confirmação intermediária ou teste.\n" + f"---" ) return run_claude(prompt, log_name or skill_name, timeout, agent=agent) From 7400aa1aecb99f9612031522d6a7039469156cd8 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Sun, 26 Apr 2026 20:18:38 -0300 Subject: [PATCH 05/12] fix(telegram): remove all inline reply() calls from skill SKILL.md files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive sweep of all 175+ skills. Root cause of all duplicate Telegram notifications: skills contained inline reply(chat_id=...) code examples that Claude executed immediately on reading, then called again at the actual end-of-skill step — causing 2x (or 4x when combined with a second instruction from notify_telegram=True in the routine). Changes: - Removed "Notify via Telegram" sections entirely from 10 skills that have a corresponding routine using notify_telegram=True: fin-weekly-report, gog-email-triage, prod-dashboard, prod-trends, pulse-monthly, pulse-weekly, sage-strategy-digest, fin-monthly-close-kickoff, social-analytics-report, social-youtube-report - Rewrote Telegram instructions without inline reply() code in 4 skills that are called without notify_telegram (heartbeat or conditional): int-sync-meetings, int-github-review, int-linear-review (plain text), custom-getfy-sync, custom-omc-sync (plain text, local/gitignored) The corresponding routines (gitignored) were updated locally to add notify_telegram=True where the section was removed. Co-Authored-By: Claude Sonnet 4.6 --- .../skills/fin-monthly-close-kickoff/SKILL.md | 6 ------ .claude/skills/fin-weekly-report/SKILL.md | 6 ------ .claude/skills/gog-email-triage/SKILL.md | 8 -------- .claude/skills/int-github-review/SKILL.md | 7 +++---- .claude/skills/int-linear-review/SKILL.md | 7 +++---- .claude/skills/int-sync-meetings/SKILL.md | 11 ++++++----- .claude/skills/prod-dashboard/SKILL.md | 7 ------- .claude/skills/prod-trends/SKILL.md | 8 -------- .claude/skills/pulse-monthly/SKILL.md | 6 ------ .claude/skills/pulse-weekly/SKILL.md | 8 -------- .claude/skills/sage-strategy-digest/SKILL.md | 8 -------- .claude/skills/social-analytics-report/SKILL.md | 13 ------------- .claude/skills/social-youtube-report/SKILL.md | 5 ----- dashboard/terminal-server/package-lock.json | 3 --- uv.lock | 16 ++-------------- 15 files changed, 14 insertions(+), 105 deletions(-) diff --git a/.claude/skills/fin-monthly-close-kickoff/SKILL.md b/.claude/skills/fin-monthly-close-kickoff/SKILL.md index a62548f3..83a97202 100644 --- a/.claude/skills/fin-monthly-close-kickoff/SKILL.md +++ b/.claude/skills/fin-monthly-close-kickoff/SKILL.md @@ -182,9 +182,3 @@ Create the directory `workspace/finance/reports/monthly/` if it does not exist. **Checklist:** X/10 completed **Finance team pending items:** {N} items ``` - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + "Monthly Close" + month's result + pending items (2-3 lines) diff --git a/.claude/skills/fin-weekly-report/SKILL.md b/.claude/skills/fin-weekly-report/SKILL.md index ef3c85f2..4c344aa2 100644 --- a/.claude/skills/fin-weekly-report/SKILL.md +++ b/.claude/skills/fin-weekly-report/SKILL.md @@ -164,9 +164,3 @@ Create the directory `workspace/finance/reports/weekly/` if it does not exist. **MRR total:** R$ X,XXX (Stripe: R$ X,XXX | Evo Academy: R$ X,XXX) | **Projected 30d balance:** R$ XX,XXX **Alerts:** {N} overdue accounts | {N} pending invoices ``` - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + "Financial Weekly" + revenue vs expenses + MRR + alerts (2-3 lines) diff --git a/.claude/skills/gog-email-triage/SKILL.md b/.claude/skills/gog-email-triage/SKILL.md index 44cc3d45..3bda6b0f 100644 --- a/.claude/skills/gog-email-triage/SKILL.md +++ b/.claude/skills/gog-email-triage/SKILL.md @@ -354,11 +354,3 @@ See `skills/gog/_shared/references/testing.md` for complete test plan. - If email subject/sender contains obvious credentials or secrets, redact in output - For recurring newsletters, suggest creating a filter/rule rather than manual archiving - - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/int-github-review/SKILL.md b/.claude/skills/int-github-review/SKILL.md index d9ac780a..5cf8edcc 100644 --- a/.claude/skills/int-github-review/SKILL.md +++ b/.claude/skills/int-github-review/SKILL.md @@ -118,7 +118,6 @@ Create directory if it does not exist. ### Notify via Telegram -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" +Upon completion, use the Telegram reply tool to send a short summary to the user. +Format: emoji + routine name + main result (1-3 lines). +If the routine had no updates, send anyway with "no updates". diff --git a/.claude/skills/int-linear-review/SKILL.md b/.claude/skills/int-linear-review/SKILL.md index 1deb55e7..e0ce493d 100644 --- a/.claude/skills/int-linear-review/SKILL.md +++ b/.claude/skills/int-linear-review/SKILL.md @@ -97,7 +97,6 @@ Create the directory `workspace/projects/linear-reviews/` if it does not exist. ### Notify via Telegram -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" +Upon completion, use the Telegram reply tool to send a short summary to the user. +Format: emoji + routine name + main result (1-3 lines). +If the routine had no updates, send anyway with "no updates". diff --git a/.claude/skills/int-sync-meetings/SKILL.md b/.claude/skills/int-sync-meetings/SKILL.md index c4b22f22..2302f85e 100644 --- a/.claude/skills/int-sync-meetings/SKILL.md +++ b/.claude/skills/int-sync-meetings/SKILL.md @@ -186,12 +186,13 @@ Without listing tasks one by one — just counts. If the user wants details, the ### Step 9 — Notify via Telegram -Send the Step 8 summary via Telegram to the user using the `/int-telegram` skill: -- Chat ID: `YOUR_CHAT_ID` -- Use `reply(chat_id="YOUR_CHAT_ID", text="...")` via MCP -- Short format: emoji + title + meeting and task count +Only send if at least one new meeting was processed (i.e., you did NOT stop at Step 2). -If there are no new meetings (stopped at Step 2), do **NOT** send any Telegram message — stay silent. Only notify when at least one new meeting was processed. +Use the Telegram reply tool with the user's chat_id and a short summary: +- Format: emoji + title + meeting count + task count +- One line only + +If there are no new meetings (stopped at Step 2), do **NOT** send any Telegram message — stay completely silent. ## Notes diff --git a/.claude/skills/prod-dashboard/SKILL.md b/.claude/skills/prod-dashboard/SKILL.md index 56c3c84a..56da10f4 100644 --- a/.claude/skills/prod-dashboard/SKILL.md +++ b/.claude/skills/prod-dashboard/SKILL.md @@ -142,10 +142,3 @@ Present a short summary: **Health:** Product {status} | Community {status} | Financial {status} | Routines {status} **Alerts:** {N} attention points ``` - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + health status of each area (1-3 lines) -- If there were no updates, send anyway with "no updates" diff --git a/.claude/skills/prod-trends/SKILL.md b/.claude/skills/prod-trends/SKILL.md index 051ce18d..062f5270 100644 --- a/.claude/skills/prod-trends/SKILL.md +++ b/.claude/skills/prod-trends/SKILL.md @@ -163,11 +163,3 @@ Create `memory/trends/` if it does not exist. - **If a source has no data, skip** — do not block due to a missing report - **Focus on action** — each insight should lead to a concrete recommendation - **Do not alarm without evidence** — red only when the metric truly indicates risk - - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/pulse-monthly/SKILL.md b/.claude/skills/pulse-monthly/SKILL.md index c84a2439..dbe8cfb1 100644 --- a/.claude/skills/pulse-monthly/SKILL.md +++ b/.claude/skills/pulse-monthly/SKILL.md @@ -178,9 +178,3 @@ Create the directory `workspace/community/reports/monthly/` if it does not exist **Sentiment:** {trend} | **Resolution:** {X}% **Highlights:** {N} features, {N} bugs, {N} docs gaps ``` - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + "Community Monthly" + MAM + sentiment + highlights (2-3 lines) diff --git a/.claude/skills/pulse-weekly/SKILL.md b/.claude/skills/pulse-weekly/SKILL.md index ff41cc33..10d584fb 100644 --- a/.claude/skills/pulse-weekly/SKILL.md +++ b/.claude/skills/pulse-weekly/SKILL.md @@ -111,11 +111,3 @@ Report saved to workspace/community/reports/weekly/ - **Docs gap is gold** — each question without docs becomes a backlog item - **Comparison is fundamental** — always show trend vs previous week - **Product insights** — the most valuable section, handle with care - - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/sage-strategy-digest/SKILL.md b/.claude/skills/sage-strategy-digest/SKILL.md index ff47d1c3..f4f8135e 100644 --- a/.claude/skills/sage-strategy-digest/SKILL.md +++ b/.claude/skills/sage-strategy-digest/SKILL.md @@ -96,11 +96,3 @@ Present a short and direct version. - **Opinions flagged** — when it is opinion vs data, make it clear - **One recommendation** — do not give 10 suggestions, give 1 clear one - **Connect the dots** — the value of the digest is crossing areas, not repeating individual reports - - -### Notify via Telegram - -Upon completion, send a short summary via Telegram to the user: -- Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) -- If the routine had no updates, send anyway with "no updates" diff --git a/.claude/skills/social-analytics-report/SKILL.md b/.claude/skills/social-analytics-report/SKILL.md index ce8aec24..fdc2c2c9 100644 --- a/.claude/skills/social-analytics-report/SKILL.md +++ b/.claude/skills/social-analytics-report/SKILL.md @@ -137,16 +137,3 @@ workspace/social/reports/consolidated/[C] YYYY-MM-DD-social-analytics.html ``` Create directory if it does not exist. - -### Step 9 — Telegram - -Notify: `reply(chat_id="YOUR_CHAT_ID", text="...")` -Format: -``` -📊 Social Analytics — {period} -👥 Total followers: {N} ({delta}) -📹 YouTube: {subs} sub | {eng}% eng -📸 Instagram: {followers} fol | {eng}% eng -💼 LinkedIn: profile connected -🏆 Top: "{best content}" ({platform}, {eng}%) -``` diff --git a/.claude/skills/social-youtube-report/SKILL.md b/.claude/skills/social-youtube-report/SKILL.md index 63d2ace6..8d5c42bf 100644 --- a/.claude/skills/social-youtube-report/SKILL.md +++ b/.claude/skills/social-youtube-report/SKILL.md @@ -64,8 +64,3 @@ workspace/social/reports/youtube/[C] YYYY-MM-DD-youtube-{period}.html ``` Criar diretório if it does not exist. - -### Step 7 — Telegram - -Notify: `reply(chat_id="946857210", text="...")` -Format: emoji + canal + inscritos + delta + melhor vídeo diff --git a/dashboard/terminal-server/package-lock.json b/dashboard/terminal-server/package-lock.json index 1a4af597..0611b4de 100644 --- a/dashboard/terminal-server/package-lock.json +++ b/dashboard/terminal-server/package-lock.json @@ -858,7 +858,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1063,7 +1062,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1765,7 +1763,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/uv.lock b/uv.lock index 6d6116d7..69360e19 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'darwin'", @@ -589,7 +589,7 @@ wheels = [ [[package]] name = "evo-nexus" -version = "0.32.3" +version = "0.32.2" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -826,18 +826,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/bc/e30e1e3d5e8860b0e0ce4d2b16b2681b77fd13542fc0d72f7e3c22d16eff/greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6", size = 284315, upload-time = "2026-04-08T17:02:52.322Z" }, { url = "https://files.pythonhosted.org/packages/5b/cc/e023ae1967d2a26737387cac083e99e47f65f58868bd155c4c80c01ec4e0/greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82", size = 601916, upload-time = "2026-04-08T16:24:35.533Z" }, { url = "https://files.pythonhosted.org/packages/67/32/5be1677954b6d8810b33abe94e3eb88726311c58fa777dc97e390f7caf5a/greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31", size = 616399, upload-time = "2026-04-08T16:30:54.536Z" }, - { url = "https://files.pythonhosted.org/packages/82/0a/3a4af092b09ea02bcda30f33fd7db397619132fe52c6ece24b9363130d34/greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508", size = 621077, upload-time = "2026-04-08T16:40:34.946Z" }, { url = "https://files.pythonhosted.org/packages/74/bf/2d58d5ea515704f83e34699128c9072a34bea27d2b6a556e102105fe62a5/greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398", size = 611978, upload-time = "2026-04-08T15:56:31.335Z" }, - { url = "https://files.pythonhosted.org/packages/8c/39/3786520a7d5e33ee87b3da2531f589a3882abf686a42a3773183a41ef010/greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb", size = 416893, upload-time = "2026-04-08T16:43:02.392Z" }, { url = "https://files.pythonhosted.org/packages/bd/69/6525049b6c179d8a923256304d8387b8bdd4acab1acf0407852463c6d514/greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b", size = 1571957, upload-time = "2026-04-08T16:26:17.041Z" }, { url = "https://files.pythonhosted.org/packages/4e/6c/bbfb798b05fec736a0d24dc23e81b45bcee87f45a83cfb39db031853bddc/greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf", size = 1637223, upload-time = "2026-04-08T15:57:27.556Z" }, { url = "https://files.pythonhosted.org/packages/b7/7d/981fe0e7c07bd9d5e7eb18decb8590a11e3955878291f7a7de2e9c668eb7/greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab", size = 237902, upload-time = "2026-04-08T17:03:14.16Z" }, { url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" }, { url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" }, { url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" }, - { url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" }, { url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" }, { url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" }, { url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" }, { url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" }, @@ -845,9 +841,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, @@ -855,9 +849,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, - { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, - { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, @@ -865,9 +857,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, - { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, @@ -875,9 +865,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, From 3cfeed2292b08f7447f2907dd2b12ff6c568cd1c Mon Sep 17 00:00:00 2001 From: jbmendonca Date: Mon, 27 Apr 2026 15:13:17 -0400 Subject: [PATCH 06/12] feat: merge v0.33.0 upstream with TKR security hardening and failover routing - Base: upstream main (v0.33.0) with all official updates - Added: TKR security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) - Added: CORS CSRF token support (X-CSRF-Token headers) - Added: Provider failover chain (getProviderCandidates, resolveProviderChain) - Added: Platform observability, cache, queue, and metrics modules - Added: Runtime config, structured logging, request/session security - Added: Frontend: CommandPalette, ThemeContext, Observability page, SecurityTab - Preserved: upstream OpenRouter Smart Router native integration - Adapted: Provider-config.js enhanced with routing and default_model support --- .github/workflows/ci.yml | 4 +- .pre-commit-config.yaml | 23 + ROADMAP.md | 283 +++-- config/plugin-registry.json | 40 + dashboard/backend/app.py | 52 +- dashboard/backend/blueprint_registry.py | 62 + dashboard/backend/db_compat.py | 195 ++++ dashboard/backend/db_migrations.py | 48 + dashboard/backend/migrations/alembic.ini | 3 + dashboard/backend/migrations/env.py | 50 + .../versions/0001_bootstrap_schema.py | 24 + dashboard/backend/platform_cache.py | 135 +++ dashboard/backend/platform_metrics.py | 87 ++ dashboard/backend/platform_observability.py | 90 ++ dashboard/backend/platform_plugins.py | 193 ++++ dashboard/backend/platform_queue.py | 62 + dashboard/backend/platform_support.py | 75 ++ dashboard/backend/request_security.py | 41 + dashboard/backend/routes/platform.py | 135 +++ dashboard/backend/runtime_config.py | 122 ++ dashboard/backend/schema_migrations.py | 291 +++++ dashboard/backend/session_security.py | 82 ++ dashboard/backend/structured_logging.py | 60 + dashboard/backend/totp_security.py | 78 ++ dashboard/frontend/playwright.config.ts | 26 + .../frontend/public/manifest.webmanifest | 29 + dashboard/frontend/public/sw.js | 65 ++ .../frontend/scripts/start-e2e-server.mjs | 48 + .../src/components/CommandPalette.tsx | 341 ++++++ .../src/components/agent-chat/ChatBlocks.tsx | 303 +++++ .../frontend/src/context/ThemeContext.tsx | 52 + dashboard/frontend/src/lib/navigation.ts | 136 +++ dashboard/frontend/src/lib/pwa.ts | 18 + dashboard/frontend/src/lib/theme.ts | 44 + .../frontend/src/pages/Observability.tsx | 243 ++++ .../src/pages/SettingsSecurityTab.tsx | 257 +++++ .../integrations/CustomIntegrationModal.tsx | 300 +++++ .../src/pages/integrations/DatabasesTab.tsx | 552 +++++++++ .../pages/integrations/IntegrationCard.tsx | 150 +++ .../pages/integrations/SocialAccountsTab.tsx | 118 ++ .../frontend/src/pages/integrations/types.ts | 226 ++++ .../frontend/test-results/.last-run.json | 4 + .../frontend/tests/e2e/setup-login.spec.ts | 37 + .../terminal-server/src/claude-bridge.js | 12 +- dashboard/terminal-server/src/http-routes.js | 276 +++++ .../src/platform-event-broker.js | 100 ++ .../terminal-server/src/platform-metrics.js | 31 + .../terminal-server/src/platform-support.js | 41 + .../terminal-server/src/provider-config.js | 167 ++- .../terminal-server/src/runtime-config.js | 41 + .../src/terminal-server-runtime.js | 1025 +++++++++++++++++ .../src/utils/structured-logger.js | 77 ++ .../src/utils/terminal-audit-log.js | 32 + .../test/platform-event-broker.test.js | 44 + .../test/provider-config.test.js | 85 ++ .../test/runtime-config.test.js | 50 + tests/backend/test_auth_security.py | 66 +- tests/backend/test_health_routes.py | 88 +- tests/backend/test_provider_routing.py | 31 + tests/backend/test_runtime_config.py | 90 ++ 60 files changed, 7254 insertions(+), 186 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 config/plugin-registry.json create mode 100644 dashboard/backend/blueprint_registry.py create mode 100644 dashboard/backend/db_compat.py create mode 100644 dashboard/backend/db_migrations.py create mode 100644 dashboard/backend/migrations/alembic.ini create mode 100644 dashboard/backend/migrations/env.py create mode 100644 dashboard/backend/migrations/versions/0001_bootstrap_schema.py create mode 100644 dashboard/backend/platform_cache.py create mode 100644 dashboard/backend/platform_metrics.py create mode 100644 dashboard/backend/platform_observability.py create mode 100644 dashboard/backend/platform_plugins.py create mode 100644 dashboard/backend/platform_queue.py create mode 100644 dashboard/backend/platform_support.py create mode 100644 dashboard/backend/request_security.py create mode 100644 dashboard/backend/routes/platform.py create mode 100644 dashboard/backend/runtime_config.py create mode 100644 dashboard/backend/schema_migrations.py create mode 100644 dashboard/backend/session_security.py create mode 100644 dashboard/backend/structured_logging.py create mode 100644 dashboard/backend/totp_security.py create mode 100644 dashboard/frontend/playwright.config.ts create mode 100644 dashboard/frontend/public/manifest.webmanifest create mode 100644 dashboard/frontend/public/sw.js create mode 100644 dashboard/frontend/scripts/start-e2e-server.mjs create mode 100644 dashboard/frontend/src/components/CommandPalette.tsx create mode 100644 dashboard/frontend/src/components/agent-chat/ChatBlocks.tsx create mode 100644 dashboard/frontend/src/context/ThemeContext.tsx create mode 100644 dashboard/frontend/src/lib/navigation.ts create mode 100644 dashboard/frontend/src/lib/pwa.ts create mode 100644 dashboard/frontend/src/lib/theme.ts create mode 100644 dashboard/frontend/src/pages/Observability.tsx create mode 100644 dashboard/frontend/src/pages/SettingsSecurityTab.tsx create mode 100644 dashboard/frontend/src/pages/integrations/CustomIntegrationModal.tsx create mode 100644 dashboard/frontend/src/pages/integrations/DatabasesTab.tsx create mode 100644 dashboard/frontend/src/pages/integrations/IntegrationCard.tsx create mode 100644 dashboard/frontend/src/pages/integrations/SocialAccountsTab.tsx create mode 100644 dashboard/frontend/src/pages/integrations/types.ts create mode 100644 dashboard/frontend/test-results/.last-run.json create mode 100644 dashboard/frontend/tests/e2e/setup-login.spec.ts create mode 100644 dashboard/terminal-server/src/http-routes.js create mode 100644 dashboard/terminal-server/src/platform-event-broker.js create mode 100644 dashboard/terminal-server/src/platform-metrics.js create mode 100644 dashboard/terminal-server/src/platform-support.js create mode 100644 dashboard/terminal-server/src/runtime-config.js create mode 100644 dashboard/terminal-server/src/terminal-server-runtime.js create mode 100644 dashboard/terminal-server/src/utils/structured-logger.js create mode 100644 dashboard/terminal-server/src/utils/terminal-audit-log.js create mode 100644 dashboard/terminal-server/test/platform-event-broker.test.js create mode 100644 dashboard/terminal-server/test/provider-config.test.js create mode 100644 dashboard/terminal-server/test/runtime-config.test.js create mode 100644 tests/backend/test_provider_routing.py create mode 100644 tests/backend/test_runtime_config.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea50f213..660a47ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [develop, main] + branches: [main] paths: - "dashboard/**" - "tests/**" @@ -10,7 +10,7 @@ on: - "uv.lock" - ".github/workflows/ci.yml" push: - branches: [develop, main] + branches: [main] paths: - "dashboard/**" - "tests/**" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..50cde338 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.11 + hooks: + - id: ruff-check + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + files: ^dashboard/frontend/.*\.(ts|tsx|js|jsx|json|css|md|html|yaml|yml)$ + + - repo: local + hooks: + - id: frontend-typecheck + name: frontend type-check + entry: npm --prefix dashboard/frontend run build + language: system + files: ^dashboard/frontend/ + pass_filenames: false + always_run: true diff --git a/ROADMAP.md b/ROADMAP.md index df015adf..381c7550 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,135 +1,254 @@ # EvoNexus Roadmap -> Unofficial toolkit for Claude Code — AI-powered business operating system. -> -> This roadmap is updated regularly. Want to vote or suggest? [Open a discussion](https://github.com/EvolutionAPI/evo-nexus/discussions) or [create an issue](https://github.com/EvolutionAPI/evo-nexus/issues). +> Execution roadmap derived from the PRD review and current branch state. +> Last updated: 2026-04-24 --- -## Legend +## How To Read This -| Symbol | Meaning | -|--------|---------| -| `[ ]` | Not started | -| `[x]` | Done | -| `⚠️` | Breaking change | -| `🔥` | High priority | -| `💡` | Needs design discussion first | +- `Done` means implemented and validated locally. +- `Partial` means a scaffold or mitigation exists, but the item is not closed. +- `Next` means highest priority pending work. +- `Later` means important work that is gated by dependencies. --- -## v0.4 — Foundation & Stability +## Project Snapshot -> Fix, secure, and improve what already exists before growing. +- Delivered: the security hardening pass, backend maintainability work, frontend scaling work, and the phase 4 platform layer are in place. +- Open: env normalization edge cases in the last offline tooling paths. +- Focus now: close the remaining partial items before adding new feature surface area. -### Skills +--- -- [x] 🔥 **Evolution product skills** — `int-evolution-api` (33 commands), `int-evolution-go` (24 commands), `int-evo-crm` (48 commands) for managing instances, messages, contacts, conversations, pipelines via REST API. -- [x] **Version indicator & update alerts** — show current version in dashboard sidebar, alert when new GitHub releases are available. +## Current Baseline -### Developer Experience +| Area | Status | Notes | +|---|---|---| +| Security | Done | Login hardening, password policy, configurable CORS, env-first secret key handling, CSRF token plumbing, session token rotation, and 2FA/TOTP are in place. | +| Reliability | Done | Session GC, heartbeat timeout handling, health endpoints, backup hooks, restart supervision, and readiness/liveness probe split are in place. | +| Architecture | Partial | Alembic bootstrap, lazy blueprint loading, structured logs, terminal-server modularization, and restart supervision are in place. Remaining work: the last env normalization edge cases. | +| Frontend | Done | Route splitting, section-level error boundaries, theme toggle, PWA support, command palette, and mobile chat improvements are done. | +| Testing / CI | Done | Auth tests, terminal-server tests, CI, pre-commit hooks, and Playwright E2E coverage exist. | +| Platform | Done | Queue/cache abstractions, provider failover, observability, plugins, a shared platform broker, and PostgreSQL rollout wiring are in place. | -- [x] 🔥 **CLI installer** — `npx @evoapi/evo-nexus` — clones repo, installs deps, runs interactive setup wizard. -- [x] **Full Docker install** — `docker compose up dashboard` with multi-stage Dockerfile + GitHub Actions CI pushing to GHCR. -- [x] **Update checker** — dashboard checks GitHub releases and shows upgrade notification. -- [x] **settings.json** — project-level permissions (allow/deny), hooks configuration, thinking mode enabled. -- [x] **CLAUDE.md split** — reduced from 263 to 128 lines. Detailed config moved to `.claude/rules/` (agents, integrations, routines, skills). -- [x] **Inner-loop commands** — `/status` (workspace status) and `/review` (recent changes + next actions). +--- -### Dashboard UX +## Shipped So Far + +- Rate limiting on login +- Strong password policy +- Configurable CORS +- Env-first secret key handling +- Runtime config aliases for `DATABASE_URL` and `PORT` +- CSRF token plumbing for mutating requests +- Per-session token rotation and CSRF refresh on auth changes +- TOTP / 2FA enrollment, login, and disable flows for admins +- Readiness / liveness health endpoints +- Pre-commit hooks for backend lint, frontend format, and frontend type-check +- Pre-migration backup hook for SQLite +- Alembic-backed schema bootstrap +- Session GC in the terminal server +- Heartbeat, summary, and goal workers now follow the shared DB resolver +- WebSocket heartbeat timeout handling +- Terminal-server audit log +- Structured JSON logs for backend and terminal server +- Health and deep-health endpoints +- Healthcheck wiring in compose / swarm +- Session save debounce in the terminal server +- Lazy blueprint loading +- Terminal-server restart supervision +- Terminal-server runtime split into a wrapper plus dedicated runtime module +- Shared platform event broker between Flask and terminal-server +- Hot-path cache invalidation on platform events +- Frontend route splitting +- Section-level error boundaries +- Global theme toggle +- Command palette with Ctrl+K +- PWA manifest and service worker +- Mobile chat layout improvements +- AgentChat transcript shell extraction +- AgentChat block extraction into focused components +- Integrations page decomposition into focused modules +- Shared `PageSkeleton` loading states across major pages +- Provider failover routing and preflight selection +- Observability dashboard +- Plugin registry and install / uninstall flow +- PostgreSQL-ready runtime and DB adapters +- Compose and Swarm PostgreSQL rollout wiring +- CI workflow for backend, terminal server, and frontend build +- Playwright E2E coverage for setup, login, and protected-route navigation -- [x] **Sidebar reorganization** — 5 collapsible groups (Main, Operations, Data, System, Admin) with localStorage persistence. -- [x] **Active agent visualization** — Claude Code hooks track agent launches via `PreToolUse` events, writing to `agent-status.json`. Dashboard polls `/api/agents/active` and shows "RUNNING" badges with pulse animation on agent cards and overview. -- [x] **Agents page redesign** — unique icons and accent colors per agent, status dots, slash command badges, memory count pills, hover glow effects. -- [x] **Overview page redesign** — stat cards with icons and trend indicators, active agents bar, quick actions row, improved reports and routines tables. +--- -### Agent Generalization +## Delivery Roadmap -- [x] **Agent prompts generalized** — all 9 agent prompts cleaned of hardcoded personal references. User-specific context preserved in `_improvements.md` per agent memory folder. +### Phase 1 - Close Remaining Risk ---- +Goal: make the current stack safe enough for sensitive data and stable deployments. + +| Item | Priority | Status | Exit Criterion | +|---|---|---|---| +| D6 Backup before migrations | P0 | Done | Automatic backup runs before any schema change. | +| S4 CSRF tokens for mutations | P1 | Done | Mutating requests now require the XHR header plus a per-session CSRF token. | +| S5 Audit log for terminal server | P1 | Done | Sensitive actions are recorded with actor, timestamp, and target. | +| P5 WebSocket heartbeat timeout | P1 | Done | Dead connections are detected and closed automatically. | +| T2 Terminal-server integration tests | P1 | Done | WebSocket session lifecycle is covered by automated tests. | +| D1 / D2 Health checks and probes | P1 | Done | Separate readiness and liveness endpoints are exposed and the deploy healthchecks use the live probe. | + +Exit criterion: no remaining P0 security gaps, and unhealthy processes are detected automatically. + +### Phase 2 - Backend Maintainability + +Goal: reduce startup coupling and make migrations and runtime behavior explicit. -## v0.5 — Extensibility & Ecosystem +| Item | Priority | Status | Exit Criterion | +|---|---|---|---| +| A1 Alembic extraction | P1 | Done | Schema changes are versioned and reversible. | +| A2 Split `server.js` | P2 | Done | `server.js` now delegates to a dedicated runtime module and platform event broker. | +| D3 Structured logs | P2 | Done | Logs are JSON and searchable across services. | +| D4 Env-based config | P1 | Partial | Most runtime config is env-driven; `DATABASE_URL` and `PORT` aliases are supported, and the heartbeat, summary, and goal workers now follow the shared DB resolver, but a few offline tools still keep local-path fallbacks. | +| D5 Isolated process restart | P1 | Done | Terminal-server failure no longer brings down the dashboard. | +| P3 Debounce `saveSessionsToDisk` | P2 | Done | Disk writes are batched and bounded. | +| P4 Lazy blueprint loading | P2 | Done | Backend startup avoids eager loading every route. | -> Make EvoNexus composable and self-extending. +Exit criterion: backend startup is faster, migrations are explicit, and service boundaries are clearer. -### Agent System +### Phase 3 - Frontend Scale And UX -- [x] 🔥 **Generalize existing agents** — all 9 agent prompts generalized. User-specific context preserved in `_improvements.md` per agent memory folder. Adapter patterns documented as future work. -- [x] 🔥 **New business agents** — expand functional coverage: - - [x] **Marketing Agent** — orchestrate existing `mkt-*` skills, attribution, budget, full funnel - - [x] **HR / People Agent** — onboarding, 1:1s, performance reviews, hiring pipeline - - [x] **Customer Success Agent** — health score, churn prediction, NPS/CSAT, client onboarding - - [x] **Legal / Compliance Agent** — contracts, renewals, GDPR/LGPD, compliance checklists - - [x] **Product Agent** — discovery, feature prioritization (RICE/ICE), PLG metrics, feedback loop - - [x] **Data / BI Agent** — cross-area consolidated dashboard, unified KPIs, alerts, trend analysis -- [x] **Custom agents** — `custom-` prefix, gitignored, auto-discovered by dashboard (core/custom badges), `create-agent` skill, `create-command` skill -- [x] **Help agent (Oracle)** — `/oracle` answers questions about the workspace by reading the actual docs. No RAG needed — reads `docs/llms-full.txt` and source files directly +Goal: keep the dashboard maintainable as pages and workflows grow. -### Routines & Scheduling +| Item | Priority | Status | Exit Criterion | +|---|---|---|---| +| U1 Decompose `AgentChat.tsx` | P2 | Done | Core chat blocks and the transcript shell are split into focused components. | +| U2 Decompose `Integrations.tsx` | P2 | Done | Provider-specific logic, social accounts, and database flows are split into focused modules. | +| U3 Theme toggle | P3 | Done | Light and dark preference is user-controlled. | +| U4 PWA support | P3 | Done | Dashboard has a manifest and service worker. | +| U5 Skeleton states everywhere | P2 | Done | Every major page now uses the shared `PageSkeleton` loading surface. | +| U7 Command palette | P3 | Done | Keyboard-driven navigation is available. | +| 4.4 Mobile responsive chat | P2 | Done | Chat works on narrow screens without layout breakage. | -- [x] 🔥 **Trigger registry** — define and manage named triggers (webhook, cron, event-based) that invoke skills or routines -- [x] **Non-recurrent scheduled actions** — one-off scheduled tasks (e.g., "post this on LinkedIn Friday at 10am") without creating a full routine -- [x] **Systematic routines** — pure Python routines via `run_script()` — no AI, no tokens, no cost. `create-routine` skill generates the code +Exit criterion: the dashboard can absorb new pages without becoming a monolith. -### Integrations +### Phase 4 - Platform Expansion -- [ ] **Complete Obsidian integration** — finish `obs-*` skills: bidirectional sync, canvas, bases, CLI +Goal: unlock multi-provider, observability, and enterprise deployment paths. -### Import / Export +| Item | Priority | Status | Exit Criterion | +|---|---|---|---| +| A3 Message queue | P2 | Done | Queue events now flow through a shared broker between Flask and terminal server. | +| A4 Redis cache | P3 | Done | Provider, observability, and queue hot paths now use cache-backed reads with event-driven invalidation. | +| A5 PostgreSQL option | P2 | Done | Compose and Swarm now wire `DATABASE_URL` to a PostgreSQL service. | +| 4.1 Native provider failover | P2 | Done | Routing is configurable and the terminal server now preflights and falls back through ready providers. | +| 4.2 Observability dashboard | P2 | Done | Tokens, latency, queue, cache, and plugin state are visible in one place. | +| 4.3 Plugin system | P2 | Done | Third-party agent packs can be registered, installed, and removed safely. | +| 4.5 PostgreSQL backend | P2 | Done | DB adapter and migrations work across SQLite and PostgreSQL. | -- [x] **Backup system** — export workspace state as ZIP (agents, skills, routines, memory, config); import to restore. Support local, git, and cloud bucket targets. +Exit criterion: the platform can scale across load, providers, and deployment models. --- -## v0.12 — Engineering Layer +## Priority Backlog + +### Security + +- No open Phase 1 security items remain. + +### Reliability + +- No open Phase 1 reliability items remain. + +### Architecture + +- D4 Config normalization + +### Testing / CI -> Add a complete software development team alongside the business agents. -### Engineering Layer (delivered) +No open Testing / CI items remain. -- [x] 🔥 **19 engineering agents** — complete dev team derived from [oh-my-claudecode](https://github.com/yeachan-heo/oh-my-claudecode) (MIT, Yeachan Heo). Reasoning tier (opus): apex-architect, echo-analyst, compass-planner, raven-critic, lens-reviewer, zen-simplifier, vault-security. Execution tier (sonnet): bolt-executor, hawk-debugger, grid-tester, probe-qa, oath-verifier, trail-tracer, flow-git, scroll-docs, canvas-designer, prism-scientist. Speed tier (haiku): scout-explorer, quill-writer. -- [x] 🔥 **25 `dev-*` skills** — Tier 1 orchestration (15): `dev-autopilot`, `dev-plan`, `dev-ralplan`, `dev-deep-interview`, `dev-deep-dive`, `dev-external-context`, `dev-trace`, `dev-verify`, `dev-ultraqa`, `dev-visual-verdict`, `dev-ai-slop-cleaner`, `dev-sciomc`, `dev-team`, `dev-ccg`, `dev-ralph`. Tier 2 setup (5): `dev-mcp-setup`, `dev-deepinit`, `dev-project-session-manager`, `dev-configure-notifications`, `dev-release`. Tier 3 utilities (5): `dev-cancel`, `dev-remember`, `dev-ask`, `dev-learner`, `dev-skillify`. -- [x] **15 dev templates** — `.claude/templates/dev-*.md` for each agent's primary output (architecture-decision, work-plan, code-review, bug-report, verification-report, deep-interview-spec, security-audit, test-strategy, trace-report, explore-report, design-spec, analysis-report, research-brief, critique, simplification-report). -- [x] **`workspace/development/` folder** — 7 subfolders (architecture, plans, specs, reviews, debug, verifications, research) for engineering layer artifacts. Distinct from `workspace/projects/` (active git repos). -- [x] **Two-layer architecture documented** — `.claude/rules/agents.md`, `CLAUDE.md`, `docs/agents/overview.md`, `docs/agents/engineering-layer.md`, `docs/architecture.md`, `docs/introduction.md`, site `Home.tsx`. -- [x] **Open source attribution** — `NOTICE.md` at repo root with full MIT license, version pinned (v4.11.4), modifications listed. Credits in `README.md`. -- [x] **Pattern compliance** — all 19 engineering agents follow the EvoNexus standard: rich frontmatter (Examples + commentary), Workspace Context, Shared Knowledge Base, Working Folder, Identity, Anti-patterns, Domain, How You Work, Skills You Can Use, Handoffs, Output Format, Continuity. Verified by `@lens-reviewer` (3 fixes applied: oath-verifier disallowedTools, raven-critic and trail-tracer Skills section). +### Frontend / UX -### Cross-layer pipelines (now possible) +- No open Phase 3 frontend items remain. -- [x] **End-to-end implementation** — `dev-autopilot` orchestrates spec → plan → code → QA → validation across multiple engineering agents. -- [x] **High-stakes consensus planning** — `dev-ralplan` runs Planner/Architect/Critic consensus loop with RALPLAN-DR structured deliberation. -- [x] **Bug investigation** — `@trail-tracer` (multi-hypothesis) → `@hawk-debugger` (root cause + minimal fix) → `@oath-verifier` (regression check). -- [x] **Pre-merge gate** — `@lens-reviewer` (code quality) → `@vault-security` (OWASP audit) → `dev-ultraqa` (build/test/fix loop) → `@oath-verifier` (acceptance criteria). +### Platform Expansion + +- No open Platform Expansion items remain. --- -## v1.0 — Community & Growth +## Sprint Execution Plan + +Priority inside each sprint runs top to bottom. The `Depends On` column lists hard blockers only; the sprint order itself also reflects risk reduction and release sequencing. + +Implementation status: Sprint 1 is complete, Sprint 2 is partially complete, Sprint 3 is complete, and Sprint 4 is complete. + +### Sprint 1 - Security And Deploy Gates + +Goal: close the items that reduce blast radius and make production checks reliable. + +| Item | Priority | Depends On | Why It Is Here | +|---|---|---|---| +| S4 CSRF tokens for mutations | P1 | None | Must land before any new write-heavy surface area expands. | +| D1 / D2 health checks and probes | P1 | None | Separate readiness and liveness before more rollout work. | +| S6 session key rotation | P2 | None | Completes the auth hardening pass while the auth path is already being touched. | + +### Sprint 2 - Config And Auth Hardening -> Community adoption, discoverability, and self-sustaining ecosystem. +Goal: normalize runtime configuration and finish the remaining auth hardening. -### Community & Docs +| Item | Priority | Depends On | Why It Is Here | +|---|---|---|---| +| D4 config normalization | P1 | None | Remove the last hardcoded runtime assumptions before deeper refactors. | +| S7 2FA / TOTP for admins | P2 | S4 + S6 | Builds on the hardened login and session flow. | +| A2 `server.js` modularization | P2 | D4 | Refactor after the config surface is stable. | +| T5 pre-commit hooks | P2 | Done | Lint, format, and frontend type-check gates run before commits. | -- [x] 🔥 **Public roadmap** — this file. Community input welcome via [discussions](https://github.com/EvolutionAPI/evo-nexus/discussions). -- [x] **Telegram & Discord channels** — activate community channels, document in README and docs site. -- [ ] **In-app tutorials** — contextual tutorials surfaced inside the dashboard, not just external docs. -- [x] **Resume Claude sessions in chat** — list active/resumable Claude sessions in dashboard chat with `--resume` support. +### Sprint 3 - Verification And Scale Prep -### Development +Goal: prove the stable flows end to end and prepare the platform layer for scale. -- [ ] **Testing framework** — define and implement test strategy for skills, routines, and agent behaviors; prevent regressions. +| Item | Priority | Depends On | Why It Is Here | +|---|---|---|---| +| T3 Playwright E2E | P2 | Done | Validates the core user journeys after the config and auth work settle. | +| A3 Message queue | P2 | Done | Async coordination is now handled by the shared platform broker. | +| A4 Redis cache | P3 | Done | Hot-path caching is now active with invalidation on platform events. | + +### Sprint 4 - Production Rollout + +Goal: finish the remaining deployment wiring for PostgreSQL-backed production use. + +| Item | Priority | Depends On | Why It Is Here | +|---|---|---|---| +| A5 PostgreSQL option | P2 | Done | PostgreSQL rollout wiring is now present in both Compose and the Swarm stack. | --- -## Contributing +## Dependency Rules -Want to help? Pick any `[ ]` item and: +- S4 should land before any new write-heavy surfaces. +- P5 should land before more WebSocket features are built. +- 4.1 should reuse the current Smart Router instead of duplicating provider loading logic. +- D4 remains the only partially open configuration cleanup item. -1. Check [open issues](https://github.com/EvolutionAPI/evo-nexus/issues) -2. Read [CONTRIBUTING.md](CONTRIBUTING.md) -3. For `💡` items, open a [discussion](https://github.com/EvolutionAPI/evo-nexus/discussions) first — design is still open +--- + +## Success Metrics + +| Metric | Target | +|---|---| +| First Contentful Paint | < 1.5s | +| Bundle size gzipped | < 400KB | +| Auth coverage | >= 80% | +| Terminal server WebSocket coverage | >= 70% | +| Login brute-force attempts | Bounded by lockout policy | +| Session GC lag | <= 24h idle | +| Backend startup time | < 2s | --- -*Last updated: 2026-04-10 — [Evolution Foundation](https://evolutionfoundation.com.br)* +## Notes + +- This file replaces the old v0.4-v1.0 milestone list. +- The next concrete delivery gate is D4 cleanup, which should be treated as the current execution queue. diff --git a/config/plugin-registry.json b/config/plugin-registry.json new file mode 100644 index 00000000..9a9390b4 --- /dev/null +++ b/config/plugin-registry.json @@ -0,0 +1,40 @@ +{ + "plugins": [ + { + "id": "observability-scout", + "name": "Observability Scout", + "version": "1.0.0", + "category": "observability", + "description": "Installs a read-only agent pack for platform telemetry and incident triage.", + "agents": [ + { + "name": "observability-scout", + "description": "Read-only observability analyst for EvoNexus. Uses metrics, health, costs, and provider routing data to identify regressions.", + "model": "haiku", + "color": "cyan", + "memory": "project", + "disallowedTools": ["Write", "Edit", "Bash", "NotebookEdit"], + "prompt": "You are Observability Scout.\nAnalyze EvoNexus platform health, provider routing, queue/cache status, and cost trends.\nRecommend concrete fixes with clear severity. Do not edit files." + } + ] + }, + { + "id": "provider-router", + "name": "Provider Router", + "version": "1.0.0", + "category": "platform", + "description": "Installs a read-only agent pack for provider failover and routing audits.", + "agents": [ + { + "name": "provider-router", + "description": "Read-only provider routing specialist for EvoNexus. Reviews failover order, provider health, and selected model mode.", + "model": "haiku", + "color": "violet", + "memory": "project", + "disallowedTools": ["Write", "Edit", "Bash", "NotebookEdit"], + "prompt": "You are Provider Router.\nAudit provider health, failover order, and model compatibility.\nRecommend a safe routing chain and explain why a fallback should or should not be activated." + } + ] + } + ] +} diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 2dccb597..bfb192d0 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -8,10 +8,29 @@ from datetime import timedelta from dotenv import load_dotenv -from flask import Flask, send_from_directory, request, jsonify +from flask import Flask, send_from_directory, request, jsonify, abort from flask_cors import CORS from flask_login import LoginManager, current_user, login_user +# TKR Security Hardening imports +try: + from runtime_config import load_dashboard_runtime_config + _runtime_config = load_dashboard_runtime_config +except ImportError: + _runtime_config = None +try: + from structured_logging import install_request_logging +except ImportError: + install_request_logging = None +try: + from request_security import require_xhr +except ImportError: + require_xhr = None +try: + from session_security import attach_session_token +except ImportError: + attach_session_token = None + # Workspace root: two levels up from backend/ WORKSPACE = Path(__file__).resolve().parent.parent.parent @@ -91,7 +110,13 @@ def _cors_allowed_origins(): # Flask <2.2 exposed this through app.config; keep compatibility. app.config["JSON_AS_ASCII"] = False -CORS(app, origins=_cors_allowed_origins(), supports_credentials=True) +CORS( + app, + origins=_cors_allowed_origins(), + supports_credentials=True, + allow_headers=["Content-Type", "X-Requested-With", "X-CSRF-Token", "Authorization"], + expose_headers=["X-CSRF-Token"], +) # --------------- Database --------------- from models import db, User, BrainRepoConfig, needs_setup, seed_roles, seed_systems @@ -743,6 +768,7 @@ def unauthorized(): "/api/auth/login", "/api/auth/needs-setup", "/api/auth/setup", + "/api/auth/csrf", "/api/health", "/api/auth/needs-onboarding", "/api/config/workspace-status", @@ -815,6 +841,21 @@ def auth_middleware(): if not current_user.is_authenticated: return jsonify({"error": "Authentication required"}), 401 +# --------------- TKR Security Headers --------------- +@app.after_request +def attach_security_headers(response): + """Attach security-related headers to every response.""" + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + # Attach session token if module available + if attach_session_token is not None: + try: + attach_session_token(response) + except Exception: + pass + return response + # --------------- Register blueprints --------------- from routes.overview import bp as overview_bp from routes.workspace import bp as workspace_bp @@ -923,6 +964,13 @@ def auth_middleware(): app.register_blueprint(plugins_bp) app.register_blueprint(mcp_servers_bp) +# TKR Platform routes (observability, cache, queue) +try: + from routes.platform import bp as platform_bp + app.register_blueprint(platform_bp) +except ImportError: + pass # Platform module not available + # --------------- Social Auth blueprints --------------- from auth.youtube import bp as youtube_auth_bp from auth.instagram import bp as instagram_auth_bp diff --git a/dashboard/backend/blueprint_registry.py b/dashboard/backend/blueprint_registry.py new file mode 100644 index 00000000..2ed22c83 --- /dev/null +++ b/dashboard/backend/blueprint_registry.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from importlib import import_module + + +BLUEPRINT_MODULES = [ + "routes.overview", + "routes.workspace", + "routes.agents", + "routes.routines", + "routes.skills", + "routes.templates_routes", + "routes.memory", + "routes.costs", + "routes.config", + "routes.integrations", + "routes.scheduler", + "routes.services", + "routes.auth_routes", + "routes.systems", + "routes.docs", + "routes.mempalace", + "routes.tasks", + "routes.triggers", + "routes.backups", + "routes.providers", + "routes.settings", + "routes.shares", + "routes.heartbeats", + "routes.goals", + "routes.tickets", + "routes.platform", + "routes.health", + "routes.knowledge", + "routes.knowledge_public", + "routes.knowledge_proxy", + "routes.knowledge_v1", + "routes.databases", +] + +SOCIAL_BLUEPRINT_MODULES = [ + "auth.youtube", + "auth.instagram", + "auth.linkedin", + "auth.twitter", + "auth.tiktok", + "auth.twitch", +] + + +def _register_modules(app, module_names): + for module_name in module_names: + module = import_module(module_name) + blueprint = getattr(module, "bp", None) + if blueprint is None: + raise RuntimeError(f"Blueprint module {module_name} does not expose bp") + app.register_blueprint(blueprint) + + +def register_blueprints(app) -> None: + _register_modules(app, BLUEPRINT_MODULES) + _register_modules(app, SOCIAL_BLUEPRINT_MODULES) diff --git a/dashboard/backend/db_compat.py b/dashboard/backend/db_compat.py new file mode 100644 index 00000000..414edd23 --- /dev/null +++ b/dashboard/backend/db_compat.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any, Iterable + +from sqlalchemy import create_engine, text + +from runtime_config import database_backend, database_uri, sqlite_path_from_uri + + +@dataclass +class CompatRow: + keys_list: list[str] + values_list: list[Any] + + def __getitem__(self, item: int | str) -> Any: + if isinstance(item, int): + return self.values_list[item] + idx = self.keys_list.index(item) + return self.values_list[idx] + + def keys(self) -> list[str]: + return list(self.keys_list) + + def get(self, key: str, default: Any = None) -> Any: + try: + return self[key] + except Exception: + return default + + def items(self): + return list(zip(self.keys_list, self.values_list)) + + def __iter__(self): + return iter(self.items()) + + def __len__(self) -> int: + return len(self.values_list) + + def as_dict(self) -> dict[str, Any]: + return dict(self.items()) + + +def _translate_qmark_sql(sql: str, params: Iterable[Any] | dict[str, Any] | None) -> tuple[str, dict[str, Any] | tuple[Any, ...]]: + if params is None: + return sql, {} + if isinstance(params, dict): + return sql, params + + values = list(params) + if "?" not in sql: + return sql, tuple(values) + + chunks = sql.split("?") + translated: list[str] = [chunks[0]] + bind: dict[str, Any] = {} + for idx, chunk in enumerate(chunks[1:]): + key = f"p{idx}" + translated.append(f":{key}") + translated.append(chunk) + if idx < len(values): + bind[key] = values[idx] + return "".join(translated), bind + + +@lru_cache(maxsize=8) +def _engine_for(database_url: str): + return create_engine(database_url, pool_pre_ping=True, future=True) + + +class CompatCursor: + def __init__(self, connection: "CompatConnection"): + self._connection = connection + self._rows: list[CompatRow] = [] + self._index = 0 + self.rowcount = -1 + self._last_result = None + + def execute(self, sql: str, params: Iterable[Any] | dict[str, Any] | None = None): + if self._connection.backend == "sqlite": + bind = params if isinstance(params, dict) else tuple(params or ()) # type: ignore[arg-type] + cursor = self._connection._raw.execute(sql, bind) + keys = [desc[0] for desc in cursor.description] if cursor.description else [] + self._rows = [CompatRow(keys, list(row)) for row in cursor.fetchall()] if keys else [] + self.rowcount = cursor.rowcount + self._index = 0 + self._last_result = cursor + return self + + translated_sql, bind = _translate_qmark_sql(sql, params) + result = self._connection._conn.execute(text(translated_sql), bind) # type: ignore[arg-type] + keys = list(result.keys()) if result.returns_rows else [] + self._rows = [CompatRow(keys, list(row)) for row in result.fetchall()] if keys else [] + self.rowcount = result.rowcount + self._index = 0 + self._last_result = result + return self + + def fetchone(self) -> CompatRow | None: + if self._index >= len(self._rows): + return None + row = self._rows[self._index] + self._index += 1 + return row + + def fetchall(self) -> list[CompatRow]: + if self._index >= len(self._rows): + return [] + remaining = self._rows[self._index :] + self._index = len(self._rows) + return remaining + + +class CompatConnection: + def __init__(self, database_url: str | None = None, timeout: int = 30): + self.database_url = database_url or database_uri() + self.backend = database_backend(self.database_url) + self._raw = None + self._engine = None + self._conn = None + self._transaction = None + + if self.backend == "sqlite": + sqlite_path = sqlite_path_from_uri(self.database_url) + if sqlite_path is None: + sqlite_path = Path(self.database_url.removeprefix("sqlite:///")) + self._raw = sqlite3.connect(str(sqlite_path), timeout=timeout) + else: + self._engine = _engine_for(self.database_url) + self._conn = self._engine.connect() + self._transaction = self._conn.begin() + + def cursor(self) -> CompatCursor: + return CompatCursor(self) + + def execute(self, sql: str, params: Iterable[Any] | dict[str, Any] | None = None): + return self.cursor().execute(sql, params) + + def executescript(self, script: str): + statements = [statement.strip() for statement in script.split(";") if statement.strip()] + for statement in statements: + self.execute(statement) + return self + + def commit(self) -> None: + if self.backend == "sqlite": + assert self._raw is not None + self._raw.commit() + return + if self._conn is not None and self._transaction is not None: + self._transaction.commit() + self._transaction = self._conn.begin() + + def rollback(self) -> None: + if self.backend == "sqlite": + assert self._raw is not None + self._raw.rollback() + return + if self._conn is not None and self._transaction is not None: + self._transaction.rollback() + self._transaction = self._conn.begin() + + def close(self) -> None: + if self.backend == "sqlite": + if self._raw is not None: + self._raw.close() + return + if self._transaction is not None: + try: + self._transaction.commit() + except Exception: + try: + self._transaction.rollback() + except Exception: + pass + if self._conn is not None: + self._conn.close() + + def __enter__(self) -> "CompatConnection": + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + if exc_type is None: + self.commit() + else: + self.rollback() + self.close() + return False + + +def connect_dashboard_db(database_url: str | None = None, timeout: int = 30) -> CompatConnection: + return CompatConnection(database_url=database_url, timeout=timeout) diff --git a/dashboard/backend/db_migrations.py b/dashboard/backend/db_migrations.py new file mode 100644 index 00000000..5684184d --- /dev/null +++ b/dashboard/backend/db_migrations.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path +from urllib.parse import urlparse + +from alembic import command +from alembic.config import Config + +from structured_logging import emit_json_log + + +MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" + + +def build_alembic_config(database_uri: str) -> Config: + config = Config(str(MIGRATIONS_DIR / "alembic.ini")) + config.set_main_option("script_location", str(MIGRATIONS_DIR)) + config.set_main_option("sqlalchemy.url", database_uri) + return config + + +def run_database_migrations(database_uri: str) -> None: + scheme = urlparse(database_uri).scheme or "unknown" + emit_json_log( + "info", + "database_migration_start", + service="dashboard", + database_scheme=scheme, + script_location=str(MIGRATIONS_DIR), + ) + try: + command.upgrade(build_alembic_config(database_uri), "head") + except Exception as exc: + emit_json_log( + "error", + "database_migration_failed", + service="dashboard", + database_scheme=scheme, + error=str(exc), + ) + raise + emit_json_log( + "info", + "database_migration_complete", + service="dashboard", + database_scheme=scheme, + revision="head", + ) diff --git a/dashboard/backend/migrations/alembic.ini b/dashboard/backend/migrations/alembic.ini new file mode 100644 index 00000000..9e959190 --- /dev/null +++ b/dashboard/backend/migrations/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = migrations +sqlalchemy.url = sqlite:///evonexus.db diff --git a/dashboard/backend/migrations/env.py b/dashboard/backend/migrations/env.py new file mode 100644 index 00000000..d0f37575 --- /dev/null +++ b/dashboard/backend/migrations/env.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +BASE_DIR = Path(__file__).resolve().parents[1] +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +config = context.config + +db_url = os.environ.get("EVONEXUS_DATABASE_URL", "").strip() +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +target_metadata = None + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/dashboard/backend/migrations/versions/0001_bootstrap_schema.py b/dashboard/backend/migrations/versions/0001_bootstrap_schema.py new file mode 100644 index 00000000..42de0d66 --- /dev/null +++ b/dashboard/backend/migrations/versions/0001_bootstrap_schema.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from alembic import op + +from models import db +from schema_migrations import downgrade_app_schema, upgrade_app_schema + + +revision = "0001_bootstrap_schema" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + db.metadata.create_all(bind=bind) + upgrade_app_schema(bind) + + +def downgrade() -> None: + bind = op.get_bind() + downgrade_app_schema(bind) + db.metadata.drop_all(bind=bind) diff --git a/dashboard/backend/platform_cache.py b/dashboard/backend/platform_cache.py new file mode 100644 index 00000000..8033e0d0 --- /dev/null +++ b/dashboard/backend/platform_cache.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import os +import threading +import time +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Callable + + +_lock = threading.Lock() +_memory_cache: dict[str, tuple[float | None, Any]] = {} + + +@dataclass(frozen=True) +class CacheStatus: + backend: str + available: bool + detail: str | None = None + + +class _MemoryCacheBackend: + backend = "memory" + + def get(self, key: str) -> Any: + now = time.time() + with _lock: + item = _memory_cache.get(key) + if not item: + return None + expires_at, value = item + if expires_at is not None and expires_at < now: + _memory_cache.pop(key, None) + return None + return value + + def set(self, key: str, value: Any, ttl: int | None = None) -> None: + expires_at = (time.time() + ttl) if ttl else None + with _lock: + _memory_cache[key] = (expires_at, value) + + def delete(self, key: str) -> None: + with _lock: + _memory_cache.pop(key, None) + + def clear(self) -> None: + with _lock: + _memory_cache.clear() + + def status(self) -> CacheStatus: + with _lock: + size = len(_memory_cache) + return CacheStatus(backend="memory", available=True, detail=f"{size} entries") + + +class _RedisCacheBackend: + backend = "redis" + + def __init__(self, url: str): + import redis # type: ignore + + self._redis = redis.Redis.from_url(url, decode_responses=True) + self._prefix = "evonexus:platform:cache:" + self._redis.ping() + + def _key(self, key: str) -> str: + return f"{self._prefix}{key}" + + def get(self, key: str) -> Any: + raw = self._redis.get(self._key(key)) + return None if raw is None else json.loads(raw) + + def set(self, key: str, value: Any, ttl: int | None = None) -> None: + payload = json.dumps(value, ensure_ascii=False) + if ttl: + self._redis.setex(self._key(key), ttl, payload) + else: + self._redis.set(self._key(key), payload) + + def delete(self, key: str) -> None: + self._redis.delete(self._key(key)) + + def clear(self) -> None: + for key in self._redis.scan_iter(match=f"{self._prefix}*"): + self._redis.delete(key) + + def status(self) -> CacheStatus: + try: + pong = self._redis.ping() + return CacheStatus(backend="redis", available=bool(pong), detail=os.environ.get("REDIS_URL")) + except Exception as exc: + return CacheStatus(backend="redis", available=False, detail=str(exc)[:200]) + + +@lru_cache(maxsize=1) +def _get_backend() -> Any: + redis_url = os.environ.get("REDIS_URL", "").strip() + if redis_url: + try: + return _RedisCacheBackend(redis_url) + except Exception: + pass + return _MemoryCacheBackend() + + +def cache_get(key: str) -> Any: + return _get_backend().get(key) + + +def cache_set(key: str, value: Any, ttl: int | None = None) -> None: + _get_backend().set(key, value, ttl) + + +def cache_delete(key: str) -> None: + _get_backend().delete(key) + + +def cache_get_or_set(key: str, loader: Callable[[], Any], ttl: int | None = None) -> Any: + cached = cache_get(key) + if cached is not None: + return cached + value = loader() + cache_set(key, value, ttl=ttl) + return value + + +def cache_status() -> dict[str, Any]: + status = _get_backend().status() + return { + "backend": status.backend, + "available": status.available, + "detail": status.detail, + } + diff --git a/dashboard/backend/platform_metrics.py b/dashboard/backend/platform_metrics.py new file mode 100644 index 00000000..5e64e0a8 --- /dev/null +++ b/dashboard/backend/platform_metrics.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections import defaultdict +from statistics import mean +from typing import Any + +from platform_support import PROVIDER_METRICS_PATH, append_jsonl, read_jsonl + + +def record_provider_event( + *, + provider_id: str, + event: str, + model: str | None = None, + latency_ms: float | None = None, + success: bool | None = None, + detail: str | None = None, + mode: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "provider_id": provider_id, + "event": event, + "model": model, + "latency_ms": latency_ms, + "success": success, + "detail": detail, + "mode": mode, + "metadata": metadata or {}, + } + return append_jsonl(PROVIDER_METRICS_PATH, payload) + + +def load_provider_events(limit: int = 500) -> list[dict[str, Any]]: + return read_jsonl(PROVIDER_METRICS_PATH, limit=limit) + + +def summarize_provider_events(limit: int = 500) -> dict[str, Any]: + events = load_provider_events(limit=limit) + by_provider: dict[str, dict[str, Any]] = defaultdict(lambda: { + "events": 0, + "successes": 0, + "failures": 0, + "latencies": [], + "models": defaultdict(lambda: {"events": 0, "successes": 0, "failures": 0, "latencies": []}), + "last_event": None, + }) + + for event in events: + provider_id = event.get("provider_id") or "unknown" + provider_bucket = by_provider[provider_id] + provider_bucket["events"] += 1 + provider_bucket["last_event"] = event + model = event.get("model") or "unknown" + model_bucket = provider_bucket["models"][model] + model_bucket["events"] += 1 + if event.get("success") is True: + provider_bucket["successes"] += 1 + model_bucket["successes"] += 1 + elif event.get("success") is False: + provider_bucket["failures"] += 1 + model_bucket["failures"] += 1 + latency = event.get("latency_ms") + if isinstance(latency, (int, float)): + provider_bucket["latencies"].append(float(latency)) + model_bucket["latencies"].append(float(latency)) + + summary = [] + for provider_id, bucket in by_provider.items(): + latencies = bucket.pop("latencies") + models = [] + for model_id, model_bucket in bucket.pop("models").items(): + model_latencies = model_bucket.pop("latencies") + model_bucket["avg_latency_ms"] = round(mean(model_latencies), 2) if model_latencies else None + models.append({"model": model_id, **model_bucket}) + bucket["avg_latency_ms"] = round(mean(latencies), 2) if latencies else None + bucket["success_rate"] = round((bucket["successes"] / bucket["events"]) * 100, 1) if bucket["events"] else None + bucket["models"] = sorted(models, key=lambda row: (-row["events"], row["model"])) + summary.append({"provider_id": provider_id, **bucket}) + + summary.sort(key=lambda row: (-row["events"], row["provider_id"])) + return { + "events": events[-100:], + "providers": summary, + "total_events": len(events), + } + diff --git a/dashboard/backend/platform_observability.py b/dashboard/backend/platform_observability.py new file mode 100644 index 00000000..1be6dcd5 --- /dev/null +++ b/dashboard/backend/platform_observability.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import requests +from flask import current_app + +from platform_cache import cache_get_or_set, cache_status +from platform_metrics import summarize_provider_events +from platform_plugins import list_plugins, load_installed_plugins, load_plugin_registry +from platform_queue import list_events, queue_status +from platform_support import WORKSPACE, database_scheme, read_json + + +def _load_provider_config() -> dict[str, Any]: + config_path = WORKSPACE / "config" / "providers.json" + fallback_path = WORKSPACE / "config" / "providers.example.json" + config = read_json(config_path, read_json(fallback_path, {})) + return config if isinstance(config, dict) else {} + + +def _terminal_server_snapshot() -> dict[str, Any]: + import os + + url = os.environ.get("TERMINAL_SERVER_URL", "http://127.0.0.1:32352").rstrip("/") + try: + resp = requests.get(f"{url}/api/health/deep", timeout=3) + data = resp.json() + return { + "status": data.get("status", "warning") if resp.ok else "error", + "reachable": resp.ok, + "http_status": resp.status_code, + "url": url, + "snapshot": data, + } + except Exception as exc: + return { + "status": "warning", + "reachable": False, + "http_status": None, + "url": url, + "error": str(exc)[:200], + } + + +def _build_observability_summary() -> dict[str, Any]: + from routes.health import _build_report + from routes.costs import costs_summary + + backend_health = _build_report(deep=True) + terminal = _terminal_server_snapshot() + costs_resp = costs_summary() + costs = costs_resp.get_json(silent=True) if hasattr(costs_resp, "get_json") else None + provider_config = _load_provider_config() + registry = load_plugin_registry() + installed = load_installed_plugins() + + provider_metrics = summarize_provider_events(limit=500) + plugin_list = list_plugins() + events = list_events(limit=25) + + try: + database_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI") + except RuntimeError: + from runtime_config import database_uri as resolve_database_uri + + database_uri = resolve_database_uri() + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "backend": backend_health, + "terminal_server": terminal, + "costs": costs or {}, + "provider_config": provider_config, + "provider_metrics": provider_metrics, + "cache": cache_status(), + "queue": queue_status(), + "plugins": { + "registry_count": len(registry.get("plugins", [])), + "installed_count": len(installed.get("plugins", {})), + "items": plugin_list, + }, + "recent_events": events, + "database_backend": database_scheme(database_uri), + } + + +def build_observability_summary() -> dict[str, Any]: + return cache_get_or_set("observability:summary", _build_observability_summary, ttl=30) diff --git a/dashboard/backend/platform_plugins.py b/dashboard/backend/platform_plugins.py new file mode 100644 index 00000000..85efc482 --- /dev/null +++ b/dashboard/backend/platform_plugins.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from platform_queue import publish_event +from platform_support import INSTALLED_PLUGINS_PATH, PLUGIN_REGISTRY_PATH, WORKSPACE, ensure_platform_data_dir, read_json, write_json + + +_SAFE_PLUGIN_ID = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +_AGENTS_DIR = WORKSPACE / ".claude" / "agents" + + +DEFAULT_PLUGIN_REGISTRY = { + "plugins": [ + { + "id": "observability-scout", + "name": "Observability Scout", + "version": "1.0.0", + "category": "observability", + "description": "Installs a read-only agent pack for platform telemetry and incident triage.", + "agents": [ + { + "name": "observability-scout", + "description": "Read-only observability analyst for EvoNexus. Uses metrics, health, costs, and provider routing data to identify regressions.", + "model": "haiku", + "color": "cyan", + "memory": "project", + "disallowedTools": ["Write", "Edit", "Bash", "NotebookEdit"], + "prompt": ( + "You are Observability Scout.\n" + "Analyze EvoNexus platform health, provider routing, queue/cache status, and cost trends.\n" + "Recommend concrete fixes with clear severity. Do not edit files." + ), + } + ], + }, + { + "id": "provider-router", + "name": "Provider Router", + "version": "1.0.0", + "category": "platform", + "description": "Installs a read-only agent pack for provider failover and routing audits.", + "agents": [ + { + "name": "provider-router", + "description": "Read-only provider routing specialist for EvoNexus. Reviews failover order, provider health, and selected model mode.", + "model": "haiku", + "color": "violet", + "memory": "project", + "disallowedTools": ["Write", "Edit", "Bash", "NotebookEdit"], + "prompt": ( + "You are Provider Router.\n" + "Audit provider health, failover order, and model compatibility.\n" + "Recommend a safe routing chain and explain why a fallback should or should not be activated." + ), + } + ], + }, + ] +} + + +def _safe_plugin_id(plugin_id: str) -> str: + plugin_id = (plugin_id or "").strip().lower() + if not _SAFE_PLUGIN_ID.match(plugin_id): + raise ValueError("Invalid plugin id") + return plugin_id + + +def load_plugin_registry() -> dict[str, Any]: + registry = read_json(PLUGIN_REGISTRY_PATH, DEFAULT_PLUGIN_REGISTRY) + if not isinstance(registry, dict) or "plugins" not in registry: + return DEFAULT_PLUGIN_REGISTRY + plugins = registry.get("plugins") + if not isinstance(plugins, list): + return DEFAULT_PLUGIN_REGISTRY + return registry + + +def load_installed_plugins() -> dict[str, Any]: + payload = read_json(INSTALLED_PLUGINS_PATH, {"plugins": {}}) + if not isinstance(payload, dict): + return {"plugins": {}} + if not isinstance(payload.get("plugins"), dict): + payload["plugins"] = {} + return payload + + +def _save_installed_plugins(payload: dict[str, Any]) -> None: + ensure_platform_data_dir() + write_json(INSTALLED_PLUGINS_PATH, payload) + + +def _render_agent(agent: dict[str, Any], plugin_id: str) -> str: + frontmatter: dict[str, Any] = { + "name": agent["name"], + "description": agent["description"], + "model": agent.get("model", "haiku"), + "color": agent.get("color", "cyan"), + "memory": agent.get("memory", "project"), + "plugin": plugin_id, + "disallowedTools": agent.get("disallowedTools", ["Write", "Edit", "Bash", "NotebookEdit"]), + } + prompt = agent.get("prompt", "").strip() + return "---\n" + yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip() + "\n---\n\n" + prompt + "\n" + + +def _installed_files_for(plugin: dict[str, Any]) -> list[dict[str, str]]: + files: list[dict[str, str]] = [] + for agent in plugin.get("agents", []) or []: + files.append({ + "type": "agent", + "name": agent["name"], + "path": f".claude/agents/{agent['name']}.md", + }) + return files + + +def list_plugins() -> list[dict[str, Any]]: + registry = load_plugin_registry() + installed = load_installed_plugins().get("plugins", {}) + result = [] + for plugin in registry.get("plugins", []): + plugin_id = plugin.get("id") + if not plugin_id: + continue + state = installed.get(plugin_id, {}) + result.append({ + **plugin, + "installed": bool(state), + "installed_at": state.get("installed_at"), + "installed_files": state.get("files", []), + }) + return result + + +def install_plugin(plugin_id: str, workspace: Path = WORKSPACE) -> dict[str, Any]: + plugin_key = _safe_plugin_id(plugin_id) + registry = load_plugin_registry() + plugin = next((item for item in registry.get("plugins", []) if item.get("id") == plugin_key), None) + if not plugin: + raise KeyError(f"Unknown plugin: {plugin_key}") + + installed = load_installed_plugins() + if plugin_key in installed.get("plugins", {}): + return installed["plugins"][plugin_key] + + _AGENTS_DIR.mkdir(parents=True, exist_ok=True) + + written_files: list[str] = [] + for agent in plugin.get("agents", []) or []: + target = workspace / ".claude" / "agents" / f"{agent['name']}.md" + if target.exists(): + raise FileExistsError(f"Agent file already exists: {target.name}") + target.write_text(_render_agent(agent, plugin_key), encoding="utf-8") + written_files.append(str(target.relative_to(workspace)).replace("\\", "/")) + + state = { + "id": plugin_key, + "name": plugin.get("name", plugin_key), + "version": plugin.get("version", "1.0.0"), + "installed_at": datetime.now(timezone.utc).isoformat(), + "files": written_files, + } + installed.setdefault("plugins", {})[plugin_key] = state + _save_installed_plugins(installed) + publish_event("plugin-installed", {"plugin_id": plugin_key, "files": written_files}) + return state + + +def uninstall_plugin(plugin_id: str, workspace: Path = WORKSPACE) -> dict[str, Any]: + plugin_key = _safe_plugin_id(plugin_id) + installed = load_installed_plugins() + state = installed.get("plugins", {}).get(plugin_key) + if not state: + raise KeyError(f"Plugin not installed: {plugin_key}") + + removed: list[str] = [] + for rel_path in state.get("files", []): + target = workspace / rel_path + if target.exists(): + target.unlink() + removed.append(rel_path) + + installed["plugins"].pop(plugin_key, None) + _save_installed_plugins(installed) + publish_event("plugin-uninstalled", {"plugin_id": plugin_key, "files": removed}) + return {"id": plugin_key, "removed_files": removed} diff --git a/dashboard/backend/platform_queue.py b/dashboard/backend/platform_queue.py new file mode 100644 index 00000000..a495e4d1 --- /dev/null +++ b/dashboard/backend/platform_queue.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os +from typing import Any + +import json + +from platform_cache import cache_delete +from platform_support import PLATFORM_EVENTS_PATH, append_jsonl, ensure_platform_data_dir, read_jsonl + + +def publish_event(topic: str, payload: dict[str, Any], source: str = "dashboard") -> dict[str, Any]: + ensure_platform_data_dir() + event = append_jsonl(PLATFORM_EVENTS_PATH, { + "topic": topic, + "source": source, + "payload": payload, + }) + + redis_url = os.environ.get("REDIS_URL", "").strip() + if redis_url: + try: + import redis # type: ignore + + redis.Redis.from_url(redis_url, decode_responses=True).publish( + "evonexus:platform", + json.dumps(event, ensure_ascii=False), + ) + except Exception: + pass + + for cache_key in ( + "providers:list", + "observability:summary", + "observability:providers", + "platform:queue:50", + ): + try: + cache_delete(cache_key) + except Exception: + pass + + return event + + +def list_events(limit: int = 100, topics: list[str] | None = None) -> list[dict[str, Any]]: + events = read_jsonl(PLATFORM_EVENTS_PATH, limit=limit) + if topics: + allowed = set(topics) + events = [event for event in events if event.get("topic") in allowed] + return events + + +def queue_status() -> dict[str, Any]: + events = read_jsonl(PLATFORM_EVENTS_PATH, limit=200) + latest = events[-1] if events else None + return { + "backend": "file", + "path": str(PLATFORM_EVENTS_PATH), + "event_count": len(events), + "latest_event": latest, + } diff --git a/dashboard/backend/platform_support.py b/dashboard/backend/platform_support.py new file mode 100644 index 00000000..29ee13d4 --- /dev/null +++ b/dashboard/backend/platform_support.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +WORKSPACE = Path(__file__).resolve().parent.parent.parent +PLATFORM_DATA_DIR = WORKSPACE / "dashboard" / "data" / "platform" +PLATFORM_EVENTS_PATH = PLATFORM_DATA_DIR / "events.jsonl" +PROVIDER_METRICS_PATH = PLATFORM_DATA_DIR / "provider-metrics.jsonl" +INSTALLED_PLUGINS_PATH = PLATFORM_DATA_DIR / "installed-plugins.json" +PLUGIN_REGISTRY_PATH = WORKSPACE / "config" / "plugin-registry.json" + + +def ensure_platform_data_dir() -> Path: + PLATFORM_DATA_DIR.mkdir(parents=True, exist_ok=True) + return PLATFORM_DATA_DIR + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def read_json(path: Path, default: Any) -> Any: + try: + if not path.exists(): + return default + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return default + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def append_jsonl(path: Path, payload: dict[str, Any]) -> dict[str, Any]: + ensure_platform_data_dir() + event = {"ts": _now_iso(), **payload} + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(event, ensure_ascii=False) + "\n") + return event + + +def read_jsonl(path: Path, limit: int | None = None) -> list[dict[str, Any]]: + if not path.exists(): + return [] + rows: list[dict[str, Any]] = [] + try: + for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line: + continue + try: + row = json.loads(line) + except Exception: + continue + if isinstance(row, dict): + rows.append(row) + except Exception: + return [] + if limit is not None and limit >= 0: + return rows[-limit:] + return rows + + +def database_scheme(database_uri: str | None) -> str: + if not database_uri: + return "sqlite" + if "://" not in database_uri: + return "sqlite" + return database_uri.split(":", 1)[0].lower() diff --git a/dashboard/backend/request_security.py b/dashboard/backend/request_security.py new file mode 100644 index 00000000..c1e356d8 --- /dev/null +++ b/dashboard/backend/request_security.py @@ -0,0 +1,41 @@ +"""HTTP request security helpers shared by Flask blueprints.""" + +from __future__ import annotations + +from flask import abort, request + +from session_security import CSRF_HEADER_NAME, XHR_HEADER_NAME, XHR_HEADER_VALUE, issue_session_token + +MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} + +XHR_EXEMPT_PREFIXES = ( + "/api/knowledge/v1/", + "/api/triggers/webhook/", +) + + +def should_require_xhr(path: str, method: str, authorization_header: str = "") -> bool: + """Return True when a mutating API request must carry the XHR header.""" + + if method.upper() not in MUTATING_METHODS: + return False + if not path.startswith("/api/"): + return False + if (authorization_header or "").strip().startswith("Bearer "): + return False + for prefix in XHR_EXEMPT_PREFIXES: + if path.startswith(prefix): + return False + return True + + +def require_xhr(req=request) -> None: + """Abort with 403 if a mutating request lacks the XHR and CSRF headers.""" + + if not should_require_xhr(req.path, req.method, req.headers.get("Authorization", "")): + return + if req.headers.get(XHR_HEADER_NAME) != XHR_HEADER_VALUE: + abort(403, description="CSRF check failed: X-Requested-With header missing.") + expected = issue_session_token(force=False) + if req.headers.get(CSRF_HEADER_NAME, "").strip() != expected: + abort(403, description="CSRF check failed: invalid or missing CSRF token.") diff --git a/dashboard/backend/routes/platform.py b/dashboard/backend/routes/platform.py new file mode 100644 index 00000000..c13c3e3f --- /dev/null +++ b/dashboard/backend/routes/platform.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from flask import Blueprint, jsonify, request +from flask_login import login_required + +from models import has_permission +from platform_cache import cache_delete, cache_get, cache_get_or_set +from platform_observability import build_observability_summary +from platform_plugins import install_plugin, list_plugins, uninstall_plugin +from platform_queue import list_events, queue_status + +bp = Blueprint("platform", __name__) + + +def _require(resource: str, action: str): + from flask_login import current_user + + if not has_permission(current_user.role, resource, action): + return jsonify({"error": "Forbidden"}), 403 + return None + + +@bp.route("/api/observability/summary") +@login_required +def observability_summary(): + denied = _require("systems", "view") + if denied: + return denied + return jsonify(build_observability_summary()) + + +@bp.route("/api/observability/providers") +@login_required +def observability_providers(): + denied = _require("systems", "view") + if denied: + return denied + def _load_snapshot(): + summary = build_observability_summary() + return { + "provider_metrics": summary.get("provider_metrics", {}), + "provider_config": summary.get("provider_config", {}), + } + + return jsonify(cache_get_or_set("observability:providers", _load_snapshot, ttl=30)) + + +@bp.route("/api/platform/cache") +@login_required +def platform_cache(): + denied = _require("systems", "view") + if denied: + return denied + return jsonify({ + "cached_provider_list": cache_get("providers:list"), + "cached_observability": cache_get("observability:summary"), + "cached_observability_providers": cache_get("observability:providers"), + "cached_platform_queue": cache_get("platform:queue:50"), + }) + + +@bp.route("/api/platform/cache/clear", methods=["POST"]) +@login_required +def platform_cache_clear(): + denied = _require("config", "manage") + if denied: + return denied + cache_delete("providers:list") + cache_delete("observability:summary") + cache_delete("observability:providers") + cache_delete("platform:queue:50") + return jsonify({"status": "ok"}) + + +@bp.route("/api/platform/queue") +@login_required +def platform_queue(): + denied = _require("systems", "view") + if denied: + return denied + return jsonify(cache_get_or_set( + "platform:queue:50", + lambda: { + "status": queue_status(), + "events": list_events(limit=50), + }, + ttl=10, + )) + + +@bp.route("/api/platform/plugins") +@login_required +def plugins_list(): + denied = _require("systems", "view") + if denied: + return denied + return jsonify({"plugins": list_plugins()}) + + +@bp.route("/api/platform/plugins/install", methods=["POST"]) +@login_required +def plugins_install(): + denied = _require("config", "manage") + if denied: + return denied + data = request.get_json(silent=True) or {} + plugin_id = (data.get("plugin_id") or "").strip() + if not plugin_id: + return jsonify({"error": "plugin_id is required"}), 400 + try: + result = install_plugin(plugin_id) + except KeyError as exc: + return jsonify({"error": str(exc)}), 404 + except FileExistsError as exc: + return jsonify({"error": str(exc)}), 409 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + cache_delete("observability:summary") + return jsonify({"status": "ok", "plugin": result}) + + +@bp.route("/api/platform/plugins//uninstall", methods=["POST"]) +@login_required +def plugins_uninstall(plugin_id: str): + denied = _require("config", "manage") + if denied: + return denied + try: + result = uninstall_plugin(plugin_id) + except KeyError as exc: + return jsonify({"error": str(exc)}), 404 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + cache_delete("observability:summary") + return jsonify({"status": "ok", "plugin": result}) diff --git a/dashboard/backend/runtime_config.py b/dashboard/backend/runtime_config.py new file mode 100644 index 00000000..da1266ec --- /dev/null +++ b/dashboard/backend/runtime_config.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import os +import re +import secrets +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +WORKSPACE = Path(__file__).resolve().parent.parent.parent + + +def is_production() -> bool: + env = ( + os.environ.get("EVONEXUS_ENV") + or os.environ.get("FLASK_ENV") + or os.environ.get("ENV") + or "" + ).strip().lower() + return env in {"production", "prod"} + + +def cors_allowed_origins() -> list[str] | str: + raw = os.environ.get("CORS_ALLOWED_ORIGINS", "").strip() + if raw: + if raw == "*": + return "*" + origins = [origin.strip() for origin in re.split(r"[,\s]+", raw) if origin.strip()] + return origins or "*" + return "*" if not is_production() else [] + + +def database_uri(workspace: Path = WORKSPACE) -> str: + for key in ("SQLALCHEMY_DATABASE_URI", "EVONEXUS_DATABASE_URL", "DATABASE_URL"): + raw = os.environ.get(key, "").strip() + if raw: + return raw + return f"sqlite:///{workspace / 'dashboard' / 'data' / 'evonexus.db'}" + + +def database_backend(database_uri_value: str) -> str: + if "://" not in database_uri_value: + return "sqlite" + return (database_uri_value.split(":", 1)[0] or "sqlite").lower() + + +def sqlite_path_from_uri(database_uri_value: str) -> Path | None: + prefix = "sqlite:///" + if not database_uri_value.startswith(prefix): + return None + return Path(database_uri_value.removeprefix(prefix)) + + +def dashboard_port(workspace: Path = WORKSPACE) -> int: + for key in ("EVONEXUS_PORT", "DASHBOARD_PORT", "PORT"): + raw = os.environ.get(key, "").strip() + if raw: + try: + port = int(raw) + if 1 <= port <= 65535: + return port + except ValueError: + pass + + config_path = workspace / "config" / "workspace.yaml" + if config_path.is_file(): + try: + cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + dashboard_cfg = cfg.get("dashboard", {}) if isinstance(cfg, dict) else {} + for candidate in (dashboard_cfg.get("port"), cfg.get("port") if isinstance(cfg, dict) else None): + if candidate is None: + continue + port = int(candidate) + if 1 <= port <= 65535: + return port + except Exception: + pass + + return 8080 + + +def resolve_secret_key(workspace: Path = WORKSPACE) -> str: + """Resolve the dashboard secret key from env or the local fallback file.""" + + secret_key = os.environ.get("EVONEXUS_SECRET_KEY", "").strip() + if secret_key: + return secret_key + + if is_production(): + raise RuntimeError("EVONEXUS_SECRET_KEY must be set in production") + + key_file = workspace / "dashboard" / "data" / ".secret_key" + key_file.parent.mkdir(parents=True, exist_ok=True) + if key_file.exists(): + return key_file.read_text(encoding="utf-8").strip() + + secret_key = secrets.token_hex(32) + key_file.write_text(secret_key, encoding="utf-8") + key_file.chmod(0o600) + return secret_key + + +@dataclass(frozen=True) +class DashboardRuntimeConfig: + secret_key: str + database_uri: str + database_backend: str + cors_allowed_origins: list[str] | str + dashboard_port: int + + +def load_dashboard_runtime_config(workspace: Path = WORKSPACE) -> DashboardRuntimeConfig: + db_uri = database_uri(workspace) + return DashboardRuntimeConfig( + secret_key=resolve_secret_key(workspace), + database_uri=db_uri, + database_backend=database_backend(db_uri), + cors_allowed_origins=cors_allowed_origins(), + dashboard_port=dashboard_port(workspace), + ) diff --git a/dashboard/backend/schema_migrations.py b/dashboard/backend/schema_migrations.py new file mode 100644 index 00000000..5c46a280 --- /dev/null +++ b/dashboard/backend/schema_migrations.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from sqlalchemy import inspect + + +def _table_names(connection) -> set[str]: + return set(inspect(connection).get_table_names()) + + +def _column_names(connection, table_name: str) -> set[str]: + if table_name not in _table_names(connection): + return set() + return {column["name"] for column in inspect(connection).get_columns(table_name)} + + +def _scalar(connection, sql: str, params: tuple | dict | None = None): + result = connection.exec_driver_sql(sql, params or ()) + row = result.fetchone() + return row[0] if row else None + + +def _execute(connection, sql: str, params: tuple | dict | None = None) -> None: + connection.exec_driver_sql(sql, params or ()) + + +def upgrade_app_schema(connection) -> None: + """Apply the schema drift fixes that used to live inline in app.py.""" + + tables = _table_names(connection) + dialect_name = getattr(getattr(connection, "dialect", None), "name", "sqlite") + is_postgres = dialect_name == "postgresql" + current_ts = "CURRENT_TIMESTAMP" if is_postgres else "datetime('now')" + + if "roles" in tables: + role_cols = _column_names(connection, "roles") + if "agent_access_json" not in role_cols: + _execute(connection, "ALTER TABLE roles ADD COLUMN agent_access_json TEXT DEFAULT '{\"mode\": \"all\"}'") + if "workspace_folders_json" not in role_cols: + _execute(connection, "ALTER TABLE roles ADD COLUMN workspace_folders_json TEXT DEFAULT '{\"mode\": \"all\"}'") + + # Goal cascade helpers: keep the view and trigger available on every boot. + if "goals" in tables and "goal_tasks" in tables: + _execute( + connection, + """ + CREATE VIEW IF NOT EXISTS goal_progress_v AS + SELECT g.id as goal_id, g.slug, g.target_value, + COUNT(t.id) as total_tasks, + COUNT(CASE WHEN t.status='done' THEN 1 END) as done_tasks, + CASE WHEN COUNT(t.id) > 0 + THEN CAST(COUNT(CASE WHEN t.status='done' THEN 1 END) AS REAL) / COUNT(t.id) * 100.0 + ELSE 0 END as pct_complete + FROM goals g LEFT JOIN goal_tasks t ON t.goal_id = g.id + GROUP BY g.id; + """, + ) + if is_postgres: + _execute( + connection, + """ + CREATE OR REPLACE FUNCTION trg_task_done_updates_goal_fn() + RETURNS trigger AS $$ + BEGIN + IF NEW.goal_id IS NOT NULL AND NEW.status = 'done' AND OLD.status <> 'done' THEN + UPDATE goals + SET current_value = current_value + 1, updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.goal_id; + UPDATE goals + SET status = 'achieved' + WHERE id = NEW.goal_id AND current_value >= target_value AND status = 'active'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """, + ) + _execute( + connection, + """ + CREATE TRIGGER trg_task_done_updates_goal + AFTER UPDATE OF status ON goal_tasks + FOR EACH ROW + EXECUTE FUNCTION trg_task_done_updates_goal_fn(); + """, + ) + else: + _execute( + connection, + """ + CREATE TRIGGER IF NOT EXISTS trg_task_done_updates_goal + AFTER UPDATE OF status ON goal_tasks + WHEN NEW.goal_id IS NOT NULL AND NEW.status = 'done' AND OLD.status != 'done' + BEGIN + UPDATE goals SET current_value = current_value + 1, updated_at = datetime('now') WHERE id = NEW.goal_id; + UPDATE goals SET status = 'achieved' WHERE id = NEW.goal_id AND current_value >= target_value AND status = 'active'; + END; + """, + ) + + mission_count = _scalar(connection, "SELECT COUNT(*) FROM missions") + if mission_count == 0: + _now_seed = "2026-04-14T00:00:00.000000Z" + _execute( + connection, + """ + INSERT INTO missions (slug, title, description, target_metric, target_value, current_value, due_date, status, created_at, updated_at) + VALUES ('evo-revenue-1m-q4-2026', 'Evolution Revenue $1M Q4 2026', + 'Atingir $1M de receita anual até o Q4 2026', + 'revenue_usd', 1000000, 0, '2026-12-31', 'active', ?, ?) + """, + (_now_seed, _now_seed), + ) + mission_id = _scalar(connection, "SELECT id FROM missions WHERE slug = 'evo-revenue-1m-q4-2026'") + for slug, title, description in [ + ("evo-ai", "Evo AI", "CRM + AI agents — produto principal"), + ("evo-summit", "Evolution Summit", "Evento de lançamento (14-16 Abr 2026)"), + ("evo-academy", "Evo Academy", "Plataforma de cursos"), + ]: + _execute( + connection, + """ + INSERT INTO projects (slug, mission_id, title, description, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', ?, ?) + """, + (slug, mission_id, title, description, _now_seed, _now_seed), + ) + + project_ids = { + "evo-ai": _scalar(connection, "SELECT id FROM projects WHERE slug = 'evo-ai'"), + "evo-summit": _scalar(connection, "SELECT id FROM projects WHERE slug = 'evo-summit'"), + "evo-academy": _scalar(connection, "SELECT id FROM projects WHERE slug = 'evo-academy'"), + } + goals_seed = [ + ("evo-ai-100-customers", project_ids["evo-ai"], "100 paying customers by Jun 30", "customers", "count", 100, "2026-06-30"), + ("evo-ai-billing-v2", project_ids["evo-ai"], "Ship billing v2", "shipped", "boolean", 1, "2026-05-31"), + ("evo-summit-200-tickets", project_ids["evo-summit"], "Sell 200 tickets", "tickets_sold", "count", 200, "2026-04-13"), + ("evo-summit-3-sponsors", project_ids["evo-summit"], "Close 3 sponsors", "sponsors", "count", 3, "2026-04-10"), + ("evo-academy-50-students", project_ids["evo-academy"], "50 beta students", "students", "count", 50, "2026-06-30"), + ] + for slug, project_id, title, target_metric, metric_type, target_value, due_date in goals_seed: + _execute( + connection, + """ + INSERT INTO goals (slug, project_id, title, target_metric, metric_type, target_value, current_value, status, created_at, updated_at, due_date) + VALUES (?, ?, ?, ?, ?, ?, 0, 'active', ?, ?, ?) + """, + (slug, project_id, title, target_metric, metric_type, target_value, _now_seed, _now_seed, due_date), + ) + + if "tickets" in tables: + ticket_cols = _column_names(connection, "tickets") + for column_name, ddl in [ + ("source_agent", "ALTER TABLE tickets ADD COLUMN source_agent TEXT"), + ("source_session_id", "ALTER TABLE tickets ADD COLUMN source_session_id TEXT"), + ("workspace_path", "ALTER TABLE tickets ADD COLUMN workspace_path TEXT"), + ("memory_md_path", "ALTER TABLE tickets ADD COLUMN memory_md_path TEXT"), + ("thread_session_id", "ALTER TABLE tickets ADD COLUMN thread_session_id TEXT"), + ("message_count", "ALTER TABLE tickets ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0"), + ("last_summary_at_message", "ALTER TABLE tickets ADD COLUMN last_summary_at_message INTEGER NOT NULL DEFAULT 0"), + ]: + if column_name not in ticket_cols: + _execute(connection, ddl) + ticket_cols.add(column_name) + + if "users" in tables: + user_cols = _column_names(connection, "users") + if "totp_secret" not in user_cols: + _execute(connection, "ALTER TABLE users ADD COLUMN totp_secret TEXT") + if "totp_enabled" not in user_cols: + _execute( + connection, + "ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE" if is_postgres else "ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0", + ) + if "totp_last_used_step" not in user_cols: + _execute(connection, "ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER") + if "totp_confirmed_at" not in user_cols: + _execute(connection, "ALTER TABLE users ADD COLUMN totp_confirmed_at TIMESTAMP" if is_postgres else "ALTER TABLE users ADD COLUMN totp_confirmed_at TEXT") + + # Knowledge tables are managed outside SQLAlchemy models, so keep them here. + if "knowledge_connections" not in tables: + _execute( + connection, + """ + CREATE TABLE IF NOT EXISTS knowledge_connections ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + connection_string_encrypted BLOB, + host TEXT, + port INTEGER, + database_name TEXT, + username TEXT, + ssl_mode TEXT, + status TEXT DEFAULT 'disconnected', + schema_version TEXT, + pgvector_version TEXT, + postgres_version TEXT, + last_health_check TIMESTAMP, + last_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + ) + _execute( + connection, + """ + CREATE TABLE IF NOT EXISTS knowledge_connection_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id TEXT REFERENCES knowledge_connections(id) ON DELETE CASCADE, + event_type TEXT, + details TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + ) + _execute(connection, "CREATE INDEX IF NOT EXISTS idx_kconn_status ON knowledge_connections(status)") + _execute( + connection, + "CREATE INDEX IF NOT EXISTS idx_kconn_events_conn ON knowledge_connection_events(connection_id, created_at)", + ) + + if "knowledge_api_keys" not in tables: + _execute( + connection, + """ + CREATE TABLE IF NOT EXISTS knowledge_api_keys ( + id TEXT PRIMARY KEY, + name TEXT, + prefix TEXT NOT NULL, + token_hash TEXT NOT NULL, + connection_id TEXT NOT NULL, + space_ids TEXT NOT NULL DEFAULT '[]', + scopes TEXT NOT NULL DEFAULT '["read"]', + rate_limit_per_min INTEGER NOT NULL DEFAULT 60, + rate_limit_per_day INTEGER NOT NULL DEFAULT 10000, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT + ); + """, + ) + _execute(connection, "CREATE INDEX IF NOT EXISTS idx_kak_prefix ON knowledge_api_keys(prefix)") + + # Fix corrupted datetime columns that can crash SQLAlchemy on load. + for table_name, column_name in [("roles", "created_at"), ("users", "created_at"), ("users", "last_login")]: + try: + if column_name in _column_names(connection, table_name): + _execute( + connection, + f"UPDATE {table_name} SET {column_name} = {current_ts} WHERE {column_name} IS NOT NULL AND typeof({column_name}) != 'text'" if not is_postgres else f"UPDATE {table_name} SET {column_name} = CURRENT_TIMESTAMP WHERE {column_name} IS NOT NULL", + ) + _execute( + connection, + f"UPDATE {table_name} SET {column_name} = {current_ts} WHERE {column_name} IS NOT NULL AND {column_name} != '' AND {column_name} NOT LIKE '____-__-__%'" if not is_postgres else f"UPDATE {table_name} SET {column_name} = CURRENT_TIMESTAMP WHERE {column_name} IS NOT NULL", + ) + except Exception: + pass + + +def downgrade_app_schema(connection) -> None: + """Drop the schema additions managed by upgrade_app_schema.""" + + dialect_name = getattr(getattr(connection, "dialect", None), "name", "sqlite") + is_postgres = dialect_name == "postgresql" + + if is_postgres: + for sql in [ + "DROP TRIGGER IF EXISTS trg_task_done_updates_goal ON goal_tasks", + "DROP FUNCTION IF EXISTS trg_task_done_updates_goal_fn()", + "DROP VIEW IF EXISTS goal_progress_v", + "DROP TABLE IF EXISTS knowledge_api_keys", + "DROP TABLE IF EXISTS knowledge_connection_events", + "DROP TABLE IF EXISTS knowledge_connections", + ]: + try: + _execute(connection, sql) + except Exception: + pass + return + + for sql in [ + "DROP TRIGGER IF EXISTS trg_task_done_updates_goal", + "DROP VIEW IF EXISTS goal_progress_v", + "DROP TABLE IF EXISTS knowledge_api_keys", + "DROP TABLE IF EXISTS knowledge_connection_events", + "DROP TABLE IF EXISTS knowledge_connections", + ]: + try: + _execute(connection, sql) + except Exception: + pass diff --git a/dashboard/backend/session_security.py b/dashboard/backend/session_security.py new file mode 100644 index 00000000..3205f133 --- /dev/null +++ b/dashboard/backend/session_security.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timezone + +from flask import abort, request, session + + +CSRF_SESSION_KEY = "_evonexus_csrf_token" +CSRF_ISSUED_AT_KEY = "_evonexus_csrf_issued_at" +CSRF_HEADER_NAME = "X-CSRF-Token" +XHR_HEADER_NAME = "X-Requested-With" +XHR_HEADER_VALUE = "XMLHttpRequest" + + +def _rotation_minutes() -> int: + raw = os.environ.get("EVONEXUS_SESSION_KEY_ROTATION_MINUTES", "").strip() + try: + value = int(raw) + except (TypeError, ValueError): + value = 24 * 60 + return value if value > 0 else 24 * 60 + + +def _parse_issued_at(raw: object) -> datetime | None: + if not raw: + return None + if isinstance(raw, datetime): + return raw.astimezone(timezone.utc) if raw.tzinfo else raw.replace(tzinfo=timezone.utc) + text = str(raw).strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + return parsed.astimezone(timezone.utc) if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def issue_session_token(force: bool = False) -> str: + """Return the current per-session CSRF token, rotating it when needed.""" + + token = session.get(CSRF_SESSION_KEY) + issued_at = _parse_issued_at(session.get(CSRF_ISSUED_AT_KEY)) + age_minutes = None + if issued_at is not None: + age_minutes = (datetime.now(timezone.utc) - issued_at).total_seconds() / 60.0 + + if force or not token or age_minutes is None or age_minutes >= _rotation_minutes(): + token = secrets.token_urlsafe(32) + session[CSRF_SESSION_KEY] = token + session[CSRF_ISSUED_AT_KEY] = datetime.now(timezone.utc).isoformat() + + return str(token) + + +def current_session_token() -> str | None: + token = session.get(CSRF_SESSION_KEY) + return str(token) if token else None + + +def attach_session_token(response): + token = issue_session_token(force=False) + response.headers[CSRF_HEADER_NAME] = token + expose = response.headers.get("Access-Control-Expose-Headers", "") + exposed = [item.strip() for item in expose.split(",") if item.strip()] + if CSRF_HEADER_NAME not in exposed: + exposed.append(CSRF_HEADER_NAME) + response.headers["Access-Control-Expose-Headers"] = ", ".join(exposed) + return response + + +def require_csrf_token(req=request) -> None: + expected = issue_session_token(force=False) + provided = req.headers.get(CSRF_HEADER_NAME, "").strip() + if not provided or provided != expected: + abort(403, description="CSRF check failed: invalid or missing CSRF token.") + + +def force_rotate_session_token() -> str: + return issue_session_token(force=True) diff --git a/dashboard/backend/structured_logging.py b/dashboard/backend/structured_logging.py new file mode 100644 index 00000000..d7bac813 --- /dev/null +++ b/dashboard/backend/structured_logging.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from flask import g, request + + +def _json_default(value: Any): + if isinstance(value, Path): + return str(value) + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + pass + return str(value) + + +def emit_json_log(level: str, event: str, **fields: Any) -> None: + payload = { + "ts": datetime.now(timezone.utc).isoformat(), + "level": level, + "event": event, + **fields, + } + stream = sys.stderr if level.lower() in {"warning", "error", "critical"} else sys.stdout + print(json.dumps(payload, ensure_ascii=False, default=_json_default), file=stream, flush=True) + + +def install_request_logging(app, service: str = "dashboard") -> None: + """Emit one structured JSON record for every API/WebSocket request.""" + + @app.before_request + def _start_request_timer(): + if request.path.startswith("/api/") or request.path.startswith("/ws/"): + g._structured_started_at = time.perf_counter() + + @app.after_request + def _log_request(response): + if request.path.startswith("/api/") or request.path.startswith("/ws/"): + started_at = getattr(g, "_structured_started_at", None) + duration_ms = None + if started_at is not None: + duration_ms = round((time.perf_counter() - started_at) * 1000, 1) + emit_json_log( + "info", + "http_request", + service=service, + method=request.method, + path=request.path, + status=response.status_code, + duration_ms=duration_ms, + remote_addr=request.headers.get("X-Forwarded-For", request.remote_addr), + ) + return response diff --git a/dashboard/backend/totp_security.py b/dashboard/backend/totp_security.py new file mode 100644 index 00000000..4e61aee1 --- /dev/null +++ b/dashboard/backend/totp_security.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import re +import secrets +import struct +import time +import urllib.parse + + +TOTP_ISSUER = "EvoNexus" + + +def generate_totp_secret() -> str: + return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") + + +def provisioning_uri(secret: str, account_name: str, issuer: str = TOTP_ISSUER) -> str: + label = urllib.parse.quote(f"{issuer}:{account_name}") + params = urllib.parse.urlencode({ + "secret": secret, + "issuer": issuer, + "algorithm": "SHA1", + "digits": 6, + "period": 30, + }) + return f"otpauth://totp/{label}?{params}" + + +def normalize_totp_code(code: str | None) -> str: + return re.sub(r"\s+", "", str(code or "")).strip() + + +def _decode_secret(secret: str) -> bytes: + normalized = re.sub(r"\s+", "", secret or "").strip().upper() + padding = "=" * ((8 - len(normalized) % 8) % 8) + return base64.b32decode(normalized + padding, casefold=True) + + +def _hotp(secret: str, counter: int, digits: int = 6) -> str: + key = _decode_secret(secret) + msg = struct.pack(">Q", counter) + digest = hmac.new(key, msg, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + binary = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF + return str(binary % (10 ** digits)).zfill(digits) + + +def generate_totp_code(secret: str, *, interval: int = 30, digits: int = 6, for_time: float | None = None) -> str: + now = for_time if for_time is not None else time.time() + counter = int(now // interval) + return _hotp(secret, counter, digits=digits) + + +def verify_totp_code( + secret: str, + code: str | None, + *, + interval: int = 30, + digits: int = 6, + window: int = 1, + last_used_step: int | None = None, + for_time: float | None = None, +) -> dict[str, object]: + normalized = normalize_totp_code(code) + if not normalized.isdigit() or len(normalized) != digits: + return {"valid": False, "step": None} + + now = for_time if for_time is not None else time.time() + current_step = int(now // interval) + for step in range(current_step - window, current_step + window + 1): + if last_used_step is not None and step <= last_used_step: + continue + if _hotp(secret, step, digits=digits) == normalized: + return {"valid": True, "step": step} + return {"valid": False, "step": None} diff --git a/dashboard/frontend/playwright.config.ts b/dashboard/frontend/playwright.config.ts new file mode 100644 index 00000000..bcc05795 --- /dev/null +++ b/dashboard/frontend/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const frontendRoot = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL: 'http://127.0.0.1:8080', + trace: 'on-first-retry', + }, + webServer: { + command: 'node scripts/start-e2e-server.mjs', + cwd: frontendRoot, + url: 'http://127.0.0.1:8080', + timeout: 180_000, + reuseExistingServer: false, + env: { + ...process.env, + EVONEXUS_PORT: '8080', + }, + }, +}) diff --git a/dashboard/frontend/public/manifest.webmanifest b/dashboard/frontend/public/manifest.webmanifest new file mode 100644 index 00000000..f4d40ccd --- /dev/null +++ b/dashboard/frontend/public/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "Evo Workspace", + "short_name": "EvoNexus", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0C111D", + "theme_color": "#0C111D", + "description": "EvoNexus dashboard and agent workspace.", + "icons": [ + { + "src": "/favicon.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/EVO_NEXUS.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "any" + } + ] +} + diff --git a/dashboard/frontend/public/sw.js b/dashboard/frontend/public/sw.js new file mode 100644 index 00000000..0ea13939 --- /dev/null +++ b/dashboard/frontend/public/sw.js @@ -0,0 +1,65 @@ +const CACHE_NAME = 'evonexus-shell-v1' +const PRECACHE_URLS = [ + '/', + '/index.html', + '/favicon.png', + '/EVO_NEXUS.webp', + '/manifest.webmanifest', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting()), + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.map((key) => (key === CACHE_NAME ? null : caches.delete(key)))).then(() => self.clients.claim()), + ), + ) +}) + +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAME) + const cached = await cache.match(request) + if (cached) return cached + + const response = await fetch(request) + if (response && response.ok) { + cache.put(request, response.clone()) + } + return response +} + +async function networkFirstNavigation(request) { + const cache = await caches.open(CACHE_NAME) + try { + const response = await fetch(request) + if (response && response.ok) { + cache.put('/index.html', response.clone()) + } + return response + } catch { + const cachedIndex = await cache.match('/index.html') + return cachedIndex || cache.match('/') + } +} + +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/terminal/')) return + + if (request.mode === 'navigate') { + event.respondWith(networkFirstNavigation(request)) + return + } + + event.respondWith(cacheFirst(request)) +}) + diff --git a/dashboard/frontend/scripts/start-e2e-server.mjs b/dashboard/frontend/scripts/start-e2e-server.mjs new file mode 100644 index 00000000..2f4a4dfc --- /dev/null +++ b/dashboard/frontend/scripts/start-e2e-server.mjs @@ -0,0 +1,48 @@ +import { execFileSync, spawn } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const frontendRoot = path.resolve(scriptDir, '..') +const repoRoot = path.resolve(frontendRoot, '..', '..') + +function sqliteUrl(filePath) { + return `sqlite:///${path.resolve(filePath).replace(/\\/g, '/')}` +} + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evonexus-e2e-')) +const databasePath = path.join(tempDir, 'dashboard.db') + +execFileSync('npm', ['run', 'build'], { + cwd: frontendRoot, + stdio: 'inherit', + env: process.env, + shell: true, +}) + +const child = spawn(process.platform === 'win32' ? 'python' : 'python3', ['dashboard/backend/app.py'], { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + SQLALCHEMY_DATABASE_URI: sqliteUrl(databasePath), + EVONEXUS_SECRET_KEY: process.env.EVONEXUS_SECRET_KEY || 'e2e-secret-key', + EVONEXUS_PORT: process.env.EVONEXUS_PORT || '8080', + CORS_ALLOWED_ORIGINS: 'http://127.0.0.1:8080', + EVONEXUS_ENV: 'development', + }, +}) + +const shutdown = () => { + if (!child.killed) { + child.kill('SIGTERM') + } +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) +child.on('exit', (code, signal) => { + process.exit(code ?? (signal ? 1 : 0)) +}) diff --git a/dashboard/frontend/src/components/CommandPalette.tsx b/dashboard/frontend/src/components/CommandPalette.tsx new file mode 100644 index 00000000..35187155 --- /dev/null +++ b/dashboard/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,341 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from 'react' +import { useLocation, useNavigate, type NavigateFunction } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Moon, RefreshCw, Search, Sun, X, type LucideIcon } from 'lucide-react' +import { useAuth } from '../context/AuthContext' +import { useTheme } from '../context/ThemeContext' +import { DOCS_NAV_ITEM, getVisibleNavGroups, NAV_GROUPS, type NavItem } from '../lib/navigation' + +interface CommandPaletteContextValue { + isOpen: boolean + openCommandPalette: () => void + closeCommandPalette: () => void + toggleCommandPalette: () => void +} + +interface PaletteCommand { + id: string + label: string + description: string + group: string + icon: LucideIcon + action: () => void + keywords: string[] +} + +const CommandPaletteContext = createContext(null) + +export function useCommandPalette() { + const ctx = useContext(CommandPaletteContext) + if (!ctx) { + throw new Error('useCommandPalette must be used within ') + } + return ctx +} + +function commandLabel(item: NavItem, t: (key: string) => string) { + return t(`nav.${item.labelKey}`) +} + +function buildRouteCommands( + hasPermission: (resource: string, action: string) => boolean, + t: (key: string) => string, + navigate: NavigateFunction, + close: () => void, +): PaletteCommand[] { + const visibleGroups = getVisibleNavGroups(hasPermission) + const byKey = new Map(visibleGroups.map((group) => [group.key, group])) + const commands: PaletteCommand[] = [] + + for (const group of NAV_GROUPS) { + const visible = byKey.get(group.key) + if (!visible) continue + + for (const item of visible.items) { + commands.push({ + id: `route:${item.to}`, + label: commandLabel(item, t), + description: item.to === '/' ? 'Go to the main dashboard' : item.to, + group: t(`nav.groups.${group.key}`), + icon: item.icon, + keywords: [item.to, group.key, commandLabel(item, t), t(`nav.groups.${group.key}`)], + action: () => { + close() + navigate(item.to) + }, + }) + } + } + + commands.push({ + id: `route:${DOCS_NAV_ITEM.to}`, + label: t('nav.docs'), + description: 'Open the public documentation view', + group: 'Public', + icon: DOCS_NAV_ITEM.icon, + keywords: [DOCS_NAV_ITEM.to, 'docs', 'documentation'], + action: () => { + close() + navigate(DOCS_NAV_ITEM.to) + }, + }) + + return commands +} + +export function CommandPaletteProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + + const openCommandPalette = useCallback(() => setIsOpen(true), []) + const closeCommandPalette = useCallback(() => setIsOpen(false), []) + const toggleCommandPalette = useCallback(() => setIsOpen((prev) => !prev), []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase() + if ((event.metaKey || event.ctrlKey) && key === 'k') { + event.preventDefault() + setIsOpen(true) + } + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + return ( + + {children} + + + ) +} + +function CommandPaletteDialog() { + const { isOpen, closeCommandPalette } = useCommandPalette() + const { theme, toggleTheme } = useTheme() + const { hasPermission } = useAuth() + const { t } = useTranslation() + const navigate = useNavigate() + const location = useLocation() + const inputRef = useRef(null) + const [query, setQuery] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + if (!isOpen) { + setQuery('') + setSelectedIndex(0) + return + } + + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + const timer = window.setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, 0) + + return () => { + window.clearTimeout(timer) + document.body.style.overflow = prevOverflow + } + }, [isOpen]) + + const commands = useMemo(() => { + const routeCommands = buildRouteCommands(hasPermission, t, navigate, closeCommandPalette) + const actionCommands: PaletteCommand[] = [ + { + id: 'action:theme', + label: theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme', + description: 'Toggle the dashboard appearance', + group: 'Shell', + icon: theme === 'dark' ? Sun : Moon, + keywords: ['theme', 'dark', 'light', 'appearance'], + action: () => { + toggleTheme() + closeCommandPalette() + }, + }, + { + id: 'action:reload', + label: 'Reload app', + description: 'Refresh the current page', + group: 'Shell', + icon: RefreshCw, + keywords: ['reload', 'refresh', 'restart'], + action: () => window.location.reload(), + }, + ] + + return [...actionCommands, ...routeCommands] + }, [closeCommandPalette, hasPermission, navigate, t, theme, toggleTheme]) + + const filteredCommands = useMemo(() => { + const term = query.trim().toLowerCase() + if (!term) return commands + return commands.filter((command) => { + const haystack = [command.label, command.description, command.group, ...command.keywords].join(' ').toLowerCase() + return haystack.includes(term) + }) + }, [commands, query]) + + useEffect(() => { + setSelectedIndex((current) => { + if (filteredCommands.length === 0) return 0 + return Math.min(current, filteredCommands.length - 1) + }) + }, [filteredCommands.length]) + + if (!isOpen) return null + + const grouped = filteredCommands.reduce>((acc, command) => { + const bucket = acc[command.group] ?? [] + bucket.push(command) + acc[command.group] = bucket + return acc + }, {}) + + const groupOrder = Object.keys(grouped) + const flatCommands = groupOrder.flatMap((group) => grouped[group]) + + const runSelected = () => { + const command = flatCommands[selectedIndex] + if (!command) return + command.action() + } + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex((current) => (flatCommands.length === 0 ? 0 : (current + 1) % flatCommands.length)) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex((current) => (flatCommands.length === 0 ? 0 : (current - 1 + flatCommands.length) % flatCommands.length)) + return + } + + if (event.key === 'Enter') { + event.preventDefault() + runSelected() + return + } + + if (event.key === 'Escape') { + event.preventDefault() + closeCommandPalette() + } + } + + return ( +
+ +
+ +
+ {flatCommands.length === 0 ? ( +
+ No matches found +
+ ) : ( + groupOrder.map((group) => ( +
+
+ {group} +
+
+ {grouped[group].map((command) => { + const index = flatCommands.findIndex((item) => item.id === command.id) + const active = index === selectedIndex + + return ( + + ) + })} +
+
+ )) + )} +
+ + + ) +} + diff --git a/dashboard/frontend/src/components/agent-chat/ChatBlocks.tsx b/dashboard/frontend/src/components/agent-chat/ChatBlocks.tsx new file mode 100644 index 00000000..7ca3050c --- /dev/null +++ b/dashboard/frontend/src/components/agent-chat/ChatBlocks.tsx @@ -0,0 +1,303 @@ +import { useState } from 'react' +import { + Ban, + Check, + CheckCircle2, + ChevronDown, + ChevronRight, + Edit2, + FileCode, + FileText, + ShieldAlert, + Terminal as TermIcon, +} from 'lucide-react' +import { AgentAvatar } from '../AgentAvatar' + +export function TypingIndicator({ accentColor, isThinking }: { accentColor: string; isThinking?: boolean }) { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ + {isThinking ? 'Thinking...' : 'Typing...'} + +
+ ) +} + +export function AgentInputToggle({ parsedInput, rawInput }: { parsedInput: any; rawInput: string }) { + const [showInput, setShowInput] = useState(false) + return ( +
+ + {showInput && ( +
+          {parsedInput ? JSON.stringify(parsedInput, null, 2) : rawInput}
+        
+ )} +
+ ) +} + +export function TypingIndicatorMini({ accentColor }: { accentColor: string }) { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ) +} + +export function ToolCard({ block, accentColor }: { block: any; accentColor: string }) { + const [open, setOpen] = useState(false) + + let parsedInput: any = null + try { + parsedInput = JSON.parse(block.input) + } catch {} + + const isAgentTool = block.toolName === 'Agent' || block.toolName === 'SendMessage' + const subagentName = parsedInput?.subagent_type || parsedInput?.name || parsedInput?.to || '' + const subagentDesc = parsedInput?.description || parsedInput?.summary || block.subagentType || '' + + if (isAgentTool) { + const isRunning = block.subagentStatus === 'running' + const isDone = block.done || block.subagentStatus === 'completed' || block.subagentStatus === 'failed' + const subagentTools = block.subagentTools || [] + const toolCount = subagentTools.length + + const getToolIcon = (toolName: string) => { + if (toolName === 'Bash') return + if (toolName === 'Read') return + if (toolName === 'Edit' || toolName === 'Write') return + return + } + + return ( +
+ + + {open && ( +
+
+ {subagentTools.length === 0 ? ( +
No tools yet
+ ) : ( + subagentTools.map((tool: any, index: number) => { + let inputPreview = '' + try { + const parsed = JSON.parse(tool.input) + inputPreview = (parsed.command || parsed.file_path || parsed.path || parsed.pattern || parsed.description || tool.input).slice(0, 60) + } catch { + inputPreview = tool.input.slice(0, 60) + } + + return ( +
+ {getToolIcon(tool.toolName)} + {tool.toolName} + {inputPreview && {inputPreview}} +
+ ) + }) + )} +
+ {block.input && } +
+ )} +
+ ) + } + + if (block.toolName === 'TodoWrite' && Array.isArray(parsedInput?.todos)) { + const todos: Array<{ content: string; status: string; priority?: string; id?: string }> = parsedInput.todos + const completedCount = todos.filter((todo) => todo.status === 'completed').length + + return ( +
+ + +
+ {todos.map((todo, index) => { + const isPending = todo.status === 'pending' + const isInProgress = todo.status === 'in_progress' + const isCompleted = todo.status === 'completed' + const icon = isPending ? '○' : isInProgress ? '◐' : '●' + return ( +
+ + {icon} + + + {todo.content} + +
+ ) + })} +
+
+ ) + } + + const displayInfo = parsedInput + ? (parsedInput.command || parsedInput.file_path || parsedInput.path || parsedInput.pattern || parsedInput.description || '') + : '' + + return ( +
+ + + {open && block.input && ( +
+
+            {parsedInput ? JSON.stringify(parsedInput, null, 2) : block.input}
+          
+
+ )} +
+ ) +} + +export function ApprovalCard({ req, accentColor, onAllow, onDeny }: { req: any; accentColor: string; onAllow: () => void; onDeny: () => void }) { + let summary = '' + const inp = req.input as any + + if (req.toolName === 'Bash') { + summary = inp?.command ? String(inp.command).slice(0, 120) : '' + } else if (req.toolName === 'Write') { + const lines = inp?.content ? String(inp.content).split('\n').slice(0, 5).join('\n') : '' + summary = inp?.file_path ? `${inp.file_path}${lines ? '\n' + lines : ''}` : lines + } else if (req.toolName === 'Edit') { + summary = inp?.file_path ? String(inp.file_path) : '' + } else if (req.toolName === 'Agent') { + const agentName = inp?.subagent_type || inp?.agent || '' + const prompt = inp?.prompt || inp?.description || '' + summary = agentName ? `@${agentName}${prompt ? ' — ' + String(prompt).slice(0, 80) : ''}` : String(prompt).slice(0, 100) + } + if (!summary && req.title) summary = req.title + + return ( +
+ +
+
+ {req.toolName} + {summary && {summary}} +
+ {req.description &&

{req.description}

} +
+
+ + +
+
+ ) +} diff --git a/dashboard/frontend/src/context/ThemeContext.tsx b/dashboard/frontend/src/context/ThemeContext.tsx new file mode 100644 index 00000000..b94a2d8c --- /dev/null +++ b/dashboard/frontend/src/context/ThemeContext.tsx @@ -0,0 +1,52 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' +import { applyTheme, persistTheme, resolveInitialTheme, type ThemeMode } from '../lib/theme' + +interface ThemeContextValue { + theme: ThemeMode + isDark: boolean + isLight: boolean + setTheme: (theme: ThemeMode) => void + toggleTheme: () => void +} + +const ThemeContext = createContext(null) + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => resolveInitialTheme()) + + useEffect(() => { + applyTheme(theme) + }, [theme]) + + const setTheme = useCallback((nextTheme: ThemeMode) => { + persistTheme(nextTheme) + setThemeState(nextTheme) + }, []) + + const toggleTheme = useCallback(() => { + setTheme(theme === 'dark' ? 'light' : 'dark') + }, [setTheme, theme]) + + return ( + + {children} + + ) +} + +export function useTheme() { + const ctx = useContext(ThemeContext) + if (!ctx) { + throw new Error('useTheme must be used within ') + } + return ctx +} + diff --git a/dashboard/frontend/src/lib/navigation.ts b/dashboard/frontend/src/lib/navigation.ts new file mode 100644 index 00000000..196df408 --- /dev/null +++ b/dashboard/frontend/src/lib/navigation.ts @@ -0,0 +1,136 @@ +import type { LucideIcon } from 'lucide-react' +import { + Activity, + BookOpen, + Bot, + BarChart3, + Brain, + Calendar, + CalendarClock, + Cpu, + Database, + DollarSign, + FolderOpen, + HardDriveDownload, + Heart, + Layout, + LayoutDashboard, + Library, + Monitor, + Plug, + Puzzle, + ScrollText, + Settings, + Share2, + Shield, + Target, + Ticket, + Users, + Webhook, + Clock, + Zap, +} from 'lucide-react' + +export interface NavItem { + to: string + labelKey: string + icon: LucideIcon + resource: string | null + desktopOnly?: boolean +} + +export interface NavGroup { + key: string + collapsible: boolean + adminOnly?: boolean + items: NavItem[] +} + +export const NAV_GROUPS: NavGroup[] = [ + { + key: 'main', + collapsible: false, + items: [ + { to: '/', labelKey: 'overview', icon: LayoutDashboard, resource: null }, + ], + }, + { + key: 'operations', + collapsible: true, + items: [ + { to: '/agents', labelKey: 'agents', icon: Bot, resource: 'agents' }, + { to: '/skills', labelKey: 'skills', icon: Zap, resource: 'skills' }, + { to: '/routines', labelKey: 'routines', icon: Clock, resource: 'routines' }, + { to: '/tasks', labelKey: 'tasks', icon: CalendarClock, resource: 'tasks' }, + { to: '/triggers', labelKey: 'triggers', icon: Webhook, resource: 'triggers' }, + { to: '/heartbeats', labelKey: 'heartbeats', icon: Heart, resource: 'heartbeats' }, + { to: '/activity', labelKey: 'activity', icon: Activity, resource: 'scheduler' }, + { to: '/goals', labelKey: 'goals', icon: Target, resource: 'goals' }, + { to: '/topics', labelKey: 'issues', icon: Ticket, resource: 'tickets' }, + { to: '/templates', labelKey: 'templates', icon: Layout, resource: 'templates' }, + ], + }, + { + key: 'data', + collapsible: true, + items: [ + { to: '/workspace', labelKey: 'workspace', icon: FolderOpen, resource: 'workspace' }, + { to: '/shares', labelKey: 'shareLinks', icon: Share2, resource: 'workspace' }, + { to: '/memory', labelKey: 'memory', icon: Brain, resource: 'memory' }, + { to: '/mempalace', labelKey: 'mempalace', icon: Library, resource: 'mempalace' }, + { to: '/knowledge', labelKey: 'knowledge', icon: Database, resource: 'knowledge' }, + { to: '/costs', labelKey: 'costs', icon: DollarSign, resource: 'costs' }, + ], + }, + { + key: 'system', + collapsible: true, + items: [ + { to: '/settings', labelKey: 'settings', icon: Settings, resource: 'config' }, + { to: '/systems', labelKey: 'systems', icon: Monitor, resource: 'systems' }, + { to: '/observability', labelKey: 'observability', icon: BarChart3, resource: 'systems' }, + { to: '/providers', labelKey: 'providers', icon: Cpu, resource: 'config' }, + { to: '/plugins', labelKey: 'plugins', icon: Puzzle, resource: 'config' }, + { to: '/integrations', labelKey: 'integrations', icon: Plug, resource: 'integrations' }, + { to: '/scheduler', labelKey: 'scheduler', icon: Calendar, resource: 'scheduler' }, + { to: '/backups', labelKey: 'backups', icon: HardDriveDownload, resource: 'config' }, + ], + }, + { + key: 'admin', + collapsible: true, + adminOnly: true, + items: [ + { to: '/users', labelKey: 'users', icon: Users, resource: 'users' }, + { to: '/roles', labelKey: 'roles', icon: Shield, resource: 'users' }, + { to: '/audit', labelKey: 'audit', icon: ScrollText, resource: 'audit' }, + ], + }, +] + +export const DOCS_NAV_ITEM: NavItem = { + to: '/docs', + labelKey: 'docs', + icon: BookOpen, + resource: null, +} + +export function getVisibleNavGroups(hasPermission: (resource: string, action: string) => boolean): NavGroup[] { + return NAV_GROUPS + .map((group) => { + const items = group.items.filter((item) => item.resource === null || hasPermission(item.resource, 'view')) + if (items.length === 0) return null + + if (group.adminOnly) { + const hasAnyAdmin = group.items.some((item) => item.resource && hasPermission(item.resource, 'view')) + if (!hasAnyAdmin) return null + } + + return { ...group, items } + }) + .filter((group): group is NavGroup => group !== null) +} + +export function getVisibleNavItems(hasPermission: (resource: string, action: string) => boolean): NavItem[] { + return [...getVisibleNavGroups(hasPermission).flatMap((group) => group.items), DOCS_NAV_ITEM] +} diff --git a/dashboard/frontend/src/lib/pwa.ts b/dashboard/frontend/src/lib/pwa.ts new file mode 100644 index 00000000..18b96849 --- /dev/null +++ b/dashboard/frontend/src/lib/pwa.ts @@ -0,0 +1,18 @@ +export function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return + if (!import.meta.env.PROD) return + + const register = () => { + navigator.serviceWorker.register('/sw.js').catch((error) => { + console.warn('[pwa] service worker registration failed', error) + }) + } + + if (document.readyState === 'complete') { + register() + return + } + + window.addEventListener('load', register, { once: true }) +} + diff --git a/dashboard/frontend/src/lib/theme.ts b/dashboard/frontend/src/lib/theme.ts new file mode 100644 index 00000000..fbed42a4 --- /dev/null +++ b/dashboard/frontend/src/lib/theme.ts @@ -0,0 +1,44 @@ +export type ThemeMode = 'dark' | 'light' + +export const THEME_STORAGE_KEY = 'evonexus.theme' + +function getStoredTheme(): ThemeMode | null { + if (typeof window === 'undefined') return null + try { + const value = window.localStorage.getItem(THEME_STORAGE_KEY) + if (value === 'dark' || value === 'light') return value + } catch {} + return null +} + +function getPreferredTheme(): ThemeMode { + if (typeof window === 'undefined') return 'dark' + return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark' +} + +export function resolveInitialTheme(): ThemeMode { + return getStoredTheme() ?? getPreferredTheme() +} + +export function applyTheme(theme: ThemeMode) { + if (typeof document === 'undefined') return + + const root = document.documentElement + root.dataset.theme = theme + root.style.colorScheme = theme + + const body = document.body + body.dataset.theme = theme + + const themeColor = theme === 'light' ? '#f5f7fb' : '#0C111D' + const meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null + if (meta) meta.content = themeColor +} + +export function persistTheme(theme: ThemeMode) { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme) + } catch {} +} + diff --git a/dashboard/frontend/src/pages/Observability.tsx b/dashboard/frontend/src/pages/Observability.tsx new file mode 100644 index 00000000..2667a22d --- /dev/null +++ b/dashboard/frontend/src/pages/Observability.tsx @@ -0,0 +1,243 @@ +import { useEffect, useState } from 'react' +import { Activity, RefreshCw, ShieldCheck, Database, Plug, Clock3, AlertTriangle } from 'lucide-react' +import { api } from '../lib/api' +import { PageSkeleton } from '../components/PageStates' + +type Summary = any + +function MetricCard({ + icon: Icon, + label, + value, + detail, +}: { + icon: any + label: string + value: string + detail?: string +}) { + return ( +
+
+
+

{label}

+
{value}
+
+
+ +
+
+ {detail &&

{detail}

} +
+ ) +} + +export default function Observability() { + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = async () => { + setLoading(true) + setError(null) + try { + const data = await api.get('/observability/summary') + setSummary(data) + } catch { + setError('Failed to load observability summary') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + if (loading && !summary) { + return ( +
+
+
+

Observability

+

Platform health, provider metrics, and runtime state.

+
+
+ +
+ ) + } + + const providerMetrics = summary?.provider_metrics?.providers || [] + const routing = summary?.provider_config?.routing?.failover_order || [] + + return ( +
+
+
+

Observability

+

Platform health, provider metrics, queue state, and cache usage.

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+ +
+
+
+
+

Provider metrics

+

Success rate and latency by provider.

+
+
+ {summary?.provider_metrics?.total_events || 0} events +
+
+ +
+ + + + + + + + + + + + {providerMetrics.map((row: any) => ( + + + + + + + + ))} + {providerMetrics.length === 0 && ( + + + + )} + +
ProviderEventsSuccessLatencyLast event
{row.provider_id}{row.events}{row.success_rate ?? 'n/a'}%{row.avg_latency_ms ?? 'n/a'} ms{row.last_event?.event || 'n/a'}
+ No provider metrics yet. +
+
+
+ +
+
+

Routing

+

Current failover order used by the terminal server.

+
+ {routing.map((item: string) => ( + + {item} + + ))} +
+
+ +
+

System snapshot

+
+
+ Cache + {summary?.cache?.backend || 'unknown'} +
+
+ Queue + {summary?.queue?.backend || 'unknown'} +
+
+ Generated at + {summary?.generated_at ? new Date(summary.generated_at).toLocaleString() : 'n/a'} +
+
+
+
+
+ +
+
+
+

Recent events

+

Queue and platform activity from the last snapshot.

+
+
+ + {summary?.recent_events?.length || 0} +
+
+ +
+ {(summary?.recent_events || []).slice().reverse().map((event: any, index: number) => ( +
+
+ +
+
+
+ {event.topic || 'event'} + {event.source || 'dashboard'} +
+

+ {JSON.stringify(event.payload || {}).slice(0, 220)} +

+
+ + {event.ts ? new Date(event.ts).toLocaleTimeString() : ''} + +
+ ))} + {(summary?.recent_events || []).length === 0 && ( +
+ No recent events recorded. +
+ )} +
+
+
+ ) +} diff --git a/dashboard/frontend/src/pages/SettingsSecurityTab.tsx b/dashboard/frontend/src/pages/SettingsSecurityTab.tsx new file mode 100644 index 00000000..a25c229d --- /dev/null +++ b/dashboard/frontend/src/pages/SettingsSecurityTab.tsx @@ -0,0 +1,257 @@ +import { useCallback, useEffect, useState } from 'react' +import { AlertTriangle, Copy, KeyRound, QrCode, ShieldCheck } from 'lucide-react' +import { useAuth } from '../context/AuthContext' +import { api } from '../lib/api' + +type ToastType = 'success' | 'error' | 'info' + +const inp = 'w-full px-4 py-3 rounded-lg bg-[#0f1520] border border-[#1e2a3a] text-[#e2e8f0] placeholder-[#3d4f65] text-sm transition-colors duration-200 focus:outline-none focus:border-[#00FFA7]/60 focus:ring-1 focus:ring-[#00FFA7]/20' +const lbl = 'block text-[11px] font-semibold text-[#5a6b7f] mb-1.5 tracking-[0.08em] uppercase' + +interface SecurityTabProps { + showToast: (msg: string, type?: ToastType) => void +} + +export default function SecurityTab({ showToast }: SecurityTabProps) { + const { hasPermission, user } = useAuth() + const canManage = hasPermission('config', 'manage') + const [status, setStatus] = useState(null) + const [enrollment, setEnrollment] = useState(null) + const [verificationCode, setVerificationCode] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + try { + const data = await api.get('/auth/2fa/status') + setStatus(data) + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to load 2FA status', 'error') + } finally { + setLoading(false) + } + }, [showToast]) + + useEffect(() => { + load() + }, [load]) + + const startSetup = async () => { + if (!canManage) { + showToast('Only admin accounts can manage 2FA', 'error') + return + } + setSaving(true) + try { + const data = await api.post('/auth/2fa/setup') + setEnrollment(data) + setVerificationCode('') + showToast('2FA enrollment started') + await load() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to start 2FA setup', 'error') + } finally { + setSaving(false) + } + } + + const confirmSetup = async () => { + if (!canManage) { + showToast('Only admin accounts can manage 2FA', 'error') + return + } + if (!verificationCode.trim()) { + showToast('Enter the verification code from your authenticator app', 'error') + return + } + setSaving(true) + try { + await api.post('/auth/2fa/confirm', { code: verificationCode.trim() }) + setEnrollment(null) + setVerificationCode('') + setPassword('') + showToast('Two-factor authentication enabled') + await load() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to confirm 2FA', 'error') + } finally { + setSaving(false) + } + } + + const disableTwoFactor = async () => { + if (!canManage) { + showToast('Only admin accounts can manage 2FA', 'error') + return + } + if (!password.trim()) { + showToast('Enter your current password to disable 2FA', 'error') + return + } + setSaving(true) + try { + await api.post('/auth/2fa/disable', { password, totp_code: verificationCode.trim() }) + setEnrollment(null) + setVerificationCode('') + setPassword('') + showToast('Two-factor authentication disabled') + await load() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to disable 2FA', 'error') + } finally { + setSaving(false) + } + } + + const secret = enrollment?.secret || (status?.enrollment_pending ? 'Enrollment pending in session' : '') + const provisioningUri = enrollment?.otpauth_uri || '' + + return ( +
+
+
+
+

+ + Two-factor authentication +

+

+ Protect admin access with a TOTP authenticator app. +

+
+
+
+ {status?.enabled ? 'Enabled' : 'Disabled'} +
+
{user?.role || 'user'}
+
+
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) =>
)} +
+ ) : ( +
+
+
+ + Current status +
+

+ {status?.enabled + ? `Enabled${status?.confirmed_at ? ` since ${status.confirmed_at}` : ''}` + : '2FA is not enabled for this account.'} +

+ {status?.last_used_step ? ( +

Last accepted time-step: {status.last_used_step}

+ ) : null} +
+ + {!canManage && ( +
+ + Only admin accounts can enroll or disable 2FA. +
+ )} + + {secret && ( +
+
+ + Enrollment secret +
+
+ {secret} +
+ {provisioningUri && ( +
+ {provisioningUri} +
+ )} + +
+ )} + + {canManage && ( +
+
+ + +
+ +
+
+ + setVerificationCode(e.target.value)} + className={inp} + placeholder="123456" + inputMode="numeric" + autoComplete="one-time-code" + /> +
+
+ + setPassword(e.target.value)} + className={inp} + placeholder="••••••••" + autoComplete="current-password" + /> +
+
+ + +
+ )} +
+ )} +
+
+ ) +} + diff --git a/dashboard/frontend/src/pages/integrations/CustomIntegrationModal.tsx b/dashboard/frontend/src/pages/integrations/CustomIntegrationModal.tsx new file mode 100644 index 00000000..f9efd4a1 --- /dev/null +++ b/dashboard/frontend/src/pages/integrations/CustomIntegrationModal.tsx @@ -0,0 +1,300 @@ +import { useEffect, useRef, useState } from 'react' +import { Eye, EyeOff, Loader2, Plus, X } from 'lucide-react' +import { api } from '../../lib/api' +import { CATEGORY_OPTIONS, EMPTY_FORM, type CustomIntegrationForm, slugify } from './types' + +interface CustomModalProps { + open: boolean + initial?: CustomIntegrationForm & { slug: string } + isEdit: boolean + onClose: () => void + onSaved: (envWritten?: boolean) => void +} + +export function CustomIntegrationModal({ open, initial, isEdit, onClose, onSaved }: CustomModalProps) { + const [form, setForm] = useState(EMPTY_FORM) + const [slugManual, setSlugManual] = useState(false) + const [errors, setErrors] = useState>>({}) + const [saving, setSaving] = useState(false) + const [visibleRows, setVisibleRows] = useState>(new Set()) + const overlayRef = useRef(null) + + useEffect(() => { + if (open) { + const baseForm = initial + ? { + ...initial, + envKeys: (initial.envKeys as unknown as (string | { name: string; value: string })[]).map((k) => + typeof k === 'string' ? { name: k, value: '' } : k + ), + } + : EMPTY_FORM + setForm(baseForm) + setSlugManual(isEdit) + setErrors({}) + setVisibleRows(new Set()) + setSaving(false) + } + }, [open, initial, isEdit]) + + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open, onClose]) + + const setField = (key: K, value: CustomIntegrationForm[K]) => { + setForm((prev) => { + const next = { ...prev, [key]: value } + if (key === 'displayName' && !slugManual) { + next.slug = slugify(value as string) + } + return next + }) + setErrors((prev) => ({ ...prev, [key]: undefined })) + } + + const validate = (): boolean => { + const errs: Partial> = {} + if (!form.displayName.trim()) errs.displayName = 'Required' + if (!form.slug.trim()) { + errs.slug = 'Required' + } else if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(form.slug)) { + errs.slug = 'Lowercase letters, digits and hyphens only' + } + setErrors(errs) + return Object.keys(errs).length === 0 + } + + const handleSave = async () => { + if (!validate()) return + setSaving(true) + try { + const envKeyNames = form.envKeys.map((r) => r.name).filter((n) => n.trim()) + const envValues: Record = {} + for (const row of form.envKeys) { + if (row.name.trim() && row.value.trim()) { + envValues[row.name.trim()] = row.value.trim() + } + } + const hasEnvValues = Object.keys(envValues).length > 0 + + if (isEdit && initial?.slug) { + await api.patch(`/integrations/custom/${initial.slug}`, { + displayName: form.displayName, + description: form.description, + category: form.category, + envKeys: envKeyNames, + ...(hasEnvValues ? { envValues } : {}), + }) + } else { + await api.post('/integrations/custom', { + slug: form.slug, + displayName: form.displayName, + description: form.description, + category: form.category, + envKeys: envKeyNames, + ...(hasEnvValues ? { envValues } : {}), + }) + } + onSaved(hasEnvValues) + onClose() + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Error saving' + setErrors({ displayName: msg }) + } finally { + setSaving(false) + } + } + + const addEnvRow = () => { + setField('envKeys', [...form.envKeys, { name: '', value: '' }]) + } + + const removeEnvRow = (idx: number) => { + setField('envKeys', form.envKeys.filter((_, i) => i !== idx)) + setVisibleRows((prev) => { + const next = new Set(prev) + next.delete(idx) + return next + }) + } + + const updateEnvRow = (idx: number, field: 'name' | 'value', val: string) => { + const next = form.envKeys.map((r, i) => + i === idx ? { ...r, [field]: field === 'name' ? val.toUpperCase() : val } : r + ) + setField('envKeys', next) + } + + const toggleRowVisibility = (idx: number) => { + setVisibleRows((prev) => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx) + else next.add(idx) + return next + }) + } + + if (!open) return null + + return ( +
+
+
+
+

+ {isEdit ? 'Edit Custom Integration' : 'New Custom Integration'} +

+ +
+ +
+
+ + setField('displayName', e.target.value)} + placeholder="My Custom API" + className="w-full rounded-lg border border-[#21262d] bg-[#161b22] px-3 py-2 text-sm text-[#e6edf3] placeholder-[#3F3F46] focus:outline-none focus:border-[#00FFA7]/50 transition-colors" + /> + {errors.displayName &&

{errors.displayName}

} +
+ +
+ +
+ custom-int- + { + setSlugManual(true) + setField('slug', e.target.value) + }} + disabled={isEdit} + placeholder="my-api" + className="flex-1 bg-transparent px-1 py-2 text-sm text-[#e6edf3] placeholder-[#3F3F46] focus:outline-none disabled:opacity-50" + /> +
+ {errors.slug &&

{errors.slug}

} +
+ +
+ +