4040 started_at REAL NOT NULL,
4141 finished_at REAL,
4242 status TEXT NOT NULL,
43- error_text TEXT
43+ error_text TEXT,
44+ artifact_path TEXT
4445);
4546CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at DESC);
4647CREATE INDEX IF NOT EXISTS idx_runs_source ON runs(source_type, source_id);
@@ -58,6 +59,7 @@ class RunRecord:
5859 finished_at : Optional [float ]
5960 status : str
6061 error_text : Optional [str ]
62+ artifact_path : Optional [str ] = None
6163
6264 @property
6365 def duration_seconds (self ) -> Optional [float ]:
@@ -100,6 +102,17 @@ def __init__(self, path: Union[str, Path] = ":memory:") -> None:
100102 )
101103 self ._conn .row_factory = sqlite3 .Row
102104 self ._conn .executescript (_SCHEMA )
105+ self ._migrate_schema ()
106+
107+ def _migrate_schema (self ) -> None :
108+ """Add columns that older store files are missing."""
109+ cols = {row ["name" ] for row in self ._conn .execute (
110+ "PRAGMA table_info(runs)" ,
111+ ).fetchall ()}
112+ if "artifact_path" not in cols :
113+ self ._conn .execute (
114+ "ALTER TABLE runs ADD COLUMN artifact_path TEXT" ,
115+ )
103116
104117 @property
105118 def path (self ) -> str :
@@ -121,17 +134,27 @@ def start_run(self, source_type: str, source_id: str,
121134
122135 def finish_run (self , run_id : int , status : str ,
123136 error_text : Optional [str ] = None ,
124- finished_at : Optional [float ] = None ) -> bool :
137+ finished_at : Optional [float ] = None ,
138+ artifact_path : Optional [str ] = None ) -> bool :
125139 """Update a pending run with its final status; return False if unknown."""
126140 _validate_status (status )
127141 if status == STATUS_RUNNING :
128142 raise ValueError ("cannot finish a run with status=running" )
129143 ts = float (finished_at ) if finished_at is not None else time .time ()
130144 with self ._lock :
131145 cursor = self ._conn .execute (
132- "UPDATE runs SET finished_at = ?, status = ?, error_text = ?"
133- " WHERE id = ?" ,
134- (ts , status , error_text , int (run_id )),
146+ "UPDATE runs SET finished_at = ?, status = ?, error_text = ?,"
147+ " artifact_path = ? WHERE id = ?" ,
148+ (ts , status , error_text , artifact_path , int (run_id )),
149+ )
150+ return cursor .rowcount > 0
151+
152+ def attach_artifact (self , run_id : int , artifact_path : str ) -> bool :
153+ """Attach or replace the artifact path on a finished run."""
154+ with self ._lock :
155+ cursor = self ._conn .execute (
156+ "UPDATE runs SET artifact_path = ? WHERE id = ?" ,
157+ (artifact_path , int (run_id )),
135158 )
136159 return cursor .rowcount > 0
137160
@@ -177,23 +200,36 @@ def count(self, source_type: Optional[str] = None) -> int:
177200 return int (row [0 ])
178201
179202 def clear (self ) -> int :
180- """Delete every row; return the number removed."""
203+ """Delete every row (and its artifact file) ; return rows removed."""
181204 with self ._lock :
205+ paths = [r [0 ] for r in self ._conn .execute (
206+ "SELECT artifact_path FROM runs WHERE artifact_path IS NOT NULL" ,
207+ ).fetchall ()]
182208 cursor = self ._conn .execute ("DELETE FROM runs" )
183- return int (cursor .rowcount )
209+ removed = int (cursor .rowcount )
210+ _remove_artifact_files (paths )
211+ return removed
184212
185213 def prune (self , keep_latest : int ) -> int :
186- """Keep only the newest ``keep_latest`` rows; return rows removed ."""
214+ """Keep only the newest ``keep_latest`` rows; delete the rest ."""
187215 if keep_latest < 0 :
188216 raise ValueError ("keep_latest must be >= 0" )
189217 with self ._lock :
218+ paths = [r [0 ] for r in self ._conn .execute (
219+ "SELECT artifact_path FROM runs WHERE artifact_path IS NOT NULL"
220+ " AND id NOT IN ("
221+ "SELECT id FROM runs ORDER BY started_at DESC LIMIT ?)" ,
222+ (int (keep_latest ),),
223+ ).fetchall ()]
190224 cursor = self ._conn .execute (
191225 "DELETE FROM runs WHERE id NOT IN ("
192226 "SELECT id FROM runs ORDER BY started_at DESC LIMIT ?"
193227 ")" ,
194228 (int (keep_latest ),),
195229 )
196- return int (cursor .rowcount )
230+ removed = int (cursor .rowcount )
231+ _remove_artifact_files (paths )
232+ return removed
197233
198234 def close (self ) -> None :
199235 with self ._lock :
@@ -204,6 +240,7 @@ def close(self) -> None:
204240
205241
206242def _row_to_record (row : sqlite3 .Row ) -> RunRecord :
243+ artifact = row ["artifact_path" ] if "artifact_path" in row .keys () else None
207244 return RunRecord (
208245 id = int (row ["id" ]),
209246 source_type = str (row ["source_type" ]),
@@ -215,7 +252,23 @@ def _row_to_record(row: sqlite3.Row) -> RunRecord:
215252 status = str (row ["status" ]),
216253 error_text = (str (row ["error_text" ])
217254 if row ["error_text" ] is not None else None ),
255+ artifact_path = str (artifact ) if artifact is not None else None ,
218256 )
219257
220258
259+ def _remove_artifact_files (paths ) -> None :
260+ """Best-effort delete of artifact files; ignore missing entries."""
261+ for raw in paths :
262+ if not raw :
263+ continue
264+ try :
265+ Path (raw ).unlink ()
266+ except FileNotFoundError :
267+ continue
268+ except OSError as error :
269+ autocontrol_logger .warning (
270+ "failed to remove artifact %r: %r" , raw , error ,
271+ )
272+
273+
221274default_history_store = HistoryStore (path = _default_history_path ())
0 commit comments