github-proyect-tracking 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +456 -2
- package/bin/build.js +42 -0
- package/bin/cli.js +317 -0
- package/package.json +35 -4
- package/skill/SKILL.md +171 -0
- package/skill/references/api.md +163 -0
- package/skill/references/architecture.md +171 -0
- package/skill/scripts/tracking.py +196 -0
- package/skill/scripts/tracking_core/__init__.py +1 -0
- package/skill/scripts/tracking_core/commands.py +846 -0
- package/skill/scripts/tracking_core/common.py +163 -0
- package/skill/scripts/tracking_core/events.py +204 -0
- package/skill/scripts/tracking_core/github.py +315 -0
- package/skill/scripts/tracking_core/state.py +379 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Persistencia local, esquema v2, migración v1 y locks con heartbeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .common import (
|
|
15
|
+
EXIT_NO_PROCESS, EXIT_RUNTIME, EXIT_VALIDATION, LOCK_STALE_SECONDS,
|
|
16
|
+
LOCK_WAIT_SECONDS, PROCESS_STATES, SCHEMA_VERSION, STATE_DIR_ENV,
|
|
17
|
+
SUPPORTED_SCHEMA_VERSIONS, SkillError, now_iso, validate_repo_url,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def state_dir():
|
|
22
|
+
override = os.environ.get(STATE_DIR_ENV)
|
|
23
|
+
if override:
|
|
24
|
+
return Path(override).expanduser()
|
|
25
|
+
return Path.home() / ".github-proyect-tracking"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def state_file_for(owner, repo):
|
|
29
|
+
return state_dir() / f"{owner}__{repo}.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _corrupt(path, why):
|
|
33
|
+
return SkillError(EXIT_RUNTIME, "STATE_CORRUPT",
|
|
34
|
+
f"archivo de estado corrupto ({path}): {why}",
|
|
35
|
+
{"hint": "usa 'clear --hard' para regenerarlo y 'adopt' "
|
|
36
|
+
"para recuperar el Issue remoto"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _valid_state_timestamp(value):
|
|
40
|
+
if not isinstance(value, str) or not value:
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
44
|
+
return True
|
|
45
|
+
except ValueError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def migrate_state(state):
|
|
50
|
+
if state.get("schema_version") != 1:
|
|
51
|
+
return state
|
|
52
|
+
migrated = dict(state)
|
|
53
|
+
migrated["schema_version"] = SCHEMA_VERSION
|
|
54
|
+
if migrated.get("completed_steps") is None:
|
|
55
|
+
migrated["completed_steps"] = []
|
|
56
|
+
if migrated.get("history") is None:
|
|
57
|
+
migrated["history"] = []
|
|
58
|
+
migrated["history"] = [dict(item) if isinstance(item, dict) else item
|
|
59
|
+
for item in migrated["history"]]
|
|
60
|
+
for item in migrated["history"]:
|
|
61
|
+
if isinstance(item, dict) and "outcome" not in item:
|
|
62
|
+
item["outcome"] = "CANCELADO" if item.get("forced") else "FINALIZADO"
|
|
63
|
+
return migrated
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_state(state, path):
|
|
67
|
+
if not isinstance(state, dict):
|
|
68
|
+
raise _corrupt(path, "no es un objeto JSON")
|
|
69
|
+
version = state.get("schema_version")
|
|
70
|
+
if version not in SUPPORTED_SCHEMA_VERSIONS:
|
|
71
|
+
raise SkillError(EXIT_RUNTIME, "STATE_SCHEMA",
|
|
72
|
+
f"schema_version {version!r} no soportado "
|
|
73
|
+
f"(esperado 1 o {SCHEMA_VERSION}) en {path}",
|
|
74
|
+
{"hint": "usa clear --hard y adopt para reconstruir"})
|
|
75
|
+
|
|
76
|
+
def require_string(key, allow_none=True, allow_empty=False):
|
|
77
|
+
value = state.get(key)
|
|
78
|
+
if value is None and allow_none:
|
|
79
|
+
return
|
|
80
|
+
if (not isinstance(value, str)
|
|
81
|
+
or (not allow_empty and not value.strip())):
|
|
82
|
+
raise _corrupt(path, f"{key} inválido")
|
|
83
|
+
|
|
84
|
+
active = state.get("active_process_id")
|
|
85
|
+
if active is not None and (type(active) is not int or active <= 0):
|
|
86
|
+
raise _corrupt(path, f"active_process_id inválido: {active!r}")
|
|
87
|
+
process_state = state.get("process_state")
|
|
88
|
+
if process_state not in PROCESS_STATES:
|
|
89
|
+
raise _corrupt(path, f"process_state inválido: {process_state!r}")
|
|
90
|
+
if active is not None and process_state not in ("RUNNING", "ERROR"):
|
|
91
|
+
raise _corrupt(path, "un proceso activo debe estar RUNNING o ERROR")
|
|
92
|
+
if active is None and process_state in ("RUNNING", "ERROR"):
|
|
93
|
+
raise _corrupt(path, "un proceso RUNNING o ERROR requiere active_process_id")
|
|
94
|
+
for key in ("owner", "repo"):
|
|
95
|
+
require_string(key, allow_none=False)
|
|
96
|
+
for key in ("execution_id", "issue_url", "current_step"):
|
|
97
|
+
require_string(key)
|
|
98
|
+
if active is not None:
|
|
99
|
+
require_string("execution_id", allow_none=False)
|
|
100
|
+
if state.get("github_project_url") is not None:
|
|
101
|
+
require_string("github_project_url")
|
|
102
|
+
target = validate_repo_url(state["github_project_url"])
|
|
103
|
+
if target != (state["owner"].lower(), state["repo"].lower()):
|
|
104
|
+
raise _corrupt(path, "github_project_url no coincide con owner/repo")
|
|
105
|
+
|
|
106
|
+
for key in ("plan", "completed_steps"):
|
|
107
|
+
value = state.get(key)
|
|
108
|
+
if value is None and key == "plan":
|
|
109
|
+
continue
|
|
110
|
+
if (not isinstance(value, list)
|
|
111
|
+
or not all(isinstance(item, str) and item.strip()
|
|
112
|
+
for item in value)):
|
|
113
|
+
raise _corrupt(path, f"{key} inválido (lista de cadenas no vacías)")
|
|
114
|
+
if len(value) != len(set(value)):
|
|
115
|
+
raise _corrupt(path, f"{key} contiene elementos duplicados")
|
|
116
|
+
require_string("github_token", allow_empty=True)
|
|
117
|
+
for key in ("created_at", "updated_at", "cleared_at"):
|
|
118
|
+
if state.get(key) is not None and not _valid_state_timestamp(state[key]):
|
|
119
|
+
raise _corrupt(path, f"{key} no es una fecha ISO válida")
|
|
120
|
+
if state.get("adopted") is not None and not isinstance(state["adopted"], bool):
|
|
121
|
+
raise _corrupt(path, "adopted debe ser booleano")
|
|
122
|
+
|
|
123
|
+
history = state.get("history")
|
|
124
|
+
if history is not None:
|
|
125
|
+
if not isinstance(history, list):
|
|
126
|
+
raise _corrupt(path, "history inválido (lista de objetos)")
|
|
127
|
+
for item in history:
|
|
128
|
+
if not isinstance(item, dict):
|
|
129
|
+
raise _corrupt(path, "history contiene una entrada que no es objeto")
|
|
130
|
+
number = item.get("issue_number")
|
|
131
|
+
if type(number) is not int or number <= 0:
|
|
132
|
+
raise _corrupt(path, "history.issue_number inválido")
|
|
133
|
+
for key in ("execution_id", "issue_url"):
|
|
134
|
+
if (not isinstance(item.get(key), str)
|
|
135
|
+
or not item.get(key).strip()):
|
|
136
|
+
raise _corrupt(path, f"history.{key} inválido")
|
|
137
|
+
if not _valid_state_timestamp(item.get("finished_at")):
|
|
138
|
+
raise _corrupt(path, "history.finished_at inválido")
|
|
139
|
+
if not isinstance(item.get("forced"), bool):
|
|
140
|
+
raise _corrupt(path, "history.forced debe ser booleano")
|
|
141
|
+
if item.get("outcome") not in ("FINALIZADO", "CANCELADO"):
|
|
142
|
+
raise _corrupt(path, "history.outcome inválido")
|
|
143
|
+
if item["forced"] != (item["outcome"] == "CANCELADO"):
|
|
144
|
+
raise _corrupt(path, "history.forced contradice history.outcome")
|
|
145
|
+
|
|
146
|
+
aborted = state.get("aborted")
|
|
147
|
+
if aborted is not None:
|
|
148
|
+
if (not isinstance(aborted, dict)
|
|
149
|
+
or type(aborted.get("issue_number")) is not int
|
|
150
|
+
or aborted["issue_number"] <= 0
|
|
151
|
+
or not isinstance(aborted.get("issue_url"), str)
|
|
152
|
+
or not _valid_state_timestamp(aborted.get("at"))):
|
|
153
|
+
raise _corrupt(path, "aborted inválido")
|
|
154
|
+
last_error = state.get("last_error")
|
|
155
|
+
if last_error is not None:
|
|
156
|
+
if (not isinstance(last_error, dict)
|
|
157
|
+
or not isinstance(last_error.get("step"), str)
|
|
158
|
+
or not last_error["step"].strip()
|
|
159
|
+
or not _valid_state_timestamp(last_error.get("at"))):
|
|
160
|
+
raise _corrupt(path, "last_error inválido")
|
|
161
|
+
resumed = state.get("last_resume")
|
|
162
|
+
if resumed is not None:
|
|
163
|
+
if not isinstance(resumed, dict):
|
|
164
|
+
raise _corrupt(path, "last_resume inválido")
|
|
165
|
+
if type(resumed.get("issue_number")) is not int \
|
|
166
|
+
or resumed["issue_number"] <= 0 \
|
|
167
|
+
or not _valid_state_timestamp(resumed.get("at")):
|
|
168
|
+
raise _corrupt(path, "last_resume requiere Issue y fecha válidos")
|
|
169
|
+
if resumed.get("verdict") not in (
|
|
170
|
+
"NONE", "ABORTED_EXTERNALLY", "START_FIRST", "FINISH_CLEANUP",
|
|
171
|
+
"RERUN_STEP", "NEXT_STEP", "ALL_STEPS_DONE",
|
|
172
|
+
"NEXT_STEP_AGENT_DEFINED"):
|
|
173
|
+
raise _corrupt(path, "last_resume.verdict inválido")
|
|
174
|
+
value = resumed.get("event_comment_id")
|
|
175
|
+
if value is not None and (type(value) is not int or value <= 0):
|
|
176
|
+
raise _corrupt(path, "last_resume.event_comment_id inválido")
|
|
177
|
+
for key in ("event_transition_id", "failed_step"):
|
|
178
|
+
value = resumed.get(key)
|
|
179
|
+
if value is not None and (not isinstance(value, str) or not value):
|
|
180
|
+
raise _corrupt(path, f"last_resume.{key} inválido")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_state(path):
|
|
184
|
+
try:
|
|
185
|
+
raw = path.read_text(encoding="utf-8")
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
return None
|
|
188
|
+
except OSError as error:
|
|
189
|
+
raise SkillError(EXIT_RUNTIME, "STATE_UNREADABLE",
|
|
190
|
+
f"no se pudo leer {path}: {error}")
|
|
191
|
+
try:
|
|
192
|
+
state = json.loads(raw)
|
|
193
|
+
except json.JSONDecodeError as error:
|
|
194
|
+
raise _corrupt(path, f"JSON inválido: {error}")
|
|
195
|
+
state = migrate_state(state)
|
|
196
|
+
validate_state(state, path)
|
|
197
|
+
return state
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def save_state(path, state):
|
|
201
|
+
state["schema_version"] = SCHEMA_VERSION
|
|
202
|
+
state["updated_at"] = now_iso()
|
|
203
|
+
validate_state(state, path)
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
try:
|
|
206
|
+
os.chmod(path.parent, 0o700)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex[:6]}.tmp")
|
|
210
|
+
try:
|
|
211
|
+
descriptor = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
212
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
213
|
+
stream.write(json.dumps(state, indent=2, ensure_ascii=False) + "\n")
|
|
214
|
+
stream.flush()
|
|
215
|
+
os.fsync(stream.fileno())
|
|
216
|
+
os.replace(tmp, path)
|
|
217
|
+
except OSError as error:
|
|
218
|
+
tmp.unlink(missing_ok=True)
|
|
219
|
+
raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED",
|
|
220
|
+
f"no se pudo guardar {path}: {error}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class StateLock:
|
|
224
|
+
"""Candado por repositorio con propietario y heartbeat durante toda su vida."""
|
|
225
|
+
|
|
226
|
+
HEARTBEAT_INTERVAL = 5.0
|
|
227
|
+
STALE_SECONDS = LOCK_STALE_SECONDS
|
|
228
|
+
WAIT_SECONDS = LOCK_WAIT_SECONDS
|
|
229
|
+
|
|
230
|
+
def __init__(self, path):
|
|
231
|
+
self.lockdir = path.parent / (path.name + ".lock")
|
|
232
|
+
self._owner_file = self.lockdir / "owner.json"
|
|
233
|
+
self._token = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
|
234
|
+
self._lost = False
|
|
235
|
+
self._stop_event = threading.Event()
|
|
236
|
+
self._worker = None
|
|
237
|
+
|
|
238
|
+
def _write_owner(self):
|
|
239
|
+
tmp = self.lockdir / f"owner.{self._token}.tmp"
|
|
240
|
+
with tmp.open("w", encoding="utf-8") as stream:
|
|
241
|
+
json.dump({"token": self._token, "pid": os.getpid(),
|
|
242
|
+
"ts": time.time()}, stream)
|
|
243
|
+
stream.flush()
|
|
244
|
+
os.fsync(stream.fileno())
|
|
245
|
+
os.replace(tmp, self._owner_file)
|
|
246
|
+
os.utime(self.lockdir, None)
|
|
247
|
+
|
|
248
|
+
def _still_owner(self):
|
|
249
|
+
try:
|
|
250
|
+
data = json.loads(self._owner_file.read_text(encoding="utf-8"))
|
|
251
|
+
return isinstance(data, dict) and data.get("token") == self._token
|
|
252
|
+
except (OSError, json.JSONDecodeError):
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
def heartbeat(self):
|
|
256
|
+
if self._lost:
|
|
257
|
+
return
|
|
258
|
+
if not self._still_owner():
|
|
259
|
+
self._lost = True
|
|
260
|
+
return
|
|
261
|
+
try:
|
|
262
|
+
self._write_owner()
|
|
263
|
+
except OSError:
|
|
264
|
+
self._lost = True
|
|
265
|
+
|
|
266
|
+
def _heartbeat_loop(self):
|
|
267
|
+
while not self._stop_event.wait(self.HEARTBEAT_INTERVAL):
|
|
268
|
+
self.heartbeat()
|
|
269
|
+
if self._lost:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
def _steal_stale_lock(self):
|
|
273
|
+
try:
|
|
274
|
+
age = time.time() - self.lockdir.stat().st_mtime
|
|
275
|
+
except OSError:
|
|
276
|
+
return True
|
|
277
|
+
if age <= self.STALE_SECONDS:
|
|
278
|
+
return False
|
|
279
|
+
shutil.rmtree(self.lockdir, ignore_errors=True)
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def __enter__(self):
|
|
283
|
+
self.lockdir.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
deadline = time.monotonic() + self.WAIT_SECONDS
|
|
285
|
+
while True:
|
|
286
|
+
try:
|
|
287
|
+
os.mkdir(self.lockdir, 0o700)
|
|
288
|
+
self._write_owner()
|
|
289
|
+
self._worker = threading.Thread(
|
|
290
|
+
target=self._heartbeat_loop,
|
|
291
|
+
name=f"tracking-lock-{os.getpid()}", daemon=True)
|
|
292
|
+
self._worker.start()
|
|
293
|
+
return self
|
|
294
|
+
except FileExistsError:
|
|
295
|
+
if self._steal_stale_lock():
|
|
296
|
+
continue
|
|
297
|
+
if time.monotonic() > deadline:
|
|
298
|
+
raise SkillError(EXIT_RUNTIME, "STATE_LOCKED",
|
|
299
|
+
f"otro comando está escribiendo el estado "
|
|
300
|
+
f"({self.lockdir}); reintenta en unos segundos")
|
|
301
|
+
time.sleep(0.2)
|
|
302
|
+
except OSError as error:
|
|
303
|
+
shutil.rmtree(self.lockdir, ignore_errors=True)
|
|
304
|
+
raise SkillError(EXIT_RUNTIME, "STATE_LOCK_FAILED",
|
|
305
|
+
f"no se pudo adquirir {self.lockdir}: {error}")
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *exc):
|
|
308
|
+
self._stop_event.set()
|
|
309
|
+
if self._worker is not None:
|
|
310
|
+
self._worker.join(timeout=self.HEARTBEAT_INTERVAL + 1)
|
|
311
|
+
if not self._lost and self._still_owner():
|
|
312
|
+
shutil.rmtree(self.lockdir, ignore_errors=True)
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def ensure_owner(self):
|
|
316
|
+
if self._lost or not self._still_owner():
|
|
317
|
+
self._lost = True
|
|
318
|
+
raise SkillError(EXIT_RUNTIME, "STATE_LOCK_LOST",
|
|
319
|
+
"el candado de estado fue tomado por otro proceso "
|
|
320
|
+
"durante la operación; NO se escribió el estado local. "
|
|
321
|
+
"Ejecuta resume para re-sincronizar desde el Issue")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def is_unfinished(state):
|
|
325
|
+
return bool(state) \
|
|
326
|
+
and state.get("active_process_id") is not None \
|
|
327
|
+
and state.get("process_state") in ("RUNNING", "ERROR")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def list_state_files():
|
|
331
|
+
directory = state_dir()
|
|
332
|
+
if not directory.is_dir():
|
|
333
|
+
return []
|
|
334
|
+
output = []
|
|
335
|
+
for path in directory.glob("*.json"):
|
|
336
|
+
try:
|
|
337
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
338
|
+
if (isinstance(raw, dict)
|
|
339
|
+
and raw.get("schema_version") in SUPPORTED_SCHEMA_VERSIONS):
|
|
340
|
+
output.append(path)
|
|
341
|
+
except (OSError, json.JSONDecodeError):
|
|
342
|
+
continue
|
|
343
|
+
return output
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _repos_of(paths):
|
|
347
|
+
return [path.name[:-5].replace("__", "/") for path in paths]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def select_state_path(allow_recent=True):
|
|
351
|
+
files = list_state_files()
|
|
352
|
+
if not files:
|
|
353
|
+
raise SkillError(EXIT_NO_PROCESS, "NO_STATE",
|
|
354
|
+
"no hay archivo de estado; pasa --url "
|
|
355
|
+
"https://github.com/{owner}/{repo} o ejecuta init")
|
|
356
|
+
unfinished = []
|
|
357
|
+
for path in files:
|
|
358
|
+
if is_unfinished(load_state(path)):
|
|
359
|
+
unfinished.append(path)
|
|
360
|
+
if len(unfinished) == 1:
|
|
361
|
+
return unfinished[0]
|
|
362
|
+
if len(unfinished) > 1:
|
|
363
|
+
raise SkillError(EXIT_VALIDATION, "CONTEXT_AMBIGUOUS",
|
|
364
|
+
"hay varios procesos activos; indica el repositorio con --url",
|
|
365
|
+
{"active_repos": _repos_of(unfinished)})
|
|
366
|
+
if not allow_recent and len(files) > 1:
|
|
367
|
+
raise SkillError(EXIT_VALIDATION, "CONTEXT_AMBIGUOUS",
|
|
368
|
+
"hay varios estados locales; clear requiere --url",
|
|
369
|
+
{"repos": _repos_of(files)})
|
|
370
|
+
return max(files, key=lambda path: path.stat().st_mtime)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def public_state(state):
|
|
374
|
+
if not state:
|
|
375
|
+
return None
|
|
376
|
+
output = dict(state)
|
|
377
|
+
if output.get("github_token"):
|
|
378
|
+
output["github_token"] = "<stored>"
|
|
379
|
+
return output
|