flowforge-map 0.1.0__py3-none-any.whl
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.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
flowforge/storage.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""SQLite persistence: runs and their events (D16), duration history (D1), persistent cache (D3),
|
|
2
|
+
connectors (D10), each map's versions, edits and positions (D17, D21), and a few settings.
|
|
3
|
+
|
|
4
|
+
Every write of run data, events and cache rows goes through `self.redact` (D16), so the file
|
|
5
|
+
never holds a configured key or a known key shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from flowforge.scheduler.durations import EWMA_ALPHA, ewma
|
|
21
|
+
|
|
22
|
+
SCHEMA = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
24
|
+
id TEXT PRIMARY KEY, workflow_id TEXT, policy TEXT, status TEXT,
|
|
25
|
+
makespan_ms REAL, result_json TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS durations (
|
|
28
|
+
workflow_id TEXT, step_id TEXT, ewma_ms REAL, samples INTEGER,
|
|
29
|
+
PRIMARY KEY (workflow_id, step_id)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
32
|
+
key TEXT PRIMARY KEY, output_json TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS connectors (
|
|
35
|
+
id TEXT PRIMARY KEY, json TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
36
|
+
);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS run_events (
|
|
38
|
+
run_id TEXT NOT NULL, seq INTEGER NOT NULL, json TEXT NOT NULL, PRIMARY KEY (run_id, seq)
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS flow_versions (
|
|
41
|
+
map_id TEXT NOT NULL DEFAULT 'flow', version INTEGER NOT NULL, status TEXT NOT NULL, created_at TEXT,
|
|
42
|
+
decided_at TEXT, fact_sheet_hash TEXT, flow_json TEXT NOT NULL, trace_json TEXT, analysis_json TEXT,
|
|
43
|
+
changes_json TEXT, PRIMARY KEY (map_id, version)
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS flow_overrides (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, json TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
47
|
+
map_id TEXT NOT NULL DEFAULT 'flow'
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS flow_positions (
|
|
50
|
+
map_id TEXT NOT NULL DEFAULT 'flow', node_id TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
|
|
51
|
+
PRIMARY KEY (map_id, node_id)
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
54
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS checks (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, dep_id TEXT NOT NULL, kind TEXT NOT NULL,
|
|
58
|
+
status TEXT NOT NULL, latency_ms REAL, summary TEXT NOT NULL, detail_json TEXT, at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS checks_by_dep ON checks (dep_id, id);
|
|
61
|
+
"""
|
|
62
|
+
MAP_IDS = ("deps", "flow") # the dependency map and the flow map (D21)
|
|
63
|
+
FLOW_VERSION_FIELDS = ("version", "status", "created_at", "decided_at", "fact_sheet_hash", "flow_json", "trace_json",
|
|
64
|
+
"analysis_json", "changes_json")
|
|
65
|
+
|
|
66
|
+
# Columns added to `runs` after V1; added in place so existing databases keep working (D16).
|
|
67
|
+
RUN_COLUMNS = {
|
|
68
|
+
"workflow_name": "TEXT", "started_at": "TEXT", "finished_at": "TEXT", "api_calls": "INTEGER",
|
|
69
|
+
"cache_hits": "INTEGER", "tokens": "INTEGER", "credits": "REAL", "work_ms": "REAL",
|
|
70
|
+
"failure_reason": "TEXT", "steps_json": "TEXT",
|
|
71
|
+
}
|
|
72
|
+
SUMMARY_FIELDS = ("id", "workflow_id", "workflow_name", "policy", "status", "started_at", "finished_at",
|
|
73
|
+
"makespan_ms", "api_calls", "cache_hits", "tokens", "credits", "work_ms", "failure_reason",
|
|
74
|
+
"steps_json")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def now_iso() -> str:
|
|
78
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _identity(value: Any) -> Any:
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _serialised(cls: type) -> type:
|
|
86
|
+
"""Every public method holds the storage lock. One sqlite3 connection is shared by the event loop and
|
|
87
|
+
FastAPI's worker threads (sync routes), and a connection must never be used by two threads at once."""
|
|
88
|
+
def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
89
|
+
@functools.wraps(fn)
|
|
90
|
+
def locked(self: Storage, *args: Any, **kwargs: Any) -> Any:
|
|
91
|
+
with self._lock:
|
|
92
|
+
return fn(self, *args, **kwargs)
|
|
93
|
+
return locked
|
|
94
|
+
|
|
95
|
+
for name, value in list(vars(cls).items()):
|
|
96
|
+
if callable(value) and not name.startswith("_"):
|
|
97
|
+
setattr(cls, name, wrap(value))
|
|
98
|
+
return cls
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@_serialised
|
|
102
|
+
class Storage:
|
|
103
|
+
def __init__(self, path: str | Path = "flowforge.db", redact: Callable[[Any], Any] | None = None):
|
|
104
|
+
self._lock = threading.RLock()
|
|
105
|
+
self.path = Path(path)
|
|
106
|
+
if str(path) != ":memory:":
|
|
107
|
+
self.path.parent.mkdir(parents=True, exist_ok=True) # a fresh install has no ~/.flowforge yet (D34)
|
|
108
|
+
self.conn = sqlite3.connect(path, check_same_thread=False)
|
|
109
|
+
self.conn.executescript(SCHEMA)
|
|
110
|
+
self.redact: Callable[[Any], Any] = redact or _identity
|
|
111
|
+
existing = {row[1] for row in self.conn.execute("PRAGMA table_info(runs)")}
|
|
112
|
+
for column, kind in RUN_COLUMNS.items():
|
|
113
|
+
if column not in existing:
|
|
114
|
+
self.conn.execute(f"ALTER TABLE runs ADD COLUMN {column} {kind}")
|
|
115
|
+
self._migrate_maps()
|
|
116
|
+
self.conn.commit()
|
|
117
|
+
|
|
118
|
+
def _migrate_maps(self) -> None:
|
|
119
|
+
"""Databases from before D21 held one map: it becomes the flow map. Versions are numbered per map and
|
|
120
|
+
positions are keyed per map, so both tables are rebuilt; edits only gain the column."""
|
|
121
|
+
def has_map_id(table: str) -> bool:
|
|
122
|
+
return "map_id" in {row[1] for row in self.conn.execute(f"PRAGMA table_info({table})")}
|
|
123
|
+
|
|
124
|
+
if not has_map_id("flow_overrides"):
|
|
125
|
+
self.conn.execute("ALTER TABLE flow_overrides ADD COLUMN map_id TEXT NOT NULL DEFAULT 'flow'")
|
|
126
|
+
if not has_map_id("flow_versions"):
|
|
127
|
+
self.conn.executescript("""
|
|
128
|
+
ALTER TABLE flow_versions RENAME TO flow_versions_v1;
|
|
129
|
+
CREATE TABLE flow_versions (map_id TEXT NOT NULL DEFAULT 'flow', version INTEGER NOT NULL,
|
|
130
|
+
status TEXT NOT NULL, created_at TEXT, decided_at TEXT, fact_sheet_hash TEXT,
|
|
131
|
+
flow_json TEXT NOT NULL, trace_json TEXT, analysis_json TEXT, changes_json TEXT,
|
|
132
|
+
PRIMARY KEY (map_id, version));
|
|
133
|
+
INSERT INTO flow_versions SELECT 'flow', version, status, created_at, decided_at, fact_sheet_hash,
|
|
134
|
+
flow_json, trace_json, analysis_json, changes_json FROM flow_versions_v1;
|
|
135
|
+
DROP TABLE flow_versions_v1;
|
|
136
|
+
""")
|
|
137
|
+
if not has_map_id("flow_positions"):
|
|
138
|
+
self.conn.executescript("""
|
|
139
|
+
ALTER TABLE flow_positions RENAME TO flow_positions_v1;
|
|
140
|
+
CREATE TABLE flow_positions (map_id TEXT NOT NULL DEFAULT 'flow', node_id TEXT NOT NULL,
|
|
141
|
+
x REAL NOT NULL, y REAL NOT NULL, PRIMARY KEY (map_id, node_id));
|
|
142
|
+
INSERT INTO flow_positions (map_id, node_id, x, y) SELECT 'flow', node_id, x, y FROM flow_positions_v1;
|
|
143
|
+
DROP TABLE flow_positions_v1;
|
|
144
|
+
""")
|
|
145
|
+
|
|
146
|
+
def _dump(self, value: Any) -> str:
|
|
147
|
+
return json.dumps(self.redact(value), default=str)
|
|
148
|
+
|
|
149
|
+
def save_run(self, run_id: str, result: Any) -> None:
|
|
150
|
+
"""`result` is an executor RunResult (V1 API; the app uses start_run/finish_run)."""
|
|
151
|
+
self.conn.execute(
|
|
152
|
+
"INSERT OR REPLACE INTO runs (id, workflow_id, policy, status, makespan_ms, result_json)"
|
|
153
|
+
" VALUES (?, ?, ?, ?, ?, ?)",
|
|
154
|
+
(run_id, result.workflow_id, result.policy, result.status, result.makespan_ms,
|
|
155
|
+
self._dump(asdict(result))),
|
|
156
|
+
)
|
|
157
|
+
self.conn.commit()
|
|
158
|
+
|
|
159
|
+
# --- run history (D16) ----------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def start_run(self, run_id: str, workflow_id: str, workflow_name: str, policy: str,
|
|
162
|
+
started_at: str | None = None, steps: list[dict[str, Any]] | None = None) -> None:
|
|
163
|
+
"""`steps` is the plan outline (id, title, type, connector, depends_on), never params."""
|
|
164
|
+
self.conn.execute(
|
|
165
|
+
"INSERT INTO runs (id, workflow_id, workflow_name, policy, status, started_at, steps_json)"
|
|
166
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
167
|
+
(run_id, workflow_id, self.redact(workflow_name), policy, "running", started_at or now_iso(),
|
|
168
|
+
self._dump(steps or [])),
|
|
169
|
+
)
|
|
170
|
+
self.conn.commit()
|
|
171
|
+
|
|
172
|
+
def append_event(self, run_id: str, seq: int, event: dict[str, Any]) -> None:
|
|
173
|
+
self.conn.execute("INSERT OR REPLACE INTO run_events (run_id, seq, json) VALUES (?, ?, ?)",
|
|
174
|
+
(run_id, seq, self._dump(event)))
|
|
175
|
+
self.conn.commit()
|
|
176
|
+
|
|
177
|
+
def finish_run(self, run_id: str, status: str, result: dict[str, Any] | None,
|
|
178
|
+
totals: dict[str, Any], failure_reason: str | None, finished_at: str | None = None) -> None:
|
|
179
|
+
self.conn.execute(
|
|
180
|
+
"UPDATE runs SET status = ?, finished_at = ?, result_json = ?, makespan_ms = ?, api_calls = ?,"
|
|
181
|
+
" cache_hits = ?, tokens = ?, credits = ?, work_ms = ?, failure_reason = ? WHERE id = ?",
|
|
182
|
+
(status, finished_at or now_iso(), self._dump(result) if result is not None else None,
|
|
183
|
+
totals.get("makespan_ms"),
|
|
184
|
+
totals.get("api_calls"), totals.get("cache_hits"), totals.get("tokens"), totals.get("credits"),
|
|
185
|
+
totals.get("work_ms"), self.redact(failure_reason), run_id),
|
|
186
|
+
)
|
|
187
|
+
self.conn.commit()
|
|
188
|
+
|
|
189
|
+
def mark_interrupted(self) -> int:
|
|
190
|
+
"""Runs still marked running when the server starts were cut off by a stop or crash."""
|
|
191
|
+
cur = self.conn.execute(
|
|
192
|
+
"UPDATE runs SET status = 'interrupted', finished_at = ?, failure_reason = ? WHERE status = 'running'",
|
|
193
|
+
(now_iso(), "The server stopped while this was running."),
|
|
194
|
+
)
|
|
195
|
+
self.conn.commit()
|
|
196
|
+
return cur.rowcount
|
|
197
|
+
|
|
198
|
+
def list_runs(self, status: str | None = None, limit: int = 50, before: str | None = None) -> list[dict[str, Any]]:
|
|
199
|
+
sql = f"SELECT {', '.join(SUMMARY_FIELDS)} FROM runs WHERE started_at IS NOT NULL"
|
|
200
|
+
args: list[Any] = []
|
|
201
|
+
if status:
|
|
202
|
+
sql += " AND status = ?"
|
|
203
|
+
args.append(status)
|
|
204
|
+
if before:
|
|
205
|
+
sql += " AND started_at < ?"
|
|
206
|
+
args.append(before)
|
|
207
|
+
sql += " ORDER BY started_at DESC LIMIT ?"
|
|
208
|
+
args.append(limit)
|
|
209
|
+
return [dict(zip(SUMMARY_FIELDS, row, strict=True)) for row in self.conn.execute(sql, args)]
|
|
210
|
+
|
|
211
|
+
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
|
212
|
+
row = self.conn.execute(
|
|
213
|
+
f"SELECT {', '.join(SUMMARY_FIELDS)}, result_json FROM runs WHERE id = ?", (run_id,)
|
|
214
|
+
).fetchone()
|
|
215
|
+
if row is None:
|
|
216
|
+
return None
|
|
217
|
+
summary = dict(zip(SUMMARY_FIELDS, row[:-1], strict=True))
|
|
218
|
+
summary["result"] = json.loads(row[-1]) if row[-1] else None
|
|
219
|
+
return summary
|
|
220
|
+
|
|
221
|
+
def savings(self) -> dict[str, Any]:
|
|
222
|
+
"""Money and time over finished runs: time saved vs one at a time, calls skipped, tokens."""
|
|
223
|
+
row = self.conn.execute(
|
|
224
|
+
"SELECT COUNT(*), COALESCE(SUM(MAX(COALESCE(work_ms, 0) - COALESCE(makespan_ms, 0), 0)), 0),"
|
|
225
|
+
" COALESCE(SUM(cache_hits), 0), COALESCE(SUM(api_calls), 0), COALESCE(SUM(tokens), 0),"
|
|
226
|
+
" COALESCE(SUM(credits), 0) FROM runs WHERE started_at IS NOT NULL AND status != 'running'"
|
|
227
|
+
" AND result_json IS NOT NULL"
|
|
228
|
+
).fetchone()
|
|
229
|
+
return {"runs": row[0], "time_saved_ms": round(row[1]), "calls_skipped": row[2], "api_calls": row[3],
|
|
230
|
+
"tokens": row[4], "credits": row[5]}
|
|
231
|
+
|
|
232
|
+
def recent_results(self, limit: int = 50) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
233
|
+
"""(summary, result) for the newest finished runs that have a result, newest first."""
|
|
234
|
+
rows = self.conn.execute(
|
|
235
|
+
f"SELECT {', '.join(SUMMARY_FIELDS)}, result_json FROM runs"
|
|
236
|
+
" WHERE result_json IS NOT NULL AND started_at IS NOT NULL ORDER BY started_at DESC LIMIT ?", (limit,))
|
|
237
|
+
return [(dict(zip(SUMMARY_FIELDS, r[:-1], strict=True)), json.loads(r[-1])) for r in rows]
|
|
238
|
+
|
|
239
|
+
def run_events(self, run_id: str) -> list[dict[str, Any]]:
|
|
240
|
+
rows = self.conn.execute("SELECT json FROM run_events WHERE run_id = ? ORDER BY seq", (run_id,))
|
|
241
|
+
return [json.loads(r[0]) for r in rows]
|
|
242
|
+
|
|
243
|
+
def get_cached(self, key: str) -> tuple[bool, Any]:
|
|
244
|
+
row = self.conn.execute("SELECT output_json FROM cache WHERE key = ?", (key,)).fetchone()
|
|
245
|
+
return (True, json.loads(row[0])) if row else (False, None)
|
|
246
|
+
|
|
247
|
+
def put_cached(self, key: str, output: Any) -> None:
|
|
248
|
+
self.conn.execute(
|
|
249
|
+
"INSERT OR REPLACE INTO cache (key, output_json) VALUES (?, ?)", (key, self._dump(output))
|
|
250
|
+
)
|
|
251
|
+
self.conn.commit()
|
|
252
|
+
|
|
253
|
+
def duration_history(self, workflow_id: str) -> dict[str, float]:
|
|
254
|
+
rows = self.conn.execute(
|
|
255
|
+
"SELECT step_id, ewma_ms FROM durations WHERE workflow_id = ?", (workflow_id,)
|
|
256
|
+
)
|
|
257
|
+
return dict(rows.fetchall())
|
|
258
|
+
|
|
259
|
+
def record_durations(self, workflow_id: str, observed_ms: dict[str, float], alpha: float = EWMA_ALPHA) -> None:
|
|
260
|
+
for step_id, ms in observed_ms.items():
|
|
261
|
+
row = self.conn.execute(
|
|
262
|
+
"SELECT ewma_ms, samples FROM durations WHERE workflow_id = ? AND step_id = ?",
|
|
263
|
+
(workflow_id, step_id),
|
|
264
|
+
).fetchone()
|
|
265
|
+
prev, n = (None, 0) if row is None else row
|
|
266
|
+
self.conn.execute(
|
|
267
|
+
"INSERT OR REPLACE INTO durations VALUES (?, ?, ?, ?)",
|
|
268
|
+
(workflow_id, step_id, ewma(prev, ms, alpha), n + 1),
|
|
269
|
+
)
|
|
270
|
+
self.conn.commit()
|
|
271
|
+
|
|
272
|
+
def connector_rows(self) -> list[tuple[str, str]]:
|
|
273
|
+
"""(id, json) for every saved connector, by id. Rows hold secret references, never values."""
|
|
274
|
+
return self.conn.execute("SELECT id, json FROM connectors ORDER BY id").fetchall()
|
|
275
|
+
|
|
276
|
+
def put_connector(self, connector_id: str, connector_json: str) -> None:
|
|
277
|
+
self.conn.execute(
|
|
278
|
+
"INSERT INTO connectors (id, json) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET json = excluded.json",
|
|
279
|
+
(connector_id, connector_json),
|
|
280
|
+
)
|
|
281
|
+
self.conn.commit()
|
|
282
|
+
|
|
283
|
+
def delete_connector(self, connector_id: str) -> None:
|
|
284
|
+
self.conn.execute("DELETE FROM connectors WHERE id = ?", (connector_id,))
|
|
285
|
+
self.conn.commit()
|
|
286
|
+
|
|
287
|
+
# --- maps (D17, D21): Planner versions, the user's edits on top, block positions; one set per map ------
|
|
288
|
+
|
|
289
|
+
def flows(self, map_id: str) -> FlowStore:
|
|
290
|
+
"""The flow-map methods for one map ("deps" or "flow"), so a FlowService never names its map."""
|
|
291
|
+
if map_id not in MAP_IDS:
|
|
292
|
+
raise ValueError(f"unknown map '{map_id}'")
|
|
293
|
+
return FlowStore(self, map_id)
|
|
294
|
+
|
|
295
|
+
def add_flow_version(self, map_id: str, status: str, flow: Any, trace: Any, analysis: Any,
|
|
296
|
+
fact_sheet_hash: str, changes: Any = None) -> int:
|
|
297
|
+
version = 1 + int(self.conn.execute("SELECT COALESCE(MAX(version), 0) FROM flow_versions WHERE map_id = ?",
|
|
298
|
+
(map_id,)).fetchone()[0]) # numbered per map, under the lock
|
|
299
|
+
self.conn.execute(
|
|
300
|
+
"INSERT INTO flow_versions (map_id, version, status, created_at, fact_sheet_hash, flow_json, trace_json,"
|
|
301
|
+
" analysis_json, changes_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
302
|
+
(map_id, version, status, now_iso(), fact_sheet_hash, self._dump(flow), self._dump(trace),
|
|
303
|
+
self._dump(analysis), self._dump(changes) if changes is not None else None))
|
|
304
|
+
self.conn.commit()
|
|
305
|
+
return version
|
|
306
|
+
|
|
307
|
+
def _flow_row(self, row: tuple[Any, ...] | None) -> dict[str, Any] | None:
|
|
308
|
+
if row is None:
|
|
309
|
+
return None
|
|
310
|
+
out = dict(zip(FLOW_VERSION_FIELDS, row, strict=True))
|
|
311
|
+
for key in ("flow_json", "trace_json", "analysis_json", "changes_json"):
|
|
312
|
+
out[key.removesuffix("_json")] = json.loads(out.pop(key)) if out[key] else None
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
def flow_version(self, map_id: str, version: int) -> dict[str, Any] | None:
|
|
316
|
+
row = self.conn.execute(f"SELECT {', '.join(FLOW_VERSION_FIELDS)} FROM flow_versions"
|
|
317
|
+
" WHERE map_id = ? AND version = ?", (map_id, version)).fetchone()
|
|
318
|
+
return self._flow_row(row)
|
|
319
|
+
|
|
320
|
+
def latest_flow_version(self, map_id: str, status: str = "accepted") -> dict[str, Any] | None:
|
|
321
|
+
row = self.conn.execute(f"SELECT {', '.join(FLOW_VERSION_FIELDS)} FROM flow_versions"
|
|
322
|
+
" WHERE map_id = ? AND status = ? ORDER BY version DESC LIMIT 1",
|
|
323
|
+
(map_id, status)).fetchone()
|
|
324
|
+
return self._flow_row(row)
|
|
325
|
+
|
|
326
|
+
def flow_versions(self, map_id: str) -> list[dict[str, Any]]:
|
|
327
|
+
rows = self.conn.execute("SELECT version, status, created_at, decided_at, fact_sheet_hash FROM flow_versions"
|
|
328
|
+
" WHERE map_id = ? ORDER BY version DESC", (map_id,))
|
|
329
|
+
return [dict(zip(("version", "status", "created_at", "decided_at", "fact_sheet_hash"), r, strict=True))
|
|
330
|
+
for r in rows]
|
|
331
|
+
|
|
332
|
+
def count_flow_versions(self, map_id: str) -> int:
|
|
333
|
+
return int(self.conn.execute("SELECT COUNT(*) FROM flow_versions WHERE map_id = ?", (map_id,)).fetchone()[0])
|
|
334
|
+
|
|
335
|
+
def update_flow_version(self, map_id: str, version: int, status: str, flow: Any = None) -> None:
|
|
336
|
+
if flow is None:
|
|
337
|
+
self.conn.execute("UPDATE flow_versions SET status = ?, decided_at = ? WHERE map_id = ? AND version = ?",
|
|
338
|
+
(status, now_iso(), map_id, version))
|
|
339
|
+
else:
|
|
340
|
+
self.conn.execute("UPDATE flow_versions SET status = ?, decided_at = ?, flow_json = ?"
|
|
341
|
+
" WHERE map_id = ? AND version = ?",
|
|
342
|
+
(status, now_iso(), self._dump(flow), map_id, version))
|
|
343
|
+
self.conn.commit()
|
|
344
|
+
|
|
345
|
+
def flow_overrides(self, map_id: str) -> list[tuple[int, dict[str, Any]]]:
|
|
346
|
+
rows = self.conn.execute("SELECT id, json FROM flow_overrides WHERE map_id = ? ORDER BY id", (map_id,))
|
|
347
|
+
return [(r[0], json.loads(r[1])) for r in rows]
|
|
348
|
+
|
|
349
|
+
def add_flow_override(self, map_id: str, override: Any) -> int:
|
|
350
|
+
cur = self.conn.execute("INSERT INTO flow_overrides (map_id, json) VALUES (?, ?)",
|
|
351
|
+
(map_id, self._dump(override)))
|
|
352
|
+
self.conn.commit()
|
|
353
|
+
return int(cur.lastrowid)
|
|
354
|
+
|
|
355
|
+
def delete_flow_overrides(self, map_id: str, ids: list[int]) -> int:
|
|
356
|
+
if not ids:
|
|
357
|
+
return 0
|
|
358
|
+
cur = self.conn.execute(f"DELETE FROM flow_overrides WHERE map_id = ? AND id IN ({', '.join('?' * len(ids))})",
|
|
359
|
+
[map_id, *ids])
|
|
360
|
+
self.conn.commit()
|
|
361
|
+
return cur.rowcount
|
|
362
|
+
|
|
363
|
+
def flow_positions(self, map_id: str) -> dict[str, tuple[float, float]]:
|
|
364
|
+
return {r[0]: (r[1], r[2]) for r in
|
|
365
|
+
self.conn.execute("SELECT node_id, x, y FROM flow_positions WHERE map_id = ?", (map_id,))}
|
|
366
|
+
|
|
367
|
+
def put_flow_positions(self, map_id: str, positions: dict[str, tuple[float, float]], replace: bool = False) -> None:
|
|
368
|
+
if replace:
|
|
369
|
+
self.conn.execute("DELETE FROM flow_positions WHERE map_id = ?", (map_id,))
|
|
370
|
+
self.conn.executemany(
|
|
371
|
+
"INSERT INTO flow_positions (map_id, node_id, x, y) VALUES (?, ?, ?, ?)"
|
|
372
|
+
" ON CONFLICT(map_id, node_id) DO UPDATE SET x = excluded.x, y = excluded.y",
|
|
373
|
+
[(map_id, node_id, float(x), float(y)) for node_id, (x, y) in positions.items()])
|
|
374
|
+
self.conn.commit()
|
|
375
|
+
|
|
376
|
+
def clear_map(self, map_id: str) -> None:
|
|
377
|
+
"""Forget one map entirely: a different repo starts fresh (D21)."""
|
|
378
|
+
for table in ("flow_versions", "flow_overrides", "flow_positions"):
|
|
379
|
+
self.conn.execute(f"DELETE FROM {table} WHERE map_id = ?", (map_id,))
|
|
380
|
+
self.conn.commit()
|
|
381
|
+
|
|
382
|
+
# --- health checks (D24) ----------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
def add_checks(self, run_id: str, results: list[dict[str, Any]]) -> None:
|
|
385
|
+
"""One row per dependency checked. `results` are CheckResult dumps plus `kind`; redacted before storage."""
|
|
386
|
+
at = now_iso()
|
|
387
|
+
rows = []
|
|
388
|
+
for r in results:
|
|
389
|
+
detail = {k: r.get(k) for k in ("reason", "fix", "detail") if r.get(k)}
|
|
390
|
+
rows.append((run_id, r["id"], r["kind"], r["status"], r.get("latency_ms"), self.redact(r["summary"]),
|
|
391
|
+
json.dumps(self.redact(detail)), at))
|
|
392
|
+
self.conn.executemany("INSERT INTO checks (run_id, dep_id, kind, status, latency_ms, summary, detail_json, at)"
|
|
393
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
|
|
394
|
+
self.conn.commit()
|
|
395
|
+
|
|
396
|
+
def check_history(self, dep_id: str | None = None, limit: int = 12) -> list[dict[str, Any]]:
|
|
397
|
+
"""Newest first: one dependency's checks, or, without `dep_id`, the latest `limit` of each."""
|
|
398
|
+
if dep_id is not None:
|
|
399
|
+
rows = self.conn.execute("SELECT * FROM checks WHERE dep_id = ? ORDER BY id DESC LIMIT ?",
|
|
400
|
+
(dep_id, limit)).fetchall()
|
|
401
|
+
else:
|
|
402
|
+
rows = self.conn.execute(
|
|
403
|
+
"SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY dep_id ORDER BY id DESC) AS n FROM checks)"
|
|
404
|
+
" WHERE n <= ? ORDER BY id DESC", (limit,)).fetchall()
|
|
405
|
+
names = [c[0] for c in self.conn.execute("SELECT * FROM checks LIMIT 0").description]
|
|
406
|
+
out = []
|
|
407
|
+
for row in rows:
|
|
408
|
+
item = dict(zip(names, row[:len(names)], strict=True))
|
|
409
|
+
item.update(json.loads(item.pop("detail_json") or "{}"))
|
|
410
|
+
out.append(item)
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
def latest_latencies(self) -> dict[str, float]:
|
|
414
|
+
"""Each dependency's latency from its latest check that measured one (D24), for flow weights (D25)."""
|
|
415
|
+
rows = self.conn.execute(
|
|
416
|
+
"SELECT dep_id, latency_ms FROM checks WHERE id IN (SELECT MAX(id) FROM checks WHERE latency_ms IS NOT NULL"
|
|
417
|
+
" GROUP BY dep_id)").fetchall()
|
|
418
|
+
return {dep: float(ms) for dep, ms in rows}
|
|
419
|
+
|
|
420
|
+
def clear_checks(self) -> None:
|
|
421
|
+
"""A different repo starts with no check history and no yes to start its MCP servers (D21, D24)."""
|
|
422
|
+
self.conn.execute("DELETE FROM checks")
|
|
423
|
+
self.conn.execute("DELETE FROM settings WHERE key IN ('checks_confirmed', 'checks_last_run')")
|
|
424
|
+
self.conn.commit()
|
|
425
|
+
|
|
426
|
+
# --- settings ---------------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
def setting(self, key: str) -> str | None:
|
|
429
|
+
row = self.conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
|
430
|
+
return row[0] if row else None
|
|
431
|
+
|
|
432
|
+
def set_setting(self, key: str, value: str) -> None:
|
|
433
|
+
self.conn.execute("INSERT INTO settings (key, value) VALUES (?, ?)"
|
|
434
|
+
" ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
|
435
|
+
self.conn.commit()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class FlowStore:
|
|
439
|
+
"""One map's slice of Storage (D21): the same flow-map methods with the map fixed, so a FlowService never
|
|
440
|
+
names its map. Every call goes through Storage's locked methods."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, storage: Storage, map_id: str):
|
|
443
|
+
self.storage = storage
|
|
444
|
+
self.map_id = map_id
|
|
445
|
+
|
|
446
|
+
def add_flow_version(self, status: str, flow: Any, trace: Any, analysis: Any, fact_sheet_hash: str,
|
|
447
|
+
changes: Any = None) -> int:
|
|
448
|
+
return self.storage.add_flow_version(self.map_id, status, flow, trace, analysis, fact_sheet_hash, changes)
|
|
449
|
+
|
|
450
|
+
def flow_version(self, version: int) -> dict[str, Any] | None:
|
|
451
|
+
return self.storage.flow_version(self.map_id, version)
|
|
452
|
+
|
|
453
|
+
def latest_flow_version(self, status: str = "accepted") -> dict[str, Any] | None:
|
|
454
|
+
return self.storage.latest_flow_version(self.map_id, status)
|
|
455
|
+
|
|
456
|
+
def flow_versions(self) -> list[dict[str, Any]]:
|
|
457
|
+
return self.storage.flow_versions(self.map_id)
|
|
458
|
+
|
|
459
|
+
def count_flow_versions(self) -> int:
|
|
460
|
+
return self.storage.count_flow_versions(self.map_id)
|
|
461
|
+
|
|
462
|
+
def update_flow_version(self, version: int, status: str, flow: Any = None) -> None:
|
|
463
|
+
self.storage.update_flow_version(self.map_id, version, status, flow)
|
|
464
|
+
|
|
465
|
+
def flow_overrides(self) -> list[tuple[int, dict[str, Any]]]:
|
|
466
|
+
return self.storage.flow_overrides(self.map_id)
|
|
467
|
+
|
|
468
|
+
def add_flow_override(self, override: Any) -> int:
|
|
469
|
+
return self.storage.add_flow_override(self.map_id, override)
|
|
470
|
+
|
|
471
|
+
def delete_flow_overrides(self, ids: list[int]) -> int:
|
|
472
|
+
return self.storage.delete_flow_overrides(self.map_id, ids)
|
|
473
|
+
|
|
474
|
+
def flow_positions(self) -> dict[str, tuple[float, float]]:
|
|
475
|
+
return self.storage.flow_positions(self.map_id)
|
|
476
|
+
|
|
477
|
+
def put_flow_positions(self, positions: dict[str, tuple[float, float]], replace: bool = False) -> None:
|
|
478
|
+
self.storage.put_flow_positions(self.map_id, positions, replace)
|
|
479
|
+
|
|
480
|
+
def clear(self) -> None:
|
|
481
|
+
self.storage.clear_map(self.map_id)
|
flowforge/templating.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`{{steps.<id>.output[.path]}}` templates inside step params (DECISIONS.md D2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
TEMPLATE = re.compile(r"\{\{\s*steps\.([A-Za-z_][A-Za-z0-9_]*)\.output((?:\.[A-Za-z0-9_]+)*)\s*\}\}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TemplateError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def referenced_steps(value: Any) -> set[str]:
|
|
16
|
+
"""All step ids referenced anywhere inside a (nested) params value."""
|
|
17
|
+
if isinstance(value, str):
|
|
18
|
+
return {m.group(1) for m in TEMPLATE.finditer(value)}
|
|
19
|
+
if isinstance(value, dict):
|
|
20
|
+
return set().union(*(referenced_steps(v) for v in value.values())) if value else set()
|
|
21
|
+
if isinstance(value, list):
|
|
22
|
+
return set().union(*(referenced_steps(v) for v in value)) if value else set()
|
|
23
|
+
return set()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _walk(output: Any, path: str, step_id: str) -> Any:
|
|
27
|
+
cur = output
|
|
28
|
+
for part in filter(None, path.split(".")):
|
|
29
|
+
if isinstance(cur, dict) and part in cur:
|
|
30
|
+
cur = cur[part]
|
|
31
|
+
elif isinstance(cur, list) and part.isdigit() and int(part) < len(cur):
|
|
32
|
+
cur = cur[int(part)]
|
|
33
|
+
else:
|
|
34
|
+
raise TemplateError(f"steps.{step_id}.output{path}: no '{part}' in output")
|
|
35
|
+
return cur
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve(value: Any, outputs: dict[str, Any]) -> Any:
|
|
39
|
+
"""Return `value` with every template replaced using completed step outputs.
|
|
40
|
+
|
|
41
|
+
A string that is exactly one template gets the raw value (dict/list/number);
|
|
42
|
+
otherwise matches are interpolated with str().
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {k: resolve(v, outputs) for k, v in value.items()}
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [resolve(v, outputs) for v in value]
|
|
48
|
+
if not isinstance(value, str):
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
def lookup(m: re.Match[str]) -> Any:
|
|
52
|
+
step_id, path = m.group(1), m.group(2)
|
|
53
|
+
if step_id not in outputs:
|
|
54
|
+
raise TemplateError(f"steps.{step_id} has no output yet")
|
|
55
|
+
return _walk(outputs[step_id], path, step_id)
|
|
56
|
+
|
|
57
|
+
whole = TEMPLATE.fullmatch(value.strip())
|
|
58
|
+
if whole:
|
|
59
|
+
return lookup(whole)
|
|
60
|
+
return TEMPLATE.sub(lambda m: str(lookup(m)), value)
|