do-again 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {do_again-0.1.1 → do_again-0.2.0}/.github/workflows/npm-release.yml +16 -0
  2. {do_again-0.1.1 → do_again-0.2.0}/PKG-INFO +24 -6
  3. {do_again-0.1.1 → do_again-0.2.0}/README.md +23 -5
  4. {do_again-0.1.1 → do_again-0.2.0}/package-lock.json +2 -2
  5. {do_again-0.1.1 → do_again-0.2.0}/package.json +3 -2
  6. {do_again-0.1.1 → do_again-0.2.0}/pyproject.toml +1 -1
  7. do_again-0.2.0/src/do_again/cli.py +137 -0
  8. do_again-0.2.0/src/do_again/service/__init__.py +3 -0
  9. do_again-0.2.0/src/do_again/service/runtime.py +372 -0
  10. do_again-0.2.0/templates/policy.json +137 -0
  11. do_again-0.2.0/tests/test_service.py +62 -0
  12. do_again-0.1.1/src/do_again/cli.py +0 -63
  13. do_again-0.1.1/src/do_again/service/__init__.py +0 -0
  14. {do_again-0.1.1 → do_again-0.2.0}/.github/dependabot.yml +0 -0
  15. {do_again-0.1.1 → do_again-0.2.0}/.github/workflows/ci.yml +0 -0
  16. {do_again-0.1.1 → do_again-0.2.0}/.github/workflows/release.yml +0 -0
  17. {do_again-0.1.1 → do_again-0.2.0}/.gitignore +0 -0
  18. {do_again-0.1.1 → do_again-0.2.0}/.npmignore +0 -0
  19. {do_again-0.1.1 → do_again-0.2.0}/LICENSE +0 -0
  20. {do_again-0.1.1 → do_again-0.2.0}/bin/do-again.js +0 -0
  21. {do_again-0.1.1 → do_again-0.2.0}/npm-tests/launcher.test.js +0 -0
  22. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/__init__.py +0 -0
  23. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/__init__.py +0 -0
  24. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/agent.py +0 -0
  25. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/executor.py +0 -0
  26. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/core/schema.py +0 -0
  27. /do_again-0.1.1/templates/policy.json → /do_again-0.2.0/src/do_again/default_policy.json +0 -0
  28. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/__init__.py +0 -0
  29. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/base.py +0 -0
  30. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/detect.py +0 -0
  31. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/linux.py +0 -0
  32. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/macos.py +0 -0
  33. {do_again-0.1.1 → do_again-0.2.0}/src/do_again/platforms/windows.py +0 -0
  34. {do_again-0.1.1 → do_again-0.2.0}/tests/test_executor.py +0 -0
  35. {do_again-0.1.1 → do_again-0.2.0}/tests/test_platforms.py +0 -0
  36. {do_again-0.1.1 → do_again-0.2.0}/tools/check_release_version.py +0 -0
@@ -32,3 +32,19 @@ jobs:
32
32
  - run: npm ci
33
33
  - run: npm test
34
34
  - run: npm publish
35
+ - name: Wait for npm registry availability
36
+ shell: bash
37
+ run: |
38
+ version=$(node -p "require('./package.json').version")
39
+ for attempt in $(seq 1 120); do
40
+ published=$(npm view "do-again@$version" version 2>/dev/null || true)
41
+ latest=$(npm view do-again dist-tags.latest 2>/dev/null || true)
42
+ if [ "$published" = "$version" ] && [ "$latest" = "$version" ]; then
43
+ echo "do-again@$version is available from the public npm registry"
44
+ exit 0
45
+ fi
46
+ echo "Waiting for npm registry propagation ($attempt/120)..."
47
+ sleep 15
48
+ done
49
+ echo "Timed out waiting for do-again@$version to become publicly available" >&2
50
+ exit 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: do-again
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Policy-controlled local execution for agent-driven development workflows
5
5
  Project-URL: Homepage, https://github.com/Tran-Steven/do-again
6
6
  Project-URL: Repository, https://github.com/Tran-Steven/do-again
@@ -94,9 +94,27 @@ The npm package ships the same Python runtime from this repository behind a smal
94
94
  ```bash
95
95
  do-again doctor
96
96
  do-again init
97
+ do-again install
97
98
  do-again status
98
99
  ```
99
100
 
101
+ ## Service lifecycle
102
+
103
+ On macOS, Do Again can install a per-repository launchd agent:
104
+
105
+ do-again install
106
+ do-again status
107
+ do-again restart
108
+ do-again stop
109
+ do-again uninstall
110
+
111
+ For attended or cross-platform use, run the agent in the foreground:
112
+
113
+ do-again run
114
+ do-again run --once
115
+
116
+ Each repository gets an isolated runtime, control worktree, state directory, policy copy, and service label under ~/.do_again. The installed service runs from a copied runtime so installs made through either PyPI or npm remain stable after the invoking shell exits.
117
+
100
118
  ## Why Do Again
