sdxloop 0.1.0__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.
@@ -0,0 +1,257 @@
1
+ """SQLite state store: runs, tasks, phase attempts, events.
2
+
3
+ One WAL-mode database at ``<state_dir>/state.db``. A checkpoint row is
4
+ written after every state transition, which is what makes ``resume`` safe:
5
+ a phase whose result was never committed is simply re-run from its start.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import time
13
+ from collections.abc import Iterator
14
+ from pathlib import Path
15
+
16
+ from sdxloop.engine.model import (
17
+ PlanModel,
18
+ RunRecord,
19
+ RunState,
20
+ TaskRecord,
21
+ TaskSpec,
22
+ TaskState,
23
+ )
24
+ from sdxloop.errors import StateError
25
+ from sdxloop_worker.protocol import Event
26
+
27
+ _SCHEMA = """
28
+ CREATE TABLE IF NOT EXISTS runs (
29
+ run_id TEXT PRIMARY KEY,
30
+ outcome TEXT NOT NULL,
31
+ state TEXT NOT NULL,
32
+ config_json TEXT NOT NULL DEFAULT '{}',
33
+ created_at REAL NOT NULL,
34
+ updated_at REAL NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS tasks (
37
+ run_id TEXT NOT NULL,
38
+ task_id TEXT NOT NULL,
39
+ order_idx INTEGER NOT NULL,
40
+ state TEXT NOT NULL,
41
+ spec_json TEXT NOT NULL,
42
+ plan_json TEXT,
43
+ revisions INTEGER NOT NULL DEFAULT 0,
44
+ replans INTEGER NOT NULL DEFAULT 0,
45
+ last_feedback TEXT NOT NULL DEFAULT '',
46
+ session_id TEXT,
47
+ PRIMARY KEY (run_id, task_id)
48
+ );
49
+ CREATE TABLE IF NOT EXISTS phase_attempts (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ run_id TEXT NOT NULL,
52
+ task_id TEXT,
53
+ phase TEXT NOT NULL,
54
+ attempt INTEGER NOT NULL,
55
+ status TEXT NOT NULL,
56
+ output_json TEXT,
57
+ started_at REAL NOT NULL,
58
+ ended_at REAL NOT NULL
59
+ );
60
+ CREATE TABLE IF NOT EXISTS events (
61
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ run_id TEXT NOT NULL,
63
+ ts REAL NOT NULL,
64
+ type TEXT NOT NULL,
65
+ job_id TEXT,
66
+ data_json TEXT NOT NULL DEFAULT '{}'
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_events_run ON events (run_id, seq);
69
+ """
70
+
71
+
72
+ class StateStore:
73
+ def __init__(self, path: Path) -> None:
74
+ self.path = path
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ self._conn = sqlite3.connect(path, check_same_thread=False)
77
+ self._conn.row_factory = sqlite3.Row
78
+ self._conn.execute("PRAGMA journal_mode=WAL")
79
+ self._conn.executescript(_SCHEMA)
80
+ self._conn.commit()
81
+
82
+ def close(self) -> None:
83
+ self._conn.close()
84
+
85
+ # -- runs --------------------------------------------------------------
86
+
87
+ def create_run(self, run_id: str, outcome: str, config_json: str = "{}") -> RunRecord:
88
+ now = time.time()
89
+ try:
90
+ self._conn.execute(
91
+ "INSERT INTO runs (run_id, outcome, state, config_json, created_at, updated_at)"
92
+ " VALUES (?, ?, 'created', ?, ?, ?)",
93
+ (run_id, outcome, config_json, now, now),
94
+ )
95
+ except sqlite3.IntegrityError as exc:
96
+ raise StateError(f"run {run_id} already exists") from exc
97
+ self._conn.commit()
98
+ return RunRecord(
99
+ run_id=run_id, outcome=outcome, state="created", created_at=now, updated_at=now
100
+ )
101
+
102
+ def set_run_state(self, run_id: str, state: RunState) -> None:
103
+ cursor = self._conn.execute(
104
+ "UPDATE runs SET state = ?, updated_at = ? WHERE run_id = ?",
105
+ (state, time.time(), run_id),
106
+ )
107
+ if cursor.rowcount == 0:
108
+ raise StateError(f"unknown run {run_id}")
109
+ self._conn.commit()
110
+
111
+ def get_run(self, run_id: str) -> RunRecord:
112
+ row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
113
+ if row is None:
114
+ raise StateError(f"unknown run {run_id}")
115
+ return RunRecord(
116
+ run_id=row["run_id"],
117
+ outcome=row["outcome"],
118
+ state=row["state"],
119
+ created_at=row["created_at"],
120
+ updated_at=row["updated_at"],
121
+ )
122
+
123
+ def list_runs(self) -> list[RunRecord]:
124
+ rows = self._conn.execute("SELECT * FROM runs ORDER BY created_at DESC").fetchall()
125
+ return [
126
+ RunRecord(
127
+ run_id=row["run_id"],
128
+ outcome=row["outcome"],
129
+ state=row["state"],
130
+ created_at=row["created_at"],
131
+ updated_at=row["updated_at"],
132
+ )
133
+ for row in rows
134
+ ]
135
+
136
+ # -- tasks -------------------------------------------------------------
137
+
138
+ def save_tasks(self, run_id: str, specs: list[TaskSpec]) -> None:
139
+ for index, spec in enumerate(specs):
140
+ self._conn.execute(
141
+ "INSERT OR REPLACE INTO tasks (run_id, task_id, order_idx, state, spec_json)"
142
+ " VALUES (?, ?, ?, 'pending', ?)",
143
+ (run_id, spec.id, index, spec.model_dump_json()),
144
+ )
145
+ self._conn.commit()
146
+
147
+ def update_task(self, run_id: str, task: TaskRecord) -> None:
148
+ cursor = self._conn.execute(
149
+ "UPDATE tasks SET state = ?, plan_json = ?, revisions = ?, replans = ?,"
150
+ " last_feedback = ?, session_id = ? WHERE run_id = ? AND task_id = ?",
151
+ (
152
+ task.state,
153
+ task.plan.model_dump_json() if task.plan else None,
154
+ task.revisions,
155
+ task.replans,
156
+ task.last_feedback,
157
+ task.session_id,
158
+ run_id,
159
+ task.spec.id,
160
+ ),
161
+ )
162
+ if cursor.rowcount == 0:
163
+ raise StateError(f"unknown task {task.spec.id} in run {run_id}")
164
+ self._conn.commit()
165
+
166
+ def get_tasks(self, run_id: str) -> list[TaskRecord]:
167
+ rows = self._conn.execute(
168
+ "SELECT * FROM tasks WHERE run_id = ? ORDER BY order_idx", (run_id,)
169
+ ).fetchall()
170
+ records: list[TaskRecord] = []
171
+ for row in rows:
172
+ state: TaskState = row["state"]
173
+ records.append(
174
+ TaskRecord(
175
+ spec=TaskSpec.model_validate_json(row["spec_json"]),
176
+ state=state,
177
+ revisions=row["revisions"],
178
+ replans=row["replans"],
179
+ last_feedback=row["last_feedback"],
180
+ session_id=row["session_id"],
181
+ plan=(
182
+ PlanModel.model_validate_json(row["plan_json"])
183
+ if row["plan_json"]
184
+ else None
185
+ ),
186
+ )
187
+ )
188
+ return records
189
+
190
+ # -- phase attempts ----------------------------------------------------
191
+
192
+ def record_phase(
193
+ self,
194
+ run_id: str,
195
+ phase: str,
196
+ *,
197
+ task_id: str | None,
198
+ attempt: int,
199
+ status: str,
200
+ output_json: str | None,
201
+ started_at: float,
202
+ ) -> None:
203
+ self._conn.execute(
204
+ "INSERT INTO phase_attempts"
205
+ " (run_id, task_id, phase, attempt, status, output_json, started_at, ended_at)"
206
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
207
+ (run_id, task_id, phase, attempt, status, output_json, started_at, time.time()),
208
+ )
209
+ self._conn.commit()
210
+
211
+ def phase_attempts(self, run_id: str, task_id: str | None = None) -> list[sqlite3.Row]:
212
+ if task_id is None:
213
+ return list(
214
+ self._conn.execute(
215
+ "SELECT * FROM phase_attempts WHERE run_id = ? ORDER BY id", (run_id,)
216
+ )
217
+ )
218
+ return list(
219
+ self._conn.execute(
220
+ "SELECT * FROM phase_attempts WHERE run_id = ? AND task_id = ? ORDER BY id",
221
+ (run_id, task_id),
222
+ )
223
+ )
224
+
225
+ # -- events ------------------------------------------------------------
226
+
227
+ def append_event(self, event: Event) -> None:
228
+ self._conn.execute(
229
+ "INSERT INTO events (run_id, ts, type, job_id, data_json) VALUES (?, ?, ?, ?, ?)",
230
+ (event.run_id, event.ts, event.type, event.job_id, json.dumps(event.data)),
231
+ )
232
+ self._conn.commit()
233
+
234
+ def events(
235
+ self,
236
+ run_id: str,
237
+ *,
238
+ after_seq: int = 0,
239
+ type_prefix: str | None = None,
240
+ ) -> Iterator[tuple[int, Event]]:
241
+ query = "SELECT * FROM events WHERE run_id = ? AND seq > ?"
242
+ params: list[object] = [run_id, after_seq]
243
+ if type_prefix:
244
+ query += " AND type LIKE ?"
245
+ params.append(type_prefix + "%")
246
+ query += " ORDER BY seq"
247
+ for row in self._conn.execute(query, params):
248
+ yield (
249
+ row["seq"],
250
+ Event(
251
+ ts=row["ts"],
252
+ run_id=row["run_id"],
253
+ job_id=row["job_id"],
254
+ type=row["type"],
255
+ data=json.loads(row["data_json"]),
256
+ ),
257
+ )
sdxloop/errors.py ADDED
@@ -0,0 +1,73 @@
1
+ """sdxloop exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+
8
+ class SdxloopError(Exception):
9
+ """Base class for all sdxloop errors."""
10
+
11
+
12
+ class ConfigError(SdxloopError):
13
+ """Invalid or unloadable configuration."""
14
+
15
+
16
+ class SbxError(SdxloopError):
17
+ """An sbx CLI invocation failed."""
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ argv: Sequence[str] = (),
24
+ returncode: int | None = None,
25
+ stderr: str = "",
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.argv = list(argv)
29
+ self.returncode = returncode
30
+ self.stderr = stderr
31
+
32
+ def __str__(self) -> str:
33
+ base = super().__str__()
34
+ parts = [base]
35
+ if self.argv:
36
+ parts.append(f"argv={' '.join(self.argv)}")
37
+ if self.returncode is not None:
38
+ parts.append(f"rc={self.returncode}")
39
+ if self.stderr.strip():
40
+ parts.append(f"stderr={self.stderr.strip()}")
41
+ return " | ".join(parts)
42
+
43
+
44
+ class SbxNotFoundError(SbxError):
45
+ """The sbx binary is missing, or a referenced sandbox does not exist."""
46
+
47
+
48
+ class ProvisionError(SdxloopError):
49
+ """Sandbox pair provisioning failed."""
50
+
51
+
52
+ class WorkerError(SdxloopError):
53
+ """The in-sandbox worker failed or returned an invalid result."""
54
+
55
+
56
+ class WorkerTimeoutError(WorkerError):
57
+ """The worker exceeded its job timeout and was killed."""
58
+
59
+
60
+ class ProtocolError(SdxloopError):
61
+ """Host/worker protocol violation (bad event line, missing result, ...)."""
62
+
63
+
64
+ class GithubOpsError(SdxloopError):
65
+ """A GitHub operation failed in the github-ops sandbox."""
66
+
67
+
68
+ class BudgetExceededError(SdxloopError):
69
+ """A run or task exhausted one of its budgets."""
70
+
71
+
72
+ class StateError(SdxloopError):
73
+ """Invalid state transition or corrupted persisted state."""
sdxloop/events.py ADDED
@@ -0,0 +1,87 @@
1
+ """Host-side event plumbing: a synchronous pub/sub bus over protocol Events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ from collections.abc import Callable
8
+ from typing import Any, Protocol, runtime_checkable
9
+
10
+ from sdxloop_worker.protocol import Event
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ Subscriber = Callable[[Event], None]
15
+
16
+
17
+ @runtime_checkable
18
+ class Hook(Protocol):
19
+ """Extension point: receives every event published on the bus.
20
+
21
+ Implementations must be fast and must not raise; exceptions are caught
22
+ and logged so one misbehaving hook cannot break a run.
23
+ """
24
+
25
+ def on_event(self, event: Event) -> None: ...
26
+
27
+
28
+ class EventBus:
29
+ """Synchronous fan-out of protocol events to subscribers.
30
+
31
+ Subscriber exceptions are isolated: they are logged and swallowed so
32
+ telemetry consumers can never fail a run.
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._subscribers: list[Subscriber] = []
37
+
38
+ def subscribe(self, fn: Subscriber) -> Callable[[], None]:
39
+ """Register a subscriber; returns an unsubscribe callable."""
40
+ self._subscribers.append(fn)
41
+
42
+ def unsubscribe() -> None:
43
+ with contextlib.suppress(ValueError):
44
+ self._subscribers.remove(fn)
45
+
46
+ return unsubscribe
47
+
48
+ def attach_hook(self, hook: Hook) -> Callable[[], None]:
49
+ return self.subscribe(hook.on_event)
50
+
51
+ def publish(self, event: Event) -> None:
52
+ for fn in list(self._subscribers):
53
+ try:
54
+ fn(event)
55
+ except Exception:
56
+ logger.exception("event subscriber %r failed for %s", fn, event.type)
57
+
58
+ def emit(
59
+ self,
60
+ type: str,
61
+ run_id: str,
62
+ job_id: str | None = None,
63
+ **data: Any,
64
+ ) -> Event:
65
+ """Construct an Event stamped now, publish it, and return it."""
66
+ event = Event.now(type, run_id, job_id=job_id, **data)
67
+ self.publish(event)
68
+ return event
69
+
70
+
71
+ class HostEventTypes:
72
+ """Host-emitted event types (the worker's live in protocol.EventTypes)."""
73
+
74
+ RUN_START = "run.start"
75
+ RUN_STATE = "run.state"
76
+ RUN_END = "run.end"
77
+ TASK_START = "task.start"
78
+ TASK_STATE = "task.state"
79
+ TASK_END = "task.end"
80
+ PHASE_START = "phase.start"
81
+ PHASE_END = "phase.end"
82
+ SANDBOX_PROVISION_START = "sandbox.provision_start"
83
+ SANDBOX_READY = "sandbox.ready"
84
+ SANDBOX_CLEANUP = "sandbox.cleanup"
85
+
86
+
87
+ __all__ = ["Event", "EventBus", "Hook", "HostEventTypes", "Subscriber"]
sdxloop/gh/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Host-side GitHub operations (executed in the github-ops sandbox)."""
2
+
3
+ from sdxloop.gh.ops import GithubOps, IssueRef, PrRef
4
+ from sdxloop.gh.reporter import GithubReporterHook
5
+
6
+ __all__ = ["GithubOps", "GithubReporterHook", "IssueRef", "PrRef"]
sdxloop/gh/ops.py ADDED
@@ -0,0 +1,140 @@
1
+ """Typed facade over github.op jobs running in the github-ops sandbox.
2
+
3
+ The host never talks to GitHub with the user PAT directly — every operation
4
+ becomes a ``github.op`` JobRequest submitted to the github sandbox, which is
5
+ the only environment holding ``GH_TOKEN``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from sdxloop.errors import GithubOpsError
15
+ from sdxloop.ids import new_job_id
16
+ from sdxloop.worker.client import WorkerClient
17
+ from sdxloop_worker.protocol import JobRequest
18
+
19
+
20
+ class IssueRef(BaseModel):
21
+ number: int
22
+ url: str
23
+
24
+
25
+ class PrRef(BaseModel):
26
+ number: int
27
+ url: str
28
+
29
+
30
+ class GithubOps:
31
+ def __init__(
32
+ self,
33
+ client: WorkerClient,
34
+ run_id: str,
35
+ *,
36
+ timeout_s: float = 120.0,
37
+ ) -> None:
38
+ self.client = client
39
+ self.run_id = run_id
40
+ self.timeout_s = timeout_s
41
+
42
+ def _op(self, op: str, params: dict[str, Any]) -> Any:
43
+ job = JobRequest(
44
+ job_id=new_job_id(),
45
+ run_id=self.run_id,
46
+ kind="github.op",
47
+ op=op,
48
+ params=params,
49
+ timeout_s=self.timeout_s,
50
+ )
51
+ result = self.client.submit(job)
52
+ if result.status != "ok":
53
+ assert result.error is not None
54
+ raise GithubOpsError(
55
+ f"github op {op} failed: {result.error.type}: {result.error.message}"
56
+ )
57
+ return result.output_json
58
+
59
+ def issue_create(
60
+ self,
61
+ repo: str,
62
+ title: str,
63
+ body: str = "",
64
+ labels: list[str] | None = None,
65
+ ) -> IssueRef:
66
+ params: dict[str, Any] = {"repo": repo, "title": title, "body": body}
67
+ if labels:
68
+ params["labels"] = labels
69
+ return IssueRef.model_validate(self._op("issue.create", params))
70
+
71
+ def issue_comment(self, repo: str, number: int, body: str) -> str:
72
+ data = self._op("issue.comment", {"repo": repo, "number": number, "body": body})
73
+ return str(data.get("url", ""))
74
+
75
+ def pr_create(
76
+ self,
77
+ repo: str,
78
+ base: str,
79
+ head: str,
80
+ title: str,
81
+ body: str = "",
82
+ *,
83
+ draft: bool = False,
84
+ ) -> PrRef:
85
+ return PrRef.model_validate(
86
+ self._op(
87
+ "pr.create",
88
+ {
89
+ "repo": repo,
90
+ "base": base,
91
+ "head": head,
92
+ "title": title,
93
+ "body": body,
94
+ "draft": draft,
95
+ },
96
+ )
97
+ )
98
+
99
+ def pr_comment(self, repo: str, number: int, body: str) -> str:
100
+ data = self._op("pr.comment", {"repo": repo, "number": number, "body": body})
101
+ return str(data.get("url", ""))
102
+
103
+ def contents_read(self, repo: str, path: str, ref: str | None = None) -> str:
104
+ params: dict[str, Any] = {"repo": repo, "path": path}
105
+ if ref:
106
+ params["ref"] = ref
107
+ data = self._op("contents.read", params)
108
+ return str(data.get("content", ""))
109
+
110
+ def status_create(
111
+ self,
112
+ repo: str,
113
+ sha: str,
114
+ state: str,
115
+ *,
116
+ context: str = "sdxloop",
117
+ description: str = "",
118
+ target_url: str = "",
119
+ ) -> None:
120
+ params: dict[str, Any] = {"repo": repo, "sha": sha, "state": state, "context": context}
121
+ if description:
122
+ params["description"] = description
123
+ if target_url:
124
+ params["target_url"] = target_url
125
+ self._op("status.create", params)
126
+
127
+ def repo_get(self, repo: str) -> dict[str, Any]:
128
+ data = self._op("repo.get", {"repo": repo})
129
+ assert isinstance(data, dict)
130
+ return data
131
+
132
+ def search_issues(self, query: str, per_page: int = 30) -> list[dict[str, Any]]:
133
+ data = self._op("search.issues", {"query": query, "per_page": per_page})
134
+ return data if isinstance(data, list) else []
135
+
136
+ def raw(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
137
+ params: dict[str, Any] = {"method": method, "path": path}
138
+ if body is not None:
139
+ params["body"] = body
140
+ return self._op("raw.api", params)
sdxloop/gh/reporter.py ADDED
@@ -0,0 +1,75 @@
1
+ """GithubReporterHook: mirrors run progress to a GitHub tracking issue.
2
+
3
+ The default consumer of the github-ops sandbox. When ``github.report_repo``
4
+ is configured, the hook opens a tracking issue at run start, comments as
5
+ tasks finish, and posts a final summary at run end. It never raises — a
6
+ reporting failure must not fail a run — and the EventBus isolates it anyway.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from sdxloop.events import Event, HostEventTypes
14
+ from sdxloop.gh.ops import GithubOps, IssueRef
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GithubReporterHook:
20
+ def __init__(self, ops: GithubOps, repo: str) -> None:
21
+ self.ops = ops
22
+ self.repo = repo
23
+ self.issue: IssueRef | None = None
24
+ self._task_lines: list[str] = []
25
+
26
+ # -- Hook protocol -----------------------------------------------------
27
+
28
+ def on_event(self, event: Event) -> None:
29
+ try:
30
+ if event.type == HostEventTypes.RUN_START:
31
+ self._on_run_start(event)
32
+ elif event.type == HostEventTypes.TASK_END:
33
+ self._on_task_end(event)
34
+ elif event.type == HostEventTypes.RUN_END:
35
+ self._on_run_end(event)
36
+ except Exception:
37
+ logger.warning("github reporting failed for %s", event.type, exc_info=True)
38
+
39
+ # -- handlers ----------------------------------------------------------
40
+
41
+ def _on_run_start(self, event: Event) -> None:
42
+ outcome = str(event.data.get("outcome", ""))
43
+ body = (
44
+ f"sdxloop run `{event.run_id}` started.\n\n"
45
+ f"**Outcome:**\n\n> {outcome}\n\n"
46
+ "Progress is reported as comments on this issue."
47
+ )
48
+ self.issue = self.ops.issue_create(
49
+ self.repo,
50
+ title=f"sdxloop run {event.run_id}",
51
+ body=body,
52
+ labels=["sdxloop"],
53
+ )
54
+
55
+ def _on_task_end(self, event: Event) -> None:
56
+ if self.issue is None:
57
+ return
58
+ task_id = event.data.get("task_id", "?")
59
+ title = event.data.get("title", "")
60
+ state = event.data.get("state", "?")
61
+ marker = "✅" if state == "done" else "❌"
62
+ line = f"{marker} `{task_id}` {title} — **{state}**"
63
+ self._task_lines.append(line)
64
+ self.ops.issue_comment(self.repo, self.issue.number, line)
65
+
66
+ def _on_run_end(self, event: Event) -> None:
67
+ if self.issue is None:
68
+ return
69
+ state = event.data.get("state", "?")
70
+ summary = "\n".join(self._task_lines) or "_no tasks were executed_"
71
+ self.ops.issue_comment(
72
+ self.repo,
73
+ self.issue.number,
74
+ f"Run `{event.run_id}` finished: **{state}**\n\n{summary}",
75
+ )
sdxloop/ids.py ADDED
@@ -0,0 +1,35 @@
1
+ """Identifier generation for runs, jobs, and tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import secrets
7
+
8
+ # Crockford-flavored base32: lowercase, no i/l/o/u lookalikes.
9
+ _ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
10
+
11
+ _RUN_ID_RE = re.compile(r"^r[0-9abcdefghjkmnpqrstvwxyz]{8}$")
12
+
13
+
14
+ def _token(length: int) -> str:
15
+ return "".join(secrets.choice(_ALPHABET) for _ in range(length))
16
+
17
+
18
+ def new_run_id() -> str:
19
+ """A short, sandbox-name-safe run id, e.g. ``r7k2m9qp3``-style."""
20
+ return "r" + _token(8)
21
+
22
+
23
+ def is_run_id(value: str) -> bool:
24
+ return _RUN_ID_RE.fullmatch(value) is not None
25
+
26
+
27
+ def new_job_id() -> str:
28
+ return "j" + _token(10)
29
+
30
+
31
+ def task_id(index: int) -> str:
32
+ """Deterministic task ids within a run: t1, t2, ..."""
33
+ if index < 1:
34
+ raise ValueError("task index starts at 1")
35
+ return f"t{index}"
sdxloop/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Typed subprocess layer over the Docker Sandboxes ``sbx`` CLI."""
2
+
3
+ from sdxloop.sbx.cli import SbxCLI
4
+ from sdxloop.sbx.models import ExecResult, SandboxInfo, SandboxSpec, SecretSpec
5
+
6
+ __all__ = ["ExecResult", "SandboxInfo", "SandboxSpec", "SbxCLI", "SecretSpec"]