vinta-ai-workflows 0.1.7

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +830 -0
  2. package/LICENSE +21 -0
  3. package/README.md +547 -0
  4. package/package.json +27 -0
  5. package/schemas/README.md +84 -0
  6. package/schemas/mcp-preflight-cache.v1.schema.json +50 -0
  7. package/schemas/prs-context-comments.v1.schema.json +51 -0
  8. package/schemas/prs-context-frontmatter.v1.schema.json +77 -0
  9. package/schemas/sub-agent.v1.schema.json +105 -0
  10. package/schemas/vinta-ai-workflows-config.v1.schema.json +283 -0
  11. package/skills/vinta-analyze-codebase/SKILL.md +325 -0
  12. package/skills/vinta-bootstrap-ai-tools/SKILL.md +375 -0
  13. package/skills/vinta-bootstrap-ai-tools/resources/stacks/README.md +50 -0
  14. package/skills/vinta-bootstrap-ai-tools/resources/stacks/django/notes.md +52 -0
  15. package/skills/vinta-bootstrap-ai-tools/resources/stacks/medplum/notes.md +45 -0
  16. package/skills/vinta-bootstrap-ai-tools/resources/stacks/nextjs-app-router/notes.md +35 -0
  17. package/skills/vinta-bootstrap-ai-tools/resources/stacks/python-package/notes.md +31 -0
  18. package/skills/vinta-bootstrap-ai-tools/resources/stacks/typescript-monorepo/notes.md +32 -0
  19. package/skills/vinta-derive-skills/SKILL.md +200 -0
  20. package/skills/vinta-derive-skills/resources/amend-plan-template.md +355 -0
  21. package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/SKILL.md +292 -0
  22. package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/resources/one_off_script_base.py +602 -0
  23. package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/resources/one_off_script_base.ts +624 -0
  24. package/skills/vinta-derive-skills/resources/foundation-skills/create-qa-use-cases/SKILL.md +232 -0
  25. package/skills/vinta-derive-skills/resources/foundation-skills/create-spec/SKILL.md +235 -0
  26. package/skills/vinta-derive-skills/resources/foundation-skills/open-pr-from-context/SKILL.md +78 -0
  27. package/skills/vinta-derive-skills/resources/foundation-skills/open-pr-from-context/scripts/open-pr.sh +357 -0
  28. package/skills/vinta-derive-skills/resources/foundation-skills/plan-feature/SKILL.md +418 -0
  29. package/skills/vinta-derive-skills/resources/implement-plan-template.md +375 -0
  30. package/skills/vinta-derive-skills/resources/prs-context-template.md +91 -0
  31. package/skills/vinta-derive-skills/resources/systematic-debugging-mcp-tools.md +125 -0
  32. package/skills/vinta-derive-skills/resources/systematic-debugging-template.md +167 -0
  33. package/skills/vinta-derive-subagents/SKILL.md +192 -0
  34. package/skills/vinta-install-ai-tools-setup/SKILL.md +138 -0
  35. package/skills/vinta-install-ai-tools-setup/resources/setup-ai-tools.mjs +392 -0
  36. package/skills/vinta-migrate-plans-specs/SKILL.md +229 -0
  37. package/skills/vinta-sync-ai-tools/SKILL.md +184 -0
  38. package/skills/vinta-update-project-skills/SKILL.md +178 -0
  39. package/skills/vinta-write-agents-md/SKILL.md +192 -0
  40. package/vinta-ai-workflows.mjs +316 -0