101
119
 
102
120
  - Explicit policy-based command permissions
@@ -111,11 +129,11 @@ do-again status
111
129
 
112
130
  | Platform | Status | Service backend |
113
131
  | --- | --- | --- |
114
- | macOS | Core working | launchd |
115
- | Linux | Adapter scaffolded | systemd user service |
116
- | Windows | Adapter scaffolded | Windows service |
132
+ | macOS | Service lifecycle working | launchd |
133
+ | Linux | Foreground runner working; service adapter scaffolded | systemd user service |
134
+ | Windows | Foreground runner working; service adapter scaffolded | Windows service |
117
135
 
118
- Do Again is currently alpha software. macOS is the first active service platform while Linux and Windows service integration are being completed behind the same interface.
136
+ Do Again is currently alpha software. The foreground runner works across the supported Python platforms; macOS is the first platform with an installable background service while Linux and Windows service integration are being completed behind the same interface.
119
137
 
120
138
  ## Development
121
139
 
@@ -129,7 +147,7 @@ do-again doctor
129
147
 
130
148
  ## Release model
131
149
 
132
- Releases use semantic versioning. Pushing a version tag such as `v0.1.1` validates the shared release version, publishes the Python distribution to PyPI and the Node launcher to npm through Trusted Publishing, and creates the matching GitHub Release.
150
+ Releases use semantic versioning. Pushing a version tag such as `v0.2.0` validates the shared release version, publishes the Python distribution to PyPI and the Node launcher to npm through Trusted Publishing, and creates the matching GitHub Release.
133
151
 
134
152
  PyPI distribution name: `do-again`
135
153
 
@@ -59,9 +59,27 @@ The npm package ships the same Python runtime from this repository behind a smal
59
59
  ```bash
60
60
  do-again doctor
61
61
  do-again init
62
+ do-again install
62
63
  do-again status
63
64
  ```
64
65
 
66
+ ## Service lifecycle
67
+
68
+ On macOS, Do Again can install a per-repository launchd agent:
69
+
70
+ do-again install
71
+ do-again status
72
+ do-again restart
73
+ do-again stop
74
+ do-again uninstall
75
+
76
+ For attended or cross-platform use, run the agent in the foreground:
77
+
78
+ do-again run
79
+ do-again run --once
80
+
81
+ Each repository gets an isolated runtime, control worktree, state directory, policy copy, and service label under ~/.do_again. The installed service runs from a copied runtime so installs made through either PyPI or npm remain stable after the invoking shell exits.
82
+
65
83
  ## Why Do Again
66
84
 
67
85
  - Explicit policy-based command permissions
@@ -76,11 +94,11 @@ do-again status
76
94
 
77
95
  | Platform | Status | Service backend |
78
96
  | --- | --- | --- |
79
- | macOS | Core working | launchd |
80
- | Linux | Adapter scaffolded | systemd user service |
81
- | Windows | Adapter scaffolded | Windows service |
97
+ | macOS | Service lifecycle working | launchd |
98
+ | Linux | Foreground runner working; service adapter scaffolded | systemd user service |
99
+ | Windows | Foreground runner working; service adapter scaffolded | Windows service |
82
100
 
83
- Do Again is currently alpha software. macOS is the first active service platform while Linux and Windows service integration are being completed behind the same interface.
101
+ Do Again is currently alpha software. The foreground runner works across the supported Python platforms; macOS is the first platform with an installable background service while Linux and Windows service integration are being completed behind the same interface.
84
102
 
85
103
  ## Development
86
104
 
@@ -94,7 +112,7 @@ do-again doctor
94
112
 
95
113
  ## Release model
96
114
 
97
- Releases use semantic versioning. Pushing a version tag such as `v0.1.1` validates the shared release version, publishes the Python distribution to PyPI and the Node launcher to npm through Trusted Publishing, and creates the matching GitHub Release.
115
+ Releases use semantic versioning. Pushing a version tag such as `v0.2.0` validates the shared release version, publishes the Python distribution to PyPI and the Node launcher to npm through Trusted Publishing, and creates the matching GitHub Release.
98
116
 
99
117
  PyPI distribution name: `do-again`
100
118
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "do-again",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "do-again",
9
- "version": "0.1.1",
9
+ "version": "0.2.0",
10
10
  "license": "MIT",
11
11
  "bin": {
12
12
  "do-again": "bin/do-again.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "do-again",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Policy-controlled local execution for AI coding agents",
5
5
  "license": "MIT",
6
6
  "author": "Steven Tran",
@@ -12,7 +12,8 @@
12
12
  "bin/",
13
13
  "src/do_again/**/*.py",
14
14
  "README.md",
15
- "LICENSE"
15
+ "LICENSE",
16
+ "src/do_again/default_policy.json"
16
17
  ],
