nexo-brain 2.7.0 → 3.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +66 -12
- package/hooks/hooks.json +79 -0
- package/package.json +1 -1
- package/src/agent_runner.py +290 -6
- package/src/cli.py +111 -0
- package/src/client_preferences.py +94 -0
- package/src/client_sync.py +202 -2
- package/src/cognitive/__init__.py +1 -1
- package/src/cognitive/_search.py +39 -19
- package/src/dashboard/app.py +140 -0
- package/src/dashboard/templates/base.html +4 -0
- package/src/dashboard/templates/protocol.html +199 -0
- package/src/db/__init__.py +23 -1
- package/src/db/_learnings.py +31 -4
- package/src/db/_personal_scripts.py +12 -0
- package/src/db/_protocol.py +303 -0
- package/src/db/_schema.py +248 -0
- package/src/db/_watchers.py +173 -0
- package/src/db/_workflow.py +952 -0
- package/src/doctor/providers/runtime.py +918 -7
- package/src/evolution_cycle.py +62 -0
- package/src/hook_guardrails.py +308 -0
- package/src/hooks/protocol-guardrail.sh +10 -0
- package/src/nexo_sdk.py +103 -0
- package/src/plugins/cognitive_memory.py +18 -0
- package/src/plugins/cortex.py +55 -35
- package/src/plugins/guard.py +132 -16
- package/src/plugins/protocol.py +911 -0
- package/src/plugins/schedule.py +40 -6
- package/src/plugins/simple_api.py +103 -0
- package/src/plugins/skills.py +67 -0
- package/src/plugins/state_watchers.py +79 -0
- package/src/plugins/workflow.py +588 -0
- package/src/public_contribution.py +86 -12
- package/src/script_registry.py +142 -0
- package/src/scripts/deep-sleep/apply_findings.py +204 -0
- package/src/scripts/deep-sleep/collect.py +49 -4
- package/src/scripts/nexo-agent-run.py +2 -0
- package/src/scripts/nexo-daily-self-audit.py +843 -5
- package/src/scripts/nexo-evolution-run.py +343 -1
- package/src/server.py +92 -6
- package/src/skills_runtime.py +151 -0
- package/src/state_watchers_runtime.py +334 -0
- package/src/tools_learnings.py +345 -7
- package/src/tools_sessions.py +183 -0
- package/templates/CLAUDE.md.template +9 -1
- package/templates/CODEX.AGENTS.md.template +10 -2
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""NEXO DB — Durable workflow runtime."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
from db._core import get_db
|
|
10
|
+
|
|
11
|
+
RUN_STATUSES = {
|
|
12
|
+
"open",
|
|
13
|
+
"running",
|
|
14
|
+
"blocked",
|
|
15
|
+
"waiting_approval",
|
|
16
|
+
"failed",
|
|
17
|
+
"completed",
|
|
18
|
+
"cancelled",
|
|
19
|
+
}
|
|
20
|
+
STEP_STATUSES = {
|
|
21
|
+
"pending",
|
|
22
|
+
"running",
|
|
23
|
+
"blocked",
|
|
24
|
+
"waiting_approval",
|
|
25
|
+
"failed",
|
|
26
|
+
"completed",
|
|
27
|
+
"skipped",
|
|
28
|
+
"retrying",
|
|
29
|
+
}
|
|
30
|
+
GOAL_STATUSES = {
|
|
31
|
+
"active",
|
|
32
|
+
"blocked",
|
|
33
|
+
"abandoned",
|
|
34
|
+
"completed",
|
|
35
|
+
"cancelled",
|
|
36
|
+
}
|
|
37
|
+
PRIORITIES = {"low", "normal", "high", "critical"}
|
|
38
|
+
RUN_CLOSED_STATUSES = {"completed", "failed", "cancelled"}
|
|
39
|
+
GOAL_CLOSED_STATUSES = {"abandoned", "completed", "cancelled"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _workflow_run_id() -> str:
|
|
43
|
+
return f"WF-{int(time.time())}-{secrets.randbelow(100000)}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _workflow_goal_id() -> str:
|
|
47
|
+
return f"WG-{int(time.time())}-{secrets.randbelow(100000)}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _now_sql() -> str:
|
|
51
|
+
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _as_json(value, default):
|
|
55
|
+
if value is None:
|
|
56
|
+
value = default
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
return value
|
|
59
|
+
return json.dumps(value, ensure_ascii=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_json(value, default):
|
|
63
|
+
if value in (None, ""):
|
|
64
|
+
return default
|
|
65
|
+
if isinstance(value, (dict, list)):
|
|
66
|
+
return value
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(value)
|
|
69
|
+
except Exception:
|
|
70
|
+
return default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _row_to_step(row) -> dict:
|
|
74
|
+
step = dict(row)
|
|
75
|
+
step["human_gate"] = bool(step.get("human_gate"))
|
|
76
|
+
step["requires_approval"] = bool(step.get("requires_approval"))
|
|
77
|
+
step["last_state_patch"] = _parse_json(step.get("last_state_patch"), {})
|
|
78
|
+
return step
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _goal_closed_at(status: str) -> str | None:
|
|
82
|
+
return _now_sql() if status in GOAL_CLOSED_STATUSES else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _row_to_goal(row, *, include_runs: bool = False) -> dict:
|
|
86
|
+
goal = dict(row)
|
|
87
|
+
goal["shared_state"] = _parse_json(goal.get("shared_state"), {})
|
|
88
|
+
goal["open_run_count"] = int(goal.get("open_run_count") or 0)
|
|
89
|
+
goal["run_count"] = int(goal.get("run_count") or 0)
|
|
90
|
+
goal["child_count"] = int(goal.get("child_count") or 0)
|
|
91
|
+
if include_runs:
|
|
92
|
+
goal["runs"] = list_workflow_runs(goal_id=goal["goal_id"], include_closed=True, limit=50)
|
|
93
|
+
return goal
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_workflow_goal(goal_id: str, *, include_runs: bool = False) -> dict | None:
|
|
97
|
+
conn = get_db()
|
|
98
|
+
row = conn.execute(
|
|
99
|
+
"""SELECT g.*,
|
|
100
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id), 0) AS run_count,
|
|
101
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id
|
|
102
|
+
AND r.status NOT IN ('completed', 'failed', 'cancelled')), 0) AS open_run_count,
|
|
103
|
+
COALESCE((SELECT COUNT(*) FROM workflow_goals child WHERE child.parent_goal_id = g.goal_id), 0) AS child_count
|
|
104
|
+
FROM workflow_goals g
|
|
105
|
+
WHERE g.goal_id = ?""",
|
|
106
|
+
(goal_id.strip(),),
|
|
107
|
+
).fetchone()
|
|
108
|
+
return _row_to_goal(row, include_runs=include_runs) if row else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_workflow_steps(run_id: str) -> list[dict]:
|
|
112
|
+
conn = get_db()
|
|
113
|
+
rows = conn.execute(
|
|
114
|
+
"""SELECT *
|
|
115
|
+
FROM workflow_steps
|
|
116
|
+
WHERE run_id = ?
|
|
117
|
+
ORDER BY step_index ASC, id ASC""",
|
|
118
|
+
(run_id,),
|
|
119
|
+
).fetchall()
|
|
120
|
+
return [_row_to_step(row) for row in rows]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _row_to_run(row, *, include_steps: bool = True) -> dict:
|
|
124
|
+
run = dict(row)
|
|
125
|
+
run["shared_state"] = _parse_json(run.get("shared_state"), {})
|
|
126
|
+
if include_steps:
|
|
127
|
+
run["steps"] = list_workflow_steps(run["run_id"])
|
|
128
|
+
return run
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_workflow_run(run_id: str, *, include_steps: bool = True) -> dict | None:
|
|
132
|
+
conn = get_db()
|
|
133
|
+
row = conn.execute(
|
|
134
|
+
"SELECT * FROM workflow_runs WHERE run_id = ?",
|
|
135
|
+
(run_id.strip(),),
|
|
136
|
+
).fetchone()
|
|
137
|
+
return _row_to_run(row, include_steps=include_steps) if row else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_workflow_goal(
|
|
141
|
+
session_id: str,
|
|
142
|
+
title: str,
|
|
143
|
+
*,
|
|
144
|
+
objective: str = "",
|
|
145
|
+
parent_goal_id: str = "",
|
|
146
|
+
priority: str = "normal",
|
|
147
|
+
owner: str = "",
|
|
148
|
+
next_action: str = "",
|
|
149
|
+
success_signal: str = "",
|
|
150
|
+
shared_state=None,
|
|
151
|
+
) -> dict:
|
|
152
|
+
conn = get_db()
|
|
153
|
+
clean_parent_goal_id = parent_goal_id.strip()
|
|
154
|
+
if clean_parent_goal_id:
|
|
155
|
+
parent = get_workflow_goal(clean_parent_goal_id)
|
|
156
|
+
if not parent:
|
|
157
|
+
raise ValueError(f"Unknown parent_goal_id: {clean_parent_goal_id}")
|
|
158
|
+
|
|
159
|
+
goal_id = _workflow_goal_id()
|
|
160
|
+
clean_priority = priority if priority in PRIORITIES else "normal"
|
|
161
|
+
conn.execute(
|
|
162
|
+
"""INSERT INTO workflow_goals (
|
|
163
|
+
goal_id, session_id, title, objective, parent_goal_id, status,
|
|
164
|
+
priority, owner, next_action, success_signal, blocker_reason, shared_state
|
|
165
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
166
|
+
(
|
|
167
|
+
goal_id,
|
|
168
|
+
session_id.strip(),
|
|
169
|
+
title.strip(),
|
|
170
|
+
objective.strip(),
|
|
171
|
+
clean_parent_goal_id,
|
|
172
|
+
"active",
|
|
173
|
+
clean_priority,
|
|
174
|
+
owner.strip(),
|
|
175
|
+
next_action.strip(),
|
|
176
|
+
success_signal.strip(),
|
|
177
|
+
"",
|
|
178
|
+
_as_json(shared_state, {}),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
conn.commit()
|
|
182
|
+
return get_workflow_goal(goal_id) or {"goal_id": goal_id}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def update_workflow_goal(
|
|
186
|
+
goal_id: str,
|
|
187
|
+
*,
|
|
188
|
+
status: str = "",
|
|
189
|
+
title: str = "",
|
|
190
|
+
objective: str = "",
|
|
191
|
+
parent_goal_id: str = "",
|
|
192
|
+
owner: str = "",
|
|
193
|
+
next_action: str = "",
|
|
194
|
+
success_signal: str = "",
|
|
195
|
+
blocker_reason: str = "",
|
|
196
|
+
shared_state=None,
|
|
197
|
+
) -> dict | None:
|
|
198
|
+
conn = get_db()
|
|
199
|
+
row = conn.execute("SELECT * FROM workflow_goals WHERE goal_id = ?", (goal_id.strip(),)).fetchone()
|
|
200
|
+
if not row:
|
|
201
|
+
return None
|
|
202
|
+
goal = dict(row)
|
|
203
|
+
|
|
204
|
+
clean_status = status.strip().lower() if status.strip().lower() in GOAL_STATUSES else goal["status"]
|
|
205
|
+
clean_parent_goal_id = parent_goal_id.strip() if parent_goal_id else goal.get("parent_goal_id", "")
|
|
206
|
+
if clean_parent_goal_id and clean_parent_goal_id != goal_id.strip():
|
|
207
|
+
parent = get_workflow_goal(clean_parent_goal_id)
|
|
208
|
+
if not parent:
|
|
209
|
+
raise ValueError(f"Unknown parent_goal_id: {clean_parent_goal_id}")
|
|
210
|
+
elif clean_parent_goal_id == goal_id.strip():
|
|
211
|
+
raise ValueError("parent_goal_id cannot equal goal_id")
|
|
212
|
+
|
|
213
|
+
effective_shared_state = goal["shared_state"] if shared_state is None else _as_json(shared_state, {})
|
|
214
|
+
effective_blocker = blocker_reason.strip() if blocker_reason else goal.get("blocker_reason", "")
|
|
215
|
+
if clean_status != "blocked" and blocker_reason.strip() == "":
|
|
216
|
+
effective_blocker = ""
|
|
217
|
+
|
|
218
|
+
conn.execute(
|
|
219
|
+
"""UPDATE workflow_goals
|
|
220
|
+
SET title = ?,
|
|
221
|
+
objective = ?,
|
|
222
|
+
parent_goal_id = ?,
|
|
223
|
+
status = ?,
|
|
224
|
+
owner = ?,
|
|
225
|
+
next_action = ?,
|
|
226
|
+
success_signal = ?,
|
|
227
|
+
blocker_reason = ?,
|
|
228
|
+
shared_state = ?,
|
|
229
|
+
updated_at = datetime('now'),
|
|
230
|
+
closed_at = ?
|
|
231
|
+
WHERE goal_id = ?""",
|
|
232
|
+
(
|
|
233
|
+
title.strip() or goal["title"],
|
|
234
|
+
objective.strip() or goal.get("objective", ""),
|
|
235
|
+
clean_parent_goal_id,
|
|
236
|
+
clean_status,
|
|
237
|
+
owner.strip() or goal.get("owner", ""),
|
|
238
|
+
next_action.strip() or goal.get("next_action", ""),
|
|
239
|
+
success_signal.strip() or goal.get("success_signal", ""),
|
|
240
|
+
effective_blocker,
|
|
241
|
+
effective_shared_state,
|
|
242
|
+
_goal_closed_at(clean_status),
|
|
243
|
+
goal_id.strip(),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
conn.commit()
|
|
247
|
+
return get_workflow_goal(goal_id.strip())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def create_workflow_goal(
|
|
251
|
+
session_id: str,
|
|
252
|
+
title: str,
|
|
253
|
+
*,
|
|
254
|
+
objective: str = "",
|
|
255
|
+
parent_goal_id: str = "",
|
|
256
|
+
priority: str = "normal",
|
|
257
|
+
owner: str = "",
|
|
258
|
+
next_action: str = "",
|
|
259
|
+
success_signal: str = "",
|
|
260
|
+
shared_state=None,
|
|
261
|
+
) -> dict:
|
|
262
|
+
conn = get_db()
|
|
263
|
+
clean_parent_goal_id = parent_goal_id.strip()
|
|
264
|
+
if clean_parent_goal_id and not get_workflow_goal(clean_parent_goal_id, include_runs=False):
|
|
265
|
+
raise ValueError(f"Unknown parent_goal_id: {clean_parent_goal_id}")
|
|
266
|
+
|
|
267
|
+
goal_id = _workflow_goal_id()
|
|
268
|
+
clean_priority = priority if priority in PRIORITIES else "normal"
|
|
269
|
+
conn.execute(
|
|
270
|
+
"""INSERT INTO workflow_goals (
|
|
271
|
+
goal_id, session_id, title, objective, parent_goal_id,
|
|
272
|
+
status, priority, owner, next_action, success_signal, shared_state
|
|
273
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
274
|
+
(
|
|
275
|
+
goal_id,
|
|
276
|
+
session_id.strip(),
|
|
277
|
+
title.strip(),
|
|
278
|
+
objective.strip(),
|
|
279
|
+
clean_parent_goal_id,
|
|
280
|
+
"active",
|
|
281
|
+
clean_priority,
|
|
282
|
+
owner.strip(),
|
|
283
|
+
next_action.strip(),
|
|
284
|
+
success_signal.strip(),
|
|
285
|
+
_as_json(shared_state, {}),
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
conn.commit()
|
|
289
|
+
goal = get_workflow_goal(goal_id)
|
|
290
|
+
return goal or {"goal_id": goal_id, "status": "active"}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def update_workflow_goal(
|
|
294
|
+
goal_id: str,
|
|
295
|
+
*,
|
|
296
|
+
status: str = "",
|
|
297
|
+
title: str = "",
|
|
298
|
+
objective: str = "",
|
|
299
|
+
parent_goal_id: str = "",
|
|
300
|
+
owner: str = "",
|
|
301
|
+
next_action: str = "",
|
|
302
|
+
success_signal: str = "",
|
|
303
|
+
blocker_reason: str = "",
|
|
304
|
+
shared_state=None,
|
|
305
|
+
) -> dict | None:
|
|
306
|
+
conn = get_db()
|
|
307
|
+
row = conn.execute("SELECT * FROM workflow_goals WHERE goal_id = ?", (goal_id.strip(),)).fetchone()
|
|
308
|
+
if not row:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
goal = dict(row)
|
|
312
|
+
clean_status = status.strip().lower() if status.strip().lower() in GOAL_STATUSES else goal["status"]
|
|
313
|
+
clean_parent_goal_id = parent_goal_id.strip() if parent_goal_id.strip() else goal.get("parent_goal_id", "")
|
|
314
|
+
if clean_parent_goal_id and clean_parent_goal_id != goal_id.strip() and not get_workflow_goal(clean_parent_goal_id):
|
|
315
|
+
raise ValueError(f"Unknown parent_goal_id: {clean_parent_goal_id}")
|
|
316
|
+
if clean_parent_goal_id == goal_id.strip():
|
|
317
|
+
raise ValueError("goal_id cannot be its own parent")
|
|
318
|
+
|
|
319
|
+
effective_shared_state = goal["shared_state"]
|
|
320
|
+
if shared_state is not None:
|
|
321
|
+
effective_shared_state = _as_json(shared_state, {})
|
|
322
|
+
|
|
323
|
+
conn.execute(
|
|
324
|
+
"""UPDATE workflow_goals
|
|
325
|
+
SET title = ?,
|
|
326
|
+
objective = ?,
|
|
327
|
+
parent_goal_id = ?,
|
|
328
|
+
status = ?,
|
|
329
|
+
owner = ?,
|
|
330
|
+
next_action = ?,
|
|
331
|
+
success_signal = ?,
|
|
332
|
+
blocker_reason = ?,
|
|
333
|
+
shared_state = ?,
|
|
334
|
+
updated_at = datetime('now'),
|
|
335
|
+
closed_at = ?
|
|
336
|
+
WHERE goal_id = ?""",
|
|
337
|
+
(
|
|
338
|
+
title.strip() or goal["title"],
|
|
339
|
+
objective.strip() or goal.get("objective", ""),
|
|
340
|
+
clean_parent_goal_id,
|
|
341
|
+
clean_status,
|
|
342
|
+
owner.strip() or goal.get("owner", ""),
|
|
343
|
+
next_action.strip() or goal.get("next_action", ""),
|
|
344
|
+
success_signal.strip() or goal.get("success_signal", ""),
|
|
345
|
+
blocker_reason.strip() or goal.get("blocker_reason", ""),
|
|
346
|
+
effective_shared_state,
|
|
347
|
+
_goal_closed_at(clean_status),
|
|
348
|
+
goal_id.strip(),
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
conn.commit()
|
|
352
|
+
return get_workflow_goal(goal_id.strip())
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def list_workflow_goals(*, status: str = "", include_closed: bool = False, limit: int = 20) -> list[dict]:
|
|
356
|
+
conn = get_db()
|
|
357
|
+
clauses = []
|
|
358
|
+
params: list[object] = []
|
|
359
|
+
if status.strip() in GOAL_STATUSES:
|
|
360
|
+
clauses.append("g.status = ?")
|
|
361
|
+
params.append(status.strip())
|
|
362
|
+
elif not include_closed:
|
|
363
|
+
clauses.append("g.status NOT IN ('abandoned', 'completed', 'cancelled')")
|
|
364
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
365
|
+
rows = conn.execute(
|
|
366
|
+
f"""SELECT g.*,
|
|
367
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id), 0) AS run_count,
|
|
368
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id
|
|
369
|
+
AND r.status NOT IN ('completed', 'failed', 'cancelled')), 0) AS open_run_count,
|
|
370
|
+
COALESCE((SELECT COUNT(*) FROM workflow_goals child WHERE child.parent_goal_id = g.goal_id), 0) AS child_count
|
|
371
|
+
FROM workflow_goals g
|
|
372
|
+
{where}
|
|
373
|
+
ORDER BY g.updated_at DESC, g.opened_at DESC
|
|
374
|
+
LIMIT ?""",
|
|
375
|
+
params + [max(1, int(limit))],
|
|
376
|
+
).fetchall()
|
|
377
|
+
return [_row_to_goal(row, include_runs=False) for row in rows]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _find_reusable_run(session_id: str, idempotency_key: str, goal_id: str = "") -> dict | None:
|
|
381
|
+
if not session_id.strip() or not idempotency_key.strip():
|
|
382
|
+
return None
|
|
383
|
+
conn = get_db()
|
|
384
|
+
clauses = [
|
|
385
|
+
"session_id = ?",
|
|
386
|
+
"idempotency_key = ?",
|
|
387
|
+
"status NOT IN ('completed', 'failed', 'cancelled')",
|
|
388
|
+
]
|
|
389
|
+
params: list[object] = [session_id.strip(), idempotency_key.strip()]
|
|
390
|
+
if goal_id.strip():
|
|
391
|
+
clauses.append("goal_id = ?")
|
|
392
|
+
params.append(goal_id.strip())
|
|
393
|
+
row = conn.execute(
|
|
394
|
+
f"""SELECT *
|
|
395
|
+
FROM workflow_runs
|
|
396
|
+
WHERE {' AND '.join(clauses)}
|
|
397
|
+
ORDER BY opened_at DESC
|
|
398
|
+
LIMIT 1""",
|
|
399
|
+
params,
|
|
400
|
+
).fetchone()
|
|
401
|
+
return _row_to_run(row) if row else None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def list_workflow_goals(*, status: str = "", include_closed: bool = False, limit: int = 20) -> list[dict]:
|
|
405
|
+
conn = get_db()
|
|
406
|
+
clauses = []
|
|
407
|
+
params: list[object] = []
|
|
408
|
+
clean_status = status.strip().lower()
|
|
409
|
+
if clean_status in GOAL_STATUSES:
|
|
410
|
+
clauses.append("g.status = ?")
|
|
411
|
+
params.append(clean_status)
|
|
412
|
+
elif not include_closed:
|
|
413
|
+
clauses.append("g.status NOT IN ('abandoned', 'completed', 'cancelled')")
|
|
414
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
415
|
+
rows = conn.execute(
|
|
416
|
+
f"""SELECT g.*,
|
|
417
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id), 0) AS run_count,
|
|
418
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id
|
|
419
|
+
AND r.status NOT IN ('completed', 'failed', 'cancelled')), 0) AS open_run_count,
|
|
420
|
+
COALESCE((SELECT COUNT(*) FROM workflow_goals child WHERE child.parent_goal_id = g.goal_id), 0) AS child_count
|
|
421
|
+
FROM workflow_goals g
|
|
422
|
+
{where}
|
|
423
|
+
ORDER BY g.updated_at DESC, g.opened_at DESC
|
|
424
|
+
LIMIT ?""",
|
|
425
|
+
params + [max(1, int(limit))],
|
|
426
|
+
).fetchall()
|
|
427
|
+
return [_row_to_goal(row) for row in rows]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _normalize_step(step, index: int) -> dict:
|
|
431
|
+
if isinstance(step, str):
|
|
432
|
+
title = step.strip()
|
|
433
|
+
key = title.lower().replace(" ", "-")[:80]
|
|
434
|
+
return {
|
|
435
|
+
"step_key": key or f"step-{index}",
|
|
436
|
+
"title": title or f"Step {index}",
|
|
437
|
+
"step_index": index,
|
|
438
|
+
"status": "pending",
|
|
439
|
+
"max_retries": 0,
|
|
440
|
+
"retry_policy": "",
|
|
441
|
+
"retry_after": "",
|
|
442
|
+
"human_gate": False,
|
|
443
|
+
"requires_approval": False,
|
|
444
|
+
"compensation": "",
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
step = dict(step or {})
|
|
448
|
+
title = str(step.get("title") or step.get("step_key") or f"Step {index}").strip()
|
|
449
|
+
key = str(step.get("step_key") or title.lower().replace(" ", "-")[:80] or f"step-{index}").strip()
|
|
450
|
+
status = str(step.get("status") or "pending").strip().lower()
|
|
451
|
+
if status not in STEP_STATUSES:
|
|
452
|
+
status = "pending"
|
|
453
|
+
return {
|
|
454
|
+
"step_key": key,
|
|
455
|
+
"title": title,
|
|
456
|
+
"step_index": int(step.get("step_index") or index),
|
|
457
|
+
"status": status,
|
|
458
|
+
"max_retries": max(0, int(step.get("max_retries") or 0)),
|
|
459
|
+
"retry_policy": str(step.get("retry_policy") or "").strip(),
|
|
460
|
+
"retry_after": str(step.get("retry_after") or "").strip(),
|
|
461
|
+
"human_gate": bool(step.get("human_gate")),
|
|
462
|
+
"requires_approval": bool(step.get("requires_approval")),
|
|
463
|
+
"compensation": str(step.get("compensation") or "").strip(),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def create_workflow_run(
|
|
468
|
+
session_id: str,
|
|
469
|
+
goal: str,
|
|
470
|
+
*,
|
|
471
|
+
goal_id: str = "",
|
|
472
|
+
workflow_kind: str = "general",
|
|
473
|
+
protocol_task_id: str = "",
|
|
474
|
+
idempotency_key: str = "",
|
|
475
|
+
priority: str = "normal",
|
|
476
|
+
shared_state=None,
|
|
477
|
+
next_action: str = "",
|
|
478
|
+
owner: str = "",
|
|
479
|
+
steps=None,
|
|
480
|
+
) -> dict:
|
|
481
|
+
existing = _find_reusable_run(session_id, idempotency_key, goal_id=goal_id)
|
|
482
|
+
if existing:
|
|
483
|
+
existing["reused_existing"] = True
|
|
484
|
+
return existing
|
|
485
|
+
|
|
486
|
+
conn = get_db()
|
|
487
|
+
run_id = _workflow_run_id()
|
|
488
|
+
clean_priority = priority if priority in PRIORITIES else "normal"
|
|
489
|
+
clean_goal_id = goal_id.strip()
|
|
490
|
+
if clean_goal_id:
|
|
491
|
+
linked_goal = get_workflow_goal(clean_goal_id)
|
|
492
|
+
if not linked_goal:
|
|
493
|
+
raise ValueError(f"Unknown goal_id: {clean_goal_id}")
|
|
494
|
+
if linked_goal["status"] in GOAL_CLOSED_STATUSES:
|
|
495
|
+
raise ValueError(f"Goal {clean_goal_id} is closed with status '{linked_goal['status']}'")
|
|
496
|
+
steps = steps or []
|
|
497
|
+
first_step_key = ""
|
|
498
|
+
if steps:
|
|
499
|
+
first_step_key = _normalize_step(steps[0], 1)["step_key"]
|
|
500
|
+
|
|
501
|
+
conn.execute(
|
|
502
|
+
"""INSERT INTO workflow_runs (
|
|
503
|
+
run_id, session_id, protocol_task_id, goal_id, goal, workflow_kind,
|
|
504
|
+
status, priority, idempotency_key, shared_state,
|
|
505
|
+
next_action, current_step_key, last_checkpoint_label, owner
|
|
506
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
507
|
+
(
|
|
508
|
+
run_id,
|
|
509
|
+
session_id.strip(),
|
|
510
|
+
protocol_task_id.strip(),
|
|
511
|
+
clean_goal_id,
|
|
512
|
+
goal.strip(),
|
|
513
|
+
workflow_kind.strip() or "general",
|
|
514
|
+
"open",
|
|
515
|
+
clean_priority,
|
|
516
|
+
idempotency_key.strip(),
|
|
517
|
+
_as_json(shared_state, {}),
|
|
518
|
+
next_action.strip(),
|
|
519
|
+
first_step_key,
|
|
520
|
+
"opened",
|
|
521
|
+
owner.strip(),
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
for index, raw_step in enumerate(steps, 1):
|
|
525
|
+
step = _normalize_step(raw_step, index)
|
|
526
|
+
conn.execute(
|
|
527
|
+
"""INSERT INTO workflow_steps (
|
|
528
|
+
run_id, step_key, title, step_index, status, max_retries,
|
|
529
|
+
retry_policy, retry_after, human_gate, requires_approval,
|
|
530
|
+
compensation
|
|
531
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
532
|
+
(
|
|
533
|
+
run_id,
|
|
534
|
+
step["step_key"],
|
|
535
|
+
step["title"],
|
|
536
|
+
step["step_index"],
|
|
537
|
+
step["status"],
|
|
538
|
+
step["max_retries"],
|
|
539
|
+
step["retry_policy"],
|
|
540
|
+
step["retry_after"],
|
|
541
|
+
1 if step["human_gate"] else 0,
|
|
542
|
+
1 if step["requires_approval"] else 0,
|
|
543
|
+
step["compensation"],
|
|
544
|
+
),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
conn.execute(
|
|
548
|
+
"""INSERT INTO workflow_checkpoints (
|
|
549
|
+
run_id, step_key, checkpoint_label, run_status, step_status,
|
|
550
|
+
summary, shared_state, state_patch, next_action, actor
|
|
551
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
552
|
+
(
|
|
553
|
+
run_id,
|
|
554
|
+
first_step_key,
|
|
555
|
+
"opened",
|
|
556
|
+
"open",
|
|
557
|
+
"pending" if first_step_key else "",
|
|
558
|
+
"Workflow opened",
|
|
559
|
+
_as_json(shared_state, {}),
|
|
560
|
+
_as_json({}, {}),
|
|
561
|
+
next_action.strip(),
|
|
562
|
+
owner.strip() or "workflow-open",
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
conn.commit()
|
|
566
|
+
run = get_workflow_run(run_id)
|
|
567
|
+
if run:
|
|
568
|
+
run["reused_existing"] = False
|
|
569
|
+
return run or {"run_id": run_id, "reused_existing": False}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _next_pending_step(conn, run_id: str) -> dict | None:
|
|
573
|
+
row = conn.execute(
|
|
574
|
+
"""SELECT *
|
|
575
|
+
FROM workflow_steps
|
|
576
|
+
WHERE run_id = ?
|
|
577
|
+
AND status IN ('pending', 'retrying')
|
|
578
|
+
ORDER BY step_index ASC, id ASC
|
|
579
|
+
LIMIT 1""",
|
|
580
|
+
(run_id,),
|
|
581
|
+
).fetchone()
|
|
582
|
+
return _row_to_step(row) if row else None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _upsert_workflow_step(
|
|
586
|
+
conn,
|
|
587
|
+
run_id: str,
|
|
588
|
+
*,
|
|
589
|
+
step_key: str,
|
|
590
|
+
step_title: str = "",
|
|
591
|
+
step_status: str = "",
|
|
592
|
+
max_retries: int | None = None,
|
|
593
|
+
retry_policy: str = "",
|
|
594
|
+
retry_after: str = "",
|
|
595
|
+
requires_approval: bool | None = None,
|
|
596
|
+
compensation: str = "",
|
|
597
|
+
state_patch=None,
|
|
598
|
+
summary: str = "",
|
|
599
|
+
evidence: str = "",
|
|
600
|
+
) -> dict:
|
|
601
|
+
row = conn.execute(
|
|
602
|
+
"SELECT * FROM workflow_steps WHERE run_id = ? AND step_key = ?",
|
|
603
|
+
(run_id, step_key),
|
|
604
|
+
).fetchone()
|
|
605
|
+
created = False
|
|
606
|
+
if not row:
|
|
607
|
+
created = True
|
|
608
|
+
conn.execute(
|
|
609
|
+
"""INSERT INTO workflow_steps (
|
|
610
|
+
run_id, step_key, title, step_index, status
|
|
611
|
+
) VALUES (?, ?, ?, ?, ?)""",
|
|
612
|
+
(
|
|
613
|
+
run_id,
|
|
614
|
+
step_key,
|
|
615
|
+
step_title or step_key,
|
|
616
|
+
999,
|
|
617
|
+
"pending",
|
|
618
|
+
),
|
|
619
|
+
)
|
|
620
|
+
row = conn.execute(
|
|
621
|
+
"SELECT * FROM workflow_steps WHERE run_id = ? AND step_key = ?",
|
|
622
|
+
(run_id, step_key),
|
|
623
|
+
).fetchone()
|
|
624
|
+
|
|
625
|
+
step = dict(row)
|
|
626
|
+
clean_step_status = step_status if step_status in STEP_STATUSES else step["status"]
|
|
627
|
+
attempt_count = int(step.get("attempt_count") or 0)
|
|
628
|
+
if clean_step_status in {"running", "retrying"} and step.get("status") not in {"running", "retrying"}:
|
|
629
|
+
attempt_count += 1
|
|
630
|
+
elif created and clean_step_status in {"running", "retrying"}:
|
|
631
|
+
attempt_count = 1
|
|
632
|
+
|
|
633
|
+
started_at_value = step.get("started_at")
|
|
634
|
+
completed_at_value = step.get("completed_at")
|
|
635
|
+
if clean_step_status in {"running", "retrying"} and not started_at_value:
|
|
636
|
+
started_at_value = _now_sql()
|
|
637
|
+
if clean_step_status in {"completed", "skipped"}:
|
|
638
|
+
completed_at_value = _now_sql()
|
|
639
|
+
elif clean_step_status in {"running", "retrying", "blocked", "waiting_approval", "failed"}:
|
|
640
|
+
completed_at_value = None
|
|
641
|
+
|
|
642
|
+
effective_max_retries = int(max_retries) if max_retries is not None else int(step.get("max_retries") or 0)
|
|
643
|
+
effective_requires_approval = (
|
|
644
|
+
bool(requires_approval)
|
|
645
|
+
if requires_approval is not None
|
|
646
|
+
else bool(step.get("requires_approval"))
|
|
647
|
+
)
|
|
648
|
+
conn.execute(
|
|
649
|
+
"""UPDATE workflow_steps
|
|
650
|
+
SET title = ?,
|
|
651
|
+
status = ?,
|
|
652
|
+
attempt_count = ?,
|
|
653
|
+
max_retries = ?,
|
|
654
|
+
retry_policy = ?,
|
|
655
|
+
retry_after = ?,
|
|
656
|
+
human_gate = ?,
|
|
657
|
+
requires_approval = ?,
|
|
658
|
+
compensation = ?,
|
|
659
|
+
last_summary = ?,
|
|
660
|
+
last_evidence = ?,
|
|
661
|
+
last_state_patch = ?,
|
|
662
|
+
started_at = ?,
|
|
663
|
+
completed_at = ?,
|
|
664
|
+
updated_at = datetime('now')
|
|
665
|
+
WHERE run_id = ? AND step_key = ?""",
|
|
666
|
+
(
|
|
667
|
+
step_title or step.get("title") or step_key,
|
|
668
|
+
clean_step_status,
|
|
669
|
+
attempt_count,
|
|
670
|
+
effective_max_retries,
|
|
671
|
+
retry_policy or step.get("retry_policy") or "",
|
|
672
|
+
retry_after or step.get("retry_after") or "",
|
|
673
|
+
1 if (bool(step.get("human_gate")) or effective_requires_approval) else 0,
|
|
674
|
+
1 if effective_requires_approval else 0,
|
|
675
|
+
compensation or step.get("compensation") or "",
|
|
676
|
+
summary[:2000],
|
|
677
|
+
evidence[:2000],
|
|
678
|
+
_as_json(state_patch, {}),
|
|
679
|
+
started_at_value,
|
|
680
|
+
completed_at_value,
|
|
681
|
+
run_id,
|
|
682
|
+
step_key,
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
row = conn.execute(
|
|
686
|
+
"SELECT * FROM workflow_steps WHERE run_id = ? AND step_key = ?",
|
|
687
|
+
(run_id, step_key),
|
|
688
|
+
).fetchone()
|
|
689
|
+
return _row_to_step(row)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _derive_run_status(conn, run_id: str, *, requested: str = "", step_status: str = "") -> str:
|
|
693
|
+
if requested in RUN_STATUSES:
|
|
694
|
+
return requested
|
|
695
|
+
if step_status == "waiting_approval":
|
|
696
|
+
return "waiting_approval"
|
|
697
|
+
if step_status == "blocked":
|
|
698
|
+
return "blocked"
|
|
699
|
+
if step_status in {"running", "retrying"}:
|
|
700
|
+
return "running"
|
|
701
|
+
if step_status == "failed":
|
|
702
|
+
return "failed"
|
|
703
|
+
if step_status in {"completed", "skipped"}:
|
|
704
|
+
rows = conn.execute(
|
|
705
|
+
"SELECT status FROM workflow_steps WHERE run_id = ? ORDER BY step_index ASC, id ASC",
|
|
706
|
+
(run_id,),
|
|
707
|
+
).fetchall()
|
|
708
|
+
statuses = [row["status"] for row in rows]
|
|
709
|
+
if statuses and all(status in {"completed", "skipped"} for status in statuses):
|
|
710
|
+
return "completed"
|
|
711
|
+
if statuses:
|
|
712
|
+
return "running"
|
|
713
|
+
row = conn.execute("SELECT status FROM workflow_runs WHERE run_id = ?", (run_id,)).fetchone()
|
|
714
|
+
return row["status"] if row else "open"
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def record_workflow_transition(
|
|
718
|
+
run_id: str,
|
|
719
|
+
*,
|
|
720
|
+
step_key: str = "",
|
|
721
|
+
step_title: str = "",
|
|
722
|
+
step_status: str = "",
|
|
723
|
+
run_status: str = "",
|
|
724
|
+
checkpoint_label: str = "",
|
|
725
|
+
summary: str = "",
|
|
726
|
+
shared_state=None,
|
|
727
|
+
state_patch=None,
|
|
728
|
+
evidence: str = "",
|
|
729
|
+
next_action: str = "",
|
|
730
|
+
retry_after: str = "",
|
|
731
|
+
max_retries: int | None = None,
|
|
732
|
+
retry_policy: str = "",
|
|
733
|
+
requires_approval: bool | None = None,
|
|
734
|
+
compensation: str = "",
|
|
735
|
+
actor: str = "",
|
|
736
|
+
owner: str = "",
|
|
737
|
+
) -> dict | None:
|
|
738
|
+
conn = get_db()
|
|
739
|
+
run_row = conn.execute("SELECT * FROM workflow_runs WHERE run_id = ?", (run_id.strip(),)).fetchone()
|
|
740
|
+
if not run_row:
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
step = None
|
|
744
|
+
clean_step_status = step_status if step_status in STEP_STATUSES else ""
|
|
745
|
+
if step_key.strip():
|
|
746
|
+
step = _upsert_workflow_step(
|
|
747
|
+
conn,
|
|
748
|
+
run_id.strip(),
|
|
749
|
+
step_key=step_key.strip(),
|
|
750
|
+
step_title=step_title.strip(),
|
|
751
|
+
step_status=clean_step_status,
|
|
752
|
+
max_retries=max_retries,
|
|
753
|
+
retry_policy=retry_policy.strip(),
|
|
754
|
+
retry_after=retry_after.strip(),
|
|
755
|
+
requires_approval=requires_approval,
|
|
756
|
+
compensation=compensation.strip(),
|
|
757
|
+
state_patch=state_patch,
|
|
758
|
+
summary=summary.strip(),
|
|
759
|
+
evidence=evidence.strip(),
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
clean_run_status = _derive_run_status(
|
|
763
|
+
conn,
|
|
764
|
+
run_id.strip(),
|
|
765
|
+
requested=run_status.strip(),
|
|
766
|
+
step_status=clean_step_status,
|
|
767
|
+
)
|
|
768
|
+
current_step_key = run_row["current_step_key"] or ""
|
|
769
|
+
if step:
|
|
770
|
+
if step["status"] in {"completed", "skipped"}:
|
|
771
|
+
pending = _next_pending_step(conn, run_id.strip())
|
|
772
|
+
current_step_key = pending["step_key"] if pending else ""
|
|
773
|
+
else:
|
|
774
|
+
current_step_key = step["step_key"]
|
|
775
|
+
shared_state_json = run_row["shared_state"]
|
|
776
|
+
if shared_state is not None:
|
|
777
|
+
shared_state_json = _as_json(shared_state, {})
|
|
778
|
+
label = checkpoint_label.strip() or clean_step_status or clean_run_status or "checkpoint"
|
|
779
|
+
conn.execute(
|
|
780
|
+
"""UPDATE workflow_runs
|
|
781
|
+
SET status = ?,
|
|
782
|
+
shared_state = ?,
|
|
783
|
+
next_action = ?,
|
|
784
|
+
current_step_key = ?,
|
|
785
|
+
last_checkpoint_label = ?,
|
|
786
|
+
owner = CASE
|
|
787
|
+
WHEN ? != '' THEN ?
|
|
788
|
+
ELSE owner
|
|
789
|
+
END,
|
|
790
|
+
updated_at = datetime('now'),
|
|
791
|
+
closed_at = CASE
|
|
792
|
+
WHEN ? IN ('completed', 'failed', 'cancelled') THEN datetime('now')
|
|
793
|
+
ELSE NULL
|
|
794
|
+
END
|
|
795
|
+
WHERE run_id = ?""",
|
|
796
|
+
(
|
|
797
|
+
clean_run_status,
|
|
798
|
+
shared_state_json,
|
|
799
|
+
next_action.strip() or run_row["next_action"] or "",
|
|
800
|
+
current_step_key,
|
|
801
|
+
label,
|
|
802
|
+
owner.strip(),
|
|
803
|
+
owner.strip(),
|
|
804
|
+
clean_run_status,
|
|
805
|
+
run_id.strip(),
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
conn.execute(
|
|
809
|
+
"""INSERT INTO workflow_checkpoints (
|
|
810
|
+
run_id, step_key, checkpoint_label, run_status, step_status, summary,
|
|
811
|
+
shared_state, state_patch, evidence, next_action, retry_after,
|
|
812
|
+
requires_approval, compensation_note, attempt, actor
|
|
813
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
814
|
+
(
|
|
815
|
+
run_id.strip(),
|
|
816
|
+
step_key.strip(),
|
|
817
|
+
label,
|
|
818
|
+
clean_run_status,
|
|
819
|
+
clean_step_status,
|
|
820
|
+
summary.strip()[:2000],
|
|
821
|
+
shared_state_json,
|
|
822
|
+
_as_json(state_patch, {}),
|
|
823
|
+
evidence.strip()[:2000],
|
|
824
|
+
next_action.strip()[:1000],
|
|
825
|
+
retry_after.strip(),
|
|
826
|
+
1 if bool(requires_approval) else 0,
|
|
827
|
+
compensation.strip()[:1000],
|
|
828
|
+
int(step.get("attempt_count") or 0) if step else 0,
|
|
829
|
+
actor.strip() or "workflow-update",
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
conn.commit()
|
|
833
|
+
return get_workflow_run(run_id.strip())
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def list_workflow_runs(*, status: str = "", goal_id: str = "", include_closed: bool = False, limit: int = 20) -> list[dict]:
|
|
837
|
+
conn = get_db()
|
|
838
|
+
clauses = []
|
|
839
|
+
params: list[object] = []
|
|
840
|
+
clean_goal_id = goal_id.strip()
|
|
841
|
+
if clean_goal_id:
|
|
842
|
+
clauses.append("goal_id = ?")
|
|
843
|
+
params.append(clean_goal_id)
|
|
844
|
+
if status.strip() in RUN_STATUSES:
|
|
845
|
+
clauses.append("status = ?")
|
|
846
|
+
params.append(status.strip())
|
|
847
|
+
elif not include_closed:
|
|
848
|
+
clauses.append("status NOT IN ('completed', 'failed', 'cancelled')")
|
|
849
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
850
|
+
rows = conn.execute(
|
|
851
|
+
f"""SELECT *
|
|
852
|
+
FROM workflow_runs
|
|
853
|
+
{where}
|
|
854
|
+
ORDER BY updated_at DESC, opened_at DESC
|
|
855
|
+
LIMIT ?""",
|
|
856
|
+
params + [max(1, int(limit))],
|
|
857
|
+
).fetchall()
|
|
858
|
+
return [_row_to_run(row, include_steps=False) for row in rows]
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def get_workflow_replay(run_id: str, *, limit: int = 50) -> list[dict]:
|
|
862
|
+
conn = get_db()
|
|
863
|
+
rows = conn.execute(
|
|
864
|
+
"""SELECT *
|
|
865
|
+
FROM workflow_checkpoints
|
|
866
|
+
WHERE run_id = ?
|
|
867
|
+
ORDER BY id DESC
|
|
868
|
+
LIMIT ?""",
|
|
869
|
+
(run_id.strip(), max(1, int(limit))),
|
|
870
|
+
).fetchall()
|
|
871
|
+
checkpoints = []
|
|
872
|
+
for row in rows:
|
|
873
|
+
item = dict(row)
|
|
874
|
+
item["shared_state"] = _parse_json(item.get("shared_state"), {})
|
|
875
|
+
item["state_patch"] = _parse_json(item.get("state_patch"), {})
|
|
876
|
+
item["requires_approval"] = bool(item.get("requires_approval"))
|
|
877
|
+
checkpoints.append(item)
|
|
878
|
+
return checkpoints
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def get_workflow_resume_state(run_id: str) -> dict | None:
|
|
882
|
+
run = get_workflow_run(run_id.strip())
|
|
883
|
+
if not run:
|
|
884
|
+
return None
|
|
885
|
+
|
|
886
|
+
steps = run.get("steps") or []
|
|
887
|
+
current = None
|
|
888
|
+
if run.get("current_step_key"):
|
|
889
|
+
current = next((step for step in steps if step["step_key"] == run["current_step_key"]), None)
|
|
890
|
+
|
|
891
|
+
waiting = next((step for step in steps if step["status"] == "waiting_approval"), None)
|
|
892
|
+
if waiting:
|
|
893
|
+
return {
|
|
894
|
+
"run": run,
|
|
895
|
+
"resume_state": "waiting_approval",
|
|
896
|
+
"can_resume": False,
|
|
897
|
+
"requires_approval": True,
|
|
898
|
+
"next_step": waiting,
|
|
899
|
+
"message": f"Workflow is waiting for approval on step '{waiting['title']}'.",
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
retryable = next(
|
|
903
|
+
(
|
|
904
|
+
step
|
|
905
|
+
for step in steps
|
|
906
|
+
if step["status"] == "failed"
|
|
907
|
+
and int(step.get("max_retries") or 0) > int(step.get("attempt_count") or 0)
|
|
908
|
+
),
|
|
909
|
+
None,
|
|
910
|
+
)
|
|
911
|
+
if retryable:
|
|
912
|
+
return {
|
|
913
|
+
"run": run,
|
|
914
|
+
"resume_state": "retry_available",
|
|
915
|
+
"can_resume": True,
|
|
916
|
+
"requires_approval": False,
|
|
917
|
+
"next_step": retryable,
|
|
918
|
+
"message": (
|
|
919
|
+
f"Retry step '{retryable['title']}' "
|
|
920
|
+
f"(attempt {int(retryable.get('attempt_count') or 0) + 1}/{int(retryable.get('max_retries') or 0)})."
|
|
921
|
+
),
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if current and current["status"] in {"running", "blocked", "retrying"}:
|
|
925
|
+
return {
|
|
926
|
+
"run": run,
|
|
927
|
+
"resume_state": current["status"],
|
|
928
|
+
"can_resume": current["status"] != "blocked",
|
|
929
|
+
"requires_approval": False,
|
|
930
|
+
"next_step": current,
|
|
931
|
+
"message": f"Resume current step '{current['title']}' from status '{current['status']}'.",
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
pending = next((step for step in steps if step["status"] in {"pending", "retrying"}), None)
|
|
935
|
+
if pending:
|
|
936
|
+
return {
|
|
937
|
+
"run": run,
|
|
938
|
+
"resume_state": "ready",
|
|
939
|
+
"can_resume": True,
|
|
940
|
+
"requires_approval": False,
|
|
941
|
+
"next_step": pending,
|
|
942
|
+
"message": f"Continue with step '{pending['title']}'.",
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
"run": run,
|
|
947
|
+
"resume_state": run.get("status") or "open",
|
|
948
|
+
"can_resume": run.get("status") not in RUN_CLOSED_STATUSES,
|
|
949
|
+
"requires_approval": False,
|
|
950
|
+
"next_step": current,
|
|
951
|
+
"message": run.get("next_action") or "Workflow state available.",
|
|
952
|
+
}
|