flexlock 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flexlock/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
flexlock/snapshot.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Snapshotting utilities for FlexLock."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from omegaconf import OmegaConf
|
|
6
|
+
from .git_utils import create_shadow_snapshot
|
|
7
|
+
from .data_hash import hash_data
|
|
8
|
+
from .load_stage import load_stage_from_path
|
|
9
|
+
from .run_record import RunRecord, COMPLETE_MARKER, COMPLETE_VERSION
|
|
10
|
+
from loguru import logger
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_complete_marker(save_dir: Path, result=None) -> Path:
|
|
15
|
+
"""Write run.complete atomically into ``save_dir``.
|
|
16
|
+
|
|
17
|
+
Thin wrapper over :meth:`RunRecord.mark_complete` kept for backward
|
|
18
|
+
compatibility. ``_find_matching_run`` requires both ``run.lock`` and
|
|
19
|
+
``run.complete`` for a cache hit.
|
|
20
|
+
"""
|
|
21
|
+
return RunRecord(save_dir).mark_complete(result=result)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_complete(run_dir: Path) -> bool:
|
|
25
|
+
"""Return True if ``run_dir`` has both ``run.lock`` and ``run.complete``."""
|
|
26
|
+
return RunRecord(run_dir).is_complete
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RunTracker:
|
|
30
|
+
def __init__(self, save_dir, parent_lock=None, note=None):
|
|
31
|
+
self.save_dir = Path(save_dir)
|
|
32
|
+
self.parent_lock = Path(parent_lock) if parent_lock else None
|
|
33
|
+
self.data = {"timestamp": datetime.now().isoformat()}
|
|
34
|
+
|
|
35
|
+
# Free-text intent recorded alongside run metadata (sibling of
|
|
36
|
+
# timestamp/config). It never enters the fingerprint, so it can't
|
|
37
|
+
# perturb caching or diffs.
|
|
38
|
+
if note:
|
|
39
|
+
self.data["note"] = str(note)
|
|
40
|
+
|
|
41
|
+
if self.parent_lock:
|
|
42
|
+
# We record the link, effectively saying "See parent for Git/Env"
|
|
43
|
+
self.data["parent"] = str(self.parent_lock)
|
|
44
|
+
|
|
45
|
+
def record_env(self, repos: dict):
|
|
46
|
+
# Optimization: If we have a parent, we MIGHT skip recording git
|
|
47
|
+
# IF we are sure code hasn't changed between main process and worker
|
|
48
|
+
# (which is true for multiprocessing/slurm typically).
|
|
49
|
+
if self.parent_lock:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
self.data["repos"] = {}
|
|
53
|
+
for name, repo_info in repos.items():
|
|
54
|
+
path = repo_info["path"]
|
|
55
|
+
snapshot_data = create_shadow_snapshot(
|
|
56
|
+
path,
|
|
57
|
+
ref_name=str(self.save_dir) + f"_{uuid.uuid1().hex}",
|
|
58
|
+
)
|
|
59
|
+
# Store metadata for comparison-time filtering
|
|
60
|
+
if repo_info.get("include"):
|
|
61
|
+
snapshot_data["include"] = repo_info["include"]
|
|
62
|
+
if repo_info.get("exclude"):
|
|
63
|
+
snapshot_data["exclude"] = repo_info["exclude"]
|
|
64
|
+
if repo_info.get("module"):
|
|
65
|
+
snapshot_data["module"] = repo_info["module"]
|
|
66
|
+
snapshot_data["path"] = path
|
|
67
|
+
self.data["repos"][name] = snapshot_data
|
|
68
|
+
|
|
69
|
+
def record_data(self, data_paths: dict):
|
|
70
|
+
self.data["data"] = {k: hash_data(v) for k, v in data_paths.items()}
|
|
71
|
+
|
|
72
|
+
def add_lineage(self, name: str, path: str, info: dict):
|
|
73
|
+
"""Add lineage information from upstream FlexLock runs."""
|
|
74
|
+
if "lineage" not in self.data:
|
|
75
|
+
self.data["lineage"] = {}
|
|
76
|
+
|
|
77
|
+
self.data["lineage"][name] = {"path": path, "info": info}
|
|
78
|
+
|
|
79
|
+
def finalize(self, config):
|
|
80
|
+
"""
|
|
81
|
+
Prepares the final snapshot dict but does not write it to disk.
|
|
82
|
+
|
|
83
|
+
This allows callers to decide where to store the snapshot
|
|
84
|
+
(file, DB, or both).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
config: OmegaConf configuration object
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
dict: Complete snapshot data structure
|
|
91
|
+
"""
|
|
92
|
+
self.data["config"] = OmegaConf.to_container(config, resolve=True)
|
|
93
|
+
return self.data
|
|
94
|
+
|
|
95
|
+
def save(self, config):
|
|
96
|
+
"""
|
|
97
|
+
Finalize and write to disk as run.lock.
|
|
98
|
+
Now implemented by calling finalize() then writing.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
dict: The snapshot data that was written
|
|
102
|
+
"""
|
|
103
|
+
snapshot_data = self.finalize(config)
|
|
104
|
+
|
|
105
|
+
# Read the concrete save_dir from the finalized snapshot. save_dir is
|
|
106
|
+
# baked to a string once at submit time (apply_save_dir_policy), so this
|
|
107
|
+
# is just a lookup — no resolver fires here.
|
|
108
|
+
resolved_save_dir = Path(
|
|
109
|
+
snapshot_data["config"].get("save_dir", str(self.save_dir))
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Single on-disk-contract owner performs the atomic write.
|
|
113
|
+
RunRecord(resolved_save_dir).write_lock(snapshot_data)
|
|
114
|
+
|
|
115
|
+
# Remember the resolved dir so mark_complete can write next to run.lock
|
|
116
|
+
self._resolved_save_dir = resolved_save_dir
|
|
117
|
+
|
|
118
|
+
return snapshot_data
|
|
119
|
+
|
|
120
|
+
def mark_complete(self, result=None) -> Path:
|
|
121
|
+
"""Write ``run.complete`` next to the previously-written ``run.lock``.
|
|
122
|
+
|
|
123
|
+
Call this only after the user function returns successfully. The
|
|
124
|
+
``_find_matching_run`` cache check requires both files; an
|
|
125
|
+
interrupted run leaves ``run.lock`` without ``run.complete`` and is
|
|
126
|
+
correctly skipped instead of being treated as a stale cache hit.
|
|
127
|
+
"""
|
|
128
|
+
target = getattr(self, "_resolved_save_dir", self.save_dir)
|
|
129
|
+
return RunRecord(target).mark_complete(result=result)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def snapshot(
|
|
133
|
+
cfg,
|
|
134
|
+
repos=None,
|
|
135
|
+
data=None,
|
|
136
|
+
prevs=None,
|
|
137
|
+
parent_lock=None,
|
|
138
|
+
save_path=None,
|
|
139
|
+
return_snapshot=False,
|
|
140
|
+
fingerprint=None,
|
|
141
|
+
note=None,
|
|
142
|
+
):
|
|
143
|
+
"""
|
|
144
|
+
Create a snapshot of the current run state.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
cfg: OmegaConf configuration
|
|
148
|
+
repos: Dict of repository paths to track
|
|
149
|
+
data: Dict of data paths to hash
|
|
150
|
+
prevs: List of paths to search for upstream lineage
|
|
151
|
+
parent_lock: Path to parent run.lock (for delta snapshots)
|
|
152
|
+
save_path: Custom save directory (overrides cfg.save_dir)
|
|
153
|
+
return_snapshot: If True, return snapshot dict instead of/in addition to saving
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
dict if return_snapshot=True, else None
|
|
157
|
+
"""
|
|
158
|
+
if "save_dir" not in cfg:
|
|
159
|
+
logger.warning("No save_dir specified in config; skipping snapshot.")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Use custom save_path if provided, otherwise use cfg.save_dir
|
|
163
|
+
save_dir = Path(save_path) if save_path else Path(cfg.save_dir)
|
|
164
|
+
|
|
165
|
+
tracker = RunTracker(save_dir, parent_lock=parent_lock, note=note)
|
|
166
|
+
|
|
167
|
+
# Store the precomputed fingerprint (index key) so run.lock/DB snapshots
|
|
168
|
+
# carry it and `flexlock reindex` can rebuild the index from disk.
|
|
169
|
+
if fingerprint is not None:
|
|
170
|
+
tracker.data["fingerprint"] = fingerprint
|
|
171
|
+
|
|
172
|
+
# 1. Record Git & Data (Hashing)
|
|
173
|
+
if repos:
|
|
174
|
+
tracker.record_env(repos)
|
|
175
|
+
if data:
|
|
176
|
+
logger.debug(f"Recording data for snapshot: {data}")
|
|
177
|
+
tracker.record_data(data)
|
|
178
|
+
|
|
179
|
+
# 2. Record Lineage (Automatic Discovery)
|
|
180
|
+
if prevs:
|
|
181
|
+
logger.debug(f"Looking for upstream FlexLock runs in: {prevs}")
|
|
182
|
+
|
|
183
|
+
def _find_snapshot_dir(start_path: Path) -> tuple[Path, dict] | None:
|
|
184
|
+
"""
|
|
185
|
+
Find run.lock at or directly in start_path.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
tuple: (snapshot_dir, snapshot_data) or None
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
p = Path(start_path).resolve()
|
|
192
|
+
except Exception:
|
|
193
|
+
return None
|
|
194
|
+
if p.is_file():
|
|
195
|
+
p = p.parent
|
|
196
|
+
|
|
197
|
+
lock_file = p / "run.lock"
|
|
198
|
+
if lock_file.exists():
|
|
199
|
+
try:
|
|
200
|
+
data = OmegaConf.to_container(
|
|
201
|
+
OmegaConf.load(lock_file), resolve=True
|
|
202
|
+
)
|
|
203
|
+
logger.debug(f"Found snapshot at: {p}")
|
|
204
|
+
return (p, data)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.warning(f"Failed to read run.lock at {p}: {e}")
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
for path_str in prevs:
|
|
211
|
+
result = _find_snapshot_dir(path_str)
|
|
212
|
+
|
|
213
|
+
if result:
|
|
214
|
+
snapshot_dir, snapshot_data = result
|
|
215
|
+
# We found an upstream FlexLock run!
|
|
216
|
+
logger.debug(f"Found upstream FlexLock run at: {snapshot_dir}")
|
|
217
|
+
try:
|
|
218
|
+
# Extract stage info from snapshot data
|
|
219
|
+
stage_info = {
|
|
220
|
+
"config": snapshot_data.get("config", {}),
|
|
221
|
+
"timestamp": snapshot_data.get("timestamp"),
|
|
222
|
+
"repos": snapshot_data.get("repos", {}),
|
|
223
|
+
"parent": snapshot_data.get("parent"),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Add to lineage
|
|
227
|
+
tracker.add_lineage(
|
|
228
|
+
name=snapshot_dir.name, # e.g. "run_2023..." or "task_abc123"
|
|
229
|
+
path=str(snapshot_dir),
|
|
230
|
+
info=stage_info,
|
|
231
|
+
)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.warning(
|
|
234
|
+
f"Found snapshot at {snapshot_dir} but failed to process: {e}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# 3. Save and/or return
|
|
238
|
+
if return_snapshot:
|
|
239
|
+
# For DB storage: return snapshot without writing file
|
|
240
|
+
snapshot_data = tracker.finalize(cfg)
|
|
241
|
+
return snapshot_data
|
|
242
|
+
else:
|
|
243
|
+
# Traditional behavior: write to file
|
|
244
|
+
tracker.save(cfg)
|
|
245
|
+
return None
|
flexlock/status_cli.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""CLI for monitoring FlexLock task database status."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from flexlock.taskdb import (
|
|
9
|
+
get_status_counts,
|
|
10
|
+
get_status_counts_by_tag,
|
|
11
|
+
get_failed_tasks,
|
|
12
|
+
get_all_tasks,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_status_summary(db_path: Path, tags=None, label: str = ""):
|
|
17
|
+
"""Print summary of task statuses, optionally scoped to ``tags``."""
|
|
18
|
+
status_counts = get_status_counts(db_path, tags=tags)
|
|
19
|
+
|
|
20
|
+
pending = status_counts.get("pending", 0)
|
|
21
|
+
running = status_counts.get("running", 0)
|
|
22
|
+
done = status_counts.get("done", 0)
|
|
23
|
+
failed = status_counts.get("failed", 0)
|
|
24
|
+
interrupted = status_counts.get("interrupted", 0)
|
|
25
|
+
total = pending + running + done + failed + interrupted
|
|
26
|
+
|
|
27
|
+
header = f"Task Status Summary{label}"
|
|
28
|
+
print("\n" + "=" * 60)
|
|
29
|
+
print(header)
|
|
30
|
+
print("=" * 60)
|
|
31
|
+
print(f"Pending: {pending:>5}")
|
|
32
|
+
print(f"Running: {running:>5}")
|
|
33
|
+
print(f"Done: {done:>5}")
|
|
34
|
+
print(f"Failed: {failed:>5}")
|
|
35
|
+
print(f"Interrupted: {interrupted:>5}")
|
|
36
|
+
print("-" * 60)
|
|
37
|
+
print(f"Total: {total:>5}")
|
|
38
|
+
|
|
39
|
+
if total > 0:
|
|
40
|
+
terminal = done + failed + interrupted
|
|
41
|
+
progress = terminal / total * 100
|
|
42
|
+
print(f"Progress: {progress:>5.1f}% ({terminal}/{total} completed)")
|
|
43
|
+
|
|
44
|
+
if pending == 0 and running == 0:
|
|
45
|
+
issues = failed + interrupted
|
|
46
|
+
if issues:
|
|
47
|
+
label_parts = []
|
|
48
|
+
if failed:
|
|
49
|
+
label_parts.append(f"{failed} failed")
|
|
50
|
+
if interrupted:
|
|
51
|
+
label_parts.append(f"{interrupted} interrupted")
|
|
52
|
+
print(f"\nStatus: ⚠️ Finished with issues ({', '.join(label_parts)})")
|
|
53
|
+
else:
|
|
54
|
+
print(f"\nStatus: ✓ All tasks completed successfully")
|
|
55
|
+
elif pending == 0 and running > 0:
|
|
56
|
+
print(f"\nStatus: ⚠️ {running} task(s) running with no pending — possible zombie(s)")
|
|
57
|
+
else:
|
|
58
|
+
print(f"\nStatus: ⏳ In progress")
|
|
59
|
+
|
|
60
|
+
print("=" * 60 + "\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_tags_breakdown(db_path: Path):
|
|
64
|
+
"""Print a per-tag status breakdown table."""
|
|
65
|
+
by_tag = get_status_counts_by_tag(db_path)
|
|
66
|
+
if not by_tag:
|
|
67
|
+
print("No tasks found in DB.\n")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
print("\n" + "=" * 60)
|
|
71
|
+
print("Per-Tag Status Breakdown")
|
|
72
|
+
print("=" * 60)
|
|
73
|
+
|
|
74
|
+
for tag, counts in sorted(by_tag.items(), key=lambda x: (x[0] is None, x[0])):
|
|
75
|
+
tag_label = repr(tag) if tag is not None else "(untagged)"
|
|
76
|
+
pending = counts.get("pending", 0)
|
|
77
|
+
running = counts.get("running", 0)
|
|
78
|
+
done = counts.get("done", 0)
|
|
79
|
+
failed = counts.get("failed", 0)
|
|
80
|
+
interrupted = counts.get("interrupted", 0)
|
|
81
|
+
total = pending + running + done + failed + interrupted
|
|
82
|
+
terminal = done + failed + interrupted
|
|
83
|
+
pct = terminal / total * 100 if total else 0
|
|
84
|
+
|
|
85
|
+
print(f"\nTag: {tag_label} ({total} total, {pct:.1f}% done)")
|
|
86
|
+
print(f" pending={pending} running={running} done={done} "
|
|
87
|
+
f"failed={failed} interrupted={interrupted}")
|
|
88
|
+
|
|
89
|
+
print("\n" + "=" * 60 + "\n")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_failed_tasks(db_path: Path, verbose: bool = False, tags=None):
|
|
93
|
+
"""Print details of failed and interrupted tasks."""
|
|
94
|
+
failed_tasks = get_failed_tasks(db_path, tags=tags)
|
|
95
|
+
|
|
96
|
+
if not failed_tasks:
|
|
97
|
+
print("No failed or interrupted tasks found.\n")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
print(f"\nFailed / Interrupted Tasks ({len(failed_tasks)})")
|
|
101
|
+
print("=" * 60)
|
|
102
|
+
|
|
103
|
+
for i, task_info in enumerate(failed_tasks, 1):
|
|
104
|
+
status = task_info.get("status", "failed")
|
|
105
|
+
print(f"\nTask #{i} [{status.upper()}]")
|
|
106
|
+
print("-" * 60)
|
|
107
|
+
|
|
108
|
+
task = task_info["task"]
|
|
109
|
+
print("Config:")
|
|
110
|
+
for key, value in task.items():
|
|
111
|
+
if not key.startswith("_"):
|
|
112
|
+
print(f" {key}: {value}")
|
|
113
|
+
|
|
114
|
+
error = task_info["error"]
|
|
115
|
+
if error:
|
|
116
|
+
print(f"\nError:")
|
|
117
|
+
if len(error) > 500 and not verbose:
|
|
118
|
+
print(f" {error[:500]}...")
|
|
119
|
+
print(f" (Use --verbose to see full error)")
|
|
120
|
+
else:
|
|
121
|
+
for line in error.split("\n"):
|
|
122
|
+
print(f" {line}")
|
|
123
|
+
|
|
124
|
+
if task_info["node"]:
|
|
125
|
+
print(f"\nNode: {task_info['node']}")
|
|
126
|
+
if task_info["ts_start"]:
|
|
127
|
+
print(f"Started: {task_info['ts_start']}")
|
|
128
|
+
if task_info["ts_end"]:
|
|
129
|
+
print(f"Finished: {task_info['ts_end']}")
|
|
130
|
+
|
|
131
|
+
print("\n" + "=" * 60 + "\n")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def print_all_tasks(db_path: Path, status_filter: str = None, tags=None):
|
|
135
|
+
"""Print all tasks, optionally filtered by status and/or tag."""
|
|
136
|
+
tasks = get_all_tasks(db_path, status=status_filter, tags=tags)
|
|
137
|
+
|
|
138
|
+
filter_desc = f" ({status_filter})" if status_filter else ""
|
|
139
|
+
print(f"\nAll Tasks{filter_desc} ({len(tasks)})")
|
|
140
|
+
print("=" * 60)
|
|
141
|
+
|
|
142
|
+
if not tasks:
|
|
143
|
+
print("No tasks found.\n")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
print(f"{'Status':<13} {'Task ID':<12} {'Node':<15} {'Timestamp'}")
|
|
147
|
+
print("-" * 60)
|
|
148
|
+
|
|
149
|
+
for task_info in tasks:
|
|
150
|
+
status = task_info["status"]
|
|
151
|
+
task_id = task_info["task_id"][:10]
|
|
152
|
+
node = task_info["node"] or "N/A"
|
|
153
|
+
node = node[:13] if len(node) > 13 else node
|
|
154
|
+
timestamp = task_info["ts_end"] or task_info["ts_start"] or "N/A"
|
|
155
|
+
if timestamp != "N/A":
|
|
156
|
+
timestamp = timestamp[:19]
|
|
157
|
+
|
|
158
|
+
print(f"{status:<13} {task_id:<12} {node:<15} {timestamp}")
|
|
159
|
+
|
|
160
|
+
print("=" * 60 + "\n")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def watch_status(db_path: Path, interval: int = 10, tags=None):
|
|
164
|
+
"""Watch task status in real-time, optionally scoped to ``tags``."""
|
|
165
|
+
print("Watching task status (Ctrl+C to stop)...\n")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
while True:
|
|
169
|
+
print("\033[2J\033[H", end="")
|
|
170
|
+
|
|
171
|
+
from datetime import datetime
|
|
172
|
+
print(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
173
|
+
|
|
174
|
+
print_status_summary(db_path, tags=tags)
|
|
175
|
+
|
|
176
|
+
status_counts = get_status_counts(db_path, tags=tags)
|
|
177
|
+
pending = status_counts.get("pending", 0)
|
|
178
|
+
running = status_counts.get("running", 0)
|
|
179
|
+
|
|
180
|
+
if pending == 0 and running == 0:
|
|
181
|
+
print("All tasks reached a terminal state. Exiting watch mode.\n")
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
print(f"Refreshing in {interval}s... (Ctrl+C to stop)")
|
|
185
|
+
time.sleep(interval)
|
|
186
|
+
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
print("\n\nWatch mode stopped.\n")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main():
|
|
192
|
+
"""CLI entry point for flexlock-status command."""
|
|
193
|
+
parser = argparse.ArgumentParser(
|
|
194
|
+
description="Monitor FlexLock task database status",
|
|
195
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
196
|
+
epilog="""
|
|
197
|
+
Examples:
|
|
198
|
+
# Show status summary
|
|
199
|
+
flexlock-status outputs/sweep/run.lock.tasks.db
|
|
200
|
+
|
|
201
|
+
# Per-tag breakdown (useful when multiple sweeps share a DB)
|
|
202
|
+
flexlock-status outputs/sweep/run.lock.tasks.db --tags
|
|
203
|
+
|
|
204
|
+
# Scope the entire view to one tag
|
|
205
|
+
flexlock-status outputs/sweep/run.lock.tasks.db --tag extract
|
|
206
|
+
|
|
207
|
+
# Show failed / interrupted tasks
|
|
208
|
+
flexlock-status outputs/sweep/run.lock.tasks.db --failed
|
|
209
|
+
|
|
210
|
+
# Show all tasks
|
|
211
|
+
flexlock-status outputs/sweep/run.lock.tasks.db --all
|
|
212
|
+
|
|
213
|
+
# Filter by status
|
|
214
|
+
flexlock-status outputs/sweep/run.lock.tasks.db --all --status interrupted
|
|
215
|
+
|
|
216
|
+
# Watch status in real-time (scoped to a tag)
|
|
217
|
+
flexlock-status outputs/sweep/run.lock.tasks.db --watch --tag collocate
|
|
218
|
+
""",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"db_path", type=Path, help="Path to task database (run.lock.tasks.db)"
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--failed", action="store_true", help="Show details of failed / interrupted tasks"
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument("--all", action="store_true", help="Show all tasks")
|
|
228
|
+
parser.add_argument(
|
|
229
|
+
"--status",
|
|
230
|
+
choices=["pending", "running", "done", "failed", "interrupted"],
|
|
231
|
+
help="Filter tasks by status (use with --all)",
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--watch",
|
|
235
|
+
action="store_true",
|
|
236
|
+
help="Watch status in real-time (updates every 10s)",
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"--interval",
|
|
240
|
+
type=int,
|
|
241
|
+
default=10,
|
|
242
|
+
help="Update interval for watch mode in seconds (default: 10)",
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"--verbose", "-v", action="store_true",
|
|
246
|
+
help="Show verbose output (full error messages)",
|
|
247
|
+
)
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
"--tag",
|
|
250
|
+
metavar="NAME",
|
|
251
|
+
help="Scope the entire view (summary, --failed, --all, --watch) to a "
|
|
252
|
+
"single tag. Useful to monitor one sweep inside a shared DB.",
|
|
253
|
+
)
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"--tags",
|
|
256
|
+
action="store_true",
|
|
257
|
+
dest="show_tags",
|
|
258
|
+
help="Print a per-tag status breakdown before the global summary. "
|
|
259
|
+
"Useful to discover which tags exist and what to pass to "
|
|
260
|
+
"flexlock-worker --tags.",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
args = parser.parse_args()
|
|
264
|
+
|
|
265
|
+
if not args.db_path.exists():
|
|
266
|
+
logger.error(f"Database not found: {args.db_path}")
|
|
267
|
+
sys.exit(1)
|
|
268
|
+
|
|
269
|
+
# --tag NAME scopes everything; convert to a list for the taskdb API.
|
|
270
|
+
scope_tags = [args.tag] if args.tag else None
|
|
271
|
+
tag_label = f" [tag={args.tag!r}]" if args.tag else ""
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
if args.watch:
|
|
275
|
+
watch_status(args.db_path, args.interval, tags=scope_tags)
|
|
276
|
+
elif args.failed:
|
|
277
|
+
if args.show_tags:
|
|
278
|
+
print_tags_breakdown(args.db_path)
|
|
279
|
+
print_status_summary(args.db_path, tags=scope_tags, label=tag_label)
|
|
280
|
+
print_failed_tasks(args.db_path, verbose=args.verbose, tags=scope_tags)
|
|
281
|
+
elif args.all:
|
|
282
|
+
if args.show_tags:
|
|
283
|
+
print_tags_breakdown(args.db_path)
|
|
284
|
+
print_status_summary(args.db_path, tags=scope_tags, label=tag_label)
|
|
285
|
+
print_all_tasks(args.db_path, status_filter=args.status, tags=scope_tags)
|
|
286
|
+
else:
|
|
287
|
+
if args.show_tags:
|
|
288
|
+
print_tags_breakdown(args.db_path)
|
|
289
|
+
print_status_summary(args.db_path, tags=scope_tags, label=tag_label)
|
|
290
|
+
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Error reading database: {e}")
|
|
293
|
+
import traceback
|
|
294
|
+
traceback.print_exc()
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|