@@ -0,0 +1,602 @@
1
+ """
2
+ BaseOneOffScript — canonical scaffold for a Vinta one-off operational script.
3
+
4
+ Copy this file once into your project at `<scripts_dir>/_base.py` (default
5
+ `scripts/one_off/_base.py`) and reuse from every script. The `Runtime`
6
+ interface below is what `run-one-off-script-<stack>` skills override to plug
7
+ the script into a stack-specific surface (Django mgmt command, Jupyter
8
+ notebook, Medplum bot, Vercel Function, K8s Job, etc). The default
9
+ `LocalRuntime` covers a plain CLI invocation with filesystem state, PID-file
10
+ single-instance lease, SIGINT/SIGTERM stop handling, and optional S3 upload.
11
+
12
+ Subclasses override only the per-script hooks:
13
+ - describe() -> str
14
+ - iter_targets() -> Iterator[T]
15
+ - process(item: T) -> None
16
+ - item_id(item: T) -> str
17
+ - tables_touched() -> list[str] (destructive scripts only)
18
+ - snapshot(item: T) -> dict[str, dict] (destructive scripts only)
19
+ - apply_restore_row(table, row) -> None (only if restore needed)
20
+
21
+ Engine methods (run, _safe_process, _write_backup) read from the Runtime —
22
+ DO NOT override them.
23
+
24
+ See `add-one-off-script/SKILL.md` for the full contract this file enforces.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import abc
30
+ import argparse
31
+ import csv
32
+ import logging
33
+ import os
34
+ import signal
35
+ import sys
36
+ import threading
37
+ from collections.abc import Callable, Iterator
38
+ from dataclasses import dataclass
39
+ from datetime import datetime, timezone
40
+ from pathlib import Path
41
+ from typing import Any, Generic, TypeVar
42
+
43
+ try:
44
+ import boto3 # type: ignore
45
+ _BOTO3_AVAILABLE = True
46
+ except ImportError:
47
+ boto3 = None # type: ignore
48
+ _BOTO3_AVAILABLE = False
49
+
50
+
51
+ T = TypeVar("T")
52
+
53
+
54
+ # ============================================================================
55
+ # Configuration
56
+ # ============================================================================
57
+
58
+
59
+ @dataclass
60
+ class ScriptConfig:
61
+ """Per-script configuration. Build once in the script's `if __name__ == "__main__"` block."""
62
+
63
+ name: str
64
+ """Stable identifier — typically the parent folder name (`<YYYY-MM-DD>-<descriptive-kebab>`)."""
65
+
66
+ log_dir: Path = Path(".vinta-ai-workflows/one-off-runs")
67
+ """Parent dir for run state. Per-script files land in `<log_dir>/<name>/`. Must be writable."""
68
+
69
+ batch_size: int = 500
70
+ """Rows per chunk in iter_targets. Caps in-memory working set."""
71
+
72
+ csv_max_cells: int = 1_000_000
73
+ """Roll over to a new CSV chunk when (rows * cols) would exceed this."""
74
+
75
+ fsync_every: int = 50
76
+ """fsync the log + processed-items file every N items."""
77
+
78
+
79
+ # ============================================================================
80
+ # Runtime interface
81
+ # ============================================================================
82
+
83
+
84
+ class Runtime(abc.ABC):
85
+ """Pluggable surface the engine calls into. Default = `LocalRuntime`.
86
+
87
+ Stack-specific runners ship subclasses that adapt the contract to their
88
+ surface. A runtime owns: log emission + flush, single-instance lease,
89
+ stop signal, processed-id persistence, where CSV chunks land, and final
90
+ artifact upload.
91
+
92
+ Implementations are NOT thread-safe by default — the engine runs single
93
+ threaded, and subclasses that need pooling are expected to add their
94
+ own locking.
95
+ """
96
+
97
+ def __init__(self, config: ScriptConfig) -> None:
98
+ self.config = config
99
+ self.run_dir = config.log_dir / config.name
100
+ self.run_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ # ---- lifecycle ----
103
+
104
+ @abc.abstractmethod
105
+ def acquire_lease(self) -> None:
106
+ """Raise RuntimeError if another instance holds the lease."""
107
+
108
+ @abc.abstractmethod
109
+ def release_lease(self) -> None:
110
+ """Best-effort release. Must be idempotent."""
111
+
112
+ @abc.abstractmethod
113
+ def install_stop_handler(self, on_stop: Callable[[str], None]) -> None:
114
+ """Wire the runtime's interrupt source (signal, bot cancel, function abort)
115
+ to call `on_stop(reason)` exactly once. The engine expects a second
116
+ invocation of the stop source to force-exit."""
117
+
118
+ @abc.abstractmethod
119
+ def should_stop(self) -> bool:
120
+ """True once the stop handler has fired."""
121
+
122
+ # ---- logging ----
123
+
124
+ @abc.abstractmethod
125
+ def log(self, level: str, message: str) -> None:
126
+ """Emit one log line. Required levels: INFO, WARN, ERROR."""
127
+
128
+ @abc.abstractmethod
129
+ def fsync_log(self) -> None:
130
+ """Best-effort durable flush of the log sink."""
131
+
132
+ # ---- processed-items log (resume) ----
133
+
134
+ @abc.abstractmethod
135
+ def load_processed_ids(self) -> set[str]:
136
+ """Return all item ids that previous runs marked complete."""
137
+
138
+ @abc.abstractmethod
139
+ def mark_processed(self, item_id: str) -> None:
140
+ """Append + fsync `item_id`. Crash-safe."""
141
+
142
+ # ---- artifact paths ----
143
+
144
+ def artifact_path(self, filename: str) -> Path:
145
+ """Return the local path to write a per-run artifact (CSV chunk).
146
+ Default lands under `<log_dir>/<name>/`. Subclasses may override
147
+ for runtimes that stream artifacts directly to remote storage."""
148
+ return self.run_dir / filename
149
+
150
+ @abc.abstractmethod
151
+ def list_run_artifacts(self) -> list[Path]:
152
+ """Every artifact the engine produced this run — log, processed log,
153
+ CSV chunks. Used by `upload_run_artifacts()`."""
154
+
155
+ # ---- final upload ----
156
+
157
+ @abc.abstractmethod
158
+ def upload_run_artifacts(self) -> None:
159
+ """Best-effort upload of the run's artifacts to whatever durable
160
+ sink the runtime owns (S3, Blob, none). Failure must be logged but
161
+ not raised — the on-disk copy is authoritative."""
162
+
163
+
164
+ # ============================================================================
165
+ # LocalRuntime — default for a plain CLI invocation
166
+ # ============================================================================
167
+
168
+
169
+ class LocalRuntime(Runtime):
170
+ """Filesystem + PID-file lease + SIGINT/SIGTERM + optional S3 upload.
171
+
172
+ Reads two env vars at construction:
173
+ ONE_OFF_S3_BUCKET — destination bucket. Empty / unset → upload skipped.
174
+ ONE_OFF_S3_PREFIX — key prefix. Default `one-off-runs/<name>/`.
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ config: ScriptConfig,
180
+ s3_bucket: str | None = None,
181
+ s3_prefix: str | None = None,
182
+ ) -> None:
183
+ super().__init__(config)
184
+ self._stop = threading.Event()
185
+ self._stop_count = 0
186
+ self._setup_logging()
187
+ self._processed_path = self.run_dir / "processed.txt"
188
+ self._lease_path = self.run_dir / "lease.pid"
189
+ self._s3_bucket = s3_bucket if s3_bucket is not None else os.environ.get("ONE_OFF_S3_BUCKET") or None
190
+ default_prefix = os.environ.get("ONE_OFF_S3_PREFIX") or f"one-off-runs/{config.name}/"
191
+ self._s3_prefix = (s3_prefix if s3_prefix is not None else default_prefix).rstrip("/") + "/"
192
+
193
+ # ---- lease ----
194
+
195
+ def acquire_lease(self) -> None:
196
+ if self._lease_path.exists():
197
+ existing = self._lease_path.read_text().strip()
198
+ if existing.isdigit() and _pid_alive(int(existing)):
199
+ raise RuntimeError(
200
+ f"lease {self._lease_path} held by live process {existing} — "
201
+ "another instance is running. Stop it before starting a new run."
202
+ )
203
+ self._lease_path.write_text(str(os.getpid()))
204
+
205
+ def release_lease(self) -> None:
206
+ try:
207
+ self._lease_path.unlink()
208
+ except FileNotFoundError:
209
+ pass
210
+
211
+ # ---- stop ----
212
+
213
+ def install_stop_handler(self, on_stop: Callable[[str], None]) -> None:
214
+ def handler(signum, _frame):
215
+ self._stop_count += 1
216
+ if self._stop_count >= 2:
217
+ self.log("ERROR", f"second signal {signum} received during shutdown — forcing exit")
218
+ self.release_lease()
219
+ os._exit(130)
220
+ self._stop.set()
221
+ on_stop(f"signal {signum}")
222
+
223
+ signal.signal(signal.SIGINT, handler)
224
+ signal.signal(signal.SIGTERM, handler)
225
+
226
+ def should_stop(self) -> bool:
227
+ return self._stop.is_set()
228
+
229
+ # ---- logging ----
230
+
231
+ def _setup_logging(self) -> None:
232
+ self._log_path = self.run_dir / "run.log"
233
+ self._logger = logging.getLogger(f"one-off:{self.config.name}")
234
+ self._logger.setLevel(logging.INFO)
235
+ self._logger.handlers.clear()
236
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
237
+ self._file_handler = logging.FileHandler(self._log_path)
238
+ self._file_handler.setFormatter(fmt)
239
+ sh = logging.StreamHandler(sys.stdout)
240
+ sh.setFormatter(fmt)
241
+ self._logger.addHandler(self._file_handler)
242
+ self._logger.addHandler(sh)
243
+ self._logger.propagate = False
244
+
245
+ def log(self, level: str, message: str) -> None:
246
+ getattr(self._logger, level.lower(), self._logger.info)(message)
247
+
248
+ def fsync_log(self) -> None:
249
+ try:
250
+ self._file_handler.flush()
251
+ os.fsync(self._file_handler.stream.fileno())
252
+ except (OSError, ValueError):
253
+ pass
254
+
255
+ # ---- processed log ----
256
+
257
+ def load_processed_ids(self) -> set[str]:
258
+ if not self._processed_path.exists():
259
+ return set()
260
+ with self._processed_path.open() as f:
261
+ return {line.strip() for line in f if line.strip()}
262
+
263
+ def mark_processed(self, item_id: str) -> None:
264
+ with self._processed_path.open("a") as f:
265
+ f.write(f"{item_id}\n")
266
+ f.flush()
267
+ os.fsync(f.fileno())
268
+
269
+ # ---- artifacts ----
270
+
271
+ def list_run_artifacts(self) -> list[Path]:
272
+ return [p for p in self.run_dir.iterdir() if p.is_file() and p.name != "lease.pid"]
273
+
274
+ def upload_run_artifacts(self) -> None:
275
+ if not self._s3_bucket:
276
+ self.log("INFO", "s3: no bucket configured (ONE_OFF_S3_BUCKET unset), skipping upload")
277
+ return
278
+ if not _BOTO3_AVAILABLE:
279
+ self.log("WARN", "s3: boto3 not installed, skipping upload")
280
+ return
281
+ try:
282
+ client = boto3.client("s3")
283
+ uploaded = 0
284
+ for path in self.list_run_artifacts():
285
+ client.upload_file(str(path), self._s3_bucket, self._s3_prefix + path.name)
286
+ uploaded += 1
287
+ self.log("INFO", f"s3: uploaded {uploaded} file(s) to s3://{self._s3_bucket}/{self._s3_prefix}")
288
+ except Exception as exc:
289
+ self.log("ERROR", f"s3: upload FAILED — filesystem copy at {self.run_dir} is authoritative: {exc!r}")
290
+
291
+
292
+ # ============================================================================
293
+ # Engine
294
+ # ============================================================================
295
+
296
+
297
+ class BaseOneOffScript(Generic[T], abc.ABC):
298
+ """Subclass this. See module docstring for the full contract."""
299
+
300
+ # ---- subclass hooks (override these) ----
301
+
302
+ @abc.abstractmethod
303
+ def describe(self) -> str:
304
+ """One-paragraph description: what + why. Logged at startup."""
305
+
306
+ @abc.abstractmethod
307
+ def iter_targets(self) -> Iterator[T]:
308
+ """Yield work items. MUST be a generator — no .all(), no fetchall()."""
309
+
310
+ @abc.abstractmethod
311
+ def process(self, item: T) -> None:
312
+ """Per-item action. MUST check self.dry_run before any write."""
313
+
314
+ @abc.abstractmethod
315
+ def item_id(self, item: T) -> str:
316
+ """Stable string id for the item. Used for resume tracking + log lines."""
317
+
318
+ def tables_touched(self) -> list[str]:
319
+ """Tables this script writes to. Empty = additive only, no backup."""
320
+ return []
321
+
322
+ def snapshot(self, item: T) -> dict[str, dict[str, Any]]:
323
+ """Pre-mutation snapshot of the rows the script is about to change."""
324
+ return {}
325
+
326
+ def apply_restore_row(self, table: str, row: dict[str, str]) -> None:
327
+ """Apply one CSV row back to the source table during restore_from_backup()."""
328
+ raise NotImplementedError(f"restore not implemented for table {table!r}")
329
+
330
+ # ---- engine (do not override) ----
331
+
332
+ def __init__(
333
+ self,
334
+ config: ScriptConfig,
335
+ runtime: Runtime | None = None,
336
+ dry_run: bool = True,
337
+ resume: bool = False,
338
+ ) -> None:
339
+ self.config = config
340
+ self.runtime: Runtime = runtime if runtime is not None else LocalRuntime(config)
341
+ self.dry_run = dry_run
342
+ self.resume = resume
343
+ self._processed_ids: set[str] = set()
344
+ self._csv_writers: dict[str, _CsvChunkWriter] = {}
345
+ self._items_since_fsync = 0
346
+
347
+ self.runtime.acquire_lease()
348
+ self.runtime.install_stop_handler(self._on_stop)
349
+ if self.resume:
350
+ self._processed_ids = self.runtime.load_processed_ids()
351
+
352
+ def _on_stop(self, reason: str) -> None:
353
+ self.runtime.log("WARN", f"stop signal received ({reason}) — finishing current item then exiting cleanly")
354
+
355
+ # ---- public entry point ----
356
+
357
+ def execute(self, dry_run: bool | None = None) -> int:
358
+ """Run the script. `dry_run` overrides the constructor default if provided."""
359
+ if dry_run is not None:
360
+ self.dry_run = dry_run
361
+ return self.run()
362
+
363
+ def run(self) -> int:
364
+ log = self.runtime.log
365
+ log("INFO", "=" * 72)
366
+ log("INFO", f"script: {self.config.name}")
367
+ log("INFO", f"description: {self.describe()}")
368
+ log("INFO", f"mode: {'DRY-RUN (no writes)' if self.dry_run else 'APPLY (writes enabled)'}")
369
+ log("INFO", f"runtime: {type(self.runtime).__name__}")
370
+ log("INFO", f"started_at: {_utcnow()}")
371
+ if self.resume:
372
+ log("INFO", f"resume: skipping {len(self._processed_ids)} previously-completed items")
373
+ if self.tables_touched():
374
+ log("INFO", f"tables_touched: {', '.join(self.tables_touched())}")
375
+
376
+ processed = 0
377
+ skipped = 0
378
+ failed = 0
379
+ try:
380
+ for item in self.iter_targets():
381
+ if self.runtime.should_stop():
382
+ log("WARN", f"stop flag set; flushing and exiting cleanly after {processed} items")
383
+ break
384
+ iid = self.item_id(item)
385
+ if self.resume and iid in self._processed_ids:
386
+ skipped += 1
387
+ continue
388
+ ok = self._safe_process(item, iid)
389
+ if ok:
390
+ processed += 1
391
+ else:
392
+ failed += 1
393
+
394
+ self._items_since_fsync += 1
395
+ if self._items_since_fsync >= self.config.fsync_every:
396
+ self.runtime.fsync_log()
397
+ self._items_since_fsync = 0
398
+ finally:
399
+ log("INFO", "flushing csv backups + log handlers")
400
+ self._flush()
401
+ log("INFO", f"summary: processed={processed} skipped(resume)={skipped} failed={failed}")
402
+ log("INFO", f"finished_at: {_utcnow()}")
403
+ self.runtime.upload_run_artifacts()
404
+ self.runtime.release_lease()
405
+ return 0 if failed == 0 else 1
406
+
407
+ def restore_from_backup(self, backup_dir: Path) -> int:
408
+ """Read every <table>.NNN.csv under backup_dir and apply each row back."""
409
+ log = self.runtime.log
410
+ backup_dir = Path(backup_dir)
411
+ if not backup_dir.is_dir():
412
+ raise FileNotFoundError(f"backup dir not found: {backup_dir}")
413
+ files = sorted(p for p in backup_dir.iterdir() if p.suffix == ".csv")
414
+ if not files:
415
+ raise FileNotFoundError(f"no backup CSV files in {backup_dir}")
416
+ log("INFO", f"restore: applying {len(files)} backup file(s)")
417
+ total = 0
418
+ for path in files:
419
+ # filename pattern: <table>.NNN.csv
420
+ table = path.stem.rsplit(".", 1)[0]
421
+ log("INFO", f"restore: file={path.name} table={table}")
422
+ with path.open(newline="") as f:
423
+ reader = csv.DictReader(f)
424
+ for row in reader:
425
+ self.apply_restore_row(table, row)
426
+ total += 1
427
+ if total % 1000 == 0:
428
+ log("INFO", f"restore: {total} rows applied")
429
+ log("INFO", f"restore: complete, {total} rows applied")
430
+ return total
431
+
432
+ # ---- internals ----
433
+
434
+ def _safe_process(self, item: T, iid: str) -> bool:
435
+ log = self.runtime.log
436
+ try:
437
+ if self.tables_touched():
438
+ snap = self.snapshot(item)
439
+ if not snap:
440
+ log("WARN", f"item {iid} declared tables_touched but snapshot() returned empty — skipping")
441
+ return False
442
+ if self.dry_run:
443
+ log("INFO", f"[dry-run] would back up tables {list(snap.keys())} for item {iid}")
444
+ else:
445
+ self._write_backup(snap)
446
+
447
+ if self.dry_run:
448
+ log("INFO", f"[dry-run] would process item {iid}")
449
+ return True
450
+
451
+ self.process(item)
452
+ self.runtime.mark_processed(iid)
453
+ return True
454
+ except Exception as exc:
455
+ log("ERROR", f"item {iid} FAILED — left for next --resume run: {exc!r}")
456
+ return False
457
+
458
+ def _write_backup(self, snap: dict[str, dict[str, Any]]) -> None:
459
+ for table, row in snap.items():
460
+ writer = self._csv_writers.get(table)
461
+ if writer is None:
462
+ writer = _CsvChunkWriter(
463
+ runtime=self.runtime,
464
+ table=table,
465
+ max_cells=self.config.csv_max_cells,
466
+ )
467
+ self._csv_writers[table] = writer
468
+ writer.write(row)
469
+
470
+ def _flush(self) -> None:
471
+ for w in self._csv_writers.values():
472
+ w.close()
473
+ self.runtime.fsync_log()
474
+
475
+ # ---- CLI helper ----
476
+
477
+ @classmethod
478
+ def main(cls, build_config: Callable[[], ScriptConfig], build_runtime: Callable[[ScriptConfig], Runtime] | None = None) -> int:
479
+ """Convenience CLI entry point used by the local invocation surface.
480
+
481
+ Stack-specific runners (Django mgmt command, Jupyter, Medplum bot,
482
+ Vercel Function) call the engine directly — they do NOT use this
483
+ helper. Use this only when invoking the script as a plain Python
484
+ process from a shell.
485
+ """
486
+ parser = argparse.ArgumentParser()
487
+ parser.add_argument("--apply", action="store_true", help="Actually write (default: dry-run).")
488
+ parser.add_argument("--resume", action="store_true", help="Skip items already in the processed log.")
489
+ parser.add_argument("--status", action="store_true", help="Print run status from lease + processed log + tail.")
490
+ parser.add_argument("--restore", metavar="BACKUP_DIR", help="Restore from backup CSVs in BACKUP_DIR.")
491
+ args = parser.parse_args()
492
+
493
+ config = build_config()
494
+
495
+ if args.status:
496
+ return _print_status(config)
497
+
498
+ runtime = build_runtime(config) if build_runtime else LocalRuntime(config)
499
+ instance = cls(config=config, runtime=runtime, dry_run=not args.apply, resume=args.resume)
500
+ if args.restore:
501
+ instance.restore_from_backup(Path(args.restore))
502
+ return 0
503
+ return instance.execute()
504
+
505
+
506
+ # ============================================================================
507
+ # helpers
508
+ # ============================================================================
509
+
510
+
511
+ class _CsvChunkWriter:
512
+ """Streaming CSV writer that rolls over to a new file when cells > max_cells."""
513
+
514
+ def __init__(self, runtime: Runtime, table: str, max_cells: int) -> None:
515
+ self.runtime = runtime
516
+ self.table = table
517
+ self.max_cells = max_cells
518
+ self.files: list[Path] = []
519
+ self._chunk_idx = 0
520
+ self._fp: Any = None
521
+ self._writer: Any = None
522
+ self._cells_in_chunk = 0
523
+ self._fieldnames: list[str] | None = None
524
+
525
+ def write(self, row: dict[str, Any]) -> None:
526
+ if self._fieldnames is None:
527
+ self._fieldnames = list(row.keys())
528
+ cols = len(self._fieldnames)
529
+ if self._writer is None or self._cells_in_chunk + cols > self.max_cells:
530
+ self._roll()
531
+ self._writer.writerow(row) # type: ignore[union-attr]
532
+ self._cells_in_chunk += cols
533
+
534
+ def close(self) -> None:
535
+ if self._fp is not None:
536
+ self._fp.flush()
537
+ try:
538
+ os.fsync(self._fp.fileno())
539
+ except (OSError, ValueError):
540
+ pass
541
+ self._fp.close()
542
+ self._fp = None
543
+ self._writer = None
544
+
545
+ def _roll(self) -> None:
546
+ self.close()
547
+ self._chunk_idx += 1
548
+ path = self.runtime.artifact_path(f"{self.table}.{self._chunk_idx:03d}.csv")
549
+ self.files.append(path)
550
+ self._fp = path.open("w", newline="")
551
+ self._writer = csv.DictWriter(self._fp, fieldnames=self._fieldnames or [])
552
+ self._writer.writeheader()
553
+ self._cells_in_chunk = len(self._fieldnames or [])
554
+
555
+
556
+ def _utcnow() -> str:
557
+ return datetime.now(timezone.utc).isoformat()
558
+
559
+
560
+ def _pid_alive(pid: int) -> bool:
561
+ if pid <= 0:
562
+ return False
563
+ try:
564
+ os.kill(pid, 0)
565
+ except ProcessLookupError:
566
+ return False
567
+ except PermissionError:
568
+ return True
569
+ return True
570
+
571
+
572
+ def _print_status(config: ScriptConfig) -> int:
573
+ run_dir = config.log_dir / config.name
574
+ lease = run_dir / "lease.pid"
575
+ log_path = run_dir / "run.log"
576
+ processed = run_dir / "processed.txt"
577
+
578
+ print(f"script: {config.name}")
579
+ print(f"run_dir: {run_dir}")
580
+ if lease.exists():
581
+ pid = lease.read_text().strip()
582
+ alive = _pid_alive(int(pid)) if pid.isdigit() else False
583
+ print(f"lease: {pid} ({'running' if alive else 'STALE — process gone'})")
584
+ else:
585
+ print("lease: (no lease file — script not running)")
586
+
587
+ if processed.exists():
588
+ with processed.open() as f:
589
+ done = sum(1 for _ in f)
590
+ print(f"processed items: {done}")
591
+ else:
592
+ print("processed items: 0 (no resume log yet)")
593
+
594
+ if log_path.exists():
595
+ print(f"log tail ({log_path}):")
596
+ with log_path.open() as f:
597
+ tail = f.readlines()[-20:]
598
+ for line in tail:
599
+ print(f" {line.rstrip()}")
600
+ else:
601
+ print("(no log file yet)")
602
+ return 0