17
18
  "engines": {
18
19
  "node": ">=18"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "do-again"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Policy-controlled local execution for agent-driven development workflows"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .platforms.detect import detect_platform
9
+ from .service.runtime import (
10
+ ServiceError,
11
+ find_repo,
12
+ install_service,
13
+ restart_service,
14
+ run_foreground,
15
+ service_status,
16
+ stop_service,
17
+ uninstall_service,
18
+ )
19
+
20
+
21
+ def doctor() -> int:
22
+ info = detect_platform()
23
+ checks = {
24
+ "python": sys.version_info >= (3, 11),
25
+ "git": shutil.which("git") is not None,
26
+ "platform": info.supported,
27
+ }
28
+ for key, ok in checks.items():
29
+ print(f"{'OK' if ok else 'FAIL'} {key}")
30
+ print(f"platform_name={info.name}")
31
+ print(f"service_manager={info.service_manager}")
32
+ return 0 if all(checks.values()) else 1
33
+
34
+
35
+ def _print_service_status(value: dict[str, object]) -> None:
36
+ for key in (
37
+ "platform",
38
+ "service_manager",
39
+ "repo",
40
+ "label",
41
+ "installed",
42
+ "running",
43
+ "pid",
44
+ "runtime_dir",
45
+ "control_worktree",
46
+ ):
47
+ if key in value:
48
+ print(f"{key}={value[key]}")
49
+
50
+
51
+ def status(path: str = ".") -> int:
52
+ info = detect_platform()
53
+ try:
54
+ repo = find_repo(path)
55
+ except ServiceError:
56
+ print(f"platform={info.name}")
57
+ print(f"service_manager={info.service_manager}")
58
+ return 0
59
+ try:
60
+ value = service_status(repo)
61
+ except ServiceError as exc:
62
+ print(f"do-again: {exc}", file=sys.stderr)
63
+ return 1
64
+ _print_service_status(value)
65
+ return 0
66
+
67
+
68
+ def init_project(path: str) -> int:
69
+ target = Path(path).expanduser().resolve()
70
+ target.mkdir(parents=True, exist_ok=True)
71
+ config = target / "do-again.toml"
72
+ if config.exists():
73
+ print(str(config))
74
+ return 0
75
+ config.write_text('[do_again]\ncontrol_branch = "operator-control"\n')
76
+ print(str(config))
77
+ return 0
78
+
79
+
80
+ def _service_action(action: str, path: str) -> int:
81
+ try:
82
+ if action == "install":
83
+ value = install_service(path)
84
+ elif action == "stop":
85
+ value = stop_service(path)
86
+ elif action == "restart":
87
+ value = restart_service(path)
88
+ elif action == "uninstall":
89
+ value = uninstall_service(path)
90
+ else:
91
+ raise ServiceError(f"unsupported service action: {action}")
92
+ except ServiceError as exc:
93
+ print(f"do-again: {exc}", file=sys.stderr)
94
+ return 1
95
+ _print_service_status(value)
96
+ return 0
97
+
98
+
99
+ def main() -> int:
100
+ parser = argparse.ArgumentParser(prog="do-again")
101
+ sub = parser.add_subparsers(dest="command", required=True)
102
+ sub.add_parser("doctor")
103
+
104
+ status_parser = sub.add_parser("status")
105
+ status_parser.add_argument("path", nargs="?", default=".")
106
+
107
+ init_parser = sub.add_parser("init")
108
+ init_parser.add_argument("path", nargs="?", default=".")
109
+
110
+ for name in ("install", "stop", "restart", "uninstall"):
111
+ command = sub.add_parser(name)
112
+ command.add_argument("path", nargs="?", default=".")
113
+
114
+ run_parser = sub.add_parser("run")
115
+ run_parser.add_argument("path", nargs="?", default=".")
116
+ run_parser.add_argument("--once", action="store_true")
117
+
118
+ args = parser.parse_args()
119
+ if args.command == "doctor":
120
+ return doctor()
121
+ if args.command == "status":
122
+ return status(args.path)
123
+ if args.command == "init":
124
+ return init_project(args.path)
125
+ if args.command in {"install", "stop", "restart", "uninstall"}:
126
+ return _service_action(args.command, args.path)
127
+ if args.command == "run":
128
+ try:
129
+ return run_foreground(args.path, once=args.once)
130
+ except ServiceError as exc:
131
+ print(f"do-again: {exc}", file=sys.stderr)
132
+ return 1
133
+ return 2
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from .runtime import ServiceError, install_service, restart_service, run_foreground, service_status, stop_service, uninstall_service
2
+
3
+ __all__ = ["ServiceError", "install_service", "restart_service", "run_foreground", "service_status", "stop_service", "uninstall_service"]
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import plistlib
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import tomllib
13
+ from dataclasses import asdict, dataclass
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..core.agent import Agent
19
+ from ..platforms.detect import detect_platform
20
+ from ..platforms.macos import service_definition_path
21
+
22
+
23
+ class ServiceError(RuntimeError):
24
+ pass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RuntimeLayout:
29
+ repo: Path
30
+ branch: str
31
+ key: str
32
+ label: str
33
+ root: Path
34
+ control_worktree: Path
35
+ state_dir: Path
36
+ policy_path: Path
37
+ runtime_source: Path
38
+ metadata_path: Path
39
+ stdout_log: Path
40
+ stderr_log: Path
41
+
42
+
43
+ def _run(argv: list[str], *, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
44
+ proc = subprocess.run(argv, cwd=cwd, text=True, capture_output=True)
45
+ if check and proc.returncode != 0:
46
+ detail = (proc.stderr or proc.stdout).strip()
47
+ raise ServiceError(f"{' '.join(argv)} failed ({proc.returncode}): {detail}")
48
+ return proc
49
+
50
+
51
+ def find_repo(path: str | Path = ".") -> Path:
52
+ candidate = Path(path).expanduser().resolve()
53
+ proc = _run(["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], check=False)
54
+ if proc.returncode != 0:
55
+ raise ServiceError(f"not inside a Git repository: {candidate}")
56
+ return Path(proc.stdout.strip()).resolve()
57
+
58
+
59
+ def _config_branch(repo: Path) -> str:
60
+ config = repo / "do-again.toml"
61
+ if not config.is_file():
62
+ return "operator-control"
63
+ try:
64
+ value = tomllib.loads(config.read_text(encoding="utf-8"))
65
+ except (OSError, tomllib.TOMLDecodeError) as exc:
66
+ raise ServiceError(f"invalid Do Again config: {config}: {exc}") from exc
67
+ branch = str(value.get("do_again", {}).get("control_branch", "operator-control")).strip()
68
+ if not re.fullmatch(r"[A-Za-z0-9._/-]{1,120}", branch) or branch.startswith("-") or ".." in branch:
69
+ raise ServiceError(f"invalid control branch: {branch!r}")
70
+ return branch
71
+
72
+
73
+ def runtime_layout(repo: Path) -> RuntimeLayout:
74
+ repo = repo.resolve()
75
+ branch = _config_branch(repo)
76
+ key = hashlib.sha256(str(repo).encode("utf-8")).hexdigest()[:12]
77
+ label = f"io.github.tran-steven.do-again.{key}"
78
+ home = Path(os.environ.get("DO_AGAIN_HOME", str(Path.home() / ".do_again"))).expanduser().resolve()
79
+ root = home / "projects" / key
80
+ return RuntimeLayout(
81
+ repo=repo,
82
+ branch=branch,
83
+ key=key,
84
+ label=label,
85
+ root=root,
86
+ control_worktree=root / "control",
87
+ state_dir=root / "state",
88
+ policy_path=root / "policy.json",
89
+ runtime_source=root / "runtime",
90
+ metadata_path=root / "runtime.json",
91
+ stdout_log=root / "logs" / "agent.out.log",
92
+ stderr_log=root / "logs" / "agent.err.log",
93
+ )
94
+
95
+
96
+ def _default_policy() -> dict[str, Any]:
97
+ text = resources.files("do_again").joinpath("default_policy.json").read_text(encoding="utf-8")
98
+ value = json.loads(text)
99
+ if not isinstance(value, dict):
100
+ raise ServiceError("bundled default policy is invalid")
101
+ return value
102
+
103
+
104
+ def _ensure_remote_control_branch(layout: RuntimeLayout) -> None:
105
+ remote = _run(
106
+ ["git", "-C", str(layout.repo), "ls-remote", "--exit-code", "--heads", "origin", layout.branch],
107
+ check=False,
108
+ )
109
+ if remote.returncode != 0:
110
+ _run(["git", "-C", str(layout.repo), "push", "origin", f"HEAD:refs/heads/{layout.branch}"])
111
+ _run(["git", "-C", str(layout.repo), "fetch", "--quiet", "origin", layout.branch])
112
+
113
+
114
+ def _ensure_control_worktree(layout: RuntimeLayout) -> None:
115
+ if layout.control_worktree.exists():
116
+ check = _run(
117
+ ["git", "-C", str(layout.control_worktree), "rev-parse", "--show-toplevel"],
118
+ check=False,
119
+ )
120
+ if check.returncode != 0:
121
+ raise ServiceError(f"control path exists but is not a Git worktree: {layout.control_worktree}")
122
+ dirty = _run(["git", "-C", str(layout.control_worktree), "status", "--porcelain"])
123
+ if dirty.stdout.strip():
124
+ raise ServiceError(f"control worktree is dirty: {layout.control_worktree}")
125
+ return
126
+ layout.control_worktree.parent.mkdir(parents=True, exist_ok=True)
127
+ _run(
128
+ [
129
+ "git",
130
+ "-C",
131
+ str(layout.repo),
132
+ "worktree",
133
+ "add",
134
+ "--detach",
135
+ str(layout.control_worktree),
136
+ f"origin/{layout.branch}",
137
+ ]
138
+ )
139
+
140
+
141
+ def _copy_runtime(layout: RuntimeLayout) -> None:
142
+ package_root = Path(__file__).resolve().parents[1]
143
+ target = layout.runtime_source / "do_again"
144
+ if target.exists():
145
+ shutil.rmtree(target)
146
+ layout.runtime_source.mkdir(parents=True, exist_ok=True)
147
+ shutil.copytree(
148
+ package_root,
149
+ target,
150
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
151
+ )
152
+
153
+
154
+ def prepare_runtime(repo: Path) -> RuntimeLayout:
155
+ layout = runtime_layout(repo)
156
+ layout.state_dir.mkdir(parents=True, exist_ok=True)
157
+ layout.stdout_log.parent.mkdir(parents=True, exist_ok=True)
158
+ _ensure_remote_control_branch(layout)
159
+ _ensure_control_worktree(layout)
160
+
161
+ policy = _default_policy()
162
+ policy["control_branch"] = layout.branch
163
+ policy["agent_launchd_label"] = layout.label
164
+ layout.policy_path.write_text(json.dumps(policy, indent=2, sort_keys=True) + "\n", encoding="utf-8")
165
+ _copy_runtime(layout)
166
+
167
+ metadata = {
168
+ "schema_version": 1,
169
+ "repo": str(layout.repo),
170
+ "control_branch": layout.branch,
171
+ "control_worktree": str(layout.control_worktree),
172
+ "state_dir": str(layout.state_dir),
173
+ "policy": str(layout.policy_path),
174
+ "service_label": layout.label,
175
+ }
176
+ layout.metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
177
+ return layout
178
+
179
+
180
+ def _domain() -> str:
181
+ return f"gui/{os.getuid()}"
182
+
183
+
184
+ def _launchd_plist(layout: RuntimeLayout) -> dict[str, Any]:
185
+ path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin")
186
+ return {
187
+ "Label": layout.label,
188
+ "ProgramArguments": [
189
+ sys.executable,
190
+ "-m",
191
+ "do_again.core.agent",
192
+ "--repo",
193
+ str(layout.repo),
194
+ "--control-worktree",
195
+ str(layout.control_worktree),
196
+ "--branch",
197
+ layout.branch,
198
+ "--policy",
199
+ str(layout.policy_path),
200
+ "--state-dir",
201
+ str(layout.state_dir),
202
+ ],
203
+ "WorkingDirectory": str(layout.repo),
204
+ "EnvironmentVariables": {
205
+ "PATH": path,
206
+ "PYTHONPATH": str(layout.runtime_source),
207
+ "PYTHONDONTWRITEBYTECODE": "1",
208
+ "PYTHONUNBUFFERED": "1",
209
+ },
210
+ "RunAtLoad": True,
211
+ "KeepAlive": True,
212
+ "ProcessType": "Background",
213
+ "ThrottleInterval": 5,
214
+ "StandardOutPath": str(layout.stdout_log),
215
+ "StandardErrorPath": str(layout.stderr_log),
216
+ }
217
+
218
+
219
+ def _require_macos() -> None:
220
+ info = detect_platform()
221
+ if info.name != "macos":
222
+ raise ServiceError(
223
+ f"background service lifecycle is currently supported on macOS only; detected {info.name}"
224
+ )
225
+
226
+
227
+ def _launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
228
+ return _run(["launchctl", *args], check=check)
229
+
230
+
231
+ def _loaded(layout: RuntimeLayout) -> bool:
232
+ return _launchctl("print", f"{_domain()}/{layout.label}", check=False).returncode == 0
233
+
234
+
235
+ def _wait_loaded(layout: RuntimeLayout, expected: bool, timeout: float = 8.0) -> None:
236
+ deadline = time.monotonic() + timeout
237
+ while time.monotonic() < deadline:
238
+ if _loaded(layout) is expected:
239
+ return
240
+ time.sleep(0.1)
241
+ state = "loaded" if expected else "unloaded"
242
+ raise ServiceError(f"launchd service did not become {state}: {layout.label}")
243
+
244
+
245
+ def service_status(path: str | Path = ".") -> dict[str, Any]:
246
+ repo = find_repo(path)
247
+ layout = runtime_layout(repo)
248
+ info = detect_platform()
249
+ result: dict[str, Any] = {
250
+ "repo": str(repo),
251
+ "platform": info.name,
252
+ "service_manager": info.service_manager,
253
+ "label": layout.label,
254
+ "runtime_dir": str(layout.root),
255
+ "control_worktree": str(layout.control_worktree),
256
+ "installed": False,
257
+ "running": False,
258
+ "pid": None,
259
+ }
260
+ if info.name != "macos":
261
+ return result
262
+ plist = service_definition_path(layout.label)
263
+ result["installed"] = plist.is_file()
264
+ probe = _launchctl("print", f"{_domain()}/{layout.label}", check=False)
265
+ if probe.returncode == 0:
266
+ result["running"] = True
267
+ match = re.search(r"\bpid\s*=\s*(\d+)", probe.stdout)
268
+ if match:
269
+ result["pid"] = int(match.group(1))
270
+ return result
271
+
272
+
273
+ def install_service(path: str | Path = ".") -> dict[str, Any]:
274
+ _require_macos()
275
+ repo = find_repo(path)
276
+ existing = runtime_layout(repo)
277
+ if _loaded(existing):
278
+ proc = _launchctl("bootout", f"{_domain()}/{existing.label}", check=False)
279
+ if proc.returncode != 0:
280
+ raise ServiceError(f"launchctl bootout failed: {(proc.stderr or proc.stdout).strip()}")
281
+ _wait_loaded(existing, False)
282
+
283
+ layout = prepare_runtime(repo)
284
+ plist = service_definition_path(layout.label)
285
+ plist.parent.mkdir(parents=True, exist_ok=True)
286
+ plist.write_bytes(plistlib.dumps(_launchd_plist(layout), sort_keys=True))
287
+ os.chmod(plist, 0o644)
288
+
289
+ boot = _launchctl("bootstrap", _domain(), str(plist), check=False)
290
+ if boot.returncode != 0:
291
+ raise ServiceError(f"launchctl bootstrap failed: {(boot.stderr or boot.stdout).strip()}")
292
+ _wait_loaded(layout, True)
293
+ return service_status(repo)
294
+
295
+
296
+ def stop_service(path: str | Path = ".") -> dict[str, Any]:
297
+ _require_macos()
298
+ repo = find_repo(path)
299
+ layout = runtime_layout(repo)
300
+ before = _loaded(layout)
301
+ proc = _launchctl("bootout", f"{_domain()}/{layout.label}", check=False)
302
+ if before and proc.returncode != 0:
303
+ raise ServiceError(f"launchctl bootout failed: {(proc.stderr or proc.stdout).strip()}")
304
+ if before:
305
+ _wait_loaded(layout, False)
306
+ return service_status(repo)
307
+
308
+
309
+ def restart_service(path: str | Path = ".") -> dict[str, Any]:
310
+ _require_macos()
311
+ repo = find_repo(path)
312
+ layout = runtime_layout(repo)
313
+ plist = service_definition_path(layout.label)
314
+ if not plist.is_file():
315
+ raise ServiceError("Do Again is not installed for this repository; run do-again install first")
316
+ current = service_status(repo)
317
+ if current["running"]:
318
+ proc = _launchctl("kickstart", "-k", f"{_domain()}/{layout.label}", check=False)
319
+ else:
320
+ proc = _launchctl("bootstrap", _domain(), str(plist), check=False)
321
+ if proc.returncode != 0:
322
+ raise ServiceError(f"launchctl restart failed: {(proc.stderr or proc.stdout).strip()}")
323
+ _wait_loaded(layout, True)
324
+ return service_status(repo)
325
+
326
+
327
+ def uninstall_service(path: str | Path = ".") -> dict[str, Any]:
328
+ _require_macos()
329
+ repo = find_repo(path)
330
+ layout = runtime_layout(repo)
331
+ if _loaded(layout):
332
+ proc = _launchctl("bootout", f"{_domain()}/{layout.label}", check=False)
333
+ if proc.returncode != 0:
334
+ raise ServiceError(f"launchctl bootout failed: {(proc.stderr or proc.stdout).strip()}")
335
+ _wait_loaded(layout, False)
336
+ plist = service_definition_path(layout.label)
337
+ try:
338
+ plist.unlink()
339
+ except FileNotFoundError:
340
+ pass
341
+ if layout.control_worktree.exists():
342
+ _run(
343
+ ["git", "-C", str(repo), "worktree", "remove", "--force", str(layout.control_worktree)],
344
+ check=False,
345
+ )
346
+ if layout.root.exists():
347
+ shutil.rmtree(layout.root)
348
+ return {
349
+ "repo": str(repo),
350
+ "label": layout.label,
351
+ "installed": False,
352
+ "running": False,
353
+ }
354
+
355
+
356
+ def run_foreground(path: str | Path = ".", *, once: bool = False) -> int:
357
+ repo = find_repo(path)
358
+ if detect_platform().name == "macos" and service_status(repo)["running"]:
359
+ raise ServiceError("background service is already running; stop it before using foreground mode")
360
+ layout = prepare_runtime(repo)
361
+ agent = Agent(
362
+ repo=repo,
363
+ control_worktree=layout.control_worktree,
364
+ branch=layout.branch,
365
+ policy_path=layout.policy_path,
366
+ state_dir=layout.state_dir,
367
+ )
368
+ return agent.run(once=once)
369
+
370
+
371
+ def layout_as_dict(path: str | Path = ".") -> dict[str, Any]:
372
+ return asdict(runtime_layout(find_repo(path)))
@@ -0,0 +1,137 @@
1
+ {
2
+ "schema_version": 1,
3
+ "control_branch": "operator-control",
4
+ "poll_seconds": 3,
5
+ "max_request_ttl_seconds": 3600,
6
+ "default_timeout_seconds": 120,
7
+ "absolute_timeout_seconds": 3600,
8
+ "max_output_bytes": 262144,
9
+ "allowed_operations": [
10
+ "status",
11
+ "read_file",
12
+ "run_tests",
13
+ "repo_script",
14
+ "control_plane_launcher",
15
+ "deploy_cp1_bundle_at_stop",
16
+ "scratch_script",
17
+ "extended_exec",
18
+ "process_signal",
19
+ "launchctl_action",
20
+ "self_update",
21
+ "artifact_get",
22
+ "artifact_put",
23
+ "capture_screenshot",
24
+ "full_process_list"
25
+ ],
26
+ "approved_cwd_roots": [
27
+ "$REPO",
28
+ "$HOME/.do_again_control_plane",
29
+ "$HOME/.do_again_bridge",
30
+ "$HOME/.do_again",
31
+ "/tmp"
32
+ ],
33
+ "read_roots": [
34
+ "$REPO",
35
+ "$HOME/.do_again_control_plane",
36
+ "$HOME/.do_again_bridge",
37
+ "$HOME/.do_again",
38
+ "/tmp"
39
+ ],
40
+ "repo_script_prefixes": [
41
+ "tools/",
42
+ "control_plane/",
43
+ "scripts/"
44
+ ],
45
+ "allowed_launcher_subcommands": [
46
+ "session-intake",
47
+ "session-resolution"
48
+ ],
49
+ "extended_exec_binaries": [
50
+ "python3",
51
+ "git",
52
+ "bash",
53
+ "zsh",
54
+ "sh",
55
+ "ps",
56
+ "jq",
57
+ "cat",
58
+ "tail",
59
+ "head",
60
+ "grep",
61
+ "rg",
62
+ "find",
63
+ "sed",
64
+ "ls",
65
+ "stat",
66
+ "shasum",
67
+ "date",
68
+ "sleep",
69
+ "wc",
70
+ "sort",
71
+ "uniq",
72
+ "cut",
73
+ "tr",
74
+ "pwd",
75
+ "uname",
76
+ "df",
77
+ "du",
78
+ "lsof",
79
+ "vm_stat",
80
+ "networkQuality"
81
+ ],
82
+ "git_allowed_subcommands": [
83
+ "status",
84
+ "fetch",
85
+ "pull",
86
+ "log",
87
+ "show",
88
+ "diff",
89
+ "rev-parse",
90
+ "branch",
91
+ "switch",
92
+ "checkout",
93
+ "worktree",
94
+ "merge-base",
95
+ "cat-file",
96
+ "ls-tree",
97
+ "remote"
98
+ ],
99
+ "launchctl_allowed_subcommands": [
100
+ "print",
101
+ "kickstart",
102
+ "bootstrap",
103
+ "bootout"
104
+ ],
105
+ "launchctl_label_prefixes": [
106
+ "io.github.tran-steven."
107
+ ],
108
+ "process_signal_allowlist": [
109
+ "TERM",
110
+ "INT",
111
+ "HUP",
112
+ "KILL"
113
+ ],
114
+ "python_allowed_modules": [
115
+ "unittest",
116
+ "py_compile"
117
+ ],
118
+ "hard_denied_binaries": [
119
+ "sudo",
120
+ "su",
121
+ "doas",
122
+ "rm",
123
+ "dd",
124
+ "diskutil",
125
+ "fdisk",
126
+ "mkfs",
127
+ "mount",
128
+ "umount",
129
+ "docker",
130
+ "podman"
131
+ ],
132
+ "max_scratch_script_bytes": 65536,
133
+ "max_inline_artifact_bytes": 450000,
134
+ "max_screenshot_width": 1440,
135
+ "screenshot_jpeg_quality": 55,
136
+ "agent_launchd_label": "io.github.tran-steven.do-again"
137
+ }
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from do_again.platforms.base import PlatformInfo
10
+ from do_again.service.runtime import (
11
+ ServiceError,
12
+ _default_policy,
13
+ _launchd_plist,
14
+ runtime_layout,
15
+ )
16
+
17
+
18
+ class ServiceRuntimeTests(unittest.TestCase):
19
+ def test_runtime_layout_is_stable_and_project_scoped(self) -> None:
20
+ with tempfile.TemporaryDirectory() as tmp:
21
+ root = Path(tmp)
22
+ repo = root / "repo"
23
+ repo.mkdir()
24
+ with patch.dict(os.environ, {"DO_AGAIN_HOME": str(root / "home")}):
25
+ first = runtime_layout(repo)
26
+ second = runtime_layout(repo)
27
+ self.assertEqual(first.key, second.key)
28
+ self.assertEqual(first.label, second.label)
29
+ self.assertTrue(first.label.startswith("io.github.tran-steven.do-again."))
30
+ self.assertEqual(first.root.parent, (root / "home" / "projects").resolve())
31
+
32
+ def test_default_policy_is_packaged(self) -> None:
33
+ policy = _default_policy()
34
+ self.assertEqual(policy["schema_version"], 1)
35
+ self.assertIn("scratch_script", policy["allowed_operations"])
36
+ self.assertTrue(policy["agent_launchd_label"].startswith("io.github.tran-steven.do-again"))
37
+
38
+ def test_launchd_plist_uses_copied_runtime(self) -> None:
39
+ with tempfile.TemporaryDirectory() as tmp:
40
+ root = Path(tmp)
41
+ repo = root / "repo"
42
+ repo.mkdir()
43
+ with patch.dict(os.environ, {"DO_AGAIN_HOME": str(root / "home")}):
44
+ layout = runtime_layout(repo)
45
+ value = _launchd_plist(layout)
46
+ self.assertEqual(value["Label"], layout.label)
47
+ self.assertEqual(value["WorkingDirectory"], str(repo.resolve()))
48
+ self.assertEqual(value["EnvironmentVariables"]["PYTHONPATH"], str(layout.runtime_source))
49
+ self.assertIn("do_again.core.agent", value["ProgramArguments"])
50
+ self.assertTrue(value["KeepAlive"])
51
+
52
+ @patch("do_again.service.runtime.detect_platform")
53
+ def test_background_lifecycle_rejects_non_macos(self, detect) -> None:
54
+ detect.return_value = PlatformInfo(name="linux", service_manager="systemd", supported=True)
55
+ from do_again.service.runtime import install_service
56
+
57
+ with self.assertRaises(ServiceError):
58
+ install_service(".")
59
+
60
+
61
+ if __name__ == "__main__":
62
+ unittest.main()
@@ -1,63 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import shutil
5
- import subprocess
6
- import sys
7
- from pathlib import Path
8
-
9
- from .platforms.detect import detect_platform
10
-
11
-
12
- def doctor() -> int:
13
- info = detect_platform()
14
- checks = {
15
- "python": sys.version_info >= (3, 11),
16
- "git": shutil.which("git") is not None,
17
- "platform": info.supported,
18
- }
19
- for key, ok in checks.items():
20
- print(f"{'OK' if ok else 'FAIL'} {key}")
21
- print(f"platform_name={info.name}")
22
- print(f"service_manager={info.service_manager}")
23
- return 0 if all(checks.values()) else 1
24
-
25
-
26
- def status() -> int:
27
- info = detect_platform()
28
- print(f"platform={info.name}")
29
- print(f"service_manager={info.service_manager}")
30
- return 0
31
-
32
-
33
- def init_project(path: str) -> int:
34
- target = Path(path).expanduser().resolve()
35
- target.mkdir(parents=True, exist_ok=True)
36
- config = target / "do-again.toml"
37
- if config.exists():
38
- print(str(config))
39
- return 0
40
- config.write_text('[do_again]\ncontrol_branch = "operator-control"\n')
41
- print(str(config))
42
- return 0
43
-
44
-
45
- def main() -> int:
46
- parser = argparse.ArgumentParser(prog="do-again")
47
- sub = parser.add_subparsers(dest="command", required=True)
48
- sub.add_parser("doctor")
49
- sub.add_parser("status")
50
- init_parser = sub.add_parser("init")
51
- init_parser.add_argument("path", nargs="?", default=".")
52
- args = parser.parse_args()
53
- if args.command == "doctor":
54
- return doctor()
55
- if args.command == "status":
56
- return status()
57
- if args.command == "init":
58
- return init_project(args.path)
59
- return 2
60
-
61
-
62
- if __name__ == "__main__":
63
- raise SystemExit(main())
File without changes
File without changes
File without changes
File without changes
File without changes