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.
Files changed (92) hide show
  1. package/bin/generate-manifest.mjs +253 -0
  2. package/bin/install.mjs +372 -26
  3. package/docs/INBOX.md +233 -0
  4. package/ftm/SKILL.md +34 -0
  5. package/ftm-audit/SKILL.md +69 -0
  6. package/ftm-brainstorm/SKILL.md +51 -0
  7. package/ftm-browse/SKILL.md +39 -0
  8. package/ftm-capture/SKILL.md +370 -0
  9. package/ftm-capture.yml +4 -0
  10. package/ftm-codex-gate/SKILL.md +59 -0
  11. package/ftm-config/SKILL.md +35 -0
  12. package/ftm-council/SKILL.md +56 -0
  13. package/ftm-dashboard/SKILL.md +34 -0
  14. package/ftm-debug/SKILL.md +84 -0
  15. package/ftm-diagram/SKILL.md +44 -0
  16. package/ftm-executor/SKILL.md +97 -0
  17. package/ftm-git/SKILL.md +60 -0
  18. package/ftm-inbox/backend/__init__.py +0 -0
  19. package/ftm-inbox/backend/adapters/__init__.py +0 -0
  20. package/ftm-inbox/backend/adapters/_retry.py +64 -0
  21. package/ftm-inbox/backend/adapters/base.py +230 -0
  22. package/ftm-inbox/backend/adapters/freshservice.py +104 -0
  23. package/ftm-inbox/backend/adapters/gmail.py +125 -0
  24. package/ftm-inbox/backend/adapters/jira.py +136 -0
  25. package/ftm-inbox/backend/adapters/registry.py +192 -0
  26. package/ftm-inbox/backend/adapters/slack.py +110 -0
  27. package/ftm-inbox/backend/db/__init__.py +0 -0
  28. package/ftm-inbox/backend/db/connection.py +54 -0
  29. package/ftm-inbox/backend/db/schema.py +78 -0
  30. package/ftm-inbox/backend/executor/__init__.py +7 -0
  31. package/ftm-inbox/backend/executor/engine.py +149 -0
  32. package/ftm-inbox/backend/executor/step_runner.py +98 -0
  33. package/ftm-inbox/backend/main.py +103 -0
  34. package/ftm-inbox/backend/models/__init__.py +1 -0
  35. package/ftm-inbox/backend/models/unified_task.py +36 -0
  36. package/ftm-inbox/backend/planner/__init__.py +6 -0
  37. package/ftm-inbox/backend/planner/generator.py +127 -0
  38. package/ftm-inbox/backend/planner/schema.py +34 -0
  39. package/ftm-inbox/backend/requirements.txt +5 -0
  40. package/ftm-inbox/backend/routes/__init__.py +0 -0
  41. package/ftm-inbox/backend/routes/execute.py +186 -0
  42. package/ftm-inbox/backend/routes/health.py +52 -0
  43. package/ftm-inbox/backend/routes/inbox.py +68 -0
  44. package/ftm-inbox/backend/routes/plan.py +271 -0
  45. package/ftm-inbox/bin/launchagent.mjs +91 -0
  46. package/ftm-inbox/bin/setup.mjs +188 -0
  47. package/ftm-inbox/bin/start.sh +10 -0
  48. package/ftm-inbox/bin/status.sh +17 -0
  49. package/ftm-inbox/bin/stop.sh +8 -0
  50. package/ftm-inbox/config.example.yml +55 -0
  51. package/ftm-inbox/package-lock.json +2898 -0
  52. package/ftm-inbox/package.json +26 -0
  53. package/ftm-inbox/postcss.config.js +6 -0
  54. package/ftm-inbox/src/app.css +199 -0
  55. package/ftm-inbox/src/app.html +18 -0
  56. package/ftm-inbox/src/lib/api.ts +166 -0
  57. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
  58. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
  59. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
  60. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
  61. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
  62. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
  63. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
  64. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
  65. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
  66. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
  67. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
  68. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
  69. package/ftm-inbox/src/lib/theme.ts +47 -0
  70. package/ftm-inbox/src/routes/+layout.svelte +76 -0
  71. package/ftm-inbox/src/routes/+page.svelte +401 -0
  72. package/ftm-inbox/static/favicon.png +0 -0
  73. package/ftm-inbox/svelte.config.js +12 -0
  74. package/ftm-inbox/tailwind.config.ts +63 -0
  75. package/ftm-inbox/tsconfig.json +13 -0
  76. package/ftm-inbox/vite.config.ts +6 -0
  77. package/ftm-intent/SKILL.md +44 -0
  78. package/ftm-manifest.json +3794 -0
  79. package/ftm-map/SKILL.md +50 -0
  80. package/ftm-mind/SKILL.md +173 -66
  81. package/ftm-pause/SKILL.md +43 -0
  82. package/ftm-researcher/SKILL.md +55 -0
  83. package/ftm-resume/SKILL.md +47 -0
  84. package/ftm-retro/SKILL.md +54 -0
  85. package/ftm-routine/SKILL.md +36 -0
  86. package/ftm-state/blackboard/capabilities.json +5 -0
  87. package/ftm-state/blackboard/capabilities.schema.json +27 -0
  88. package/ftm-upgrade/SKILL.md +41 -0
  89. package/hooks/ftm-blackboard-enforcer.sh +28 -27
  90. package/hooks/ftm-plan-gate.sh +21 -25
  91. package/install.sh +238 -111
  92. 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,6 @@
1
+ """
2
+ ftm-inbox planner package.
3
+
4
+ Generates structured YAML execution plans from task payloads via Claude CLI,
5
+ and persists them to SQLite for per-step approval workflows.
6
+ """
@@ -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
@@ -0,0 +1,5 @@
1
+ fastapi>=0.111.0
2
+ uvicorn[standard]>=0.29.0
3
+ pyyaml>=6.0.1
4
+ requests>=2.31.0
5
+ pydantic>=2.6.0
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]}