forkcell 0.1.0a0__py3-none-any.whl

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.
forkcell/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """ForkCell MVP package."""
2
+
3
+ __version__ = "0.1.0a0"
4
+
5
+ from forkcell.api import ForkCellClient, ForkCellCommandError, ForkCellSandbox
6
+
7
+ __all__ = ["ForkCellClient", "ForkCellCommandError", "ForkCellSandbox", "__version__"]
forkcell/api.py ADDED
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, Mapping
11
+
12
+
13
+ class ForkCellCommandError(RuntimeError):
14
+ def __init__(self, args: list[str], returncode: int, stdout: str, stderr: str) -> None:
15
+ self.args_list = args
16
+ self.returncode = returncode
17
+ self.stdout = stdout
18
+ self.stderr = stderr
19
+ super().__init__(
20
+ f"forkcell command failed ({returncode}): {' '.join(args)}\nstdout:\n{stdout}\nstderr:\n{stderr}"
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CommandResult:
26
+ args: list[str]
27
+ returncode: int
28
+ stdout: str
29
+ stderr: str
30
+ json: dict[str, Any]
31
+
32
+
33
+ def _extract_json_object(text: str) -> dict[str, Any]:
34
+ decoder = json.JSONDecoder()
35
+ for index, char in enumerate(text):
36
+ if char != "{":
37
+ continue
38
+ try:
39
+ value, end = decoder.raw_decode(text[index:])
40
+ except json.JSONDecodeError:
41
+ continue
42
+ if isinstance(value, dict) and not text[index + end :].strip():
43
+ return value
44
+ raise ValueError("missing JSON object in command output")
45
+
46
+
47
+ def _normalize_command(command: str | Iterable[str]) -> list[str]:
48
+ if isinstance(command, str):
49
+ return ["sh", "-lc", command]
50
+ return [str(part) for part in command]
51
+
52
+
53
+ LEGACY_BACKEND_ALIASES = {
54
+ "openshell-native-overlay": "native-overlay",
55
+ "openshell-layer-clone": "layer-clone",
56
+ "openshell-volume": "volume-delta",
57
+ }
58
+
59
+
60
+ def _normalize_backend(backend: str) -> str:
61
+ return LEGACY_BACKEND_ALIASES.get(backend, backend)
62
+
63
+
64
+ class ForkCellClient:
65
+ """Small Python facade for agent-style ForkCell workflows.
66
+
67
+ The facade intentionally shells out to `python -m forkcell.cli` so the API
68
+ and CLI share the same state, receipts, and review artifacts during the
69
+ current governed-runtime integration.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ *,
75
+ root: str | Path | None = None,
76
+ python: str | None = None,
77
+ env: Mapping[str, str] | None = None,
78
+ ) -> None:
79
+ self.root = Path(root or Path.cwd()).resolve()
80
+ self.python = python or sys.executable
81
+ self.env = dict(os.environ)
82
+ if env:
83
+ self.env.update(env)
84
+
85
+ def cli(self, args: list[str], *, check: bool = True) -> CommandResult:
86
+ full_args = [self.python, "-m", "forkcell.cli", *args]
87
+ proc = subprocess.run(full_args, cwd=self.root, env=self.env, text=True, capture_output=True)
88
+ parsed: dict[str, Any] = {}
89
+ output = proc.stdout.strip()
90
+ if output:
91
+ try:
92
+ parsed = _extract_json_object(output)
93
+ except ValueError:
94
+ parsed = {}
95
+ if check and proc.returncode != 0:
96
+ raise ForkCellCommandError(full_args, proc.returncode, proc.stdout, proc.stderr)
97
+ return CommandResult(
98
+ args=full_args,
99
+ returncode=proc.returncode,
100
+ stdout=proc.stdout,
101
+ stderr=proc.stderr,
102
+ json=parsed,
103
+ )
104
+
105
+ def create_native_cell(
106
+ self,
107
+ *,
108
+ source: str | Path,
109
+ name: str | None = None,
110
+ backend: str = "native-overlay",
111
+ ) -> "ForkCellSandbox":
112
+ cell = name or f"fc-api-{uuid.uuid4().hex[:8]}"
113
+ self.cli(["native", "init", cell, "--from", str(Path(source).resolve())])
114
+ return ForkCellSandbox(client=self, name=cell, backend=_normalize_backend(backend))
115
+
116
+ def native_cell(self, name: str, *, backend: str = "native-overlay") -> "ForkCellSandbox":
117
+ return ForkCellSandbox(client=self, name=name, backend=_normalize_backend(backend))
118
+
119
+ def review_status(self) -> dict[str, Any]:
120
+ return self.cli(["review", "status", "--format", "json"]).json
121
+
122
+
123
+ @dataclass
124
+ class ForkCellSandbox:
125
+ client: ForkCellClient
126
+ name: str
127
+ backend: str = "native-overlay"
128
+ auto_delete: bool = True
129
+
130
+ def __post_init__(self) -> None:
131
+ self.backend = _normalize_backend(self.backend)
132
+
133
+ def __enter__(self) -> "ForkCellSandbox":
134
+ return self
135
+
136
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
137
+ if self.auto_delete:
138
+ self.delete(check=False)
139
+
140
+ def status(self) -> dict[str, Any]:
141
+ return self.client.cli(["native", "status", self.name]).json
142
+
143
+ def checkpoint(self, *, name: str | None = None) -> dict[str, Any]:
144
+ args = ["native", "checkpoint", self.name]
145
+ if name:
146
+ args.extend(["--name", name])
147
+ return self.client.cli(args).json
148
+
149
+ def restore(self, checkpoint: str | None = None) -> dict[str, Any]:
150
+ args = ["native", "restore", self.name]
151
+ if checkpoint:
152
+ args.append(checkpoint)
153
+ return self.client.cli(args).json
154
+
155
+ def run(
156
+ self,
157
+ command: str | Iterable[str],
158
+ *,
159
+ checkpoint_before: bool = False,
160
+ checkpoint_name: str | None = None,
161
+ restore_on_fail: bool = False,
162
+ policy: str | Path | None = None,
163
+ logs_since: str = "5m",
164
+ ) -> dict[str, Any]:
165
+ if self.backend == "native-overlay":
166
+ args = ["native", "run"]
167
+ elif self.backend == "layer-clone":
168
+ args = ["native", "run-layer"]
169
+ else:
170
+ args = ["run", self.name, "--backend", self.backend]
171
+ if checkpoint_before:
172
+ args.append("--checkpoint-before")
173
+ if checkpoint_name:
174
+ args.extend(["--checkpoint-name", checkpoint_name])
175
+ if restore_on_fail:
176
+ args.append("--restore-on-fail")
177
+ if policy:
178
+ args.extend(["--policy", str(policy)])
179
+ if logs_since:
180
+ args.extend(["--logs-since", logs_since])
181
+ if self.backend in {"native-overlay", "layer-clone"}:
182
+ args.append(self.name)
183
+ args.extend(["--", *_normalize_command(command)])
184
+ run = self.client.cli(args).json
185
+ receipt = Path(run.get("receipt", ""))
186
+ if receipt.exists():
187
+ return json.loads(receipt.read_text())
188
+ return run
189
+
190
+ def delete(self, *, check: bool = True) -> dict[str, Any]:
191
+ return self.client.cli(["native", "delete", self.name], check=check).json
forkcell/checkpoint.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import shlex
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Protocol
10
+
11
+
12
+ class OpenShellRunner(Protocol):
13
+ def __call__(self, args: list[str], *, check: bool = False) -> Any:
14
+ ...
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CheckpointArtifact:
19
+ path: Path
20
+ sha256: str
21
+ metrics: dict[str, Any]
22
+
23
+
24
+ def sha256_file(path: Path) -> str:
25
+ h = hashlib.sha256()
26
+ with path.open("rb") as f:
27
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+
32
+ def artifact_name(checkpoint_id: str) -> str:
33
+ return f"{checkpoint_id}.tgz"
34
+
35
+
36
+ def remote_path(workspace: str, name: str) -> str:
37
+ return f"{workspace.rstrip('/')}/{name}"
38
+
39
+
40
+ class CheckpointProvider(Protocol):
41
+ provider_name: str
42
+
43
+ def create(self, *, cell_name: str, workspace: str, checkpoint_id: str) -> CheckpointArtifact:
44
+ ...
45
+
46
+ def restore(self, *, cell_name: str, workspace: str, artifact: Path) -> dict[str, Any]:
47
+ ...
48
+
49
+
50
+ class OpenShellTarFullProvider:
51
+ """Portable baseline provider: full workspace tar over OpenShell upload/download."""
52
+
53
+ provider_name = "openshell-tar-full"
54
+
55
+ def __init__(self, *, openshell: OpenShellRunner, checkpoint_dir: Path) -> None:
56
+ self._openshell = openshell
57
+ self._checkpoint_dir = checkpoint_dir
58
+
59
+ def create(self, *, cell_name: str, workspace: str, checkpoint_id: str) -> CheckpointArtifact:
60
+ started = time.perf_counter()
61
+ before_stats = self._workspace_stats(cell_name, workspace)
62
+ remote_name = f".forkcell_{artifact_name(checkpoint_id)}"
63
+ remote_tar = remote_path(workspace, remote_name)
64
+ local_tar = self._checkpoint_dir / artifact_name(checkpoint_id)
65
+ local_tar.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ workspace_q = shlex.quote(workspace)
68
+ remote_name_q = shlex.quote(remote_name)
69
+ remote_tar_q = shlex.quote(remote_tar)
70
+ # GNU tar may return 1 when files change while archiving; keep that as degraded success.
71
+ tar_script = (
72
+ f"tar -C {workspace_q} --exclude {remote_name_q} -czf {remote_tar_q} .; "
73
+ "rc=$?; [ $rc -le 1 ] || exit $rc"
74
+ )
75
+ self._openshell(["sandbox", "exec", "--name", cell_name, "sh", "-lc", tar_script], check=True)
76
+ try:
77
+ self._openshell(["sandbox", "download", cell_name, remote_tar, str(local_tar)], check=True)
78
+ finally:
79
+ self._openshell(["sandbox", "exec", "--name", cell_name, "rm", "-f", remote_tar], check=False)
80
+
81
+ duration_ms = int(round((time.perf_counter() - started) * 1000))
82
+ metrics: dict[str, Any] = {
83
+ "provider": self.provider_name,
84
+ "operation": "checkpoint_create",
85
+ "duration_ms": duration_ms,
86
+ "workspace": workspace,
87
+ "workspace_file_count": before_stats.get("file_count"),
88
+ "workspace_bytes": before_stats.get("bytes"),
89
+ "artifact_bytes": local_tar.stat().st_size,
90
+ "local_artifact": str(local_tar),
91
+ "full_archive": True,
92
+ }
93
+ if before_stats.get("error"):
94
+ metrics["workspace_stats_error"] = before_stats["error"]
95
+ return CheckpointArtifact(path=local_tar, sha256=sha256_file(local_tar), metrics=metrics)
96
+
97
+ def restore(self, *, cell_name: str, workspace: str, artifact: Path) -> dict[str, Any]:
98
+ started = time.perf_counter()
99
+ if not artifact.exists():
100
+ raise FileNotFoundError(str(artifact))
101
+
102
+ remote_name = f".forkcell_restore_{artifact.name}"
103
+ remote_tar = remote_path(workspace, remote_name)
104
+ self._openshell(["sandbox", "upload", cell_name, str(artifact), remote_tar], check=True)
105
+
106
+ workspace_q = shlex.quote(workspace)
107
+ remote_name_q = shlex.quote(remote_name)
108
+ remote_tar_q = shlex.quote(remote_tar)
109
+ cleanup_script = (
110
+ f"set -e; mkdir -p {workspace_q}; "
111
+ f"find {workspace_q} -mindepth 1 -maxdepth 1 ! -name {remote_name_q} -exec rm -rf {{}} +; "
112
+ f"tar -C {workspace_q} -xzf {remote_tar_q}; rm -f {remote_tar_q}"
113
+ )
114
+ self._openshell(["sandbox", "exec", "--name", cell_name, "sh", "-lc", cleanup_script], check=True)
115
+
116
+ after_stats = self._workspace_stats(cell_name, workspace)
117
+ duration_ms = int(round((time.perf_counter() - started) * 1000))
118
+ metrics: dict[str, Any] = {
119
+ "provider": self.provider_name,
120
+ "operation": "checkpoint_restore",
121
+ "duration_ms": duration_ms,
122
+ "workspace": workspace,
123
+ "workspace_file_count_after": after_stats.get("file_count"),
124
+ "workspace_bytes_after": after_stats.get("bytes"),
125
+ "artifact_bytes": artifact.stat().st_size,
126
+ "local_artifact": str(artifact),
127
+ "full_archive": True,
128
+ }
129
+ if after_stats.get("error"):
130
+ metrics["workspace_stats_error"] = after_stats["error"]
131
+ return metrics
132
+
133
+ def _workspace_stats(self, cell_name: str, workspace: str) -> dict[str, Any]:
134
+ workspace_q = shlex.quote(workspace)
135
+ script = (
136
+ f"cd {workspace_q}; "
137
+ "files=$(find . -mindepth 1 -type f | wc -l | tr -d ' '); "
138
+ "bytes=$(find . -mindepth 1 -type f -exec wc -c {} + 2>/dev/null "
139
+ "| awk '{s += $1} END {print s + 0}'); "
140
+ "printf '{\"file_count\":%s,\"bytes\":%s}\\n' \"$files\" \"$bytes\""
141
+ )
142
+ result = self._openshell(["sandbox", "exec", "--name", cell_name, "sh", "-lc", script], check=False)
143
+ if result.returncode != 0:
144
+ return {"file_count": None, "bytes": None, "error": (result.stderr or result.stdout)[-500:]}
145
+ try:
146
+ return json.loads(result.stdout.strip().splitlines()[-1])
147
+ except Exception as exc:
148
+ return {"file_count": None, "bytes": None, "error": f"parse_failed: {exc}"}