aiblueprint-cli 1.4.99 → 1.4.100
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/agents-config/skills/agents-manager/SKILL.md +2 -2
- package/agents-config/skills/agents-manager/agents/openai.yaml +7 -0
- package/agents-config/skills/agents-manager/assets/codex-icon.svg +20 -0
- package/agents-config/skills/apex/SKILL.md +120 -118
- package/agents-config/skills/apex/agents/openai.yaml +10 -0
- package/agents-config/skills/apex/assets/codex-icon.svg +15 -0
- package/agents-config/skills/apex/scripts/apex-state.py +740 -0
- package/agents-config/skills/apex/scripts/setup-templates.sh +27 -145
- package/agents-config/skills/apex/scripts/test_apex_state.py +413 -0
- package/agents-config/skills/apex/scripts/update-progress.sh +17 -73
- package/agents-config/skills/apex/steps/step-00-init.md +85 -231
- package/agents-config/skills/apex/steps/step-00b-branch.md +10 -118
- package/agents-config/skills/apex/steps/step-00b-economy.md +12 -239
- package/agents-config/skills/apex/steps/step-00b-interactive.md +13 -162
- package/agents-config/skills/apex/steps/step-00b-save.md +13 -114
- package/agents-config/skills/apex/steps/step-01-analyze.md +40 -361
- package/agents-config/skills/apex/steps/step-02-plan.md +55 -562
- package/agents-config/skills/apex/steps/step-02b-tasks.md +15 -291
- package/agents-config/skills/apex/steps/step-03-execute-teams.md +47 -267
- package/agents-config/skills/apex/steps/step-03-execute.md +32 -212
- package/agents-config/skills/apex/steps/step-04-validate.md +42 -246
- package/agents-config/skills/apex/steps/step-05-examine.md +47 -371
- package/agents-config/skills/apex/steps/step-06-resolve.md +19 -221
- package/agents-config/skills/apex/steps/step-07-tests.md +19 -234
- package/agents-config/skills/apex/steps/step-08-run-tests.md +13 -300
- package/agents-config/skills/apex/steps/step-09-finish.md +36 -200
- package/agents-config/skills/apex/steps/step-10-verify.md +46 -264
- package/agents-config/skills/appstore-connect/agents/openai.yaml +7 -0
- package/agents-config/skills/appstore-connect/assets/codex-icon.svg +17 -0
- package/agents-config/skills/commit/agents/openai.yaml +10 -0
- package/agents-config/skills/commit/assets/codex-icon.svg +17 -0
- package/agents-config/skills/create-pr/agents/openai.yaml +10 -0
- package/agents-config/skills/create-pr/assets/codex-icon.svg +17 -0
- package/agents-config/skills/environments-manager/SKILL.md +1 -1
- package/agents-config/skills/environments-manager/agents/openai.yaml +7 -0
- package/agents-config/skills/environments-manager/assets/codex-icon.svg +16 -0
- package/agents-config/skills/environments-manager/examples/scripts/claude-worktree-remove.sh +19 -3
- package/agents-config/skills/environments-manager/examples/scripts/worktree-up.sh +1 -1
- package/agents-config/skills/environments-manager/references/claude.md +1 -1
- package/agents-config/skills/fix-pr-comments/agents/openai.yaml +10 -0
- package/agents-config/skills/fix-pr-comments/assets/codex-icon.svg +17 -0
- package/agents-config/skills/grill-me/SKILL.md +25 -4
- package/agents-config/skills/grill-me/agents/openai.yaml +8 -0
- package/agents-config/skills/grill-me/assets/codex-icon.svg +16 -0
- package/agents-config/skills/hooks-manager/SKILL.md +19 -9
- package/agents-config/skills/hooks-manager/assets/codex-icon.svg +15 -4
- package/agents-config/skills/hooks-manager/references/claude-code.md +32 -0
- package/agents-config/skills/hooks-manager/references/codex.md +23 -0
- package/agents-config/skills/hooks-manager/references/cursor.md +18 -0
- package/agents-config/skills/hooks-manager/references/hook-types.md +5 -3
- package/agents-config/skills/hooks-manager/references/input-output-schemas.md +2 -2
- package/agents-config/skills/hooks-manager/references/research-sources.md +25 -0
- package/agents-config/skills/hooks-manager/references/router.md +32 -0
- package/agents-config/skills/hooks-manager/references/troubleshooting.md +3 -3
- package/agents-config/skills/merge/agents/openai.yaml +10 -0
- package/agents-config/skills/merge/assets/codex-icon.svg +17 -0
- package/agents-config/skills/oneshot/SKILL.md +4 -0
- package/agents-config/skills/oneshot/agents/openai.yaml +10 -0
- package/agents-config/skills/oneshot/assets/codex-icon.svg +18 -0
- package/agents-config/skills/prompt-creator/agents/openai.yaml +7 -0
- package/agents-config/skills/prompt-creator/assets/codex-icon.svg +16 -0
- package/agents-config/skills/rules-manager/agents/openai.yaml +7 -0
- package/agents-config/skills/rules-manager/assets/codex-icon.svg +23 -0
- package/agents-config/skills/skill-manager/SKILL.md +45 -3
- package/agents-config/skills/skill-manager/agents/openai.yaml +7 -0
- package/agents-config/skills/skill-manager/assets/codex-icon.svg +23 -0
- package/agents-config/skills/skill-manager/references/skill-writing-glossary.md +201 -0
- package/agents-config/skills/skill-manager/scripts/setup-codex-icons.ts +143 -0
- package/agents-config/skills/ultrathink/agents/openai.yaml +10 -0
- package/agents-config/skills/ultrathink/assets/codex-icon.svg +20 -0
- package/agents-config/skills/use-artifacts/SKILL.md +102 -51
- package/agents-config/skills/use-artifacts/assets/local-runtime.js +299 -0
- package/agents-config/skills/use-artifacts/scripts/create_artifact.py +1 -1
- package/agents-config/skills/use-delegate/SKILL.md +4 -0
- package/agents-config/skills/use-delegate/agents/openai.yaml +10 -0
- package/agents-config/skills/use-delegate/assets/codex-icon.svg +20 -0
- package/agents-config/skills/use-goal/SKILL.md +70 -9
- package/agents-config/skills/use-goal/agents/openai.yaml +1 -1
- package/agents-config/skills/use-goal/assets/codex-icon.svg +17 -3
- package/agents-config/skills/use-goal/references/claude-code-goal.md +54 -6
- package/agents-config/skills/use-goal/references/codex-goal.md +59 -4
- package/agents-config/skills/use-goal/references/verification-harnesses.md +104 -3
- package/package.json +1 -1
- package/agents-config/skills/apex/templates/00-context.md +0 -55
- package/agents-config/skills/apex/templates/01-analyze.md +0 -10
- package/agents-config/skills/apex/templates/02-plan.md +0 -10
- package/agents-config/skills/apex/templates/03-execute.md +0 -10
- package/agents-config/skills/apex/templates/04-validate.md +0 -10
- package/agents-config/skills/apex/templates/05-examine.md +0 -10
- package/agents-config/skills/apex/templates/06-resolve.md +0 -10
- package/agents-config/skills/apex/templates/07-tests.md +0 -10
- package/agents-config/skills/apex/templates/08-run-tests.md +0 -10
- package/agents-config/skills/apex/templates/09-finish.md +0 -10
- package/agents-config/skills/apex/templates/10-verify.md +0 -9
- package/agents-config/skills/apex/templates/README.md +0 -195
- package/agents-config/skills/apex/templates/step-complete.md +0 -7
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Durable, append-only state helper for APEX runs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import errno
|
|
8
|
+
import fcntl
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
import uuid
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
RUN_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
|
25
|
+
METADATA_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
|
26
|
+
EVENT_STATUSES = {"started", "in_progress", "complete", "blocked", "recorded", "failed"}
|
|
27
|
+
SECRET_PATTERNS = (
|
|
28
|
+
re.compile(r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret|cookie)\s*[:=]\s*[^\s,;]+"),
|
|
29
|
+
re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{12,}"),
|
|
30
|
+
re.compile(r"\b(?:sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9]{12,}|github_pat_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})\b"),
|
|
31
|
+
re.compile(r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----", re.DOTALL),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def now() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fail(message: str) -> None:
|
|
40
|
+
raise SystemExit(f"apex-state: {message}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def redact(value: str | None) -> str | None:
|
|
44
|
+
if value is None:
|
|
45
|
+
return None
|
|
46
|
+
redacted = value
|
|
47
|
+
for pattern in SECRET_PATTERNS:
|
|
48
|
+
redacted = pattern.sub("[REDACTED_SECRET]", redacted)
|
|
49
|
+
return redacted
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_root(value: str) -> Path:
|
|
53
|
+
root = Path(value).expanduser().resolve()
|
|
54
|
+
if not root.is_dir():
|
|
55
|
+
fail(f"root is not a directory: {root}")
|
|
56
|
+
if redact(str(root)) != str(root):
|
|
57
|
+
fail("root path contains secret-like content")
|
|
58
|
+
return root
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_run_id(run_id: str) -> str:
|
|
62
|
+
if redact(run_id) != run_id:
|
|
63
|
+
fail("run ID contains secret-like content")
|
|
64
|
+
if not RUN_ID_RE.fullmatch(run_id):
|
|
65
|
+
fail("run ID must contain only letters, numbers, dot, underscore, or hyphen")
|
|
66
|
+
return run_id
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_metadata(value: str, label: str) -> str:
|
|
70
|
+
if redact(value) != value:
|
|
71
|
+
fail(f"{label} contains secret-like content")
|
|
72
|
+
if not METADATA_RE.fullmatch(value):
|
|
73
|
+
fail(f"{label} must contain only letters, numbers, dot, underscore, or hyphen")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def slugify(value: str) -> str:
|
|
78
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
79
|
+
return slug[:48] or "task"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ensure_directory(path: Path, *, create: bool) -> Path:
|
|
83
|
+
try:
|
|
84
|
+
metadata = path.lstat()
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
if not create:
|
|
87
|
+
fail(f"missing state directory: {path}")
|
|
88
|
+
try:
|
|
89
|
+
path.mkdir(mode=0o700)
|
|
90
|
+
except FileExistsError:
|
|
91
|
+
pass
|
|
92
|
+
metadata = path.lstat()
|
|
93
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
94
|
+
fail(f"state path must not be a symlink: {path}")
|
|
95
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
96
|
+
fail(f"state path is not a directory: {path}")
|
|
97
|
+
os.chmod(path, 0o700)
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def state_root(root: Path, *, create: bool) -> Path:
|
|
102
|
+
current = root
|
|
103
|
+
for component in (".agents", "apex", "runs"):
|
|
104
|
+
current = ensure_directory(current / component, create=create)
|
|
105
|
+
try:
|
|
106
|
+
current.resolve().relative_to(root)
|
|
107
|
+
except ValueError:
|
|
108
|
+
fail(f"state path escapes repository: {current}")
|
|
109
|
+
return current
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def existing_run_dir(root: Path, run_id: str) -> Path:
|
|
113
|
+
base = state_root(root, create=False)
|
|
114
|
+
directory = ensure_directory(base / validate_run_id(run_id), create=False)
|
|
115
|
+
try:
|
|
116
|
+
directory.resolve().relative_to(base.resolve())
|
|
117
|
+
except ValueError:
|
|
118
|
+
fail(f"run directory escapes state root: {directory}")
|
|
119
|
+
return directory
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def assert_regular_or_missing(path: Path) -> None:
|
|
123
|
+
try:
|
|
124
|
+
metadata = path.lstat()
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
return
|
|
127
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
128
|
+
fail(f"state file must not be a symlink: {path}")
|
|
129
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
130
|
+
fail(f"state path is not a regular file: {path}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fsync_directory(path: Path) -> None:
|
|
134
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
135
|
+
try:
|
|
136
|
+
os.fsync(descriptor)
|
|
137
|
+
finally:
|
|
138
|
+
os.close(descriptor)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
142
|
+
assert_regular_or_missing(path)
|
|
143
|
+
handle, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
144
|
+
try:
|
|
145
|
+
os.fchmod(handle, 0o600)
|
|
146
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
147
|
+
json.dump(data, stream, indent=2, sort_keys=True)
|
|
148
|
+
stream.write("\n")
|
|
149
|
+
stream.flush()
|
|
150
|
+
os.fsync(stream.fileno())
|
|
151
|
+
os.replace(temporary, path)
|
|
152
|
+
os.chmod(path, 0o600)
|
|
153
|
+
fsync_directory(path.parent)
|
|
154
|
+
finally:
|
|
155
|
+
if os.path.exists(temporary):
|
|
156
|
+
os.unlink(temporary)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
160
|
+
assert_regular_or_missing(path)
|
|
161
|
+
try:
|
|
162
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
fail(f"missing state file: {path}")
|
|
165
|
+
except json.JSONDecodeError as error:
|
|
166
|
+
fail(f"invalid JSON in {path}: {error}")
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
fail(f"expected a JSON object in {path}")
|
|
169
|
+
return data
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@contextmanager
|
|
173
|
+
def run_lock(directory: Path) -> Iterator[None]:
|
|
174
|
+
lock_path = directory / ".state.lock"
|
|
175
|
+
assert_regular_or_missing(lock_path)
|
|
176
|
+
flags = os.O_CREAT | os.O_RDWR
|
|
177
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
178
|
+
flags |= os.O_NOFOLLOW
|
|
179
|
+
descriptor = os.open(lock_path, flags, 0o600)
|
|
180
|
+
try:
|
|
181
|
+
os.fchmod(descriptor, 0o600)
|
|
182
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
183
|
+
yield
|
|
184
|
+
finally:
|
|
185
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
186
|
+
os.close(descriptor)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def journal_path(directory: Path) -> Path:
|
|
190
|
+
return directory / "journal.jsonl"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def append_journal(directory: Path, event: dict[str, Any]) -> dict[str, Any]:
|
|
194
|
+
path = journal_path(directory)
|
|
195
|
+
assert_regular_or_missing(path)
|
|
196
|
+
recorded = dict(event)
|
|
197
|
+
recorded["id"] = str(uuid.uuid4())
|
|
198
|
+
recorded["timestamp"] = now()
|
|
199
|
+
payload = (json.dumps(recorded, sort_keys=True) + "\n").encode()
|
|
200
|
+
flags = os.O_CREAT | os.O_APPEND | os.O_WRONLY
|
|
201
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
202
|
+
flags |= os.O_NOFOLLOW
|
|
203
|
+
descriptor = os.open(path, flags, 0o600)
|
|
204
|
+
try:
|
|
205
|
+
os.fchmod(descriptor, 0o600)
|
|
206
|
+
view = memoryview(payload)
|
|
207
|
+
while view:
|
|
208
|
+
written = os.write(descriptor, view)
|
|
209
|
+
view = view[written:]
|
|
210
|
+
os.fsync(descriptor)
|
|
211
|
+
finally:
|
|
212
|
+
os.close(descriptor)
|
|
213
|
+
return recorded
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_journal(directory: Path) -> list[dict[str, Any]]:
|
|
217
|
+
path = journal_path(directory)
|
|
218
|
+
assert_regular_or_missing(path)
|
|
219
|
+
try:
|
|
220
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
221
|
+
except FileNotFoundError:
|
|
222
|
+
return []
|
|
223
|
+
events: list[dict[str, Any]] = []
|
|
224
|
+
for index, line in enumerate(lines):
|
|
225
|
+
try:
|
|
226
|
+
event = json.loads(line)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
if index == len(lines) - 1:
|
|
229
|
+
break
|
|
230
|
+
fail(f"corrupt journal entry at line {index + 1}")
|
|
231
|
+
if not isinstance(event, dict) or not isinstance(event.get("id"), str):
|
|
232
|
+
fail(f"invalid journal entry at line {index + 1}")
|
|
233
|
+
events.append(event)
|
|
234
|
+
return events
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def project_state(base: dict[str, Any], events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
238
|
+
state = dict(base)
|
|
239
|
+
state["status"] = "active"
|
|
240
|
+
state["current_phase"] = "preflight"
|
|
241
|
+
state["updated_at"] = state.get("created_at")
|
|
242
|
+
state["last_event_id"] = None
|
|
243
|
+
checkpoints: list[dict[str, Any]] = []
|
|
244
|
+
for event in events:
|
|
245
|
+
phase = event.get("phase")
|
|
246
|
+
status = event.get("status")
|
|
247
|
+
if isinstance(phase, str):
|
|
248
|
+
state["current_phase"] = phase
|
|
249
|
+
if event.get("kind") == "checkpoint" and isinstance(event.get("checkpoint"), dict):
|
|
250
|
+
checkpoints.append(event["checkpoint"])
|
|
251
|
+
if phase == "handoff" and status == "complete":
|
|
252
|
+
state["status"] = "complete"
|
|
253
|
+
elif status == "blocked":
|
|
254
|
+
state["status"] = "blocked"
|
|
255
|
+
elif state.get("status") != "complete" and status in {"started", "in_progress", "complete", "recorded"}:
|
|
256
|
+
state["status"] = "active"
|
|
257
|
+
state["last_event_id"] = event["id"]
|
|
258
|
+
state["updated_at"] = event["timestamp"]
|
|
259
|
+
state["checkpoints"] = checkpoints
|
|
260
|
+
return state
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def project_evidence(events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
264
|
+
artifacts: dict[str, dict[str, Any]] = {}
|
|
265
|
+
order: list[str] = []
|
|
266
|
+
for event in events:
|
|
267
|
+
if event.get("kind") != "artifact":
|
|
268
|
+
continue
|
|
269
|
+
action = event.get("action")
|
|
270
|
+
if action == "recorded" and isinstance(event.get("artifact"), dict):
|
|
271
|
+
artifact = dict(event["artifact"])
|
|
272
|
+
artifact_id = artifact.get("id")
|
|
273
|
+
if isinstance(artifact_id, str):
|
|
274
|
+
artifacts[artifact_id] = artifact
|
|
275
|
+
if artifact_id not in order:
|
|
276
|
+
order.append(artifact_id)
|
|
277
|
+
elif action == "invalidated":
|
|
278
|
+
artifact_id = event.get("artifact_id")
|
|
279
|
+
if artifact_id in artifacts:
|
|
280
|
+
artifacts[artifact_id]["status"] = "invalidated"
|
|
281
|
+
artifacts[artifact_id]["invalidated_at"] = event["timestamp"]
|
|
282
|
+
artifacts[artifact_id]["invalidation_reason"] = event.get("message")
|
|
283
|
+
return {"schema_version": SCHEMA_VERSION, "artifacts": [artifacts[key] for key in order]}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def load_bundle(
|
|
287
|
+
root: Path, directory: Path
|
|
288
|
+
) -> tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
|
|
289
|
+
events = read_journal(directory)
|
|
290
|
+
run_event = next(
|
|
291
|
+
(event for event in events if event.get("kind") == "run" and isinstance(event.get("run"), dict)),
|
|
292
|
+
None,
|
|
293
|
+
)
|
|
294
|
+
if run_event is None:
|
|
295
|
+
fail(f"journal has no run record: {journal_path(directory)}")
|
|
296
|
+
base = dict(run_event["run"])
|
|
297
|
+
recorded_root = Path(str(base.get("root", ""))).resolve()
|
|
298
|
+
if recorded_root != root:
|
|
299
|
+
fail(f"run belongs to {recorded_root}, not {root}")
|
|
300
|
+
if base.get("schema_version") != SCHEMA_VERSION:
|
|
301
|
+
fail(f"unsupported schema version: {base.get('schema_version')}")
|
|
302
|
+
return base, project_state(base, events), events, project_evidence(events)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_git(root: Path, *arguments: str) -> tuple[bool, bytes, str | None]:
|
|
306
|
+
try:
|
|
307
|
+
result = subprocess.run(
|
|
308
|
+
("git", *arguments),
|
|
309
|
+
cwd=root,
|
|
310
|
+
check=False,
|
|
311
|
+
capture_output=True,
|
|
312
|
+
timeout=10,
|
|
313
|
+
)
|
|
314
|
+
except subprocess.TimeoutExpired:
|
|
315
|
+
return False, b"", "git command timed out"
|
|
316
|
+
except OSError as error:
|
|
317
|
+
return False, b"", redact(str(error))
|
|
318
|
+
if result.returncode != 0:
|
|
319
|
+
error = result.stderr.decode("utf-8", errors="replace").strip()
|
|
320
|
+
return False, result.stdout, redact(error or f"git exited {result.returncode}")
|
|
321
|
+
return True, result.stdout, None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def parse_porcelain(raw: bytes) -> tuple[list[dict[str, str]], bool]:
|
|
325
|
+
tokens = raw.split(b"\0")
|
|
326
|
+
changes: list[dict[str, str]] = []
|
|
327
|
+
paths_redacted = False
|
|
328
|
+
index = 0
|
|
329
|
+
while index < len(tokens):
|
|
330
|
+
token = tokens[index]
|
|
331
|
+
index += 1
|
|
332
|
+
if not token:
|
|
333
|
+
continue
|
|
334
|
+
text = token.decode("utf-8", errors="surrogateescape")
|
|
335
|
+
if len(text) < 4:
|
|
336
|
+
continue
|
|
337
|
+
status_code = text[:2]
|
|
338
|
+
raw_path = text[3:]
|
|
339
|
+
safe_path = redact(raw_path) or "[REDACTED_SECRET]"
|
|
340
|
+
paths_redacted = paths_redacted or safe_path != raw_path
|
|
341
|
+
entry = {"status": status_code, "path": safe_path}
|
|
342
|
+
if ("R" in status_code or "C" in status_code) and index < len(tokens):
|
|
343
|
+
original = tokens[index]
|
|
344
|
+
index += 1
|
|
345
|
+
original_path = original.decode("utf-8", errors="surrogateescape")
|
|
346
|
+
safe_original = redact(original_path) or "[REDACTED_SECRET]"
|
|
347
|
+
paths_redacted = paths_redacted or safe_original != original_path
|
|
348
|
+
entry["original_path"] = safe_original
|
|
349
|
+
changes.append(entry)
|
|
350
|
+
return changes, paths_redacted
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def git_snapshot(root: Path) -> dict[str, Any]:
|
|
354
|
+
inside_ok, inside, inside_error = run_git(root, "rev-parse", "--is-inside-work-tree")
|
|
355
|
+
if not inside_ok or inside.strip() != b"true":
|
|
356
|
+
return {"available": False, "error": inside_error or "not a Git working tree"}
|
|
357
|
+
|
|
358
|
+
revision_ok, revision_raw, revision_error = run_git(root, "rev-parse", "HEAD")
|
|
359
|
+
branch_ok, branch_raw, branch_error = run_git(root, "branch", "--show-current")
|
|
360
|
+
upstream_ok, upstream_raw, upstream_error = run_git(
|
|
361
|
+
root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"
|
|
362
|
+
)
|
|
363
|
+
status_ok, status_raw, status_error = run_git(root, "status", "--porcelain=v1", "-z")
|
|
364
|
+
changes, paths_redacted = parse_porcelain(status_raw) if status_ok else (None, False)
|
|
365
|
+
errors = [error for error in (revision_error, branch_error, status_error) if error]
|
|
366
|
+
return {
|
|
367
|
+
"available": True,
|
|
368
|
+
"verified": status_ok and revision_ok and branch_ok,
|
|
369
|
+
"revision": revision_raw.decode().strip() if revision_ok else None,
|
|
370
|
+
"branch": redact(branch_raw.decode().strip()) if branch_ok else None,
|
|
371
|
+
"branch_fingerprint": hashlib.sha256(branch_raw).hexdigest() if branch_ok else None,
|
|
372
|
+
"upstream": redact(upstream_raw.decode().strip()) if upstream_ok else None,
|
|
373
|
+
"changes": changes,
|
|
374
|
+
"status_fingerprint": hashlib.sha256(status_raw).hexdigest() if status_ok else None,
|
|
375
|
+
"paths_redacted": paths_redacted,
|
|
376
|
+
"errors": errors,
|
|
377
|
+
"upstream_error": upstream_error if not upstream_ok else None,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def persist_projections(directory: Path, state: dict[str, Any], evidence: dict[str, Any]) -> None:
|
|
382
|
+
write_json(directory / "run.json", state)
|
|
383
|
+
write_json(directory / "evidence.json", evidence)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def cleanup_staging(directory: Path) -> None:
|
|
387
|
+
"""Remove only the known files created by an incomplete init."""
|
|
388
|
+
|
|
389
|
+
if not directory.exists():
|
|
390
|
+
return
|
|
391
|
+
for name in ("run.json", "tasks.json", "evidence.json", "journal.jsonl", ".state.lock"):
|
|
392
|
+
path = directory / name
|
|
393
|
+
if path.exists() and path.is_file() and not path.is_symlink():
|
|
394
|
+
path.unlink()
|
|
395
|
+
for name in ("artifacts", "tasks"):
|
|
396
|
+
path = directory / name
|
|
397
|
+
if path.exists() and path.is_dir() and not path.is_symlink():
|
|
398
|
+
try:
|
|
399
|
+
path.rmdir()
|
|
400
|
+
except OSError:
|
|
401
|
+
pass
|
|
402
|
+
try:
|
|
403
|
+
directory.rmdir()
|
|
404
|
+
except OSError:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def command_init(args: argparse.Namespace) -> None:
|
|
409
|
+
root = resolve_root(args.root)
|
|
410
|
+
base = state_root(root, create=True)
|
|
411
|
+
task = redact(args.task) or "task"
|
|
412
|
+
prefix = args.run_id or f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{slugify(task)}-{uuid.uuid4().hex[:8]}"
|
|
413
|
+
validate_run_id(prefix)
|
|
414
|
+
staging = base / f".initializing-{uuid.uuid4()}"
|
|
415
|
+
staging.mkdir(mode=0o700)
|
|
416
|
+
published = False
|
|
417
|
+
try:
|
|
418
|
+
(staging / "artifacts").mkdir(mode=0o700)
|
|
419
|
+
(staging / "tasks").mkdir(mode=0o700)
|
|
420
|
+
timestamp = now()
|
|
421
|
+
base_state: dict[str, Any] = {
|
|
422
|
+
"schema_version": SCHEMA_VERSION,
|
|
423
|
+
"run_id": prefix,
|
|
424
|
+
"root": str(root),
|
|
425
|
+
"task": task,
|
|
426
|
+
"status": "active",
|
|
427
|
+
"current_phase": "preflight",
|
|
428
|
+
"created_at": timestamp,
|
|
429
|
+
"updated_at": timestamp,
|
|
430
|
+
"baseline": git_snapshot(root),
|
|
431
|
+
"last_event_id": None,
|
|
432
|
+
"checkpoints": [],
|
|
433
|
+
}
|
|
434
|
+
write_json(staging / "tasks.json", {"schema_version": SCHEMA_VERSION, "tasks": []})
|
|
435
|
+
event = append_journal(
|
|
436
|
+
staging,
|
|
437
|
+
{
|
|
438
|
+
"kind": "run",
|
|
439
|
+
"phase": "preflight",
|
|
440
|
+
"status": "started",
|
|
441
|
+
"message": "Run initialized",
|
|
442
|
+
"run": base_state,
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
state = project_state(base_state, [event])
|
|
446
|
+
evidence = project_evidence([event])
|
|
447
|
+
persist_projections(staging, state, evidence)
|
|
448
|
+
fsync_directory(staging / "artifacts")
|
|
449
|
+
fsync_directory(staging / "tasks")
|
|
450
|
+
fsync_directory(staging)
|
|
451
|
+
|
|
452
|
+
candidate = prefix
|
|
453
|
+
directory = base / candidate
|
|
454
|
+
if directory.exists():
|
|
455
|
+
fail(f"run already exists: {candidate}")
|
|
456
|
+
try:
|
|
457
|
+
os.rename(staging, directory)
|
|
458
|
+
published = True
|
|
459
|
+
except OSError as error:
|
|
460
|
+
if error.errno in {errno.EEXIST, errno.ENOTEMPTY}:
|
|
461
|
+
fail(f"run already exists: {candidate}")
|
|
462
|
+
raise
|
|
463
|
+
fsync_directory(base)
|
|
464
|
+
print(json.dumps({"run_id": candidate, "run_dir": str(directory)}))
|
|
465
|
+
finally:
|
|
466
|
+
if not published:
|
|
467
|
+
cleanup_staging(staging)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def command_event(args: argparse.Namespace) -> None:
|
|
471
|
+
root = resolve_root(args.root)
|
|
472
|
+
directory = existing_run_dir(root, args.run_id)
|
|
473
|
+
phase = validate_metadata(args.phase, "phase")
|
|
474
|
+
task_id = validate_metadata(args.task_id, "task ID") if args.task_id else None
|
|
475
|
+
if args.status not in EVENT_STATUSES:
|
|
476
|
+
fail("unsupported event status")
|
|
477
|
+
with run_lock(directory):
|
|
478
|
+
base, _, events, _ = load_bundle(root, directory)
|
|
479
|
+
payload: dict[str, Any] = {
|
|
480
|
+
"kind": "task" if args.task_id else "phase",
|
|
481
|
+
"phase": phase,
|
|
482
|
+
"status": args.status,
|
|
483
|
+
"message": redact(args.message),
|
|
484
|
+
}
|
|
485
|
+
if task_id:
|
|
486
|
+
payload["task_id"] = task_id
|
|
487
|
+
recorded = append_journal(directory, payload)
|
|
488
|
+
events.append(recorded)
|
|
489
|
+
persist_projections(directory, project_state(base, events), project_evidence(events))
|
|
490
|
+
print(json.dumps(recorded))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def command_checkpoint(args: argparse.Namespace) -> None:
|
|
494
|
+
root = resolve_root(args.root)
|
|
495
|
+
directory = existing_run_dir(root, args.run_id)
|
|
496
|
+
phase = validate_metadata(args.phase, "phase")
|
|
497
|
+
with run_lock(directory):
|
|
498
|
+
base, _, events, _ = load_bundle(root, directory)
|
|
499
|
+
checkpoint = {
|
|
500
|
+
"id": str(uuid.uuid4()),
|
|
501
|
+
"phase": phase,
|
|
502
|
+
"message": redact(args.message),
|
|
503
|
+
"timestamp": now(),
|
|
504
|
+
"git": git_snapshot(root),
|
|
505
|
+
}
|
|
506
|
+
recorded = append_journal(
|
|
507
|
+
directory,
|
|
508
|
+
{
|
|
509
|
+
"kind": "checkpoint",
|
|
510
|
+
"phase": phase,
|
|
511
|
+
"status": "recorded",
|
|
512
|
+
"message": checkpoint["message"],
|
|
513
|
+
"checkpoint": checkpoint,
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
events.append(recorded)
|
|
517
|
+
persist_projections(directory, project_state(base, events), project_evidence(events))
|
|
518
|
+
print(json.dumps(checkpoint))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def inspect_file(path: Path) -> tuple[str, int]:
|
|
522
|
+
digest = hashlib.sha256()
|
|
523
|
+
flags = os.O_RDONLY
|
|
524
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
525
|
+
flags |= os.O_NOFOLLOW
|
|
526
|
+
descriptor = os.open(path, flags)
|
|
527
|
+
try:
|
|
528
|
+
before = os.fstat(descriptor)
|
|
529
|
+
if not stat.S_ISREG(before.st_mode):
|
|
530
|
+
fail(f"artifact is not a regular file: {path}")
|
|
531
|
+
while True:
|
|
532
|
+
chunk = os.read(descriptor, 1024 * 1024)
|
|
533
|
+
if not chunk:
|
|
534
|
+
break
|
|
535
|
+
digest.update(chunk)
|
|
536
|
+
after = os.fstat(descriptor)
|
|
537
|
+
finally:
|
|
538
|
+
os.close(descriptor)
|
|
539
|
+
identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
|
540
|
+
identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
|
541
|
+
if identity_before != identity_after:
|
|
542
|
+
fail(f"artifact changed while being recorded: {path}")
|
|
543
|
+
return digest.hexdigest(), before.st_size
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def command_artifact(args: argparse.Namespace) -> None:
|
|
547
|
+
root = resolve_root(args.root)
|
|
548
|
+
directory = existing_run_dir(root, args.run_id)
|
|
549
|
+
phase = validate_metadata(args.phase, "phase")
|
|
550
|
+
artifact_type = validate_metadata(args.type, "artifact type")
|
|
551
|
+
task_id = validate_metadata(args.task_id, "task ID") if args.task_id else None
|
|
552
|
+
if args.layer not in {"local", "provider", "public", "authenticated-live"}:
|
|
553
|
+
fail("unsupported artifact layer")
|
|
554
|
+
with run_lock(directory):
|
|
555
|
+
base, _, events, evidence = load_bundle(root, directory)
|
|
556
|
+
artifact_path = Path(args.path).expanduser()
|
|
557
|
+
artifact_path = (root / artifact_path).resolve() if not artifact_path.is_absolute() else artifact_path.resolve()
|
|
558
|
+
if redact(str(artifact_path)) != str(artifact_path):
|
|
559
|
+
fail("artifact path contains secret-like content")
|
|
560
|
+
if not artifact_path.is_file():
|
|
561
|
+
fail(f"artifact is not a file: {artifact_path}")
|
|
562
|
+
try:
|
|
563
|
+
stored_path = str(artifact_path.relative_to(root))
|
|
564
|
+
except ValueError:
|
|
565
|
+
if not args.allow_external:
|
|
566
|
+
fail("artifact is outside the repository; pass --allow-external explicitly")
|
|
567
|
+
stored_path = str(artifact_path)
|
|
568
|
+
digest, size = inspect_file(artifact_path)
|
|
569
|
+
artifact = {
|
|
570
|
+
"id": str(uuid.uuid4()),
|
|
571
|
+
"type": artifact_type,
|
|
572
|
+
"phase": phase,
|
|
573
|
+
"task_id": task_id,
|
|
574
|
+
"layer": args.layer,
|
|
575
|
+
"status": "current",
|
|
576
|
+
"path": stored_path,
|
|
577
|
+
"sha256": digest,
|
|
578
|
+
"size": size,
|
|
579
|
+
"revision": git_snapshot(root).get("revision"),
|
|
580
|
+
"created_at": now(),
|
|
581
|
+
}
|
|
582
|
+
recorded = append_journal(
|
|
583
|
+
directory,
|
|
584
|
+
{
|
|
585
|
+
"kind": "artifact",
|
|
586
|
+
"action": "recorded",
|
|
587
|
+
"phase": phase,
|
|
588
|
+
"status": "recorded",
|
|
589
|
+
"message": "Artifact recorded",
|
|
590
|
+
"artifact": artifact,
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
events.append(recorded)
|
|
594
|
+
persist_projections(directory, project_state(base, events), project_evidence(events))
|
|
595
|
+
print(json.dumps(artifact))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def command_invalidate(args: argparse.Namespace) -> None:
|
|
599
|
+
root = resolve_root(args.root)
|
|
600
|
+
directory = existing_run_dir(root, args.run_id)
|
|
601
|
+
with run_lock(directory):
|
|
602
|
+
base, state, events, evidence = load_bundle(root, directory)
|
|
603
|
+
known_ids = {item.get("id") for item in evidence.get("artifacts", [])}
|
|
604
|
+
if args.artifact_id not in known_ids:
|
|
605
|
+
fail("unknown artifact ID")
|
|
606
|
+
recorded = append_journal(
|
|
607
|
+
directory,
|
|
608
|
+
{
|
|
609
|
+
"kind": "artifact",
|
|
610
|
+
"action": "invalidated",
|
|
611
|
+
"phase": state.get("current_phase"),
|
|
612
|
+
"status": "invalidated",
|
|
613
|
+
"message": redact(args.message),
|
|
614
|
+
"artifact_id": args.artifact_id,
|
|
615
|
+
},
|
|
616
|
+
)
|
|
617
|
+
events.append(recorded)
|
|
618
|
+
projected = project_evidence(events)
|
|
619
|
+
persist_projections(directory, project_state(base, events), projected)
|
|
620
|
+
target = next(item for item in projected["artifacts"] if item["id"] == args.artifact_id)
|
|
621
|
+
print(json.dumps(target))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def drift(reference: dict[str, Any], current: dict[str, Any]) -> dict[str, bool | None]:
|
|
625
|
+
if not reference.get("available") or not current.get("available"):
|
|
626
|
+
return {"known": False, "revision_changed": None, "branch_changed": None, "changes_changed": None}
|
|
627
|
+
if not reference.get("verified") or not current.get("verified"):
|
|
628
|
+
return {"known": False, "revision_changed": None, "branch_changed": None, "changes_changed": None}
|
|
629
|
+
return {
|
|
630
|
+
"known": True,
|
|
631
|
+
"revision_changed": reference.get("revision") != current.get("revision"),
|
|
632
|
+
"branch_changed": reference.get("branch_fingerprint") != current.get("branch_fingerprint"),
|
|
633
|
+
"changes_changed": reference.get("status_fingerprint") != current.get("status_fingerprint"),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def verify_artifacts(root: Path, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
638
|
+
verified = json.loads(json.dumps(evidence))
|
|
639
|
+
for artifact in verified.get("artifacts", []):
|
|
640
|
+
if artifact.get("status") != "current":
|
|
641
|
+
artifact["integrity"] = "not-current"
|
|
642
|
+
continue
|
|
643
|
+
stored_path = artifact.get("path")
|
|
644
|
+
if not isinstance(stored_path, str):
|
|
645
|
+
artifact["status"] = "stale"
|
|
646
|
+
artifact["integrity"] = "missing-path"
|
|
647
|
+
continue
|
|
648
|
+
candidate = Path(stored_path).expanduser()
|
|
649
|
+
candidate = (root / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
|
|
650
|
+
try:
|
|
651
|
+
digest, size = inspect_file(candidate)
|
|
652
|
+
except (OSError, SystemExit) as error:
|
|
653
|
+
artifact["status"] = "stale"
|
|
654
|
+
artifact["integrity"] = "unavailable"
|
|
655
|
+
artifact["integrity_error"] = redact(str(error))
|
|
656
|
+
continue
|
|
657
|
+
if digest != artifact.get("sha256") or size != artifact.get("size"):
|
|
658
|
+
artifact["status"] = "stale"
|
|
659
|
+
artifact["integrity"] = "mismatch"
|
|
660
|
+
else:
|
|
661
|
+
artifact["integrity"] = "verified"
|
|
662
|
+
return verified
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def command_status(args: argparse.Namespace) -> None:
|
|
666
|
+
root = resolve_root(args.root)
|
|
667
|
+
directory = existing_run_dir(root, args.run_id)
|
|
668
|
+
with run_lock(directory):
|
|
669
|
+
_, state, _, evidence = load_bundle(root, directory)
|
|
670
|
+
current = git_snapshot(root)
|
|
671
|
+
checkpoints = state.get("checkpoints", [])
|
|
672
|
+
reference = checkpoints[-1].get("git", {}) if checkpoints else state.get("baseline", {})
|
|
673
|
+
result = {
|
|
674
|
+
"run_dir": str(directory),
|
|
675
|
+
"state": state,
|
|
676
|
+
"evidence": verify_artifacts(root, evidence),
|
|
677
|
+
"current_git": current,
|
|
678
|
+
"drift": drift(reference, current),
|
|
679
|
+
}
|
|
680
|
+
print(json.dumps(result, indent=2))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
684
|
+
parser = argparse.ArgumentParser(description="Manage durable APEX run state")
|
|
685
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
686
|
+
|
|
687
|
+
init = commands.add_parser("init")
|
|
688
|
+
init.add_argument("--root", required=True)
|
|
689
|
+
init.add_argument("--task", required=True)
|
|
690
|
+
init.add_argument("--run-id")
|
|
691
|
+
init.set_defaults(handler=command_init)
|
|
692
|
+
|
|
693
|
+
event = commands.add_parser("event")
|
|
694
|
+
event.add_argument("--root", required=True)
|
|
695
|
+
event.add_argument("--run-id", required=True)
|
|
696
|
+
event.add_argument("--phase", required=True)
|
|
697
|
+
event.add_argument("--status", required=True)
|
|
698
|
+
event.add_argument("--message", required=True)
|
|
699
|
+
event.add_argument("--task-id")
|
|
700
|
+
event.set_defaults(handler=command_event)
|
|
701
|
+
|
|
702
|
+
checkpoint = commands.add_parser("checkpoint")
|
|
703
|
+
checkpoint.add_argument("--root", required=True)
|
|
704
|
+
checkpoint.add_argument("--run-id", required=True)
|
|
705
|
+
checkpoint.add_argument("--phase", required=True)
|
|
706
|
+
checkpoint.add_argument("--message", required=True)
|
|
707
|
+
checkpoint.set_defaults(handler=command_checkpoint)
|
|
708
|
+
|
|
709
|
+
artifact = commands.add_parser("artifact")
|
|
710
|
+
artifact.add_argument("--root", required=True)
|
|
711
|
+
artifact.add_argument("--run-id", required=True)
|
|
712
|
+
artifact.add_argument("--path", required=True)
|
|
713
|
+
artifact.add_argument("--type", required=True)
|
|
714
|
+
artifact.add_argument("--phase", required=True)
|
|
715
|
+
artifact.add_argument("--task-id")
|
|
716
|
+
artifact.add_argument("--allow-external", action="store_true")
|
|
717
|
+
artifact.add_argument("--layer", default="local")
|
|
718
|
+
artifact.set_defaults(handler=command_artifact)
|
|
719
|
+
|
|
720
|
+
invalidate = commands.add_parser("invalidate")
|
|
721
|
+
invalidate.add_argument("--root", required=True)
|
|
722
|
+
invalidate.add_argument("--run-id", required=True)
|
|
723
|
+
invalidate.add_argument("--artifact-id", required=True)
|
|
724
|
+
invalidate.add_argument("--message", required=True)
|
|
725
|
+
invalidate.set_defaults(handler=command_invalidate)
|
|
726
|
+
|
|
727
|
+
status = commands.add_parser("status")
|
|
728
|
+
status.add_argument("--root", required=True)
|
|
729
|
+
status.add_argument("--run-id", required=True)
|
|
730
|
+
status.set_defaults(handler=command_status)
|
|
731
|
+
return parser
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def main() -> None:
|
|
735
|
+
args = build_parser().parse_args()
|
|
736
|
+
args.handler(args)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
if __name__ == "__main__":
|
|
740
|
+
main()
|