kosa8 0.1.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.
- kosa8-0.1.0/LICENSE +26 -0
- kosa8-0.1.0/PKG-INFO +100 -0
- kosa8-0.1.0/README.md +81 -0
- kosa8-0.1.0/pyproject.toml +31 -0
- kosa8-0.1.0/setup.cfg +4 -0
- kosa8-0.1.0/src/kosa8/__init__.py +69 -0
- kosa8-0.1.0/src/kosa8/checkpoints.py +195 -0
- kosa8-0.1.0/src/kosa8/client.py +667 -0
- kosa8-0.1.0/src/kosa8/errors.py +156 -0
- kosa8-0.1.0/src/kosa8/py.typed +0 -0
- kosa8-0.1.0/src/kosa8/transport.py +331 -0
- kosa8-0.1.0/src/kosa8.egg-info/PKG-INFO +100 -0
- kosa8-0.1.0/src/kosa8.egg-info/SOURCES.txt +14 -0
- kosa8-0.1.0/src/kosa8.egg-info/dependency_links.txt +1 -0
- kosa8-0.1.0/src/kosa8.egg-info/top_level.txt +1 -0
- kosa8-0.1.0/tests/test_client.py +274 -0
kosa8-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kosa8
|
|
4
|
+
|
|
5
|
+
This license covers the source code in this repository (the kosa8
|
|
6
|
+
TypeScript SDK packages, @kosa8/sdk and @kosa8/ai-sdk-sandbox) only. It does
|
|
7
|
+
not cover kosa8 itself (the kosa8 CLI, the kosa8d daemon or any other kosa8
|
|
8
|
+
software), which is licensed separately.
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
kosa8-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kosa8
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the kosa8 native API: microVM sandboxes for AI agents, with checkpoint, rewind, fork and explore.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://kosa8.com
|
|
7
|
+
Project-URL: Documentation, https://docs.kosa8.com
|
|
8
|
+
Project-URL: Source, https://github.com/kosa8ai/kosa8-py
|
|
9
|
+
Keywords: kosa8,sandbox,microvm,ai-agents,security
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# kosa8 for Python
|
|
21
|
+
|
|
22
|
+
Python client for [kosa8](https://kosa8.com): microVM sandboxes for AI agents, with
|
|
23
|
+
checkpoint, rewind, fork and explore. The counterpart of
|
|
24
|
+
[`@kosa8/sdk`](https://www.npmjs.com/package/@kosa8/sdk), method for method.
|
|
25
|
+
|
|
26
|
+
No dependencies. It talks to the local kosa8 daemon over its unix socket, or to a
|
|
27
|
+
remote kosa8 worker.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install kosa8
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from kosa8 import Kosa8Client
|
|
37
|
+
|
|
38
|
+
k = Kosa8Client()
|
|
39
|
+
k.create_sandbox(name="demo", egress="deny") # a microVM with its own kernel, no network
|
|
40
|
+
sb = k.sandbox("demo")
|
|
41
|
+
|
|
42
|
+
sb.pull("alpine:latest")
|
|
43
|
+
print(sb.run("alpine:latest", ["sh", "-c", "echo hello"]).stdout)
|
|
44
|
+
|
|
45
|
+
sb.checkpoint(label="before the risky bit") # memory, processes and disk
|
|
46
|
+
sb.run("alpine:latest", ["sh", "-c", "rm -rf /data"])
|
|
47
|
+
sb.rewind() # back, processes resumed
|
|
48
|
+
|
|
49
|
+
forks = sb.fork(4) # four live copies
|
|
50
|
+
sb.delete()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What's in it
|
|
54
|
+
|
|
55
|
+
| Area | Methods |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Status | `health`, `version` |
|
|
58
|
+
| Sandboxes | `create_sandbox`, `list_sandboxes`, `get_sandbox`, `start_sandbox`, `stop_sandbox`, `delete_sandbox` |
|
|
59
|
+
| Images | `pull`, `pull_into_sandbox`, `sandbox_images` |
|
|
60
|
+
| Running code | `run`, `exec`, `exec_frames`, `start_container`, `start_interactive`, `write_stdin`, `attach` |
|
|
61
|
+
| Network | `sandbox_egress`; `egress="deny" \| "any" \| "fakenet"` or `egress_allow=[...]` on create |
|
|
62
|
+
| Snapshots | `snapshot`, `list_snapshots`, `restore_snapshot`, `delete_snapshot` |
|
|
63
|
+
| Fork | `fork(sandbox, n)`, `explore(sandbox, variants)` (best-of-N) |
|
|
64
|
+
| Checkpoints | `checkpoint`, `list_checkpoints`, `rewind` |
|
|
65
|
+
|
|
66
|
+
`k.sandbox(id)` returns a handle with the same methods bound to one sandbox.
|
|
67
|
+
|
|
68
|
+
Checkpoints share the CLI's store (`~/.kosa8/checkpoints.json`, under the same
|
|
69
|
+
`flock`), so `kosa8 rewind` sees checkpoints taken here and the other way round.
|
|
70
|
+
|
|
71
|
+
## Connecting
|
|
72
|
+
|
|
73
|
+
| Setting | Default |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `socket_path=` | `~/.kosa8/run/kosa8.sock` |
|
|
76
|
+
| `host=` / `KOSA8_HOST` | a remote worker: `https://…`, `tcp://host:port`, `unix:///path` |
|
|
77
|
+
| `token=` / `KOSA8_TOKEN` | the worker's bearer token |
|
|
78
|
+
| `insecure=` / `KOSA8_INSECURE=1` | allow plain http to a non-loopback host (off) |
|
|
79
|
+
| `actor=` | `tool`; recorded in the audit log as `human`, `agent` or `tool` |
|
|
80
|
+
| `agent_token=` / `KOSA8_AGENT_TOKEN` | agent identity for the audit log |
|
|
81
|
+
| `timeout=` | seconds per connect and read; `None` waits |
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
Every daemon error is raised as a typed exception carrying the daemon's own
|
|
86
|
+
message and remediation: `Kosa8BadRequestError` (400), `Kosa8AuthError` (401),
|
|
87
|
+
`Kosa8PolicyError` (403), `Kosa8ApprovalRequiredError` (403 needing a signed
|
|
88
|
+
approval: repeat the call with `approval=<id>`), `Kosa8NotFoundError` (404),
|
|
89
|
+
`Kosa8ConflictError` (409), `Kosa8UnsupportedError` (501),
|
|
90
|
+
`Kosa8ConnectionError`, `Kosa8StreamError` (output kept), `Kosa8TimeoutError`.
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
kosa8-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# kosa8 for Python
|
|
2
|
+
|
|
3
|
+
Python client for [kosa8](https://kosa8.com): microVM sandboxes for AI agents, with
|
|
4
|
+
checkpoint, rewind, fork and explore. The counterpart of
|
|
5
|
+
[`@kosa8/sdk`](https://www.npmjs.com/package/@kosa8/sdk), method for method.
|
|
6
|
+
|
|
7
|
+
No dependencies. It talks to the local kosa8 daemon over its unix socket, or to a
|
|
8
|
+
remote kosa8 worker.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install kosa8
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from kosa8 import Kosa8Client
|
|
18
|
+
|
|
19
|
+
k = Kosa8Client()
|
|
20
|
+
k.create_sandbox(name="demo", egress="deny") # a microVM with its own kernel, no network
|
|
21
|
+
sb = k.sandbox("demo")
|
|
22
|
+
|
|
23
|
+
sb.pull("alpine:latest")
|
|
24
|
+
print(sb.run("alpine:latest", ["sh", "-c", "echo hello"]).stdout)
|
|
25
|
+
|
|
26
|
+
sb.checkpoint(label="before the risky bit") # memory, processes and disk
|
|
27
|
+
sb.run("alpine:latest", ["sh", "-c", "rm -rf /data"])
|
|
28
|
+
sb.rewind() # back, processes resumed
|
|
29
|
+
|
|
30
|
+
forks = sb.fork(4) # four live copies
|
|
31
|
+
sb.delete()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What's in it
|
|
35
|
+
|
|
36
|
+
| Area | Methods |
|
|
37
|
+
|---|---|
|
|
38
|
+
| Status | `health`, `version` |
|
|
39
|
+
| Sandboxes | `create_sandbox`, `list_sandboxes`, `get_sandbox`, `start_sandbox`, `stop_sandbox`, `delete_sandbox` |
|
|
40
|
+
| Images | `pull`, `pull_into_sandbox`, `sandbox_images` |
|
|
41
|
+
| Running code | `run`, `exec`, `exec_frames`, `start_container`, `start_interactive`, `write_stdin`, `attach` |
|
|
42
|
+
| Network | `sandbox_egress`; `egress="deny" \| "any" \| "fakenet"` or `egress_allow=[...]` on create |
|
|
43
|
+
| Snapshots | `snapshot`, `list_snapshots`, `restore_snapshot`, `delete_snapshot` |
|
|
44
|
+
| Fork | `fork(sandbox, n)`, `explore(sandbox, variants)` (best-of-N) |
|
|
45
|
+
| Checkpoints | `checkpoint`, `list_checkpoints`, `rewind` |
|
|
46
|
+
|
|
47
|
+
`k.sandbox(id)` returns a handle with the same methods bound to one sandbox.
|
|
48
|
+
|
|
49
|
+
Checkpoints share the CLI's store (`~/.kosa8/checkpoints.json`, under the same
|
|
50
|
+
`flock`), so `kosa8 rewind` sees checkpoints taken here and the other way round.
|
|
51
|
+
|
|
52
|
+
## Connecting
|
|
53
|
+
|
|
54
|
+
| Setting | Default |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `socket_path=` | `~/.kosa8/run/kosa8.sock` |
|
|
57
|
+
| `host=` / `KOSA8_HOST` | a remote worker: `https://…`, `tcp://host:port`, `unix:///path` |
|
|
58
|
+
| `token=` / `KOSA8_TOKEN` | the worker's bearer token |
|
|
59
|
+
| `insecure=` / `KOSA8_INSECURE=1` | allow plain http to a non-loopback host (off) |
|
|
60
|
+
| `actor=` | `tool`; recorded in the audit log as `human`, `agent` or `tool` |
|
|
61
|
+
| `agent_token=` / `KOSA8_AGENT_TOKEN` | agent identity for the audit log |
|
|
62
|
+
| `timeout=` | seconds per connect and read; `None` waits |
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
Every daemon error is raised as a typed exception carrying the daemon's own
|
|
67
|
+
message and remediation: `Kosa8BadRequestError` (400), `Kosa8AuthError` (401),
|
|
68
|
+
`Kosa8PolicyError` (403), `Kosa8ApprovalRequiredError` (403 needing a signed
|
|
69
|
+
approval: repeat the call with `approval=<id>`), `Kosa8NotFoundError` (404),
|
|
70
|
+
`Kosa8ConflictError` (409), `Kosa8UnsupportedError` (501),
|
|
71
|
+
`Kosa8ConnectionError`, `Kosa8StreamError` (output kept), `Kosa8TimeoutError`.
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kosa8"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the kosa8 native API: microVM sandboxes for AI agents, with checkpoint, rewind, fork and explore."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["kosa8", "sandbox", "microvm", "ai-agents", "security"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://kosa8.com"
|
|
24
|
+
Documentation = "https://docs.kosa8.com"
|
|
25
|
+
Source = "https://github.com/kosa8ai/kosa8-py"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
kosa8 = ["py.typed"]
|
kosa8-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""kosa8: microVM sandboxes for AI agents, with checkpoint, rewind, fork and explore.
|
|
2
|
+
|
|
3
|
+
The Python counterpart of ``@kosa8/sdk``. No dependencies: it speaks HTTP to the
|
|
4
|
+
local kosa8 daemon over its unix socket, or to a remote worker (``KOSA8_HOST``).
|
|
5
|
+
|
|
6
|
+
>>> from kosa8 import Kosa8Client
|
|
7
|
+
>>> k = Kosa8Client()
|
|
8
|
+
>>> sb = k.sandbox(k.create_sandbox(name="demo", egress="deny")["sandbox"]["id"])
|
|
9
|
+
>>> sb.pull("alpine:latest")
|
|
10
|
+
>>> sb.run("alpine:latest", ["echo", "hi"]).stdout
|
|
11
|
+
'hi\\n'
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .checkpoints import (
|
|
15
|
+
MAX_CHECKPOINTS_PER_SANDBOX,
|
|
16
|
+
CheckpointBook,
|
|
17
|
+
FileCheckpointStore,
|
|
18
|
+
MemoryCheckpointStore,
|
|
19
|
+
)
|
|
20
|
+
from .client import ExecResult, ExploreBranch, ExploreResult, Kosa8Client, Sandbox
|
|
21
|
+
from .errors import (
|
|
22
|
+
Kosa8ApiError,
|
|
23
|
+
Kosa8ApprovalRequiredError,
|
|
24
|
+
Kosa8AuthError,
|
|
25
|
+
Kosa8BadRequestError,
|
|
26
|
+
Kosa8CheckpointError,
|
|
27
|
+
Kosa8ConflictError,
|
|
28
|
+
Kosa8ConnectionError,
|
|
29
|
+
Kosa8Error,
|
|
30
|
+
Kosa8NotFoundError,
|
|
31
|
+
Kosa8PolicyError,
|
|
32
|
+
Kosa8StreamError,
|
|
33
|
+
Kosa8TimeoutError,
|
|
34
|
+
Kosa8UnsupportedError,
|
|
35
|
+
error_from_response,
|
|
36
|
+
)
|
|
37
|
+
from .transport import Transport, default_socket_path, resolve_endpoint
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Kosa8Client",
|
|
43
|
+
"Sandbox",
|
|
44
|
+
"ExecResult",
|
|
45
|
+
"ExploreBranch",
|
|
46
|
+
"ExploreResult",
|
|
47
|
+
"Transport",
|
|
48
|
+
"default_socket_path",
|
|
49
|
+
"resolve_endpoint",
|
|
50
|
+
"CheckpointBook",
|
|
51
|
+
"FileCheckpointStore",
|
|
52
|
+
"MemoryCheckpointStore",
|
|
53
|
+
"MAX_CHECKPOINTS_PER_SANDBOX",
|
|
54
|
+
"Kosa8Error",
|
|
55
|
+
"Kosa8ConnectionError",
|
|
56
|
+
"Kosa8ApiError",
|
|
57
|
+
"Kosa8BadRequestError",
|
|
58
|
+
"Kosa8AuthError",
|
|
59
|
+
"Kosa8PolicyError",
|
|
60
|
+
"Kosa8ApprovalRequiredError",
|
|
61
|
+
"Kosa8NotFoundError",
|
|
62
|
+
"Kosa8ConflictError",
|
|
63
|
+
"Kosa8UnsupportedError",
|
|
64
|
+
"Kosa8StreamError",
|
|
65
|
+
"Kosa8CheckpointError",
|
|
66
|
+
"Kosa8TimeoutError",
|
|
67
|
+
"error_from_response",
|
|
68
|
+
"__version__",
|
|
69
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Checkpoints: client-side bookkeeping over daemon snapshots, exactly as pkg/checkpoint.
|
|
2
|
+
|
|
3
|
+
A checkpoint is a snapshot with the id ``<sandbox>-ckpt-<n>``; an ordered stack
|
|
4
|
+
of them per sandbox records labels and order. The daemon has no checkpoint
|
|
5
|
+
endpoint. The default store is the CLI's own ``~/.kosa8/checkpoints.json``,
|
|
6
|
+
locked with the same ``flock`` on ``checkpoints.json.lock`` the Go tools take,
|
|
7
|
+
so checkpoints taken here are seen by ``kosa8 rewind`` and the other way round.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
|
|
19
|
+
|
|
20
|
+
from .errors import Kosa8CheckpointError
|
|
21
|
+
|
|
22
|
+
#: How many checkpoints are kept per sandbox; the oldest is evicted (pkg/checkpoint MaxPerSandbox).
|
|
23
|
+
MAX_CHECKPOINTS_PER_SANDBOX = 10
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
StoreData = Dict[str, List[Dict[str, Any]]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_sandbox_name(name: str) -> None:
|
|
30
|
+
"""Reject a name that could escape the store (pkg/checkpoint validSandbox)."""
|
|
31
|
+
if name in ("", ".", "..") or "/" in name or "\\" in name or name.startswith("."):
|
|
32
|
+
raise Kosa8CheckpointError(f"invalid sandbox name {json.dumps(name)}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryCheckpointStore:
|
|
36
|
+
"""Keeps stacks in memory only; nothing is shared with the kosa8 CLI."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._data: StoreData = {}
|
|
40
|
+
self._mu = threading.Lock()
|
|
41
|
+
|
|
42
|
+
def load(self) -> StoreData:
|
|
43
|
+
with self._mu:
|
|
44
|
+
return copy.deepcopy(self._data)
|
|
45
|
+
|
|
46
|
+
def update(self, fn: Callable[[StoreData], T]) -> T:
|
|
47
|
+
with self._mu:
|
|
48
|
+
data = copy.deepcopy(self._data)
|
|
49
|
+
out = fn(data)
|
|
50
|
+
self._data = data
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FileCheckpointStore:
|
|
55
|
+
"""The store the kosa8 CLI, MCP server and desk use (mode 0600, rewritten atomically)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: Optional[str] = None) -> None:
|
|
58
|
+
self.path = path or os.path.join(os.path.expanduser("~"), ".kosa8", "checkpoints.json")
|
|
59
|
+
self._mu = threading.Lock()
|
|
60
|
+
|
|
61
|
+
def load(self) -> StoreData:
|
|
62
|
+
try:
|
|
63
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
64
|
+
raw = f.read()
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
return {}
|
|
67
|
+
try:
|
|
68
|
+
parsed = json.loads(raw)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return {} # a corrupt store must not make the sandbox unusable (as pkg/checkpoint)
|
|
71
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
72
|
+
|
|
73
|
+
def update(self, fn: Callable[[StoreData], T]) -> T:
|
|
74
|
+
os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)
|
|
75
|
+
with self._mu:
|
|
76
|
+
fd = os.open(self.path + ".lock", os.O_RDWR | os.O_CREAT, 0o600)
|
|
77
|
+
try:
|
|
78
|
+
try:
|
|
79
|
+
import fcntl
|
|
80
|
+
|
|
81
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
82
|
+
except ImportError: # Windows: in-process lock only, as pkg/checkpoint lock_other.go
|
|
83
|
+
pass
|
|
84
|
+
data = self.load()
|
|
85
|
+
out = fn(data)
|
|
86
|
+
tmp = self.path + ".tmp"
|
|
87
|
+
with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f:
|
|
88
|
+
json.dump(data, f, indent=2)
|
|
89
|
+
os.replace(tmp, self.path)
|
|
90
|
+
return out
|
|
91
|
+
finally:
|
|
92
|
+
os.close(fd) # closing drops the flock
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _seq_of(cid: str, sandbox: str) -> int:
|
|
96
|
+
pre = sandbox + "-ckpt-"
|
|
97
|
+
if not cid.startswith(pre):
|
|
98
|
+
return 0
|
|
99
|
+
m = re.match(r"^(\d+)", cid[len(pre):])
|
|
100
|
+
return int(m.group(1)) if m else 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_FRAC = re.compile(r"\.(\d+)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ts(c: Dict[str, Any]) -> float:
|
|
107
|
+
# Go writes RFC 3339 with up to nine fractional digits; Python before 3.11
|
|
108
|
+
# takes exactly six and no "Z", so normalise both before parsing.
|
|
109
|
+
s = (c.get("created") or "").replace("Z", "+00:00")
|
|
110
|
+
s = _FRAC.sub(lambda m: "." + (m.group(1) + "000000")[:6], s, count=1)
|
|
111
|
+
try:
|
|
112
|
+
return datetime.fromisoformat(s).timestamp()
|
|
113
|
+
except ValueError:
|
|
114
|
+
return 0.0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _sorted(lst: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
118
|
+
return [c for _, _, c in sorted(((_ts(c), i, c) for i, c in enumerate(lst)), key=lambda x: (x[0], x[1]))]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _now() -> str:
|
|
122
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CheckpointBook:
|
|
126
|
+
"""The stack logic of pkg/checkpoint, over any store."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, store: Any) -> None:
|
|
129
|
+
self.store = store
|
|
130
|
+
|
|
131
|
+
def next_id(self, sandbox: str) -> Tuple[str, List[str]]:
|
|
132
|
+
"""The snapshot id the next checkpoint should use, and ids to evict to stay under the cap."""
|
|
133
|
+
validate_sandbox_name(sandbox)
|
|
134
|
+
lst = self.store.load().get(sandbox, [])
|
|
135
|
+
n = len(lst) + 1
|
|
136
|
+
for c in lst:
|
|
137
|
+
v = _seq_of(c.get("id", ""), sandbox)
|
|
138
|
+
if v >= n:
|
|
139
|
+
n = v + 1
|
|
140
|
+
over = len(lst) + 1 - MAX_CHECKPOINTS_PER_SANDBOX
|
|
141
|
+
evict = [c["id"] for c in lst[:over]] if over > 0 else []
|
|
142
|
+
return f"{sandbox}-ckpt-{n}", evict
|
|
143
|
+
|
|
144
|
+
def record(self, cid: str, sandbox: str, label: str = "", evicted: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
145
|
+
validate_sandbox_name(sandbox)
|
|
146
|
+
rec = {"id": cid, "sandbox": sandbox, "label": label or "", "created": _now()}
|
|
147
|
+
drop = set(evicted or [])
|
|
148
|
+
|
|
149
|
+
def fn(s: StoreData) -> None:
|
|
150
|
+
s[sandbox] = [e for e in s.get(sandbox, []) if e.get("id") not in drop] + [rec]
|
|
151
|
+
|
|
152
|
+
self.store.update(fn)
|
|
153
|
+
return rec
|
|
154
|
+
|
|
155
|
+
def list(self, sandbox: str) -> List[Dict[str, Any]]:
|
|
156
|
+
return _sorted(list(self.store.load().get(sandbox, [])))
|
|
157
|
+
|
|
158
|
+
def latest(self, sandbox: str) -> Dict[str, Any]:
|
|
159
|
+
lst = self.list(sandbox)
|
|
160
|
+
if not lst:
|
|
161
|
+
raise Kosa8CheckpointError(
|
|
162
|
+
f"no checkpoints for {json.dumps(sandbox)} — take one with checkpoint() or `kosa8 checkpoint {sandbox}`"
|
|
163
|
+
)
|
|
164
|
+
return lst[-1]
|
|
165
|
+
|
|
166
|
+
def resolve(self, sandbox: str, ref: str) -> Dict[str, Any]:
|
|
167
|
+
"""Find by id or (case-insensitive) label, newest match first."""
|
|
168
|
+
for c in reversed(self.list(sandbox)):
|
|
169
|
+
if c.get("id") == ref or (c.get("label") or "").lower() == ref.lower():
|
|
170
|
+
return c
|
|
171
|
+
raise Kosa8CheckpointError(f"no checkpoint {json.dumps(ref)} for sandbox {json.dumps(sandbox)}")
|
|
172
|
+
|
|
173
|
+
def pop(self, sandbox: str, cid: str) -> List[str]:
|
|
174
|
+
"""Keep ``cid`` as the top of the stack; return the ids taken after it."""
|
|
175
|
+
|
|
176
|
+
def fn(s: StoreData) -> List[str]:
|
|
177
|
+
lst = s.get(sandbox, [])
|
|
178
|
+
idx = next((i for i, c in enumerate(lst) if c.get("id") == cid), -1)
|
|
179
|
+
if idx < 0:
|
|
180
|
+
raise Kosa8CheckpointError(f"no checkpoint {json.dumps(cid)} for sandbox {json.dumps(sandbox)}")
|
|
181
|
+
orphaned = [c["id"] for c in lst[idx + 1:]]
|
|
182
|
+
s[sandbox] = lst[: idx + 1]
|
|
183
|
+
return orphaned
|
|
184
|
+
|
|
185
|
+
return self.store.update(fn)
|
|
186
|
+
|
|
187
|
+
def forget(self, sandbox: str) -> List[str]:
|
|
188
|
+
"""Drop every checkpoint of a sandbox; return the snapshot ids to delete."""
|
|
189
|
+
|
|
190
|
+
def fn(s: StoreData) -> List[str]:
|
|
191
|
+
ids = [c["id"] for c in s.get(sandbox, [])]
|
|
192
|
+
s.pop(sandbox, None)
|
|
193
|
+
return ids
|
|
194
|
+
|
|
195
|
+
return self.store.update(fn)
|