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.
- package/CHANGELOG.md +830 -0
- package/LICENSE +21 -0
- package/README.md +547 -0
- package/package.json +27 -0
- package/schemas/README.md +84 -0
- package/schemas/mcp-preflight-cache.v1.schema.json +50 -0
- package/schemas/prs-context-comments.v1.schema.json +51 -0
- package/schemas/prs-context-frontmatter.v1.schema.json +77 -0
- package/schemas/sub-agent.v1.schema.json +105 -0
- package/schemas/vinta-ai-workflows-config.v1.schema.json +283 -0
- package/skills/vinta-analyze-codebase/SKILL.md +325 -0
- package/skills/vinta-bootstrap-ai-tools/SKILL.md +375 -0
- package/skills/vinta-bootstrap-ai-tools/resources/stacks/README.md +50 -0
- package/skills/vinta-bootstrap-ai-tools/resources/stacks/django/notes.md +52 -0
- package/skills/vinta-bootstrap-ai-tools/resources/stacks/medplum/notes.md +45 -0
- package/skills/vinta-bootstrap-ai-tools/resources/stacks/nextjs-app-router/notes.md +35 -0
- package/skills/vinta-bootstrap-ai-tools/resources/stacks/python-package/notes.md +31 -0
- package/skills/vinta-bootstrap-ai-tools/resources/stacks/typescript-monorepo/notes.md +32 -0
- package/skills/vinta-derive-skills/SKILL.md +200 -0
- package/skills/vinta-derive-skills/resources/amend-plan-template.md +355 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/SKILL.md +292 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/resources/one_off_script_base.py +602 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/add-one-off-script/resources/one_off_script_base.ts +624 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/create-qa-use-cases/SKILL.md +232 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/create-spec/SKILL.md +235 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/open-pr-from-context/SKILL.md +78 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/open-pr-from-context/scripts/open-pr.sh +357 -0
- package/skills/vinta-derive-skills/resources/foundation-skills/plan-feature/SKILL.md +418 -0
- package/skills/vinta-derive-skills/resources/implement-plan-template.md +375 -0
- package/skills/vinta-derive-skills/resources/prs-context-template.md +91 -0
- package/skills/vinta-derive-skills/resources/systematic-debugging-mcp-tools.md +125 -0
- package/skills/vinta-derive-skills/resources/systematic-debugging-template.md +167 -0
- package/skills/vinta-derive-subagents/SKILL.md +192 -0
- package/skills/vinta-install-ai-tools-setup/SKILL.md +138 -0
- package/skills/vinta-install-ai-tools-setup/resources/setup-ai-tools.mjs +392 -0
- package/skills/vinta-migrate-plans-specs/SKILL.md +229 -0
- package/skills/vinta-sync-ai-tools/SKILL.md +184 -0
- package/skills/vinta-update-project-skills/SKILL.md +178 -0
- package/skills/vinta-write-agents-md/SKILL.md +192 -0
- 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
|