@manhquy/flow-skill 0.1.0 → 0.2.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/README.md +11 -8
- package/README_VN.md +7 -4
- package/package.json +1 -1
- package/skills/flow/SKILL.md +1 -1
- package/skills/flow/harness/GAP-MATRIX-0.1.17.md +4 -3
- package/skills/flow/harness/README.md +29 -2
- package/skills/flow/harness/_db.py +175 -6
- package/skills/flow/harness/flow_harness.py +165 -9
- package/skills/flow/harness/graph_executor.py +1205 -0
- package/skills/flow/harness/graph_ids.py +35 -0
- package/skills/flow/harness/graph_predicates.py +48 -0
- package/skills/flow/harness/pins/flow-topology.sha256 +1 -0
- package/skills/flow/harness/schema/014-graph-executor.sql +67 -0
- package/skills/flow/references/auto-run.md +29 -2
- package/skills/flow/references/command-dispatch.md +1 -0
- package/skills/flow/references/concierge.md +1 -0
- package/skills/flow/references/flow-topology.json +146 -0
- package/skills/flow/runner/flow.sh +216 -5
- package/skills-manifest.json +8 -2
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
"""Graph executor: record/advance API over the 014 schema (stdlib only).
|
|
2
|
+
|
|
3
|
+
There is NO resident process. Each call is a short-lived invocation that records
|
|
4
|
+
evidence or computes next-steps from topology + journal; the LLM agent and flow.sh
|
|
5
|
+
own all sequencing and all LLM work. Exit contract (fail-closed, consumed by
|
|
6
|
+
flow.sh `harness_capture_checked`):
|
|
7
|
+
0 = value on stdout / success
|
|
8
|
+
3 = definitive no-next (complete) or paused on an open interrupt
|
|
9
|
+
4 = harness unavailable / contradictory flags (FLOW_HARNESS_DISABLE set)
|
|
10
|
+
1/2 = failure
|
|
11
|
+
Security-class interrupt resolution enforces the out-of-band DEBT-commit artifact
|
|
12
|
+
(closed-set target, captured DEBT line, committed provenance via HEAD blob + blame)
|
|
13
|
+
with resolved_by + session id stored as AUDIT evidence. Documented residual limit:
|
|
14
|
+
git authorship is forgeable by an agent with git-config control.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
import _db
|
|
27
|
+
import graph_ids
|
|
28
|
+
import graph_predicates as PRED
|
|
29
|
+
|
|
30
|
+
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
TRUSTED_TOPOLOGY = os.path.join(SKILL_DIR, "references", "flow-topology.json")
|
|
32
|
+
TOPOLOGY_PIN = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
33
|
+
"pins", "flow-topology.sha256")
|
|
34
|
+
|
|
35
|
+
# Mirrors flow.sh cmd_skip's security-class regex (flow.sh:1242) - keep in sync.
|
|
36
|
+
SECURITY_RE = re.compile(
|
|
37
|
+
r"auth|authoriz|authorize|admin|tenan|payment|billing|password|token|secret|"
|
|
38
|
+
r"credential|permission|role|rbac|login|pii|data loss|migration|validation",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
# Closed-set interrupt targets: a card id or a stage name. Validated BEFORE any
|
|
42
|
+
# file/git lookup so no free-form text ever reaches a match pattern.
|
|
43
|
+
TARGET_RE = re.compile(r"^C-[0-9]{1,4}$|^[0-9]{2}-[a-z-]+$")
|
|
44
|
+
|
|
45
|
+
NODE_TYPES = {"gate_check", "git_op", "record_evidence", "interrupt"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _root():
|
|
49
|
+
return os.environ.get("FLOW_PROJECT_ROOT") or os.getcwd()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _project_key():
|
|
53
|
+
"""Project identity for rows in the SHARED db. Must be main-root scoped: stamping
|
|
54
|
+
basename(_root()) would record whichever worktree happened to mint the execution
|
|
55
|
+
(`proj-card-C-001`), and gc filtering by the caller's basename would then never
|
|
56
|
+
see it - terminal rows unpurgeable, stale sweep blind."""
|
|
57
|
+
root = _root()
|
|
58
|
+
return os.path.basename(_db._linked_worktree_main_root(root) or root)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _session_id():
|
|
62
|
+
# Mirrors flow.sh _session_env_id cascade (flow.sh:353-368).
|
|
63
|
+
for var in ("FLOW_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_SESSION_ID",
|
|
64
|
+
"CODEX_THREAD_ID", "AGY_SESSION_ID", "ANTIGRAVITY_SESSION_ID"):
|
|
65
|
+
v = os.environ.get(var)
|
|
66
|
+
if v:
|
|
67
|
+
return v.replace("\r", "").replace("\n", "").replace("|", "")
|
|
68
|
+
return "ppid:%s:%s" % (os.uname().nodename if hasattr(os, "uname") else "host",
|
|
69
|
+
os.getppid())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _git_env():
|
|
73
|
+
"""git honours GIT_DIR/GIT_WORK_TREE from the environment, and every git HOOK
|
|
74
|
+
exports them (also `git rebase -x`, `bisect run`, `submodule foreach`). Inheriting
|
|
75
|
+
them silently points our worktree translation and merge proofs at a foreign repo."""
|
|
76
|
+
env = dict(os.environ)
|
|
77
|
+
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE",
|
|
78
|
+
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"):
|
|
79
|
+
env.pop(k, None)
|
|
80
|
+
return env
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _git(root, *args):
|
|
84
|
+
try:
|
|
85
|
+
return subprocess.run(["git", "-C", root, *args], env=_git_env(),
|
|
86
|
+
capture_output=True, text=True, timeout=15)
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _git_out(root, *args):
|
|
92
|
+
r = _git(root, *args)
|
|
93
|
+
return r.stdout if (r is not None and r.returncode == 0) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _linked_main_root(root):
|
|
97
|
+
"""Main-worktree path when `root` is inside a linked worktree, else None.
|
|
98
|
+
|
|
99
|
+
Delegates to the ONE resolver (`_db`), which handles submodules,
|
|
100
|
+
--separate-git-dir (core.worktree) and monorepo sub-projects - a second local
|
|
101
|
+
heuristic is exactly what drifted before."""
|
|
102
|
+
return _db._linked_worktree_main_root(root)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------- topology ----------------
|
|
106
|
+
|
|
107
|
+
def load_topology(path):
|
|
108
|
+
"""Phase-2 fixture loader: plain JSON file. Phase 3 replaces this call site with
|
|
109
|
+
the install-dir-only, pin-verified loader + full lint."""
|
|
110
|
+
with open(path, encoding="utf-8") as fh:
|
|
111
|
+
topo = json.load(fh)
|
|
112
|
+
if not isinstance(topo.get("nodes"), dict) or not isinstance(topo.get("edges"), list):
|
|
113
|
+
raise ValueError("topology: nodes/edges missing")
|
|
114
|
+
for name, spec in topo["nodes"].items():
|
|
115
|
+
if spec.get("type") not in NODE_TYPES:
|
|
116
|
+
raise ValueError(f"topology: node {name} has type outside {sorted(NODE_TYPES)}")
|
|
117
|
+
return topo
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _successors(topo, node, latest_manifest):
|
|
121
|
+
out = []
|
|
122
|
+
for e in topo["edges"]:
|
|
123
|
+
if e.get("from") != node:
|
|
124
|
+
continue
|
|
125
|
+
pred = e.get("when", "always")
|
|
126
|
+
if _predicate(pred, latest_manifest):
|
|
127
|
+
out.append(e["to"])
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _predicate(name, manifest):
|
|
132
|
+
# Registry lives in graph_predicates (exactly {always, review_green, review_red};
|
|
133
|
+
# red-team round 7: debt-skips are a traversal semantic, not predicates).
|
|
134
|
+
return PRED.evaluate(name, manifest)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def canonical_hash(topo):
|
|
138
|
+
# Whitespace-insensitive: hash the canonical JSON form, so cosmetic edits
|
|
139
|
+
# don't invalidate the pin or strand paused executions.
|
|
140
|
+
blob = json.dumps(topo, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
141
|
+
return hashlib.sha256(blob).hexdigest()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_topology_trusted():
|
|
145
|
+
"""Shipped-topology loader: skill install dir ONLY, pin-verified.
|
|
146
|
+
|
|
147
|
+
A project-local flow-topology.json is IGNORED with a warning (topology is
|
|
148
|
+
executable-adjacent data - a cloned repo must not be able to swap it), and a
|
|
149
|
+
pin mismatch REFUSES (exit path: ValueError -> clean rc 1), never just records.
|
|
150
|
+
Returns (topology, canonical_hash)."""
|
|
151
|
+
local = os.path.join(_root(), "flow-topology.json")
|
|
152
|
+
if os.path.exists(local):
|
|
153
|
+
sys.stderr.write("flow-graph: ignoring project-local flow-topology.json "
|
|
154
|
+
"(topology loads only from the skill install dir, pin-verified)\n")
|
|
155
|
+
topo = load_topology(TRUSTED_TOPOLOGY)
|
|
156
|
+
try:
|
|
157
|
+
with open(TOPOLOGY_PIN, encoding="utf-8") as fh:
|
|
158
|
+
pin = fh.read().split()[0]
|
|
159
|
+
except (OSError, IndexError):
|
|
160
|
+
raise ValueError(f"topology pin missing/unreadable at {TOPOLOGY_PIN}")
|
|
161
|
+
h = canonical_hash(topo)
|
|
162
|
+
if h != pin:
|
|
163
|
+
raise ValueError("topology pin mismatch - refusing to run. If the edit is "
|
|
164
|
+
"intentional, regenerate the pin (see harness/README.md)")
|
|
165
|
+
return topo, h
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _skipped_stages(root):
|
|
169
|
+
p = os.path.join(root, "flow", ".skipped")
|
|
170
|
+
try:
|
|
171
|
+
with open(p, encoding="utf-8") as fh:
|
|
172
|
+
return {ln.strip() for ln in fh if ln.strip()}
|
|
173
|
+
except OSError:
|
|
174
|
+
return set()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# A debt-skipped gate is ACCEPTED-WITH-DEBT: cmd_skip only advances after the DEBT +
|
|
178
|
+
# security-class + 05 guards, and its own contract line is "planning_complete now
|
|
179
|
+
# tolerates it" (flow.sh:1258). So for traversal purposes a skipped gate evaluates
|
|
180
|
+
# predicates as a PASS - its green out-edges are taken, red ones are not.
|
|
181
|
+
_SKIPPED_GATE_MANIFEST = {"gate": {"exit": 0, "skipped": True}}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _substitute_skips(topo, nodes, skipped, _seen=None):
|
|
185
|
+
"""Transitively replace gate_check nodes whose mapped `stage` is debt-skipped
|
|
186
|
+
with THEIR successors (round-7 traversal semantic - no bypass edges in data).
|
|
187
|
+
Topology never writes flow/.skipped; only cmd_skip does. `skipped` is the
|
|
188
|
+
stage-name set (callers pass _skipped_stages(root); lint passes subsets)."""
|
|
189
|
+
if not skipped:
|
|
190
|
+
return nodes
|
|
191
|
+
seen = _seen or set()
|
|
192
|
+
out = []
|
|
193
|
+
for n in nodes:
|
|
194
|
+
spec = topo["nodes"].get(n, {})
|
|
195
|
+
stage = spec.get("stage")
|
|
196
|
+
if spec.get("type") == "gate_check" and stage and stage in skipped:
|
|
197
|
+
if n in seen:
|
|
198
|
+
continue # cycle revisit: never re-emit a skipped gate (matches _skip_walk)
|
|
199
|
+
seen.add(n)
|
|
200
|
+
out.extend(_substitute_skips(
|
|
201
|
+
topo, _successors(topo, n, _SKIPPED_GATE_MANIFEST), skipped, seen))
|
|
202
|
+
else:
|
|
203
|
+
out.append(n)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------- journal ----------------
|
|
208
|
+
|
|
209
|
+
def _latest_checkpoint(con, execution_id, ns):
|
|
210
|
+
return _db.one(con,
|
|
211
|
+
"SELECT * FROM graph_checkpoint WHERE execution_id=? AND ns=? "
|
|
212
|
+
"ORDER BY checkpoint_id DESC LIMIT 1", (execution_id, ns))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _execution(con, execution_id):
|
|
216
|
+
return _db.one(con, "SELECT * FROM graph_execution WHERE id=?", (execution_id,))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _open_interrupt(con, execution_id, ns=None):
|
|
220
|
+
if ns is None:
|
|
221
|
+
return _db.one(con, "SELECT * FROM graph_interrupt WHERE execution_id=? "
|
|
222
|
+
"AND status='open' ORDER BY created_at LIMIT 1", (execution_id,))
|
|
223
|
+
return _db.one(con, "SELECT * FROM graph_interrupt WHERE execution_id=? AND ns=? "
|
|
224
|
+
"AND status='open' ORDER BY created_at LIMIT 1", (execution_id, ns))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _visits(con, execution_id, ns, node):
|
|
228
|
+
return con.execute("SELECT COUNT(*) FROM graph_checkpoint WHERE execution_id=? "
|
|
229
|
+
"AND ns=? AND node=?", (execution_id, ns, node)).fetchone()[0]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def plan_next(con, topo, execution_id, ns, root):
|
|
233
|
+
"""Next node names for (execution, ns): entry roots before the first checkpoint,
|
|
234
|
+
else predicate-gated successors of the latest node, with skip-substitution."""
|
|
235
|
+
latest = _latest_checkpoint(con, execution_id, ns)
|
|
236
|
+
# Skips are written by cmd_skip at the main root; a card worktree must not see a
|
|
237
|
+
# different skip set than the journal was recorded under.
|
|
238
|
+
skipped = _skipped_stages(_db._linked_worktree_main_root(root) or root)
|
|
239
|
+
if latest is None:
|
|
240
|
+
if ns != "":
|
|
241
|
+
# Non-root namespaces (card:C-NNN) have no implicit entry: Phase 4 starts
|
|
242
|
+
# them by recording their dispatch node explicitly. Empty = nothing to do.
|
|
243
|
+
return []
|
|
244
|
+
entry = topo.get("entry") or []
|
|
245
|
+
return _substitute_skips(topo, list(entry[:1]), skipped)
|
|
246
|
+
manifest = json.loads(latest["manifest"])
|
|
247
|
+
nxt = _substitute_skips(topo, _successors(topo, latest["node"], manifest), skipped)
|
|
248
|
+
# A declared max_visits is a BOUND, not decoration: once a repair node has been
|
|
249
|
+
# visited that many times the two-strikes contract says escalate, so stop advising it
|
|
250
|
+
# (auto-run.md: strike 2 -> operator/cross-vendor, never an unbounded loop).
|
|
251
|
+
out = []
|
|
252
|
+
for n in nxt:
|
|
253
|
+
cap = topo["nodes"].get(n, {}).get("max_visits")
|
|
254
|
+
if cap and _visits(con, execution_id, ns, n) >= int(cap):
|
|
255
|
+
sys.stderr.write(f"flow-graph: {n} hit max_visits={cap} - escalate, not retry\n")
|
|
256
|
+
continue
|
|
257
|
+
out.append(n)
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------- verbs ----------------
|
|
262
|
+
|
|
263
|
+
def _guard_flags():
|
|
264
|
+
if os.environ.get("FLOW_HARNESS_DISABLE"):
|
|
265
|
+
sys.stderr.write(
|
|
266
|
+
"flow-graph: FLOW_HARNESS_DISABLE is set but a graph verb was invoked - "
|
|
267
|
+
"contradictory instruction (the executor IS the durable layer). Unset "
|
|
268
|
+
"FLOW_HARNESS_DISABLE to use graph commands.\n")
|
|
269
|
+
return 4
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cmd_graph_run(con, a):
|
|
274
|
+
rc = _guard_flags()
|
|
275
|
+
if rc:
|
|
276
|
+
return rc
|
|
277
|
+
if a.story and _db.one(con, "SELECT 1 FROM story WHERE id=?", (a.story,)) is None:
|
|
278
|
+
sys.stderr.write(f"flow-graph: no story {a.story} - create it first "
|
|
279
|
+
"(story add --id ...) or omit --story\n")
|
|
280
|
+
return 1
|
|
281
|
+
eid = graph_ids.new_id()
|
|
282
|
+
with _db.transaction(con):
|
|
283
|
+
con.execute(
|
|
284
|
+
"INSERT INTO graph_execution (id,project,kind,topology_version,topology_hash,story_id) "
|
|
285
|
+
"VALUES (?,?,?,?,?,?)",
|
|
286
|
+
(eid, a.project or _project_key(), a.kind,
|
|
287
|
+
a.topology_version, a.topology_hash, a.story))
|
|
288
|
+
print(eid)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
TERMINAL = ("done", "failed", "abandoned")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _no_dupes(pairs):
|
|
296
|
+
seen = {}
|
|
297
|
+
for k, v in pairs:
|
|
298
|
+
if k in seen:
|
|
299
|
+
raise ValueError(f"duplicate key {k!r} in manifest")
|
|
300
|
+
seen[k] = v
|
|
301
|
+
return seen
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _build_manifest(a):
|
|
305
|
+
"""Assemble the evidence manifest HERE, from typed flags - never from a JSON string
|
|
306
|
+
a shell concatenated. Card `status:` tokens and git ref names are attacker-adjacent
|
|
307
|
+
free text: spliced into a bash JSON literal, `status: x","gate":{"exit":0},"z":"`
|
|
308
|
+
silently overrides a RED gate with a green one (json keeps the last duplicate key).
|
|
309
|
+
A caller-supplied --manifest is still accepted for tests, but is parsed and
|
|
310
|
+
duplicate-key-rejected before it can reach the journal."""
|
|
311
|
+
m = {}
|
|
312
|
+
if a.manifest:
|
|
313
|
+
m = json.loads(a.manifest, object_pairs_hook=_no_dupes)
|
|
314
|
+
if not isinstance(m, dict):
|
|
315
|
+
raise ValueError("manifest must be a JSON object")
|
|
316
|
+
gate = {}
|
|
317
|
+
if getattr(a, "gate_exit", None) is not None:
|
|
318
|
+
gate["exit"] = int(a.gate_exit)
|
|
319
|
+
if getattr(a, "gate_cmd", None):
|
|
320
|
+
gate["cmd"] = a.gate_cmd
|
|
321
|
+
if gate:
|
|
322
|
+
m["gate"] = gate
|
|
323
|
+
for flag, key in (("card_status", "status"), ("branch", "branch"),
|
|
324
|
+
("worktree", "worktree"), ("vendor", "vendor")):
|
|
325
|
+
v = getattr(a, flag, None)
|
|
326
|
+
if v:
|
|
327
|
+
m[key] = v
|
|
328
|
+
return json.dumps(m)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_graph_session(con, a):
|
|
332
|
+
"""Print the project's current RUNNING auto_run execution, minting one atomically if
|
|
333
|
+
there is none (unless --no-create). SQLite's BEGIN IMMEDIATE is the arbitration point.
|
|
334
|
+
|
|
335
|
+
This replaces a bash pin file + mkdir claim: that design needed corpse recovery,
|
|
336
|
+
TOCTOU-free reclaim and a liveness check, and got all three wrong (a claim could be
|
|
337
|
+
stolen mid-mint, and an ABANDONED row still passed an existence-only check, silently
|
|
338
|
+
blackholing every later record). Here the query itself filters on status='running',
|
|
339
|
+
so a retired execution can never be adopted."""
|
|
340
|
+
rc = _guard_flags()
|
|
341
|
+
if rc:
|
|
342
|
+
return rc
|
|
343
|
+
project = a.project or _project_key()
|
|
344
|
+
kind = getattr(a, "kind", None) or "auto_run"
|
|
345
|
+
# running OR paused: a paused execution is waiting on an operator interrupt - still
|
|
346
|
+
# the live session (its resolution must land on the SAME execution). Only terminal
|
|
347
|
+
# states (done/failed/abandoned) are unadoptable. Planning and shipping keep separate
|
|
348
|
+
# sessions (different namespaces, different lifetimes).
|
|
349
|
+
q = ("SELECT id FROM graph_execution WHERE project=? AND kind=? "
|
|
350
|
+
"AND status IN ('running','paused') ORDER BY id DESC LIMIT 1")
|
|
351
|
+
row = _db.one(con, q, (project, kind))
|
|
352
|
+
if row:
|
|
353
|
+
print(row["id"])
|
|
354
|
+
return 0
|
|
355
|
+
if getattr(a, "no_create", False):
|
|
356
|
+
return 3
|
|
357
|
+
eid = graph_ids.new_id()
|
|
358
|
+
with _db.transaction(con):
|
|
359
|
+
row = con.execute(q, (project, kind)).fetchone() # re-check under the write lock
|
|
360
|
+
if row:
|
|
361
|
+
eid = row[0]
|
|
362
|
+
else:
|
|
363
|
+
con.execute(
|
|
364
|
+
"INSERT INTO graph_execution (id,project,kind,topology_version,topology_hash) "
|
|
365
|
+
"VALUES (?,?,?,?,?)", (eid, project, kind, 0, ""))
|
|
366
|
+
print(eid)
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_graph_record(con, a):
|
|
371
|
+
rc = _guard_flags()
|
|
372
|
+
if rc:
|
|
373
|
+
return rc
|
|
374
|
+
ex = _execution(con, a.execution)
|
|
375
|
+
if ex is None:
|
|
376
|
+
sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
|
|
377
|
+
return 1
|
|
378
|
+
if ex["status"] in TERMINAL:
|
|
379
|
+
# Terminal is terminal: a killed-at-gate execution must never be resurrected
|
|
380
|
+
# by a later record (the kill IS the operator decision).
|
|
381
|
+
sys.stderr.write(f"flow-graph: execution is terminal ({ex['status']}) - refusing record\n")
|
|
382
|
+
return 1
|
|
383
|
+
ns = a.ns or ""
|
|
384
|
+
try:
|
|
385
|
+
a.manifest = _build_manifest(a)
|
|
386
|
+
except (ValueError, TypeError) as e:
|
|
387
|
+
sys.stderr.write(f"flow-graph: bad manifest: {e}\n")
|
|
388
|
+
return 1
|
|
389
|
+
# Boundary, not heartbeat: re-running the SAME verb with the SAME evidence is a
|
|
390
|
+
# no-op. Without this, a second `flow.sh check` on a done card appends another
|
|
391
|
+
# card-review after card-verify-live and `plan_next` (which reads the LATEST
|
|
392
|
+
# checkpoint) rewinds the walk - advising re-verification of shipped work. A
|
|
393
|
+
# changed manifest (red -> green, repair cycles) still records, as it must.
|
|
394
|
+
if not getattr(a, "interrupt", False):
|
|
395
|
+
prev = _latest_checkpoint(con, a.execution, ns)
|
|
396
|
+
if prev and prev["node"] == a.node and prev["manifest"] == a.manifest:
|
|
397
|
+
print(prev["checkpoint_id"])
|
|
398
|
+
return 0
|
|
399
|
+
if os.environ.get("FLOW_GRAPH_TOPOLOGY_FIXTURE") != "1":
|
|
400
|
+
# Typo'd or contract-drifted node names would journal cleanly and then dead-end
|
|
401
|
+
# at `next` as rc 3 "complete". Validate against the shipped topology (fixtures
|
|
402
|
+
# opt out - they define their own node sets).
|
|
403
|
+
try:
|
|
404
|
+
topo_v, _ = load_topology_trusted()
|
|
405
|
+
if a.node not in topo_v["nodes"]:
|
|
406
|
+
sys.stderr.write(f"flow-graph: node {a.node!r} is not in the shipped topology\n")
|
|
407
|
+
return 1
|
|
408
|
+
except (OSError, ValueError):
|
|
409
|
+
pass # pin/loader problems surface on `next`/`lint`, not on a record
|
|
410
|
+
if getattr(a, "merge", False):
|
|
411
|
+
# The caller may NOT assert a merge: _ws_remove has no merge knowledge
|
|
412
|
+
# (flow.sh:2077+ only removes the tree), so the executor computes the proof.
|
|
413
|
+
root = _root()
|
|
414
|
+
branch = a.branch or ""
|
|
415
|
+
if not _safe_ref(branch):
|
|
416
|
+
sys.stderr.write("flow-graph: --merge needs --branch <ref>\n")
|
|
417
|
+
return 2
|
|
418
|
+
# Bases, in proof precedence: the LOCAL integration branch first (auto-run merges
|
|
419
|
+
# locally at step 5 and tears the tree down at step 8 with no push in between, so
|
|
420
|
+
# origin/HEAD is stale there and would journal shipped work as abandoned), then
|
|
421
|
+
# origin/HEAD as secondary confirmation. Never `rev-parse HEAD` of the current
|
|
422
|
+
# tree: inside a card worktree that IS the card branch (self-ancestor).
|
|
423
|
+
bases = []
|
|
424
|
+
if a.base:
|
|
425
|
+
bases = [a.base]
|
|
426
|
+
else:
|
|
427
|
+
main_root = _linked_main_root(root) or root
|
|
428
|
+
local = (_git_out(main_root, "rev-parse", "--abbrev-ref", "HEAD") or "").strip()
|
|
429
|
+
remote = (_git_out(root, "rev-parse", "--abbrev-ref", "origin/HEAD") or "").strip()
|
|
430
|
+
bases = [b for b in (local, remote) if b]
|
|
431
|
+
bases = [b for b in bases if _safe_ref(b) and b != branch]
|
|
432
|
+
if not bases:
|
|
433
|
+
sys.stderr.write("flow-graph: no usable merge base (self-ancestor or unresolvable) "
|
|
434
|
+
"- pass --base explicitly\n")
|
|
435
|
+
return 2
|
|
436
|
+
sha = (_git_out(root, "rev-parse", "--verify", "--quiet", branch, "--") or "").strip()
|
|
437
|
+
if not sha:
|
|
438
|
+
sys.stderr.write(f"flow-graph: branch {branch} not found\n")
|
|
439
|
+
return 1
|
|
440
|
+
merged, proved_by = False, None
|
|
441
|
+
for b in bases:
|
|
442
|
+
r = _git(root, "merge-base", "--is-ancestor", sha, b)
|
|
443
|
+
if r is not None and r.returncode == 0:
|
|
444
|
+
merged, proved_by = True, b
|
|
445
|
+
break
|
|
446
|
+
a.manifest = json.dumps({"branch": branch, "git_sha": sha,
|
|
447
|
+
"base": proved_by or bases[0], "bases_tried": bases,
|
|
448
|
+
"merged": merged})
|
|
449
|
+
if not merged:
|
|
450
|
+
# Unmerged teardown is abandonment, never a merge record.
|
|
451
|
+
a.node = "card-abandon"
|
|
452
|
+
latest = _latest_checkpoint(con, a.execution, ns)
|
|
453
|
+
versions = json.loads(latest["versions"]) if latest else {}
|
|
454
|
+
versions[a.node] = versions.get(a.node, 0) + 1
|
|
455
|
+
seen = json.loads(latest["seen"]) if latest else {}
|
|
456
|
+
seen[a.node] = dict(versions)
|
|
457
|
+
ck = graph_ids.new_id()
|
|
458
|
+
# Ordering is lexicographic over these ids, and a backwards clock step (NTP, resume
|
|
459
|
+
# from suspend, a second host on a shared checkout) would otherwise mint an id BELOW
|
|
460
|
+
# the journal head - the write lands but `_latest_checkpoint` never sees it.
|
|
461
|
+
if latest and ck <= latest["checkpoint_id"]:
|
|
462
|
+
ck = graph_ids.after(latest["checkpoint_id"])
|
|
463
|
+
meta = {"source": a.source, "step": (json.loads(latest["meta"])["step"] + 1) if latest else 0,
|
|
464
|
+
"ts": int(time.time())}
|
|
465
|
+
writes = json.loads(a.writes) if a.writes else []
|
|
466
|
+
if os.environ.get("FLOW_GRAPH_FAILPOINT") == "before-commit":
|
|
467
|
+
# test-only crash injection: proves the write below is atomic (rolls back whole)
|
|
468
|
+
try:
|
|
469
|
+
with _db.transaction(con):
|
|
470
|
+
con.execute("INSERT INTO graph_checkpoint "
|
|
471
|
+
"(execution_id,ns,checkpoint_id,parent_checkpoint_id,node,manifest,versions,seen,meta) "
|
|
472
|
+
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
473
|
+
(a.execution, ns, ck, a.parent, a.node, a.manifest or "{}",
|
|
474
|
+
json.dumps(versions), json.dumps(seen), json.dumps(meta)))
|
|
475
|
+
raise RuntimeError("failpoint")
|
|
476
|
+
except RuntimeError:
|
|
477
|
+
sys.stderr.write("flow-graph: FLOW_GRAPH_FAILPOINT=before-commit fired "
|
|
478
|
+
"(transaction rolled back)\n")
|
|
479
|
+
return 1
|
|
480
|
+
with _db.transaction(con):
|
|
481
|
+
for i, w in enumerate(writes):
|
|
482
|
+
con.execute("INSERT INTO graph_step_write "
|
|
483
|
+
"(execution_id,ns,checkpoint_id,task_id,idx,channel,value) "
|
|
484
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
485
|
+
(a.execution, ns, ck, w.get("task", "t0"), i,
|
|
486
|
+
w.get("channel", ""), json.dumps(w.get("value"))))
|
|
487
|
+
con.execute("INSERT INTO graph_checkpoint "
|
|
488
|
+
"(execution_id,ns,checkpoint_id,parent_checkpoint_id,node,manifest,versions,seen,meta) "
|
|
489
|
+
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
490
|
+
(a.execution, ns, ck, a.parent, a.node, a.manifest or "{}",
|
|
491
|
+
json.dumps(versions), json.dumps(seen), json.dumps(meta)))
|
|
492
|
+
if a.interrupt:
|
|
493
|
+
con.execute("INSERT INTO graph_interrupt "
|
|
494
|
+
"(execution_id,ns,interrupt_id,node,prompt,security_class) "
|
|
495
|
+
"VALUES (?,?,?,?,?,?)",
|
|
496
|
+
(a.execution, ns, graph_ids.new_id(), a.node,
|
|
497
|
+
a.prompt or "operator decision required", 1 if a.security_class else 0))
|
|
498
|
+
con.execute("UPDATE graph_execution SET status='paused', "
|
|
499
|
+
"updated_at=datetime('now') WHERE id=?", (a.execution,))
|
|
500
|
+
else:
|
|
501
|
+
con.execute("UPDATE graph_execution SET updated_at=datetime('now') WHERE id=?",
|
|
502
|
+
(a.execution,))
|
|
503
|
+
if a.interrupt:
|
|
504
|
+
# exit 3 = paused/no-value by contract: no stdout (the id is in `status`)
|
|
505
|
+
return 3
|
|
506
|
+
print(ck)
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def cmd_graph_next(con, a):
|
|
511
|
+
rc = _guard_flags()
|
|
512
|
+
if rc:
|
|
513
|
+
return rc
|
|
514
|
+
ex = _execution(con, a.execution)
|
|
515
|
+
if ex is None:
|
|
516
|
+
sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
|
|
517
|
+
return 1
|
|
518
|
+
if _open_interrupt(con, a.execution, a.ns or ""):
|
|
519
|
+
sys.stderr.write("flow-graph: this namespace has an open interrupt - "
|
|
520
|
+
"resolve it via `graph resume --interrupt-id ...`\n")
|
|
521
|
+
return 3
|
|
522
|
+
if ex["status"] == "paused" and _open_interrupt(con, a.execution):
|
|
523
|
+
sys.stderr.write("flow-graph: execution paused on an open interrupt - "
|
|
524
|
+
"resolve via `graph resume --answer ... --actor ...`\n")
|
|
525
|
+
return 3
|
|
526
|
+
if ex["status"] in TERMINAL:
|
|
527
|
+
sys.stderr.write(f"flow-graph: execution is terminal ({ex['status']})\n")
|
|
528
|
+
return 3
|
|
529
|
+
try:
|
|
530
|
+
if a.topology:
|
|
531
|
+
# Fixture-only surface: an ungated explicit path would bypass the pin, the
|
|
532
|
+
# lint, AND the hash policy in one flag (review H2) - a cloned repo could
|
|
533
|
+
# walk arbitrary topology. Tests opt in via env; production never does.
|
|
534
|
+
if os.environ.get("FLOW_GRAPH_TOPOLOGY_FIXTURE") != "1":
|
|
535
|
+
sys.stderr.write(
|
|
536
|
+
"flow-graph: --topology is a test-fixture surface; set "
|
|
537
|
+
"FLOW_GRAPH_TOPOLOGY_FIXTURE=1 to use it. Production walks the "
|
|
538
|
+
"shipped pin-verified topology only.\n")
|
|
539
|
+
return 2
|
|
540
|
+
sys.stderr.write(f"flow-graph: WARNING fixture topology in use: {a.topology} "
|
|
541
|
+
"(pin/lint/hash policy bypassed - journal hash unchanged)\n")
|
|
542
|
+
topo = load_topology(a.topology)
|
|
543
|
+
else:
|
|
544
|
+
topo, h = load_topology_trusted()
|
|
545
|
+
exh = ex["topology_hash"]
|
|
546
|
+
if not exh:
|
|
547
|
+
# Dark-phase executions were created with an empty hash: back-fill on
|
|
548
|
+
# the first verified walk so the upgrade guard applies from then on.
|
|
549
|
+
_db.update_where(con, "graph_execution", {"id": a.execution},
|
|
550
|
+
topology_hash=h,
|
|
551
|
+
topology_version=topo.get("topology_version", 1))
|
|
552
|
+
elif exh != h:
|
|
553
|
+
if getattr(a, "force_retopology", False):
|
|
554
|
+
# Fork onto the new topology: a fork checkpoint at the current node
|
|
555
|
+
# keeps the old chain walkable, then the execution re-pins its hash.
|
|
556
|
+
latest = _latest_checkpoint(con, a.execution, a.ns or "")
|
|
557
|
+
with _db.transaction(con):
|
|
558
|
+
if latest:
|
|
559
|
+
meta = json.loads(latest["meta"])
|
|
560
|
+
con.execute(
|
|
561
|
+
"INSERT INTO graph_checkpoint (execution_id,ns,checkpoint_id,"
|
|
562
|
+
"parent_checkpoint_id,node,manifest,versions,seen,meta) "
|
|
563
|
+
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
564
|
+
(a.execution, a.ns or "", graph_ids.new_id(),
|
|
565
|
+
latest["checkpoint_id"], latest["node"], latest["manifest"],
|
|
566
|
+
latest["versions"], latest["seen"],
|
|
567
|
+
json.dumps({"source": "fork", "step": meta["step"] + 1,
|
|
568
|
+
"ts": int(time.time())})))
|
|
569
|
+
con.execute("UPDATE graph_execution SET topology_hash=?, "
|
|
570
|
+
"topology_version=?, updated_at=datetime('now') WHERE id=?",
|
|
571
|
+
(h, topo.get("topology_version", 1), a.execution))
|
|
572
|
+
else:
|
|
573
|
+
sys.stderr.write(
|
|
574
|
+
"flow-graph: the shipped topology changed since this execution "
|
|
575
|
+
"started (hash mismatch - e.g. a skill upgrade). Refusing to walk "
|
|
576
|
+
"a chain recorded under different semantics. Re-run with "
|
|
577
|
+
"--force-retopology to fork onto the current topology.\n")
|
|
578
|
+
return 1
|
|
579
|
+
nxt = plan_next(con, topo, a.execution, a.ns or "", _root())
|
|
580
|
+
except (OSError, ValueError) as e:
|
|
581
|
+
# Bad/missing topology, pin mismatch, or unregistered predicate: a clean
|
|
582
|
+
# failure, never a traceback (rc 1; 3 stays reserved for complete/paused).
|
|
583
|
+
sys.stderr.write(f"flow-graph: topology error: {e}\n")
|
|
584
|
+
return 1
|
|
585
|
+
if not nxt:
|
|
586
|
+
return 3
|
|
587
|
+
print(nxt[0])
|
|
588
|
+
return 0
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _debt_provenance(root, target):
|
|
592
|
+
"""(ok, why, debt_line, author_mail) — out-of-band artifact checks (a)+(b).
|
|
593
|
+
|
|
594
|
+
(a) closed-set target, DEBT-row-restricted fixed-string match, FULL line captured
|
|
595
|
+
(the capture is $debt_line's ONLY provenance; empty capture refuses).
|
|
596
|
+
(b) committed provenance against HEAD's blob of the SAME file: refuse on ANY
|
|
597
|
+
uncommitted DEBT.md change, locate the captured line in
|
|
598
|
+
`git show HEAD:<prefix>DEBT.md`, then blame that line at HEAD for author-mail.
|
|
599
|
+
"""
|
|
600
|
+
if not TARGET_RE.match(target or ""):
|
|
601
|
+
return False, "target outside closed set", None, None
|
|
602
|
+
debt_path = os.path.join(root, "DEBT.md")
|
|
603
|
+
try:
|
|
604
|
+
with open(debt_path, encoding="utf-8") as fh:
|
|
605
|
+
rows = [ln.rstrip("\n") for ln in fh]
|
|
606
|
+
except OSError:
|
|
607
|
+
return False, "no DEBT.md", None, None
|
|
608
|
+
debt_line = next((ln for ln in rows
|
|
609
|
+
if ln.startswith("- [ ] DEBT:") and target in ln), "")
|
|
610
|
+
if not debt_line:
|
|
611
|
+
return False, "no open DEBT line naming the target", None, None
|
|
612
|
+
r = _git(root, "diff", "--quiet", "HEAD", "--", "DEBT.md")
|
|
613
|
+
if r is None or r.returncode != 0:
|
|
614
|
+
return False, "DEBT.md has uncommitted changes (or git unavailable)", None, None
|
|
615
|
+
pfx = (_git_out(root, "rev-parse", "--show-prefix") or "").strip()
|
|
616
|
+
blob = _git_out(root, "show", f"HEAD:{pfx}DEBT.md")
|
|
617
|
+
if blob is None:
|
|
618
|
+
return False, "DEBT.md not committed", None, None
|
|
619
|
+
n = next((i + 1 for i, ln in enumerate(blob.splitlines()) if ln == debt_line), 0)
|
|
620
|
+
if not n:
|
|
621
|
+
return False, "captured DEBT line absent from HEAD blob", None, None
|
|
622
|
+
porc = _git_out(root, "blame", "-w", "--line-porcelain",
|
|
623
|
+
"-L", f"{n},{n}", "HEAD", "--", "DEBT.md") or ""
|
|
624
|
+
mail = next((ln[len("author-mail "):].strip("<> ")
|
|
625
|
+
for ln in porc.splitlines() if ln.startswith("author-mail ")), None)
|
|
626
|
+
return True, "", debt_line, mail
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def cmd_graph_resume(con, a):
|
|
630
|
+
rc = _guard_flags()
|
|
631
|
+
if rc:
|
|
632
|
+
return rc
|
|
633
|
+
ex = _execution(con, a.execution)
|
|
634
|
+
if ex is None:
|
|
635
|
+
sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
|
|
636
|
+
return 1
|
|
637
|
+
if ex["status"] in TERMINAL:
|
|
638
|
+
sys.stderr.write(f"flow-graph: execution is terminal ({ex['status']}) - refusing resume\n")
|
|
639
|
+
return 1
|
|
640
|
+
# Which interrupt is being answered must be EXPLICIT when more than one is open.
|
|
641
|
+
# Resolving "whichever came first" let an operator answer a benign interrupt and
|
|
642
|
+
# un-pause an execution whose security-class interrupt was still open - the guard
|
|
643
|
+
# chain (closed-set target, committed DEBT provenance) was simply never reached.
|
|
644
|
+
opens = _db.rows(con, "SELECT * FROM graph_interrupt WHERE execution_id=? AND status='open' "
|
|
645
|
+
"ORDER BY created_at, ns, interrupt_id", (a.execution,))
|
|
646
|
+
if getattr(a, "interrupt_id", None):
|
|
647
|
+
opens = [r for r in opens if r["interrupt_id"] == a.interrupt_id]
|
|
648
|
+
elif getattr(a, "ns", None) is not None and a.ns != "":
|
|
649
|
+
opens = [r for r in opens if r["ns"] == a.ns]
|
|
650
|
+
if len(opens) > 1 and (a.answer or a.actor):
|
|
651
|
+
sys.stderr.write(
|
|
652
|
+
"flow-graph: %d interrupts are open - name the one you are answering with "
|
|
653
|
+
"--interrupt-id (or --ns):\n" % len(opens))
|
|
654
|
+
for r in opens:
|
|
655
|
+
sys.stderr.write(" %s ns=%s node=%s security_class=%s\n"
|
|
656
|
+
% (r["interrupt_id"], r["ns"] or "''", r["node"],
|
|
657
|
+
r["security_class"]))
|
|
658
|
+
return 2
|
|
659
|
+
intr = opens[0] if opens else None
|
|
660
|
+
if intr is None:
|
|
661
|
+
if a.answer or a.actor:
|
|
662
|
+
sys.stderr.write("flow-graph: no open interrupt matches this execution/selector\n")
|
|
663
|
+
return 1
|
|
664
|
+
# No interrupt + no answer: reconciliation report. For each ns, verify the
|
|
665
|
+
# latest manifest's durable claims against git so evidence-backed work is
|
|
666
|
+
# never re-dispatched even if a recording call was missed.
|
|
667
|
+
root = _root()
|
|
668
|
+
report = []
|
|
669
|
+
for row in _db.rows(con,
|
|
670
|
+
"SELECT ns, MAX(checkpoint_id) AS ck FROM graph_checkpoint "
|
|
671
|
+
"WHERE execution_id=? GROUP BY ns", (a.execution,)):
|
|
672
|
+
full = _db.one(con, "SELECT node, manifest FROM graph_checkpoint "
|
|
673
|
+
"WHERE execution_id=? AND ns=? AND checkpoint_id=?",
|
|
674
|
+
(a.execution, row["ns"], row["ck"]))
|
|
675
|
+
manifest = json.loads(full["manifest"])
|
|
676
|
+
report.append({"ns": row["ns"], "node": full["node"],
|
|
677
|
+
"evidence": verify_evidence(root, manifest)})
|
|
678
|
+
print(json.dumps({"execution": a.execution, "reconciliation": report}))
|
|
679
|
+
return 0
|
|
680
|
+
if not a.answer or not a.actor:
|
|
681
|
+
sys.stderr.write("flow-graph: resume needs --answer <json> and --actor <id>\n")
|
|
682
|
+
return 2
|
|
683
|
+
try:
|
|
684
|
+
ans = json.loads(a.answer)
|
|
685
|
+
reason = ans["reason"]
|
|
686
|
+
assert isinstance(reason, str) and reason.strip()
|
|
687
|
+
except Exception:
|
|
688
|
+
sys.stderr.write("flow-graph: --answer must be a JSON object with a non-empty "
|
|
689
|
+
"string \"reason\"\n")
|
|
690
|
+
return 1
|
|
691
|
+
root = _root()
|
|
692
|
+
audit_session = _session_id()
|
|
693
|
+
author_distinct = None
|
|
694
|
+
if a.target and not TARGET_RE.match(a.target):
|
|
695
|
+
sys.stderr.write("flow-graph: BLOCKED - target outside the closed set "
|
|
696
|
+
"(C-NNN or NN-stage)\n")
|
|
697
|
+
return 1
|
|
698
|
+
if intr["security_class"]:
|
|
699
|
+
if SECURITY_RE.search(reason):
|
|
700
|
+
sys.stderr.write("flow-graph: BLOCKED - that reason looks security-class; "
|
|
701
|
+
"security resolutions are operator-only (mirror of cmd_skip)\n")
|
|
702
|
+
return 1
|
|
703
|
+
target = a.target or ""
|
|
704
|
+
ok, why, debt_line, mail = _debt_provenance(root, target)
|
|
705
|
+
if not ok:
|
|
706
|
+
sys.stderr.write(f"flow-graph: BLOCKED - {why}. A committed open DEBT line "
|
|
707
|
+
"naming the target is required (out-of-band artifact).\n")
|
|
708
|
+
return 1
|
|
709
|
+
cfg = (_git_out(root, "config", "user.email") or "").strip()
|
|
710
|
+
# Audit evidence, NOT authorization: same-identity environments degrade
|
|
711
|
+
# (documented) to committed-provenance + audit fields.
|
|
712
|
+
author_distinct = bool(mail and cfg and mail != cfg)
|
|
713
|
+
with _db.transaction(con):
|
|
714
|
+
con.execute("UPDATE graph_interrupt SET status='resolved', resume_value=?, "
|
|
715
|
+
"resolved_by=?, resolved_at=datetime('now') "
|
|
716
|
+
"WHERE execution_id=? AND ns=? AND interrupt_id=?",
|
|
717
|
+
(json.dumps({"answer": ans, "actor": a.actor,
|
|
718
|
+
"session": audit_session,
|
|
719
|
+
"author_distinct": author_distinct}),
|
|
720
|
+
a.actor, intr["execution_id"], intr["ns"], intr["interrupt_id"]))
|
|
721
|
+
# Un-pause only when NOTHING is still open: resolving one interrupt must not
|
|
722
|
+
# release an execution whose security-class interrupt is still waiting.
|
|
723
|
+
left = con.execute("SELECT COUNT(*) FROM graph_interrupt WHERE execution_id=? "
|
|
724
|
+
"AND status='open'", (a.execution,)).fetchone()[0]
|
|
725
|
+
if not left:
|
|
726
|
+
con.execute("UPDATE graph_execution SET status='running', "
|
|
727
|
+
"updated_at=datetime('now') WHERE id=?", (a.execution,))
|
|
728
|
+
if left:
|
|
729
|
+
sys.stderr.write(f"flow-graph: {left} interrupt(s) still open - execution stays paused\n")
|
|
730
|
+
print(intr["interrupt_id"])
|
|
731
|
+
return 0
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _safe_ref(v):
|
|
735
|
+
# Manifest values reach git argv: refuse anything option-shaped so a crafted
|
|
736
|
+
# manifest cannot smuggle git flags (evidence spoofing).
|
|
737
|
+
return isinstance(v, str) and v and not v.startswith("-")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def verify_evidence(root, manifest):
|
|
741
|
+
"""Git reconciliation: check a manifest's durable claims against reality.
|
|
742
|
+
Consumed by `graph resume` (no-args reconciliation report) so evidence-backed
|
|
743
|
+
work is never re-dispatched even if a recording call was missed (the
|
|
744
|
+
between-invocations window)."""
|
|
745
|
+
out = {}
|
|
746
|
+
branch = manifest.get("branch")
|
|
747
|
+
if _safe_ref(branch):
|
|
748
|
+
out["branch_exists"] = _git_out(root, "rev-parse", "--verify",
|
|
749
|
+
"--quiet", branch, "--") is not None
|
|
750
|
+
sha, base = manifest.get("git_sha"), manifest.get("base")
|
|
751
|
+
if _safe_ref(sha) and _safe_ref(base):
|
|
752
|
+
r = _git(root, "merge-base", "--is-ancestor", sha, base)
|
|
753
|
+
out["merged"] = bool(r is not None and r.returncode == 0)
|
|
754
|
+
return out
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def cmd_graph_status(con, a):
|
|
758
|
+
rc = _guard_flags()
|
|
759
|
+
if rc:
|
|
760
|
+
return rc
|
|
761
|
+
ex = _execution(con, a.execution)
|
|
762
|
+
if ex is None:
|
|
763
|
+
sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
|
|
764
|
+
return 1
|
|
765
|
+
latest = _db.rows(con,
|
|
766
|
+
"SELECT ns, MAX(checkpoint_id) AS checkpoint_id, node FROM graph_checkpoint "
|
|
767
|
+
"WHERE execution_id=? GROUP BY ns", (a.execution,))
|
|
768
|
+
opens = _db.rows(con, "SELECT ns, interrupt_id, node, security_class FROM graph_interrupt "
|
|
769
|
+
"WHERE execution_id=? AND status='open'", (a.execution,))
|
|
770
|
+
print(json.dumps({"execution": dict(ex), "latest": latest, "open_interrupts": opens}))
|
|
771
|
+
return 0
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def cmd_graph_abandon(con, a):
|
|
775
|
+
rc = _guard_flags()
|
|
776
|
+
if rc:
|
|
777
|
+
return rc
|
|
778
|
+
with _db.transaction(con):
|
|
779
|
+
cur = con.execute("UPDATE graph_execution SET status='abandoned', outcome=?, "
|
|
780
|
+
"updated_at=datetime('now') WHERE id=?",
|
|
781
|
+
(a.outcome, a.execution))
|
|
782
|
+
if not cur.rowcount:
|
|
783
|
+
raise sqlite3.IntegrityError(f"no execution {a.execution}")
|
|
784
|
+
con.execute("UPDATE graph_interrupt SET status='abandoned' "
|
|
785
|
+
"WHERE execution_id=? AND status='open'", (a.execution,))
|
|
786
|
+
return 0
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# ---------------- card DAG (Phase 4) ----------------
|
|
790
|
+
|
|
791
|
+
_CARD_RE = re.compile(r"C-[0-9]+", re.IGNORECASE)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _card_id(tok):
|
|
795
|
+
return "C-%03d" % int(tok.split("-")[1]) # C-1 == C-001
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _read_cards(cards_dir):
|
|
799
|
+
"""Parse the REAL card format (no YAML frontmatter): a `deps:` body line whose
|
|
800
|
+
free text is scraped for C-NNN ids (mirrors flow.sh:1310-1319) and a
|
|
801
|
+
`## Allowed files` markdown section (mirrors _card_allowed_files, flow.sh:1289)
|
|
802
|
+
tokenized like _ws_tokens (flow.sh:1948) so overlap can never diverge."""
|
|
803
|
+
cards = {}
|
|
804
|
+
if not os.path.isdir(cards_dir):
|
|
805
|
+
return cards
|
|
806
|
+
for name in sorted(os.listdir(cards_dir)):
|
|
807
|
+
m = re.match(r"^(C-[0-9]+)\.md$", name)
|
|
808
|
+
if not m:
|
|
809
|
+
continue
|
|
810
|
+
cid = _card_id(m.group(1))
|
|
811
|
+
status, deps, files, in_allowed, fill = "", [], set(), False, False
|
|
812
|
+
with open(os.path.join(cards_dir, name), encoding="utf-8", errors="replace") as fh:
|
|
813
|
+
for line in fh:
|
|
814
|
+
s = line.rstrip("\n")
|
|
815
|
+
if s.startswith("## Allowed files"):
|
|
816
|
+
in_allowed = True
|
|
817
|
+
continue
|
|
818
|
+
if in_allowed and s.startswith("## "):
|
|
819
|
+
in_allowed = False
|
|
820
|
+
if in_allowed and s.strip():
|
|
821
|
+
if "[FILL" in s:
|
|
822
|
+
fill = True
|
|
823
|
+
continue
|
|
824
|
+
# Mirror _ws_tokens EXACTLY (flow.sh:2035): strip one leading bullet
|
|
825
|
+
# (incl. tabs), split on whitespace, then DELETE backticks/commas
|
|
826
|
+
# (deleting, not splitting, is what joins `a.ts`,`b.ts` the same way).
|
|
827
|
+
for tok in re.sub(r"^\s*-\s*", "", s).split():
|
|
828
|
+
# \r too: bash `tr -s ' \t'` leaves it, so a CRLF checkout would
|
|
829
|
+
# otherwise compare `src/a.ts\r` against `src/a.ts` and miss overlap.
|
|
830
|
+
tok = tok.replace("`", "").replace(",", "").replace("\r", "")
|
|
831
|
+
if tok:
|
|
832
|
+
files.add(tok)
|
|
833
|
+
elif s.startswith("status:") and not status:
|
|
834
|
+
status = s.split(":", 1)[1].strip().split()[0] if s.split(":", 1)[1].strip() else ""
|
|
835
|
+
elif s.startswith("deps:") and not deps:
|
|
836
|
+
deps = [_card_id(t) for t in _CARD_RE.findall(s)]
|
|
837
|
+
# A self-dep is kept (not silently dropped): cmd_ready blocks such a card, so the
|
|
838
|
+
# compiler must too - dropping it would advertise an undispatchable card as ready.
|
|
839
|
+
cards[cid] = {"id": cid, "status": status, "deps": sorted(set(deps)),
|
|
840
|
+
"files": sorted(files), "fill": fill}
|
|
841
|
+
return cards
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _dep_cycles(cards):
|
|
845
|
+
color, out = {}, []
|
|
846
|
+
path = []
|
|
847
|
+
|
|
848
|
+
def dfs(n):
|
|
849
|
+
color[n] = 1
|
|
850
|
+
path.append(n)
|
|
851
|
+
for d in cards.get(n, {}).get("deps", []):
|
|
852
|
+
if d not in cards:
|
|
853
|
+
continue
|
|
854
|
+
if color.get(d) == 1:
|
|
855
|
+
out.append(path[path.index(d):] + [d])
|
|
856
|
+
elif color.get(d, 0) == 0:
|
|
857
|
+
dfs(d)
|
|
858
|
+
path.pop()
|
|
859
|
+
color[n] = 2
|
|
860
|
+
|
|
861
|
+
for n in cards:
|
|
862
|
+
if color.get(n, 0) == 0:
|
|
863
|
+
dfs(n)
|
|
864
|
+
return out
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def compile_cards(root, active_files=()):
|
|
868
|
+
"""Card DAG + ready-set. deps-met mirrors cmd_ready (status of each dep == done);
|
|
869
|
+
overlap serialization is NEW deliberate behavior (legacy `ready` only advises,
|
|
870
|
+
flow.sh:1302): cards sharing an allowed-file token, or overlapping a currently
|
|
871
|
+
active worktree's tokens, are held back so two agents never edit one file."""
|
|
872
|
+
cards = _read_cards(os.path.join(root, "cards"))
|
|
873
|
+
cycles = _dep_cycles(cards)
|
|
874
|
+
# Only `todo` is buildable - cmd_ready's exact semantics (an invalid/blank status is
|
|
875
|
+
# a gate violation, not a dispatchable card).
|
|
876
|
+
todo = [c for c in cards.values() if c["status"] == "todo"]
|
|
877
|
+
in_cycle = {n for cyc in cycles for n in cyc}
|
|
878
|
+
deps_met, blocked = [], {}
|
|
879
|
+
for c in sorted(todo, key=lambda x: x["id"]):
|
|
880
|
+
missing = [d for d in c["deps"] if cards.get(d, {}).get("status") != "done"]
|
|
881
|
+
if c["id"] in in_cycle:
|
|
882
|
+
blocked[c["id"]] = {"reason": "dep cycle",
|
|
883
|
+
"cycle": next("->".join(x) for x in cycles if c["id"] in x)}
|
|
884
|
+
elif missing:
|
|
885
|
+
blocked[c["id"]] = {"reason": "deps", "missing": missing}
|
|
886
|
+
elif c["fill"]:
|
|
887
|
+
blocked[c["id"]] = {"reason": "allowed-files still has [FILL"}
|
|
888
|
+
else:
|
|
889
|
+
deps_met.append(c["id"])
|
|
890
|
+
ready, taken = [], set(active_files)
|
|
891
|
+
for cid in deps_met:
|
|
892
|
+
f = set(cards[cid]["files"])
|
|
893
|
+
if f & taken:
|
|
894
|
+
blocked[cid] = {"reason": "allowed-files overlap",
|
|
895
|
+
"with": sorted(f & taken)}
|
|
896
|
+
continue
|
|
897
|
+
taken |= f
|
|
898
|
+
ready.append(cid)
|
|
899
|
+
return {"cards": {k: v for k, v in sorted(cards.items())},
|
|
900
|
+
"deps_met": deps_met, "ready": ready, "blocked": blocked,
|
|
901
|
+
"cycles": ["->".join(c) for c in cycles]}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def cmd_graph_cards(con, a):
|
|
905
|
+
rc = _guard_flags()
|
|
906
|
+
if rc:
|
|
907
|
+
return rc
|
|
908
|
+
active = []
|
|
909
|
+
if a.active_files:
|
|
910
|
+
active = [t for t in a.active_files.replace(",", " ").split() if t]
|
|
911
|
+
out = compile_cards(_root(), active)
|
|
912
|
+
# A dep cycle blocks its OWN members, never the board: cmd_ready keeps advising every
|
|
913
|
+
# other buildable card, and auto-run routes all dispatch through this verb - one
|
|
914
|
+
# authoring typo must not halt a healthy run. rc 1 stays for an unreadable cards/.
|
|
915
|
+
for c in out["cycles"]:
|
|
916
|
+
sys.stderr.write(f"flow-graph: card dependency cycle: {c}\n")
|
|
917
|
+
print(json.dumps(out))
|
|
918
|
+
return 0
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def cmd_graph_root(con, a):
|
|
922
|
+
"""Print the directory that owns this project's durable state. Single source of
|
|
923
|
+
truth for main-tree scoping: flow.sh asks THIS instead of re-deriving the worktree
|
|
924
|
+
translation with a weaker heuristic (a second implementation drifted on monorepo
|
|
925
|
+
sub-projects and --separate-git-dir repos, re-splitting the journal)."""
|
|
926
|
+
print(os.path.dirname(_db.default_db_path()))
|
|
927
|
+
return 0
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
# ---------------- lint ----------------
|
|
931
|
+
|
|
932
|
+
# Autonomous cmd position allows ONLY read-only surfaces: flow.sh's scan-only `gate`
|
|
933
|
+
# verb and side-effect-free git queries. `check` mutates story/trace on pass and is
|
|
934
|
+
# Must-ask - a shape row for it would read as conditional permission (round-9 F1).
|
|
935
|
+
_CMD_GIT_VERBS = {"worktree", "merge-base", "rev-parse", "status", "log"}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _lint_cmd(name, cmd):
|
|
939
|
+
errs = []
|
|
940
|
+
if not isinstance(cmd, list) or not cmd or not all(isinstance(x, str) for x in cmd):
|
|
941
|
+
return [f"node {name}: cmd must be a non-empty argv array of strings"]
|
|
942
|
+
for tok in cmd:
|
|
943
|
+
if "{" in tok and tok != "{card}":
|
|
944
|
+
errs.append(f"node {name}: unknown placeholder {tok!r} (only {{card}})")
|
|
945
|
+
prog = cmd[0]
|
|
946
|
+
if prog == "flow.sh":
|
|
947
|
+
verb = cmd[1] if len(cmd) > 1 else ""
|
|
948
|
+
if verb != "gate":
|
|
949
|
+
errs.append(f"node {name}: flow.sh verb {verb!r} banned in autonomous cmd "
|
|
950
|
+
"position (only the read-only `gate` verb)")
|
|
951
|
+
else:
|
|
952
|
+
args = cmd[2:]
|
|
953
|
+
ok = (len(args) == 1 and args[0] in PRED.STAGES) or args == ["--card", "{card}"]
|
|
954
|
+
if not ok:
|
|
955
|
+
errs.append(f"node {name}: gate arg shape must be a STAGES member "
|
|
956
|
+
f"or ['--card','{{card}}'] (got {args})")
|
|
957
|
+
elif prog == "git":
|
|
958
|
+
if len(cmd) < 2 or cmd[1] not in _CMD_GIT_VERBS:
|
|
959
|
+
errs.append(f"node {name}: git verb outside {sorted(_CMD_GIT_VERBS)}")
|
|
960
|
+
else:
|
|
961
|
+
errs.append(f"node {name}: argv[0] {prog!r} outside {{flow.sh, git}}")
|
|
962
|
+
return errs
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _reachable(topo, roots):
|
|
966
|
+
seen, stack = set(), [r for r in roots if r in topo["nodes"]]
|
|
967
|
+
while stack:
|
|
968
|
+
n = stack.pop()
|
|
969
|
+
if n in seen:
|
|
970
|
+
continue
|
|
971
|
+
seen.add(n)
|
|
972
|
+
stack.extend(e["to"] for e in topo["edges"] if e["from"] == n and e["to"] in topo["nodes"])
|
|
973
|
+
return seen
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _skip_walk(topo, root, skipped):
|
|
977
|
+
"""Reachability under plan_next's skip-substitution: skipped gates pass through
|
|
978
|
+
(their edges evaluated against the accepted-with-debt PASS manifest); all other
|
|
979
|
+
edges are treated as traversable (their predicates depend on runtime gates)."""
|
|
980
|
+
smap = PRED.stage_map(topo)
|
|
981
|
+
seen, landed, stack = set(), set(), [root]
|
|
982
|
+
while stack:
|
|
983
|
+
n = stack.pop()
|
|
984
|
+
if n in seen or n not in topo["nodes"]:
|
|
985
|
+
continue
|
|
986
|
+
seen.add(n)
|
|
987
|
+
if smap.get(n) in skipped:
|
|
988
|
+
for e in topo["edges"]:
|
|
989
|
+
if e["from"] == n and _predicate(e.get("when", "always"),
|
|
990
|
+
_SKIPPED_GATE_MANIFEST):
|
|
991
|
+
stack.append(e["to"])
|
|
992
|
+
continue
|
|
993
|
+
landed.add(n)
|
|
994
|
+
stack.extend(e["to"] for e in topo["edges"] if e["from"] == n)
|
|
995
|
+
return landed
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _lint_cycles(topo):
|
|
999
|
+
"""Planning (stage-carrying) subgraphs must be acyclic; every cycle anywhere
|
|
1000
|
+
must pass through a node carrying max_visits (the bounded-repair contract)."""
|
|
1001
|
+
errs = []
|
|
1002
|
+
nodes = topo["nodes"]
|
|
1003
|
+
adj = {}
|
|
1004
|
+
for e in topo["edges"]:
|
|
1005
|
+
adj.setdefault(e["from"], []).append(e["to"])
|
|
1006
|
+
color, stack_path = {}, []
|
|
1007
|
+
cycles = []
|
|
1008
|
+
|
|
1009
|
+
def dfs(n):
|
|
1010
|
+
color[n] = 1
|
|
1011
|
+
stack_path.append(n)
|
|
1012
|
+
for m in adj.get(n, []):
|
|
1013
|
+
if m not in nodes:
|
|
1014
|
+
continue
|
|
1015
|
+
if color.get(m) == 1:
|
|
1016
|
+
cycles.append(stack_path[stack_path.index(m):] + [m])
|
|
1017
|
+
elif color.get(m, 0) == 0:
|
|
1018
|
+
dfs(m)
|
|
1019
|
+
stack_path.pop()
|
|
1020
|
+
color[n] = 2
|
|
1021
|
+
|
|
1022
|
+
for n in nodes:
|
|
1023
|
+
if color.get(n, 0) == 0:
|
|
1024
|
+
dfs(n)
|
|
1025
|
+
smap = PRED.stage_map(topo)
|
|
1026
|
+
for cyc in cycles:
|
|
1027
|
+
if any(smap.get(n) for n in cyc):
|
|
1028
|
+
errs.append(f"planning subgraph cycle: {'->'.join(cyc)}")
|
|
1029
|
+
if not any(nodes[n].get("max_visits") for n in set(cyc)):
|
|
1030
|
+
errs.append(f"unbounded cycle (no max_visits node): {'->'.join(cyc)}")
|
|
1031
|
+
return errs
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _lint(topo, pin_path=None):
|
|
1035
|
+
errs = []
|
|
1036
|
+
nodes, edges = topo["nodes"], topo["edges"]
|
|
1037
|
+
entry = topo.get("entry") or []
|
|
1038
|
+
if not entry:
|
|
1039
|
+
errs.append("entry: missing or empty (unreachability is defined against entry roots)")
|
|
1040
|
+
for e in edges:
|
|
1041
|
+
for k in ("from", "to"):
|
|
1042
|
+
if e.get(k) not in nodes:
|
|
1043
|
+
errs.append(f"edge {e.get('from')}->{e.get('to')}: unknown node ref {e.get(k)!r}")
|
|
1044
|
+
w = e.get("when", "always")
|
|
1045
|
+
if w not in PRED.REGISTRY:
|
|
1046
|
+
errs.append(f"edge {e.get('from')}->{e.get('to')}: unregistered predicate {w!r}")
|
|
1047
|
+
for name, spec in nodes.items():
|
|
1048
|
+
if spec.get("cmd") is not None:
|
|
1049
|
+
errs += _lint_cmd(name, spec["cmd"])
|
|
1050
|
+
# Green-stranded gate (review H3): a gate_check with out-edges but none
|
|
1051
|
+
# satisfiable on a PASSING gate would dead-end at runtime with rc 3 -
|
|
1052
|
+
# indistinguishable from real completion. Terminals (no out-edges) are exempt.
|
|
1053
|
+
if spec.get("type") == "gate_check":
|
|
1054
|
+
outs = [e for e in edges if e.get("from") == name]
|
|
1055
|
+
if outs and not any(_predicate(e.get("when", "always"), _SKIPPED_GATE_MANIFEST)
|
|
1056
|
+
for e in outs if e.get("when", "always") in PRED.REGISTRY):
|
|
1057
|
+
errs.append(f"node {name}: green-stranded gate (no out-edge satisfiable "
|
|
1058
|
+
"on a passing gate - runtime would report complete)")
|
|
1059
|
+
reach_all = set()
|
|
1060
|
+
for r in entry:
|
|
1061
|
+
if r not in nodes:
|
|
1062
|
+
errs.append(f"entry root {r!r} is not a node")
|
|
1063
|
+
continue
|
|
1064
|
+
sub = _reachable(topo, [r])
|
|
1065
|
+
reach_all |= sub
|
|
1066
|
+
# Planning-subgraph discriminator: it contains the UNSKIPPABLE terminal stage
|
|
1067
|
+
# (05-contract). "Any node has a stage" would be self-defeating: a rogue stage
|
|
1068
|
+
# on card-review would flip the whole card subgraph to planning and legalize
|
|
1069
|
+
# itself (round-9 F1) - exactly the debt-skippable review gate to prevent.
|
|
1070
|
+
planningy = PRED.STAGES[-1] in {nodes[n].get("stage") for n in sub}
|
|
1071
|
+
for n in sub:
|
|
1072
|
+
if nodes[n].get("type") != "gate_check":
|
|
1073
|
+
continue
|
|
1074
|
+
st = nodes[n].get("stage")
|
|
1075
|
+
if planningy and st not in PRED.STAGES:
|
|
1076
|
+
errs.append(f"node {n}: planning gate_check needs a `stage` in STAGES (got {st!r})")
|
|
1077
|
+
if not planningy and st:
|
|
1078
|
+
errs.append(f"node {n}: gate_check declares stage {st!r} in a subgraph "
|
|
1079
|
+
f"without the {PRED.STAGES[-1]} terminal - card subgraphs must "
|
|
1080
|
+
"not declare `stage` (it would become debt-skippable), and a "
|
|
1081
|
+
f"planning subgraph must include {PRED.STAGES[-1]}")
|
|
1082
|
+
# skip-reachability, scoped per subgraph (round-8 F1 + review H3): planning
|
|
1083
|
+
# roots must reach the 05-CONTRACT node itself - not just any out-edge-less
|
|
1084
|
+
# node - under EVERY subset of the skippable stages (2^5 = 32).
|
|
1085
|
+
terminals = {n for n in sub if not any(e["from"] == n for e in edges)}
|
|
1086
|
+
if planningy:
|
|
1087
|
+
term_nodes = {n for n in sub if nodes[n].get("stage") == PRED.STAGES[-1]}
|
|
1088
|
+
skippable = [s for s in PRED.SKIPPABLE_STAGES
|
|
1089
|
+
if s in set(PRED.stage_map(topo).values())]
|
|
1090
|
+
for mask in range(1 << len(skippable)):
|
|
1091
|
+
subset = {skippable[i] for i in range(len(skippable)) if mask >> i & 1}
|
|
1092
|
+
if not (_skip_walk(topo, r, subset) & term_nodes):
|
|
1093
|
+
errs.append(f"root {r}: the {PRED.STAGES[-1]} terminal is not "
|
|
1094
|
+
f"reachable when skipped={sorted(subset)}")
|
|
1095
|
+
break
|
|
1096
|
+
elif terminals and not (_skip_walk(topo, r, set()) & terminals):
|
|
1097
|
+
errs.append(f"root {r}: no terminal reachable")
|
|
1098
|
+
for n in nodes:
|
|
1099
|
+
if entry and n not in reach_all:
|
|
1100
|
+
errs.append(f"node {n}: unreachable from entry roots")
|
|
1101
|
+
errs += _lint_cycles(topo)
|
|
1102
|
+
if pin_path:
|
|
1103
|
+
try:
|
|
1104
|
+
with open(pin_path, encoding="utf-8") as fh:
|
|
1105
|
+
pin = fh.read().split()[0]
|
|
1106
|
+
except (OSError, IndexError):
|
|
1107
|
+
pin = None
|
|
1108
|
+
if pin != canonical_hash(topo):
|
|
1109
|
+
errs.append(f"pin mismatch vs {pin_path} (regenerate per harness/README.md "
|
|
1110
|
+
"if the topology edit is intentional)")
|
|
1111
|
+
return errs
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def cmd_graph_lint(con, a):
|
|
1115
|
+
rc = _guard_flags()
|
|
1116
|
+
if rc:
|
|
1117
|
+
return rc
|
|
1118
|
+
path = a.topology or TRUSTED_TOPOLOGY
|
|
1119
|
+
pin = a.pin or (TOPOLOGY_PIN if not a.topology else None)
|
|
1120
|
+
try:
|
|
1121
|
+
topo = load_topology(path)
|
|
1122
|
+
except (OSError, ValueError) as e:
|
|
1123
|
+
sys.stderr.write(f"flow-graph: lint: {e}\n")
|
|
1124
|
+
return 1
|
|
1125
|
+
try:
|
|
1126
|
+
errs = _lint(topo, pin_path=pin)
|
|
1127
|
+
except (AttributeError, TypeError, KeyError, RecursionError) as e:
|
|
1128
|
+
# Malformed shapes (node spec as string, edge as scalar, absurd depth) must
|
|
1129
|
+
# produce a lint line, never a traceback - lint is an operator-facing tool.
|
|
1130
|
+
sys.stderr.write(f"flow-graph: lint: malformed topology: {type(e).__name__}: {e}\n")
|
|
1131
|
+
return 1
|
|
1132
|
+
if errs:
|
|
1133
|
+
for e in errs:
|
|
1134
|
+
sys.stderr.write(f"flow-graph: lint: {e}\n")
|
|
1135
|
+
return 1
|
|
1136
|
+
print(json.dumps({"ok": True, "nodes": len(topo["nodes"]),
|
|
1137
|
+
"edges": len(topo["edges"]),
|
|
1138
|
+
"topology_hash": canonical_hash(topo)}))
|
|
1139
|
+
return 0
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def cmd_graph_finish(con, a):
|
|
1143
|
+
"""Close a lane: mark the execution done so `session` mints a fresh one for the next
|
|
1144
|
+
feature and `gc` can eventually reclaim it. Without a terminal transition nothing
|
|
1145
|
+
ever leaves `running`, so the journal grows forever and a completed planning lane
|
|
1146
|
+
answers "complete" for the feature that follows it."""
|
|
1147
|
+
rc = _guard_flags()
|
|
1148
|
+
if rc:
|
|
1149
|
+
return rc
|
|
1150
|
+
ex = _execution(con, a.execution)
|
|
1151
|
+
if ex is None:
|
|
1152
|
+
sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
|
|
1153
|
+
return 1
|
|
1154
|
+
if ex["status"] in TERMINAL:
|
|
1155
|
+
return 0 # idempotent
|
|
1156
|
+
opens = con.execute("SELECT COUNT(*) FROM graph_interrupt WHERE execution_id=? "
|
|
1157
|
+
"AND status='open'", (a.execution,)).fetchone()[0]
|
|
1158
|
+
if opens and not getattr(a, "force", False):
|
|
1159
|
+
sys.stderr.write(f"flow-graph: {opens} interrupt(s) still open - resolve them or "
|
|
1160
|
+
"pass --force\n")
|
|
1161
|
+
return 1
|
|
1162
|
+
with _db.transaction(con):
|
|
1163
|
+
con.execute("UPDATE graph_execution SET status='done', outcome=?, "
|
|
1164
|
+
"updated_at=datetime('now') WHERE id=?",
|
|
1165
|
+
(a.outcome or "complete", a.execution))
|
|
1166
|
+
return 0
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def cmd_graph_gc(con, a):
|
|
1170
|
+
rc = _guard_flags()
|
|
1171
|
+
if rc:
|
|
1172
|
+
return rc
|
|
1173
|
+
project = a.project or _project_key()
|
|
1174
|
+
# Purge FIRST, then sweep: rows marked stale in this invocation survive until the
|
|
1175
|
+
# NEXT gc, so doctor/status get a window to surface them (never mark-and-delete in
|
|
1176
|
+
# one call). Purge is scoped to one project - the DB is deliberately shared across
|
|
1177
|
+
# worktrees, and a sibling card's gc must not erase this project's history.
|
|
1178
|
+
with _db.transaction(con):
|
|
1179
|
+
cur = con.execute("DELETE FROM graph_execution "
|
|
1180
|
+
"WHERE status IN ('done','failed','abandoned') AND project=?",
|
|
1181
|
+
(project,))
|
|
1182
|
+
deleted = cur.rowcount
|
|
1183
|
+
marked = 0
|
|
1184
|
+
if a.stale_days is not None:
|
|
1185
|
+
cutoff = int(time.time()) - a.stale_days * 86400
|
|
1186
|
+
try:
|
|
1187
|
+
stale = _db.rows(con,
|
|
1188
|
+
"SELECT e.id FROM graph_execution e "
|
|
1189
|
+
"WHERE e.status IN ('running','paused') AND e.project=? "
|
|
1190
|
+
"AND e.created_at <= datetime('now', ?) "
|
|
1191
|
+
"AND NOT EXISTS ("
|
|
1192
|
+
" SELECT 1 FROM graph_checkpoint c WHERE c.execution_id=e.id "
|
|
1193
|
+
" AND json_extract(c.meta,'$.ts') >= ?)",
|
|
1194
|
+
(project, f"-{a.stale_days} days", cutoff))
|
|
1195
|
+
except sqlite3.OperationalError:
|
|
1196
|
+
sys.stderr.write("flow-graph: this SQLite lacks JSON1 (json_extract) - "
|
|
1197
|
+
"stale sweep unavailable\n")
|
|
1198
|
+
print(json.dumps({"deleted": deleted, "stale_marked": 0}))
|
|
1199
|
+
return 1
|
|
1200
|
+
for row in stale:
|
|
1201
|
+
_db.update_where(con, "graph_execution", {"id": row["id"]},
|
|
1202
|
+
status="abandoned", outcome="stale")
|
|
1203
|
+
marked += 1
|
|
1204
|
+
print(json.dumps({"deleted": deleted, "stale_marked": marked}))
|
|
1205
|
+
return 0
|