feed-the-machine 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/generate-manifest.mjs +253 -0
- package/bin/install.mjs +372 -26
- package/docs/INBOX.md +233 -0
- package/ftm/SKILL.md +34 -0
- package/ftm-audit/SKILL.md +69 -0
- package/ftm-brainstorm/SKILL.md +51 -0
- package/ftm-browse/SKILL.md +39 -0
- package/ftm-capture/SKILL.md +370 -0
- package/ftm-capture.yml +4 -0
- package/ftm-codex-gate/SKILL.md +59 -0
- package/ftm-config/SKILL.md +35 -0
- package/ftm-council/SKILL.md +56 -0
- package/ftm-dashboard/SKILL.md +34 -0
- package/ftm-debug/SKILL.md +84 -0
- package/ftm-diagram/SKILL.md +44 -0
- package/ftm-executor/SKILL.md +97 -0
- package/ftm-git/SKILL.md +60 -0
- package/ftm-inbox/backend/__init__.py +0 -0
- package/ftm-inbox/backend/adapters/__init__.py +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -0
- package/ftm-inbox/backend/adapters/base.py +230 -0
- package/ftm-inbox/backend/adapters/freshservice.py +104 -0
- package/ftm-inbox/backend/adapters/gmail.py +125 -0
- package/ftm-inbox/backend/adapters/jira.py +136 -0
- package/ftm-inbox/backend/adapters/registry.py +192 -0
- package/ftm-inbox/backend/adapters/slack.py +110 -0
- package/ftm-inbox/backend/db/__init__.py +0 -0
- package/ftm-inbox/backend/db/connection.py +54 -0
- package/ftm-inbox/backend/db/schema.py +78 -0
- package/ftm-inbox/backend/executor/__init__.py +7 -0
- package/ftm-inbox/backend/executor/engine.py +149 -0
- package/ftm-inbox/backend/executor/step_runner.py +98 -0
- package/ftm-inbox/backend/main.py +103 -0
- package/ftm-inbox/backend/models/__init__.py +1 -0
- package/ftm-inbox/backend/models/unified_task.py +36 -0
- package/ftm-inbox/backend/planner/__init__.py +6 -0
- package/ftm-inbox/backend/planner/generator.py +127 -0
- package/ftm-inbox/backend/planner/schema.py +34 -0
- package/ftm-inbox/backend/requirements.txt +5 -0
- package/ftm-inbox/backend/routes/__init__.py +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -0
- package/ftm-inbox/backend/routes/health.py +52 -0
- package/ftm-inbox/backend/routes/inbox.py +68 -0
- package/ftm-inbox/backend/routes/plan.py +271 -0
- package/ftm-inbox/bin/launchagent.mjs +91 -0
- package/ftm-inbox/bin/setup.mjs +188 -0
- package/ftm-inbox/bin/start.sh +10 -0
- package/ftm-inbox/bin/status.sh +17 -0
- package/ftm-inbox/bin/stop.sh +8 -0
- package/ftm-inbox/config.example.yml +55 -0
- package/ftm-inbox/package-lock.json +2898 -0
- package/ftm-inbox/package.json +26 -0
- package/ftm-inbox/postcss.config.js +6 -0
- package/ftm-inbox/src/app.css +199 -0
- package/ftm-inbox/src/app.html +18 -0
- package/ftm-inbox/src/lib/api.ts +166 -0
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
- package/ftm-inbox/src/lib/theme.ts +47 -0
- package/ftm-inbox/src/routes/+layout.svelte +76 -0
- package/ftm-inbox/src/routes/+page.svelte +401 -0
- package/ftm-inbox/static/favicon.png +0 -0
- package/ftm-inbox/svelte.config.js +12 -0
- package/ftm-inbox/tailwind.config.ts +63 -0
- package/ftm-inbox/tsconfig.json +13 -0
- package/ftm-inbox/vite.config.ts +6 -0
- package/ftm-intent/SKILL.md +44 -0
- package/ftm-manifest.json +3794 -0
- package/ftm-map/SKILL.md +50 -0
- package/ftm-mind/SKILL.md +173 -66
- package/ftm-pause/SKILL.md +43 -0
- package/ftm-researcher/SKILL.md +55 -0
- package/ftm-resume/SKILL.md +47 -0
- package/ftm-retro/SKILL.md +54 -0
- package/ftm-routine/SKILL.md +36 -0
- package/ftm-state/blackboard/capabilities.json +5 -0
- package/ftm-state/blackboard/capabilities.schema.json +27 -0
- package/ftm-upgrade/SKILL.md +41 -0
- package/hooks/ftm-blackboard-enforcer.sh +28 -27
- package/hooks/ftm-plan-gate.sh +21 -25
- package/install.sh +238 -111
- package/package.json +6 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ftm-inbox FastAPI application entry point.
|
|
3
|
+
|
|
4
|
+
Default port: 8042 (override via FTM_INBOX_PORT env var).
|
|
5
|
+
|
|
6
|
+
Startup sequence:
|
|
7
|
+
1. Open SQLite connection (WAL mode)
|
|
8
|
+
2. Initialize schema (idempotent CREATE TABLE IF NOT EXISTS)
|
|
9
|
+
3. Load adapter registry from config.yml (warn if missing, don't crash)
|
|
10
|
+
4. Register API routes
|
|
11
|
+
|
|
12
|
+
CORS allows all localhost origins for development.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
20
|
+
|
|
21
|
+
from backend.db.connection import get_connection
|
|
22
|
+
from backend.db.schema import initialize_schema
|
|
23
|
+
from backend.routes.health import router as health_router
|
|
24
|
+
from backend.routes.inbox import router as inbox_router
|
|
25
|
+
from backend.routes.plan import router as plan_router
|
|
26
|
+
from backend.routes.execute import router as execute_router
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(
|
|
29
|
+
level=logging.INFO,
|
|
30
|
+
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
31
|
+
)
|
|
32
|
+
logger = logging.getLogger("ftm-inbox")
|
|
33
|
+
|
|
34
|
+
app = FastAPI(
|
|
35
|
+
title="FTM Inbox",
|
|
36
|
+
description="Operator Cockpit backend — aggregates tasks from all connected sources.",
|
|
37
|
+
version="0.1.0",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# CORS: allow all localhost origins (frontend dev server + any local tooling)
|
|
41
|
+
app.add_middleware(
|
|
42
|
+
CORSMiddleware,
|
|
43
|
+
allow_origins=[
|
|
44
|
+
"http://localhost:5173",
|
|
45
|
+
"http://localhost:3000",
|
|
46
|
+
"http://127.0.0.1:5173",
|
|
47
|
+
"http://127.0.0.1:3000",
|
|
48
|
+
],
|
|
49
|
+
allow_credentials=True,
|
|
50
|
+
allow_methods=["*"],
|
|
51
|
+
allow_headers=["*"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Startup
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@app.on_event("startup")
|
|
59
|
+
async def startup() -> None:
|
|
60
|
+
logger.info("Starting ftm-inbox backend…")
|
|
61
|
+
|
|
62
|
+
# 1. Open DB and initialize schema
|
|
63
|
+
conn = get_connection()
|
|
64
|
+
initialize_schema(conn)
|
|
65
|
+
logger.info("Database initialized.")
|
|
66
|
+
|
|
67
|
+
# 2. Load adapter registry (non-fatal if config.yml is missing)
|
|
68
|
+
try:
|
|
69
|
+
from backend.adapters.registry import AdapterRegistry
|
|
70
|
+
registry = AdapterRegistry.from_config()
|
|
71
|
+
app.state.adapter_registry = registry
|
|
72
|
+
logger.info("Adapter registry loaded: %s", registry)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.warning("Adapter registry failed to load: %s", exc)
|
|
75
|
+
app.state.adapter_registry = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.on_event("shutdown")
|
|
79
|
+
async def shutdown() -> None:
|
|
80
|
+
from backend.db.connection import close_connection
|
|
81
|
+
close_connection()
|
|
82
|
+
logger.info("Database connection closed.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Routes
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
app.include_router(health_router)
|
|
90
|
+
app.include_router(inbox_router)
|
|
91
|
+
app.include_router(plan_router)
|
|
92
|
+
app.include_router(execute_router)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Dev entrypoint
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
import uvicorn
|
|
101
|
+
|
|
102
|
+
port = int(os.environ.get("FTM_INBOX_PORT", "8042"))
|
|
103
|
+
uvicorn.run("backend.main:app", host="0.0.0.0", port=port, reload=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ftm-inbox data models."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UnifiedTask — canonical Pydantic model for a task regardless of source.
|
|
3
|
+
|
|
4
|
+
This is the read/API-facing model. The write path uses NormalizedItem (a dataclass)
|
|
5
|
+
for speed; UnifiedTask is the model returned to callers and stored as JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnifiedTask(BaseModel):
|
|
16
|
+
"""Single unified representation of a task from any integrated source."""
|
|
17
|
+
|
|
18
|
+
id: int | None = None
|
|
19
|
+
source: str
|
|
20
|
+
source_id: str
|
|
21
|
+
title: str
|
|
22
|
+
body: str = ""
|
|
23
|
+
status: str = "open"
|
|
24
|
+
priority: str = "medium"
|
|
25
|
+
assignee: str | None = None
|
|
26
|
+
requester: str | None = None
|
|
27
|
+
created_at: str | None = None
|
|
28
|
+
updated_at: str | None = None
|
|
29
|
+
tags: list[str] = Field(default_factory=list)
|
|
30
|
+
custom_fields: dict[str, Any] = Field(default_factory=dict)
|
|
31
|
+
raw_payload: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
source_url: str | None = None
|
|
33
|
+
content_hash: str | None = None
|
|
34
|
+
ingested_at: str | None = None
|
|
35
|
+
|
|
36
|
+
model_config = {"populate_by_name": True}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plan generator — invokes Claude CLI to produce a structured YAML execution plan.
|
|
3
|
+
|
|
4
|
+
The generator calls `claude -p <prompt> --output-format json`, parses the JSON
|
|
5
|
+
envelope to extract the text result, then extracts the embedded YAML block.
|
|
6
|
+
|
|
7
|
+
On timeout or any subprocess error the function returns a safe error dict so
|
|
8
|
+
callers can surface the failure without crashing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_plan(task_data: dict, capabilities: dict | None = None) -> dict:
|
|
20
|
+
"""Invoke Claude CLI to generate a structured plan for a task.
|
|
21
|
+
|
|
22
|
+
Returns a dict with keys:
|
|
23
|
+
- steps: list[dict] — parsed plan steps (empty on failure)
|
|
24
|
+
- yaml_content: str — raw YAML string
|
|
25
|
+
- raw_response: str — full text from Claude (useful for debugging)
|
|
26
|
+
- error: str | None — set only on failure
|
|
27
|
+
"""
|
|
28
|
+
cap_note = ""
|
|
29
|
+
if capabilities:
|
|
30
|
+
available = ", ".join(
|
|
31
|
+
k for k, v in capabilities.items() if v
|
|
32
|
+
)
|
|
33
|
+
if available:
|
|
34
|
+
cap_note = f"\nAvailable integrations: {available}"
|
|
35
|
+
|
|
36
|
+
prompt = f"""Generate a structured execution plan for this task.
|
|
37
|
+
|
|
38
|
+
Task: {task_data.get('title', '')}
|
|
39
|
+
Source: {task_data.get('source', '')}
|
|
40
|
+
Body: {task_data.get('body', '')}{cap_note}
|
|
41
|
+
|
|
42
|
+
Return a YAML object with this structure:
|
|
43
|
+
steps:
|
|
44
|
+
- id: 1
|
|
45
|
+
title: "Step description"
|
|
46
|
+
target_system: "jira|freshservice|slack|gmail|local"
|
|
47
|
+
method_primary: "tool or action"
|
|
48
|
+
method_fallback: "alternative if primary unavailable"
|
|
49
|
+
risk_level: "low|medium|high"
|
|
50
|
+
approval_required: false
|
|
51
|
+
rollback: "how to undo"
|
|
52
|
+
|
|
53
|
+
Only return the YAML, no other text."""
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
["claude", "-p", prompt, "--output-format", "json"],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=120,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode != 0:
|
|
63
|
+
return {"error": result.stderr or "Claude CLI returned non-zero exit", "steps": [], "yaml_content": "", "raw_response": ""}
|
|
64
|
+
|
|
65
|
+
# Claude CLI JSON envelope: {"result": "<text>", ...}
|
|
66
|
+
try:
|
|
67
|
+
output = json.loads(result.stdout)
|
|
68
|
+
text = output.get("result", result.stdout)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
text = result.stdout
|
|
71
|
+
|
|
72
|
+
yaml_content = _extract_yaml(text)
|
|
73
|
+
try:
|
|
74
|
+
plan_data = yaml.safe_load(yaml_content) or {}
|
|
75
|
+
except yaml.YAMLError as exc:
|
|
76
|
+
return {
|
|
77
|
+
"error": f"YAML parse error: {exc}",
|
|
78
|
+
"steps": [],
|
|
79
|
+
"yaml_content": yaml_content,
|
|
80
|
+
"raw_response": text,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
raw_steps = plan_data.get("steps", [])
|
|
84
|
+
# Normalise: ensure each step has all expected keys with safe defaults
|
|
85
|
+
steps = [_normalise_step(i + 1, s) for i, s in enumerate(raw_steps)]
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"steps": steps,
|
|
89
|
+
"yaml_content": yaml_content,
|
|
90
|
+
"raw_response": text,
|
|
91
|
+
"error": None,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
return {"error": "Plan generation timed out after 120s", "steps": [], "yaml_content": "", "raw_response": ""}
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
return {"error": "Claude CLI not found — ensure 'claude' is on PATH", "steps": [], "yaml_content": "", "raw_response": ""}
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
return {"error": str(exc), "steps": [], "yaml_content": "", "raw_response": ""}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_yaml(text: str) -> str:
|
|
103
|
+
"""Extract YAML content from Claude's response, handling optional code fences."""
|
|
104
|
+
if "```yaml" in text:
|
|
105
|
+
start = text.index("```yaml") + 7
|
|
106
|
+
end = text.index("```", start)
|
|
107
|
+
return text[start:end].strip()
|
|
108
|
+
if "```" in text:
|
|
109
|
+
start = text.index("```") + 3
|
|
110
|
+
end = text.index("```", start)
|
|
111
|
+
return text[start:end].strip()
|
|
112
|
+
return text.strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalise_step(index: int, raw: dict) -> dict:
|
|
116
|
+
"""Ensure a raw step dict has all required keys."""
|
|
117
|
+
return {
|
|
118
|
+
"id": raw.get("id", index),
|
|
119
|
+
"title": raw.get("title", f"Step {index}"),
|
|
120
|
+
"target_system": raw.get("target_system", "local"),
|
|
121
|
+
"method_primary": raw.get("method_primary", ""),
|
|
122
|
+
"method_fallback": raw.get("method_fallback", ""),
|
|
123
|
+
"risk_level": raw.get("risk_level", "low"),
|
|
124
|
+
"approval_required": bool(raw.get("approval_required", False)),
|
|
125
|
+
"rollback": raw.get("rollback", ""),
|
|
126
|
+
"status": "pending",
|
|
127
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic models for structured execution plans.
|
|
3
|
+
|
|
4
|
+
A Plan contains an ordered list of PlanSteps. Each step tracks its approval
|
|
5
|
+
state independently so the operator can approve individual steps before
|
|
6
|
+
the executor runs them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanStep(BaseModel):
|
|
16
|
+
id: int
|
|
17
|
+
title: str
|
|
18
|
+
target_system: str = ""
|
|
19
|
+
method_primary: str = ""
|
|
20
|
+
method_fallback: str = ""
|
|
21
|
+
risk_level: str = "low" # low | medium | high
|
|
22
|
+
approval_required: bool = False
|
|
23
|
+
rollback: str = ""
|
|
24
|
+
status: str = "pending" # pending | approved | rejected | running | completed | failed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Plan(BaseModel):
|
|
28
|
+
id: int | None = None
|
|
29
|
+
task_id: int
|
|
30
|
+
steps: list[PlanStep] = Field(default_factory=list)
|
|
31
|
+
status: str = "draft" # draft | approved | executing | completed | failed
|
|
32
|
+
yaml_content: str = ""
|
|
33
|
+
created_at: str | None = None
|
|
34
|
+
updated_at: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution API routes — start, pause, resume, retry, audit log, SSE streaming.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
12
|
+
|
|
13
|
+
from backend.db.connection import get_connection
|
|
14
|
+
from backend.executor.engine import ExecutionEngine
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/api", tags=["execution"])
|
|
17
|
+
|
|
18
|
+
# In-memory registry of active engines (single-process; swap for Redis in production)
|
|
19
|
+
_active_engines: dict[int, ExecutionEngine] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/tasks/{task_id}/execute")
|
|
23
|
+
async def start_execution(task_id: int):
|
|
24
|
+
"""Start execution of approved plan steps."""
|
|
25
|
+
conn = get_connection()
|
|
26
|
+
plan_row = conn.execute(
|
|
27
|
+
"SELECT id, status FROM plans WHERE task_id = ? ORDER BY id DESC LIMIT 1",
|
|
28
|
+
(task_id,),
|
|
29
|
+
).fetchone()
|
|
30
|
+
|
|
31
|
+
if not plan_row:
|
|
32
|
+
raise HTTPException(404, "No plan found for this task")
|
|
33
|
+
|
|
34
|
+
plan_id = plan_row["id"]
|
|
35
|
+
engine = ExecutionEngine(task_id, plan_id)
|
|
36
|
+
_active_engines[task_id] = engine
|
|
37
|
+
|
|
38
|
+
result = await engine.execute()
|
|
39
|
+
_active_engines.pop(task_id, None)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/tasks/{task_id}/execution-stream")
|
|
44
|
+
async def execution_stream(task_id: int):
|
|
45
|
+
"""SSE stream of execution output."""
|
|
46
|
+
conn = get_connection()
|
|
47
|
+
plan_row = conn.execute(
|
|
48
|
+
"SELECT id FROM plans WHERE task_id = ? ORDER BY id DESC LIMIT 1",
|
|
49
|
+
(task_id,),
|
|
50
|
+
).fetchone()
|
|
51
|
+
|
|
52
|
+
if not plan_row:
|
|
53
|
+
raise HTTPException(404, "No plan found for this task")
|
|
54
|
+
|
|
55
|
+
plan_id = plan_row["id"]
|
|
56
|
+
engine = ExecutionEngine(task_id, plan_id)
|
|
57
|
+
_active_engines[task_id] = engine
|
|
58
|
+
|
|
59
|
+
output_queue: asyncio.Queue[str] = asyncio.Queue()
|
|
60
|
+
|
|
61
|
+
def on_output(text: str):
|
|
62
|
+
output_queue.put_nowait(text)
|
|
63
|
+
|
|
64
|
+
engine.on_output(on_output)
|
|
65
|
+
|
|
66
|
+
async def event_generator():
|
|
67
|
+
# Start execution in background
|
|
68
|
+
task = asyncio.create_task(engine.execute())
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
while not task.done():
|
|
72
|
+
try:
|
|
73
|
+
text = await asyncio.wait_for(output_queue.get(), timeout=1.0)
|
|
74
|
+
yield f"data: {json.dumps({'type': 'chunk', 'text': text})}\n\n"
|
|
75
|
+
except asyncio.TimeoutError:
|
|
76
|
+
yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n"
|
|
77
|
+
|
|
78
|
+
# Drain remaining messages
|
|
79
|
+
while not output_queue.empty():
|
|
80
|
+
text = output_queue.get_nowait()
|
|
81
|
+
yield f"data: {json.dumps({'type': 'chunk', 'text': text})}\n\n"
|
|
82
|
+
|
|
83
|
+
result = task.result()
|
|
84
|
+
yield f"data: {json.dumps({'type': 'done', 'result': result})}\n\n"
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
|
87
|
+
finally:
|
|
88
|
+
_active_engines.pop(task_id, None)
|
|
89
|
+
|
|
90
|
+
return StreamingResponse(
|
|
91
|
+
event_generator(),
|
|
92
|
+
media_type="text/event-stream",
|
|
93
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/tasks/{task_id}/pause")
|
|
98
|
+
async def pause_execution(task_id: int):
|
|
99
|
+
"""Pause execution after the current step completes."""
|
|
100
|
+
engine = _active_engines.get(task_id)
|
|
101
|
+
if not engine:
|
|
102
|
+
raise HTTPException(404, "No active execution for this task")
|
|
103
|
+
engine.pause()
|
|
104
|
+
return {"status": "pausing", "message": "Execution will pause after current step"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("/tasks/{task_id}/resume")
|
|
108
|
+
async def resume_execution(task_id: int):
|
|
109
|
+
"""Resume a paused execution."""
|
|
110
|
+
engine = _active_engines.get(task_id)
|
|
111
|
+
if engine:
|
|
112
|
+
engine.resume()
|
|
113
|
+
result = await engine.execute()
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
# No active engine — restart from where we left off
|
|
117
|
+
conn = get_connection()
|
|
118
|
+
plan_row = conn.execute(
|
|
119
|
+
"SELECT id FROM plans WHERE task_id = ? ORDER BY id DESC LIMIT 1",
|
|
120
|
+
(task_id,),
|
|
121
|
+
).fetchone()
|
|
122
|
+
if not plan_row:
|
|
123
|
+
raise HTTPException(404, "No plan found for this task")
|
|
124
|
+
|
|
125
|
+
engine = ExecutionEngine(task_id, plan_row["id"])
|
|
126
|
+
_active_engines[task_id] = engine
|
|
127
|
+
result = await engine.execute()
|
|
128
|
+
_active_engines.pop(task_id, None)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.post("/tasks/{task_id}/steps/{step_id}/retry")
|
|
133
|
+
async def retry_step(task_id: int, step_id: int):
|
|
134
|
+
"""Retry a failed step."""
|
|
135
|
+
conn = get_connection()
|
|
136
|
+
plan_row = conn.execute(
|
|
137
|
+
"SELECT id, yaml_content FROM plans WHERE task_id = ? ORDER BY id DESC LIMIT 1",
|
|
138
|
+
(task_id,),
|
|
139
|
+
).fetchone()
|
|
140
|
+
if not plan_row:
|
|
141
|
+
raise HTTPException(404, "No plan found")
|
|
142
|
+
|
|
143
|
+
import yaml
|
|
144
|
+
plan_data = yaml.safe_load(plan_row["yaml_content"]) or {}
|
|
145
|
+
for step in plan_data.get("steps", []):
|
|
146
|
+
if step.get("id") == step_id:
|
|
147
|
+
step["status"] = "approved"
|
|
148
|
+
|
|
149
|
+
updated = yaml.dump(plan_data, default_flow_style=False)
|
|
150
|
+
conn.execute(
|
|
151
|
+
"UPDATE plans SET yaml_content = ?, updated_at = datetime('now') WHERE id = ?",
|
|
152
|
+
(updated, plan_row["id"]),
|
|
153
|
+
)
|
|
154
|
+
conn.commit()
|
|
155
|
+
|
|
156
|
+
return {"status": "reset", "step_id": step_id, "message": "Step reset to approved, ready for re-execution"}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.get("/tasks/{task_id}/audit-log")
|
|
160
|
+
async def get_audit_log(task_id: int, limit: int = Query(100, ge=1, le=1000)):
|
|
161
|
+
"""Get audit log entries for a task's execution."""
|
|
162
|
+
conn = get_connection()
|
|
163
|
+
|
|
164
|
+
# Get all plan IDs for this task
|
|
165
|
+
plan_rows = conn.execute(
|
|
166
|
+
"SELECT id FROM plans WHERE task_id = ?", (task_id,)
|
|
167
|
+
).fetchall()
|
|
168
|
+
if not plan_rows:
|
|
169
|
+
return {"entries": []}
|
|
170
|
+
|
|
171
|
+
plan_ids = [r["id"] for r in plan_rows]
|
|
172
|
+
|
|
173
|
+
# Get step_ids from the plans to filter audit_log
|
|
174
|
+
rows = conn.execute(
|
|
175
|
+
f"SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",
|
|
176
|
+
(limit,),
|
|
177
|
+
).fetchall()
|
|
178
|
+
|
|
179
|
+
entries = []
|
|
180
|
+
for row in rows:
|
|
181
|
+
entry = dict(row)
|
|
182
|
+
if entry.get("result") and isinstance(entry["result"], str):
|
|
183
|
+
entry["result"] = json.loads(entry["result"])
|
|
184
|
+
entries.append(entry)
|
|
185
|
+
|
|
186
|
+
return {"entries": entries}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check endpoint for ftm-inbox backend.
|
|
3
|
+
|
|
4
|
+
GET /health
|
|
5
|
+
Returns 200 with status, version, and DB connectivity check.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_db(conn: sqlite3.Connection) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
conn.execute("SELECT 1").fetchone()
|
|
20
|
+
return True
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/health")
|
|
26
|
+
async def health_check() -> JSONResponse:
|
|
27
|
+
"""
|
|
28
|
+
Returns backend health status.
|
|
29
|
+
|
|
30
|
+
Does not depend on the adapter registry — just confirms the process
|
|
31
|
+
is alive and the database is reachable.
|
|
32
|
+
"""
|
|
33
|
+
from backend.db.connection import get_connection
|
|
34
|
+
|
|
35
|
+
db_ok = False
|
|
36
|
+
try:
|
|
37
|
+
conn = get_connection()
|
|
38
|
+
db_ok = _check_db(conn)
|
|
39
|
+
except Exception:
|
|
40
|
+
db_ok = False
|
|
41
|
+
|
|
42
|
+
status = "ok" if db_ok else "degraded"
|
|
43
|
+
code = 200 if db_ok else 503
|
|
44
|
+
|
|
45
|
+
return JSONResponse(
|
|
46
|
+
status_code=code,
|
|
47
|
+
content={
|
|
48
|
+
"status": status,
|
|
49
|
+
"db": "ok" if db_ok else "unreachable",
|
|
50
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
51
|
+
},
|
|
52
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inbox API routes — paginated task listing with source/status filtering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Query
|
|
10
|
+
|
|
11
|
+
from backend.db.connection import get_connection
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api", tags=["inbox"])
|
|
14
|
+
|
|
15
|
+
_JSON_FIELDS = ("tags", "custom_fields", "raw_payload")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/inbox")
|
|
19
|
+
async def list_inbox(
|
|
20
|
+
source: str | None = Query(None, description="Filter by source"),
|
|
21
|
+
status: str | None = Query(None, description="Filter by status"),
|
|
22
|
+
page: int = Query(1, ge=1),
|
|
23
|
+
per_page: int = Query(50, ge=1, le=200),
|
|
24
|
+
):
|
|
25
|
+
"""Return paginated inbox tasks with optional filters."""
|
|
26
|
+
conn = get_connection()
|
|
27
|
+
conditions: list[str] = []
|
|
28
|
+
params: list[str] = []
|
|
29
|
+
|
|
30
|
+
if source:
|
|
31
|
+
conditions.append("source = ?")
|
|
32
|
+
params.append(source)
|
|
33
|
+
if status:
|
|
34
|
+
conditions.append("status = ?")
|
|
35
|
+
params.append(status)
|
|
36
|
+
|
|
37
|
+
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
38
|
+
offset = (page - 1) * per_page
|
|
39
|
+
|
|
40
|
+
rows = conn.execute(
|
|
41
|
+
f"SELECT * FROM inbox{where} ORDER BY ingested_at DESC LIMIT ? OFFSET ?",
|
|
42
|
+
params + [per_page, offset],
|
|
43
|
+
).fetchall()
|
|
44
|
+
|
|
45
|
+
tasks = []
|
|
46
|
+
for row in rows:
|
|
47
|
+
task = dict(row)
|
|
48
|
+
for field in _JSON_FIELDS:
|
|
49
|
+
val = task.get(field)
|
|
50
|
+
if val and isinstance(val, str):
|
|
51
|
+
task[field] = json.loads(val)
|
|
52
|
+
tasks.append(task)
|
|
53
|
+
|
|
54
|
+
total = conn.execute(
|
|
55
|
+
f"SELECT COUNT(*) as total FROM inbox{where}", params
|
|
56
|
+
).fetchone()["total"]
|
|
57
|
+
|
|
58
|
+
return {"tasks": tasks, "total": total, "page": page, "per_page": per_page}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/inbox/sources")
|
|
62
|
+
async def list_sources():
|
|
63
|
+
"""Return distinct source names with task counts."""
|
|
64
|
+
conn = get_connection()
|
|
65
|
+
rows = conn.execute(
|
|
66
|
+
"SELECT source, COUNT(*) as count FROM inbox GROUP BY source ORDER BY count DESC"
|
|
67
|
+
).fetchall()
|
|
68
|
+
return {"sources": [{"name": r["source"], "count": r["count"]} for r in rows]}
|