@pushpalsdev/cli 1.1.22 → 1.1.23
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/package.json
CHANGED
|
@@ -8,6 +8,7 @@ that the TypeScript host parses.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import hashlib
|
|
11
12
|
import os
|
|
12
13
|
import re
|
|
13
14
|
from shutil import rmtree, which
|
|
@@ -713,9 +714,39 @@ def _resolve_rollout_watchdog_seconds(
|
|
|
713
714
|
return max(90, min(default_s, max(90, communicate_timeout_s - 60)))
|
|
714
715
|
|
|
715
716
|
|
|
716
|
-
def
|
|
717
|
-
|
|
718
|
-
|
|
717
|
+
def _baseline_snapshot_paths(baseline_snapshot: Any) -> List[str]:
|
|
718
|
+
if isinstance(baseline_snapshot, dict):
|
|
719
|
+
return [str(path) for path in baseline_snapshot.keys()]
|
|
720
|
+
if isinstance(baseline_snapshot, list):
|
|
721
|
+
return [str(path) for path in baseline_snapshot]
|
|
722
|
+
return []
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _paths_changed_after_baseline(
|
|
726
|
+
repo: str,
|
|
727
|
+
changed_paths: List[str],
|
|
728
|
+
baseline_snapshot: Any,
|
|
729
|
+
) -> List[str]:
|
|
730
|
+
baseline_paths = set(_baseline_snapshot_paths(baseline_snapshot))
|
|
731
|
+
if not baseline_paths:
|
|
732
|
+
return list(changed_paths)
|
|
733
|
+
|
|
734
|
+
delta: List[str] = []
|
|
735
|
+
baseline_fingerprints = baseline_snapshot if isinstance(baseline_snapshot, dict) else {}
|
|
736
|
+
for path in changed_paths:
|
|
737
|
+
if path not in baseline_paths:
|
|
738
|
+
delta.append(path)
|
|
739
|
+
continue
|
|
740
|
+
if baseline_fingerprints:
|
|
741
|
+
current_fingerprint = _changed_path_fingerprint(repo, path)
|
|
742
|
+
if current_fingerprint != str(baseline_fingerprints.get(path) or ""):
|
|
743
|
+
delta.append(path)
|
|
744
|
+
return delta
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _describe_non_publishable_paths(changed_paths: List[str], baseline_snapshot: Any) -> str:
|
|
748
|
+
baseline_paths = set(_baseline_snapshot_paths(baseline_snapshot))
|
|
749
|
+
inspected = [p for p in changed_paths if p not in baseline_paths] if baseline_paths else changed_paths
|
|
719
750
|
non_publishable = [p for p in inspected if not _is_publishable_changed_path(p)]
|
|
720
751
|
if not non_publishable:
|
|
721
752
|
return ""
|
|
@@ -1686,10 +1717,99 @@ def _is_publishable_changed_path(path: str) -> bool:
|
|
|
1686
1717
|
return not re.search(r"(^|/)(outputs|node_modules|\.worktrees|\.codex|dist|build|coverage)(/|$)", normalized)
|
|
1687
1718
|
|
|
1688
1719
|
|
|
1689
|
-
def
|
|
1720
|
+
def _filesystem_fingerprint(repo: str, raw_path: str) -> str:
|
|
1721
|
+
root = Path(repo)
|
|
1722
|
+
target = (root / raw_path).resolve()
|
|
1723
|
+
try:
|
|
1724
|
+
root_resolved = root.resolve()
|
|
1725
|
+
common = os.path.commonpath([str(root_resolved), str(target)])
|
|
1726
|
+
if common != str(root_resolved):
|
|
1727
|
+
return "outside-repo"
|
|
1728
|
+
except Exception:
|
|
1729
|
+
return "unresolved"
|
|
1730
|
+
digest = hashlib.sha256()
|
|
1731
|
+
if not target.exists():
|
|
1732
|
+
return "missing"
|
|
1733
|
+
if target.is_file():
|
|
1734
|
+
digest.update(b"file\0")
|
|
1735
|
+
try:
|
|
1736
|
+
digest.update(str(target.stat().st_size).encode("utf-8"))
|
|
1737
|
+
with target.open("rb") as handle:
|
|
1738
|
+
while True:
|
|
1739
|
+
chunk = handle.read(1024 * 1024)
|
|
1740
|
+
if not chunk:
|
|
1741
|
+
break
|
|
1742
|
+
digest.update(chunk)
|
|
1743
|
+
except Exception as exc:
|
|
1744
|
+
digest.update(f"read-error:{type(exc).__name__}:{exc}".encode("utf-8", errors="replace"))
|
|
1745
|
+
return digest.hexdigest()
|
|
1746
|
+
if target.is_dir():
|
|
1747
|
+
digest.update(b"dir\0")
|
|
1748
|
+
files_seen = 0
|
|
1749
|
+
try:
|
|
1750
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
1751
|
+
dirnames.sort()
|
|
1752
|
+
filenames.sort()
|
|
1753
|
+
for filename in filenames:
|
|
1754
|
+
if files_seen >= 128:
|
|
1755
|
+
digest.update(b"\0truncated")
|
|
1756
|
+
return digest.hexdigest()
|
|
1757
|
+
child = Path(dirpath) / filename
|
|
1758
|
+
try:
|
|
1759
|
+
rel = child.relative_to(root_resolved).as_posix()
|
|
1760
|
+
except Exception:
|
|
1761
|
+
rel = child.name
|
|
1762
|
+
digest.update(rel.encode("utf-8", errors="replace"))
|
|
1763
|
+
digest.update(b"\0")
|
|
1764
|
+
digest.update(str(child.stat().st_size).encode("utf-8"))
|
|
1765
|
+
digest.update(b"\0")
|
|
1766
|
+
try:
|
|
1767
|
+
with child.open("rb") as handle:
|
|
1768
|
+
digest.update(handle.read(64 * 1024))
|
|
1769
|
+
except Exception as exc:
|
|
1770
|
+
digest.update(f"read-error:{type(exc).__name__}:{exc}".encode("utf-8", errors="replace"))
|
|
1771
|
+
files_seen += 1
|
|
1772
|
+
except Exception as exc:
|
|
1773
|
+
digest.update(f"walk-error:{type(exc).__name__}:{exc}".encode("utf-8", errors="replace"))
|
|
1774
|
+
return digest.hexdigest()
|
|
1775
|
+
return "special"
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def _changed_path_fingerprint(repo: str, path: str) -> str:
|
|
1779
|
+
normalized = str(path or "").strip()
|
|
1780
|
+
if not normalized:
|
|
1781
|
+
return ""
|
|
1782
|
+
digest = hashlib.sha256()
|
|
1783
|
+
digest.update(normalized.replace("\\", "/").encode("utf-8", errors="replace"))
|
|
1784
|
+
digest.update(b"\0fs\0")
|
|
1785
|
+
digest.update(_filesystem_fingerprint(repo, normalized).encode("utf-8", errors="replace"))
|
|
1786
|
+
return digest.hexdigest()
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _capture_git_change_snapshot(repo: str) -> Dict[str, str]:
|
|
1790
|
+
return {path: _changed_path_fingerprint(repo, path) for path in summarize_git_changes(repo)}
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _normalize_baseline_snapshot(repo: str, baseline_changes: Any) -> Dict[str, str]:
|
|
1794
|
+
if isinstance(baseline_changes, dict):
|
|
1795
|
+
return {
|
|
1796
|
+
str(path): str(fingerprint)
|
|
1797
|
+
for path, fingerprint in baseline_changes.items()
|
|
1798
|
+
if str(path or "").strip()
|
|
1799
|
+
}
|
|
1800
|
+
if isinstance(baseline_changes, list):
|
|
1801
|
+
return {
|
|
1802
|
+
str(path): _changed_path_fingerprint(repo, str(path))
|
|
1803
|
+
for path in baseline_changes
|
|
1804
|
+
if str(path or "").strip()
|
|
1805
|
+
}
|
|
1806
|
+
return _capture_git_change_snapshot(repo)
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def _codex_changed_paths(repo: str, baseline_snapshot: Any) -> Tuple[List[str], List[str], List[str]]:
|
|
1690
1810
|
changed_paths = summarize_git_changes(repo)
|
|
1691
|
-
delta =
|
|
1692
|
-
effective = [p for p in
|
|
1811
|
+
delta = _paths_changed_after_baseline(repo, changed_paths, baseline_snapshot)
|
|
1812
|
+
effective = [p for p in delta if _is_publishable_changed_path(p)]
|
|
1693
1813
|
return changed_paths, delta, effective
|
|
1694
1814
|
|
|
1695
1815
|
|
|
@@ -1851,7 +1971,7 @@ def _run_codex_task(
|
|
|
1851
1971
|
prompt,
|
|
1852
1972
|
model,
|
|
1853
1973
|
)
|
|
1854
|
-
baseline_snapshot =
|
|
1974
|
+
baseline_snapshot = _normalize_baseline_snapshot(repo, baseline_changes)
|
|
1855
1975
|
|
|
1856
1976
|
with tempfile.TemporaryDirectory(prefix="pushpals-codex-") as tmp_dir:
|
|
1857
1977
|
last_message_path = Path(tmp_dir) / "codex-last-message.txt"
|
|
@@ -36,6 +36,7 @@ from openai_codex_executor import (
|
|
|
36
36
|
_build_rollout_recovery_guidance,
|
|
37
37
|
_collect_disallowed_shell_wrapper_rejections,
|
|
38
38
|
_codex_changed_paths,
|
|
39
|
+
_capture_git_change_snapshot,
|
|
39
40
|
_describe_non_publishable_paths,
|
|
40
41
|
_detect_offtrack_rollout,
|
|
41
42
|
_detect_codex_workaround_signal,
|
|
@@ -944,6 +945,75 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
944
945
|
self.assertIn("too broad/noisy", str(result.get("stderr") or ""))
|
|
945
946
|
self.assertIn("area0", str(result.get("stderr") or ""))
|
|
946
947
|
|
|
948
|
+
def test_run_codex_task_timeout_ignores_broad_dirty_baseline(self) -> None:
|
|
949
|
+
with tempfile.TemporaryDirectory(prefix="pushpals-codex-timeout-dirty-baseline-") as temp_dir:
|
|
950
|
+
repo = Path(temp_dir) / "repo"
|
|
951
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
952
|
+
(repo / "README.md").write_text("# timeout dirty baseline repo\n", encoding="utf-8")
|
|
953
|
+
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
|
|
954
|
+
subprocess.run(
|
|
955
|
+
["git", "config", "user.name", "PushPals Test"],
|
|
956
|
+
cwd=repo,
|
|
957
|
+
check=True,
|
|
958
|
+
capture_output=True,
|
|
959
|
+
text=True,
|
|
960
|
+
)
|
|
961
|
+
subprocess.run(
|
|
962
|
+
["git", "config", "user.email", "pushpals-tests@example.com"],
|
|
963
|
+
cwd=repo,
|
|
964
|
+
check=True,
|
|
965
|
+
capture_output=True,
|
|
966
|
+
text=True,
|
|
967
|
+
)
|
|
968
|
+
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True, text=True)
|
|
969
|
+
subprocess.run(
|
|
970
|
+
["git", "commit", "-m", "chore: seed timeout dirty baseline repo"],
|
|
971
|
+
cwd=repo,
|
|
972
|
+
check=True,
|
|
973
|
+
capture_output=True,
|
|
974
|
+
text=True,
|
|
975
|
+
)
|
|
976
|
+
for index in range(5):
|
|
977
|
+
root = repo / f"area{index}"
|
|
978
|
+
root.mkdir(exist_ok=True)
|
|
979
|
+
(root / "changed.txt").write_text("pre-existing dirty change\n", encoding="utf-8")
|
|
980
|
+
|
|
981
|
+
stub_path = Path(temp_dir) / "fake_codex_timeout_dirty_baseline.py"
|
|
982
|
+
stub_path.write_text(
|
|
983
|
+
"\n".join(
|
|
984
|
+
[
|
|
985
|
+
"import sys",
|
|
986
|
+
"import time",
|
|
987
|
+
"",
|
|
988
|
+
"sys.stdin.read()",
|
|
989
|
+
"print('item.completed | Still thinking without changing baseline files.', flush=True)",
|
|
990
|
+
"time.sleep(5)",
|
|
991
|
+
]
|
|
992
|
+
),
|
|
993
|
+
encoding="utf-8",
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
env_overrides = {
|
|
997
|
+
"PUSHPALS_OPENAI_CODEX_BIN_JSON": json.dumps([sys.executable, str(stub_path)]),
|
|
998
|
+
"PUSHPALS_OPENAI_CODEX_AUTH_MODE": "api_key",
|
|
999
|
+
"OPENAI_API_KEY": "pushpals-timeout-dirty-baseline-test-key",
|
|
1000
|
+
"WORKERPALS_OPENAI_CODEX_TIMEOUT_S": "1",
|
|
1001
|
+
"WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "0",
|
|
1002
|
+
"WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S": "1",
|
|
1003
|
+
}
|
|
1004
|
+
with mock.patch.dict(os.environ, env_overrides, clear=False):
|
|
1005
|
+
result = _run_codex_task(
|
|
1006
|
+
str(repo),
|
|
1007
|
+
"Make a compact scoped patch, then continue thinking too long.",
|
|
1008
|
+
[],
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
self.assertFalse(result.get("ok"), result)
|
|
1012
|
+
self.assertEqual(result.get("exitCode"), 124)
|
|
1013
|
+
self.assertIn("execution timed out", str(result.get("summary") or ""))
|
|
1014
|
+
self.assertNotIn("broad/noisy", str(result.get("summary") or ""))
|
|
1015
|
+
self.assertNotIn("too broad/noisy", str(result.get("stderr") or ""))
|
|
1016
|
+
|
|
947
1017
|
def test_run_codex_task_retries_once_when_no_edit_watchdog_fires(self) -> None:
|
|
948
1018
|
with tempfile.TemporaryDirectory(prefix="pushpals-codex-no-edit-watchdog-") as temp_dir:
|
|
949
1019
|
repo = Path(temp_dir) / "repo"
|
|
@@ -1215,6 +1285,86 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
1215
1285
|
self.assertGreaterEqual(len(delta), 2)
|
|
1216
1286
|
self.assertEqual(effective, [])
|
|
1217
1287
|
|
|
1288
|
+
def test_codex_changed_paths_ignores_publishable_paths_dirty_at_baseline(self) -> None:
|
|
1289
|
+
with tempfile.TemporaryDirectory(prefix="pushpals-codex-dirty-baseline-") as temp_dir:
|
|
1290
|
+
repo = Path(temp_dir) / "repo"
|
|
1291
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
1292
|
+
(repo / "README.md").write_text("# dirty baseline repo\n", encoding="utf-8")
|
|
1293
|
+
(repo / "src").mkdir()
|
|
1294
|
+
(repo / "src" / "existing.ts").write_text("export const value = 1;\n", encoding="utf-8")
|
|
1295
|
+
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
|
|
1296
|
+
subprocess.run(
|
|
1297
|
+
["git", "config", "user.name", "PushPals Test"],
|
|
1298
|
+
cwd=repo,
|
|
1299
|
+
check=True,
|
|
1300
|
+
capture_output=True,
|
|
1301
|
+
text=True,
|
|
1302
|
+
)
|
|
1303
|
+
subprocess.run(
|
|
1304
|
+
["git", "config", "user.email", "pushpals-tests@example.com"],
|
|
1305
|
+
cwd=repo,
|
|
1306
|
+
check=True,
|
|
1307
|
+
capture_output=True,
|
|
1308
|
+
text=True,
|
|
1309
|
+
)
|
|
1310
|
+
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True, text=True)
|
|
1311
|
+
subprocess.run(
|
|
1312
|
+
["git", "commit", "-m", "chore: seed dirty baseline repo"],
|
|
1313
|
+
cwd=repo,
|
|
1314
|
+
check=True,
|
|
1315
|
+
capture_output=True,
|
|
1316
|
+
text=True,
|
|
1317
|
+
)
|
|
1318
|
+
(repo / "README.md").write_text("# dirty baseline repo\n\npre-existing edit\n", encoding="utf-8")
|
|
1319
|
+
(repo / "src" / "existing.ts").write_text("export const value = 2;\n", encoding="utf-8")
|
|
1320
|
+
baseline = _capture_git_change_snapshot(str(repo))
|
|
1321
|
+
|
|
1322
|
+
changed_paths, delta, effective = _codex_changed_paths(str(repo), baseline)
|
|
1323
|
+
|
|
1324
|
+
self.assertIn("README.md", changed_paths)
|
|
1325
|
+
self.assertEqual(delta, [])
|
|
1326
|
+
self.assertEqual(effective, [])
|
|
1327
|
+
|
|
1328
|
+
def test_codex_changed_paths_counts_worker_edits_to_dirty_baseline_paths(self) -> None:
|
|
1329
|
+
with tempfile.TemporaryDirectory(prefix="pushpals-codex-dirty-baseline-mutated-") as temp_dir:
|
|
1330
|
+
repo = Path(temp_dir) / "repo"
|
|
1331
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
1332
|
+
(repo / "README.md").write_text("# dirty baseline mutation repo\n", encoding="utf-8")
|
|
1333
|
+
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
|
|
1334
|
+
subprocess.run(
|
|
1335
|
+
["git", "config", "user.name", "PushPals Test"],
|
|
1336
|
+
cwd=repo,
|
|
1337
|
+
check=True,
|
|
1338
|
+
capture_output=True,
|
|
1339
|
+
text=True,
|
|
1340
|
+
)
|
|
1341
|
+
subprocess.run(
|
|
1342
|
+
["git", "config", "user.email", "pushpals-tests@example.com"],
|
|
1343
|
+
cwd=repo,
|
|
1344
|
+
check=True,
|
|
1345
|
+
capture_output=True,
|
|
1346
|
+
text=True,
|
|
1347
|
+
)
|
|
1348
|
+
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True, text=True)
|
|
1349
|
+
subprocess.run(
|
|
1350
|
+
["git", "commit", "-m", "chore: seed dirty baseline mutation repo"],
|
|
1351
|
+
cwd=repo,
|
|
1352
|
+
check=True,
|
|
1353
|
+
capture_output=True,
|
|
1354
|
+
text=True,
|
|
1355
|
+
)
|
|
1356
|
+
(repo / "README.md").write_text("# dirty baseline mutation repo\n\npre-existing edit\n", encoding="utf-8")
|
|
1357
|
+
baseline = _capture_git_change_snapshot(str(repo))
|
|
1358
|
+
(repo / "README.md").write_text(
|
|
1359
|
+
"# dirty baseline mutation repo\n\npre-existing edit\nworker edit\n",
|
|
1360
|
+
encoding="utf-8",
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
_, delta, effective = _codex_changed_paths(str(repo), baseline)
|
|
1364
|
+
|
|
1365
|
+
self.assertEqual(delta, ["README.md"])
|
|
1366
|
+
self.assertEqual(effective, ["README.md"])
|
|
1367
|
+
|
|
1218
1368
|
def test_non_publishable_path_summary_names_artifact_only_dirty_paths(self) -> None:
|
|
1219
1369
|
changed_paths = [
|
|
1220
1370
|
"node_modules/react/index.js",
|