nightshift-sdk 0.2.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.
- nightshift/__init__.py +4 -0
- nightshift/__main__.py +8 -0
- nightshift/agent/__init__.py +0 -0
- nightshift/agent/entry.py +116 -0
- nightshift/auth.py +57 -0
- nightshift/cli/__init__.py +0 -0
- nightshift/cli/commands/__init__.py +0 -0
- nightshift/cli/commands/agents.py +56 -0
- nightshift/cli/commands/api_key.py +104 -0
- nightshift/cli/commands/deploy.py +151 -0
- nightshift/cli/commands/login.py +27 -0
- nightshift/cli/commands/logs.py +64 -0
- nightshift/cli/commands/run.py +94 -0
- nightshift/cli/config.py +52 -0
- nightshift/cli/main.py +39 -0
- nightshift/config.py +38 -0
- nightshift/events.py +124 -0
- nightshift/protocol/__init__.py +4 -0
- nightshift/protocol/events.py +36 -0
- nightshift/protocol/packaging.py +71 -0
- nightshift/registry.py +267 -0
- nightshift/sdk/__init__.py +4 -0
- nightshift/sdk/app.py +51 -0
- nightshift/sdk/config.py +15 -0
- nightshift/server.py +302 -0
- nightshift/task.py +111 -0
- nightshift/vm/__init__.py +0 -0
- nightshift/vm/manager.py +411 -0
- nightshift/vm/network.py +95 -0
- nightshift/vm/rootfs.py +126 -0
- nightshift_sdk-0.2.0.dist-info/METADATA +112 -0
- nightshift_sdk-0.2.0.dist-info/RECORD +35 -0
- nightshift_sdk-0.2.0.dist-info/WHEEL +4 -0
- nightshift_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- nightshift_sdk-0.2.0.dist-info/licenses/LICENSE +191 -0
nightshift/cli/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI config — reads/writes ~/.nightshift/config.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = os.path.expanduser("~/.nightshift")
|
|
13
|
+
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.toml")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> dict:
|
|
17
|
+
"""Load the CLI config file, returning {} if it doesn't exist."""
|
|
18
|
+
if not os.path.exists(CONFIG_PATH):
|
|
19
|
+
return {}
|
|
20
|
+
with open(CONFIG_PATH, "rb") as f:
|
|
21
|
+
return tomllib.load(f)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_config(data: dict) -> None:
|
|
25
|
+
"""Write the CLI config file with restricted permissions (0600)."""
|
|
26
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
27
|
+
with open(CONFIG_PATH, "wb") as f:
|
|
28
|
+
tomli_w.dump(data, f)
|
|
29
|
+
os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_url() -> str:
|
|
33
|
+
"""Get the platform URL from config."""
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
url = cfg.get("url", "")
|
|
36
|
+
if not url:
|
|
37
|
+
raise SystemExit("Not logged in. Run: nightshift login --url <URL> --api-key <KEY>")
|
|
38
|
+
return url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_api_key() -> str:
|
|
42
|
+
"""Get the API key from config."""
|
|
43
|
+
cfg = load_config()
|
|
44
|
+
key = cfg.get("api_key", "")
|
|
45
|
+
if not key:
|
|
46
|
+
raise SystemExit("Not logged in. Run: nightshift login --url <URL> --api-key <KEY>")
|
|
47
|
+
return key
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_auth_headers() -> dict[str, str]:
|
|
51
|
+
"""Get Authorization headers for API requests."""
|
|
52
|
+
return {"Authorization": f"Bearer {get_api_key()}"}
|
nightshift/cli/main.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Nightshift CLI — deploy and manage agents on the platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from nightshift.cli.commands.agents import agents
|
|
8
|
+
from nightshift.cli.commands.api_key import api_key
|
|
9
|
+
from nightshift.cli.commands.deploy import deploy
|
|
10
|
+
from nightshift.cli.commands.login import login
|
|
11
|
+
from nightshift.cli.commands.logs import logs
|
|
12
|
+
from nightshift.cli.commands.run import run
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def cli() -> None:
|
|
17
|
+
"""Nightshift — autonomous agent orchestrator with Firecracker VMs."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Register subcommands
|
|
22
|
+
cli.add_command(login)
|
|
23
|
+
cli.add_command(deploy)
|
|
24
|
+
cli.add_command(run)
|
|
25
|
+
cli.add_command(logs)
|
|
26
|
+
cli.add_command(agents)
|
|
27
|
+
cli.add_command(api_key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@cli.command()
|
|
31
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
32
|
+
@click.option("--port", default=3000, type=int, help="Bind port")
|
|
33
|
+
def serve(host: str, port: int) -> None:
|
|
34
|
+
"""Start the Nightshift platform server."""
|
|
35
|
+
import asyncio
|
|
36
|
+
|
|
37
|
+
from nightshift.server import start_server
|
|
38
|
+
|
|
39
|
+
asyncio.run(start_server(host=host, port=port))
|
nightshift/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class NightshiftConfig:
|
|
9
|
+
workspace: str = ""
|
|
10
|
+
port: int = 3000
|
|
11
|
+
kernel_path: str = "/opt/nightshift/vmlinux"
|
|
12
|
+
base_rootfs_path: str = "/opt/nightshift/rootfs.ext4"
|
|
13
|
+
vm_timeout_seconds: int = 1800 # 30 minutes
|
|
14
|
+
vm_health_timeout_seconds: int = 60
|
|
15
|
+
vm_event_port: int = 8080
|
|
16
|
+
db_path: str = "/opt/nightshift/nightshift.db"
|
|
17
|
+
agents_storage_dir: str = "/opt/nightshift/agents"
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def from_env() -> NightshiftConfig:
|
|
21
|
+
return NightshiftConfig(
|
|
22
|
+
workspace=os.environ.get("NIGHTSHIFT_WORKSPACE", os.getcwd()),
|
|
23
|
+
port=int(os.environ.get("NIGHTSHIFT_PORT", "3000")),
|
|
24
|
+
kernel_path=os.environ.get("NIGHTSHIFT_KERNEL_PATH", "/opt/nightshift/vmlinux"),
|
|
25
|
+
base_rootfs_path=os.environ.get(
|
|
26
|
+
"NIGHTSHIFT_ROOTFS_PATH", "/opt/nightshift/rootfs.ext4"
|
|
27
|
+
),
|
|
28
|
+
db_path=os.environ.get("NIGHTSHIFT_DB_PATH", "/opt/nightshift/nightshift.db"),
|
|
29
|
+
agents_storage_dir=os.environ.get(
|
|
30
|
+
"NIGHTSHIFT_AGENTS_DIR", "/opt/nightshift/agents"
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def env_vars_for_vm(self) -> dict[str, str]:
|
|
35
|
+
"""Collect platform env vars to pass into the VM."""
|
|
36
|
+
env: dict[str, str] = {}
|
|
37
|
+
env["NIGHTSHIFT_WORKSPACE"] = "/workspace"
|
|
38
|
+
return env
|
nightshift/events.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Event types and in-memory event buffer.
|
|
2
|
+
|
|
3
|
+
EventBuffer replaces the previous EventLog/EventStore/EventSink stack
|
|
4
|
+
with a single class that stores events per run_id and supports async streaming.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from typing import AsyncIterator, Literal, Union
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BaseEvent:
|
|
21
|
+
timestamp: float = field(default_factory=time.time)
|
|
22
|
+
run_id: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class StartedEvent(BaseEvent):
|
|
27
|
+
type: Literal["nightshift.started"] = "nightshift.started"
|
|
28
|
+
workspace: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CompletedEvent(BaseEvent):
|
|
33
|
+
type: Literal["nightshift.completed"] = "nightshift.completed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ErrorEvent(BaseEvent):
|
|
38
|
+
type: Literal["nightshift.error"] = "nightshift.error"
|
|
39
|
+
error: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class InterruptedEvent(BaseEvent):
|
|
44
|
+
type: Literal["nightshift.interrupted"] = "nightshift.interrupted"
|
|
45
|
+
reason: Literal["user_quit", "user_stop"] = "user_quit"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
NightshiftEvent = Union[
|
|
49
|
+
StartedEvent,
|
|
50
|
+
CompletedEvent,
|
|
51
|
+
ErrorEvent,
|
|
52
|
+
InterruptedEvent,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
TERMINAL_EVENTS = {"nightshift.completed", "nightshift.error", "nightshift.interrupted"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EventBuffer:
|
|
59
|
+
"""Simple in-memory event buffer with async streaming.
|
|
60
|
+
|
|
61
|
+
Stores events per run_id and allows consumers to stream them
|
|
62
|
+
(replay + live-tail) via an asyncio.Condition.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._runs: dict[str, list[tuple[str, dict]]] = {}
|
|
67
|
+
self._cond: asyncio.Condition = asyncio.Condition()
|
|
68
|
+
self._done: set[str] = set()
|
|
69
|
+
|
|
70
|
+
async def append(self, run_id: str, event_type: str, payload: dict) -> None:
|
|
71
|
+
"""Append an event to a run's buffer and notify waiters."""
|
|
72
|
+
self._runs.setdefault(run_id, []).append((event_type, payload))
|
|
73
|
+
async with self._cond:
|
|
74
|
+
self._cond.notify_all()
|
|
75
|
+
|
|
76
|
+
async def stream(self, run_id: str, cursor: int = 0) -> AsyncIterator[tuple[str, dict]]:
|
|
77
|
+
"""Yield events for a run, replaying from cursor then live-tailing.
|
|
78
|
+
|
|
79
|
+
Terminates when the run is marked done via cleanup().
|
|
80
|
+
"""
|
|
81
|
+
while True:
|
|
82
|
+
events = self._runs.get(run_id, [])
|
|
83
|
+
while cursor < len(events):
|
|
84
|
+
yield events[cursor]
|
|
85
|
+
cursor += 1
|
|
86
|
+
if run_id in self._done:
|
|
87
|
+
return
|
|
88
|
+
async with self._cond:
|
|
89
|
+
await self._cond.wait()
|
|
90
|
+
|
|
91
|
+
async def cleanup(self, run_id: str) -> None:
|
|
92
|
+
"""Mark a run as done and remove its events."""
|
|
93
|
+
self._runs.pop(run_id, None)
|
|
94
|
+
self._done.add(run_id)
|
|
95
|
+
async with self._cond:
|
|
96
|
+
self._cond.notify_all()
|
|
97
|
+
|
|
98
|
+
# ── Convenience methods (used by task.py, vm/manager.py, agent/entry.py) ──
|
|
99
|
+
|
|
100
|
+
async def publish(self, run_id: str, event: NightshiftEvent) -> None:
|
|
101
|
+
"""Publish a typed NightshiftEvent."""
|
|
102
|
+
event.run_id = run_id
|
|
103
|
+
payload = asdict(event)
|
|
104
|
+
event_type = payload.pop("type")
|
|
105
|
+
await self.append(run_id, event_type, payload)
|
|
106
|
+
|
|
107
|
+
async def publish_raw(self, run_id: str, event_type: str, data: dict) -> None:
|
|
108
|
+
"""Forward a raw event dict (e.g. from the guest SSE stream)."""
|
|
109
|
+
payload = {k: v for k, v in data.items() if k != "type"}
|
|
110
|
+
await self.append(run_id, event_type, payload)
|
|
111
|
+
|
|
112
|
+
async def stream_sse(self, run_id: str) -> AsyncIterator[dict]:
|
|
113
|
+
"""Stream events formatted for SSE (event + data keys)."""
|
|
114
|
+
async for event_type, payload in self.stream(run_id):
|
|
115
|
+
yield {
|
|
116
|
+
"event": event_type,
|
|
117
|
+
"data": json.dumps({"type": event_type, **payload}),
|
|
118
|
+
}
|
|
119
|
+
if event_type in TERMINAL_EVENTS:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Backwards-compatible alias used by existing code
|
|
124
|
+
EventLog = EventBuffer
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Serialize Claude Agent SDK messages to JSON dicts for SSE transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import asdict, is_dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def serialize_message(message: Any) -> dict:
|
|
11
|
+
"""Convert an agent message to a JSON-serializable dict.
|
|
12
|
+
|
|
13
|
+
Handles:
|
|
14
|
+
- dicts (passed through with defaults)
|
|
15
|
+
- dataclasses (converted via asdict)
|
|
16
|
+
- pydantic models (converted via model_dump)
|
|
17
|
+
- objects with a __dict__ attribute
|
|
18
|
+
"""
|
|
19
|
+
if isinstance(message, dict):
|
|
20
|
+
data = dict(message)
|
|
21
|
+
elif is_dataclass(message) and not isinstance(message, type):
|
|
22
|
+
data = asdict(message)
|
|
23
|
+
elif hasattr(message, "model_dump"):
|
|
24
|
+
data = message.model_dump()
|
|
25
|
+
elif hasattr(message, "__dict__"):
|
|
26
|
+
data = dict(message.__dict__)
|
|
27
|
+
else:
|
|
28
|
+
data = {"data": str(message)}
|
|
29
|
+
|
|
30
|
+
if "type" not in data:
|
|
31
|
+
data["type"] = type(message).__name__
|
|
32
|
+
|
|
33
|
+
if "timestamp" not in data:
|
|
34
|
+
data["timestamp"] = time.time()
|
|
35
|
+
|
|
36
|
+
return data
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Package agent source code for injection into a Firecracker VM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _find_pyproject(start_path: str) -> str | None:
|
|
12
|
+
"""Walk up from start_path to find the nearest pyproject.toml."""
|
|
13
|
+
directory = os.path.dirname(os.path.abspath(start_path))
|
|
14
|
+
while True:
|
|
15
|
+
candidate = os.path.join(directory, "pyproject.toml")
|
|
16
|
+
if os.path.isfile(candidate):
|
|
17
|
+
return candidate
|
|
18
|
+
parent = os.path.dirname(directory)
|
|
19
|
+
if parent == directory:
|
|
20
|
+
return None
|
|
21
|
+
directory = parent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def package_agent(
|
|
25
|
+
module_path: str,
|
|
26
|
+
function_name: str,
|
|
27
|
+
prompt: str,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Package an agent's source file and a manifest for VM injection.
|
|
30
|
+
|
|
31
|
+
Creates a temp directory containing:
|
|
32
|
+
- The agent's source file (copied)
|
|
33
|
+
- manifest.json
|
|
34
|
+
- pyproject.toml (auto-detected from agent's project, if found)
|
|
35
|
+
- uv.lock (if present alongside pyproject.toml)
|
|
36
|
+
|
|
37
|
+
Returns the path to the temp directory.
|
|
38
|
+
"""
|
|
39
|
+
pkg_dir = tempfile.mkdtemp(prefix="nightshift-pkg-")
|
|
40
|
+
|
|
41
|
+
# Copy the agent source file
|
|
42
|
+
source_filename = os.path.basename(module_path)
|
|
43
|
+
shutil.copy2(module_path, os.path.join(pkg_dir, source_filename))
|
|
44
|
+
|
|
45
|
+
# Auto-detect and copy pyproject.toml + uv.lock
|
|
46
|
+
pyproject_path = _find_pyproject(module_path)
|
|
47
|
+
has_pyproject = False
|
|
48
|
+
if pyproject_path:
|
|
49
|
+
shutil.copy2(pyproject_path, os.path.join(pkg_dir, "pyproject.toml"))
|
|
50
|
+
has_pyproject = True
|
|
51
|
+
project_dir = os.path.dirname(pyproject_path)
|
|
52
|
+
lock_path = os.path.join(project_dir, "uv.lock")
|
|
53
|
+
if os.path.isfile(lock_path):
|
|
54
|
+
shutil.copy2(lock_path, os.path.join(pkg_dir, "uv.lock"))
|
|
55
|
+
|
|
56
|
+
# Write manifest
|
|
57
|
+
manifest = {
|
|
58
|
+
"module": os.path.splitext(source_filename)[0],
|
|
59
|
+
"function": function_name,
|
|
60
|
+
"prompt": prompt,
|
|
61
|
+
"has_pyproject": has_pyproject,
|
|
62
|
+
}
|
|
63
|
+
with open(os.path.join(pkg_dir, "manifest.json"), "w") as f:
|
|
64
|
+
json.dump(manifest, f)
|
|
65
|
+
|
|
66
|
+
return pkg_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cleanup_package(pkg_dir: str) -> None:
|
|
70
|
+
"""Remove a package directory created by package_agent."""
|
|
71
|
+
shutil.rmtree(pkg_dir, ignore_errors=True)
|
nightshift/registry.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""SQLite-backed agent and run registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import aiosqlite
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _now() -> str:
|
|
14
|
+
return datetime.now(timezone.utc).isoformat()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _uuid() -> str:
|
|
18
|
+
return str(uuid.uuid4())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AgentRecord:
|
|
23
|
+
id: str
|
|
24
|
+
tenant_id: str
|
|
25
|
+
name: str
|
|
26
|
+
source_filename: str
|
|
27
|
+
function_name: str
|
|
28
|
+
config_json: str
|
|
29
|
+
storage_path: str
|
|
30
|
+
created_at: str
|
|
31
|
+
updated_at: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RunRecord:
|
|
36
|
+
id: str
|
|
37
|
+
agent_id: str
|
|
38
|
+
tenant_id: str
|
|
39
|
+
prompt: str
|
|
40
|
+
status: str
|
|
41
|
+
created_at: str
|
|
42
|
+
completed_at: str | None = None
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentRegistry:
|
|
47
|
+
"""Async SQLite registry for agents, runs, and API keys."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, db_path: str) -> None:
|
|
50
|
+
self.db_path = db_path
|
|
51
|
+
self._db: aiosqlite.Connection | None = None
|
|
52
|
+
|
|
53
|
+
async def init_db(self) -> None:
|
|
54
|
+
"""Open the database and create tables if needed."""
|
|
55
|
+
self._db = await aiosqlite.connect(self.db_path)
|
|
56
|
+
self._db.row_factory = aiosqlite.Row
|
|
57
|
+
await self._db.executescript(
|
|
58
|
+
"""
|
|
59
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
tenant_id TEXT NOT NULL,
|
|
62
|
+
name TEXT NOT NULL,
|
|
63
|
+
source_filename TEXT NOT NULL,
|
|
64
|
+
function_name TEXT NOT NULL,
|
|
65
|
+
config_json TEXT NOT NULL,
|
|
66
|
+
storage_path TEXT NOT NULL,
|
|
67
|
+
created_at TEXT NOT NULL,
|
|
68
|
+
updated_at TEXT NOT NULL,
|
|
69
|
+
UNIQUE(tenant_id, name)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
agent_id TEXT NOT NULL,
|
|
75
|
+
tenant_id TEXT NOT NULL,
|
|
76
|
+
prompt TEXT NOT NULL,
|
|
77
|
+
status TEXT NOT NULL DEFAULT 'started',
|
|
78
|
+
created_at TEXT NOT NULL,
|
|
79
|
+
completed_at TEXT,
|
|
80
|
+
error TEXT
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
84
|
+
key_hash TEXT PRIMARY KEY,
|
|
85
|
+
tenant_id TEXT NOT NULL,
|
|
86
|
+
label TEXT DEFAULT '',
|
|
87
|
+
created_at TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
"""
|
|
90
|
+
)
|
|
91
|
+
await self._db.commit()
|
|
92
|
+
|
|
93
|
+
async def close(self) -> None:
|
|
94
|
+
if self._db:
|
|
95
|
+
await self._db.close()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def db(self) -> aiosqlite.Connection:
|
|
99
|
+
if self._db is None:
|
|
100
|
+
raise RuntimeError("Database not initialized — call init_db() first")
|
|
101
|
+
return self._db
|
|
102
|
+
|
|
103
|
+
# ── Agents ────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
async def upsert_agent(
|
|
106
|
+
self,
|
|
107
|
+
tenant_id: str,
|
|
108
|
+
name: str,
|
|
109
|
+
source_filename: str,
|
|
110
|
+
function_name: str,
|
|
111
|
+
config_json: str,
|
|
112
|
+
storage_path: str,
|
|
113
|
+
agent_id: str | None = None,
|
|
114
|
+
) -> AgentRecord:
|
|
115
|
+
"""Insert or update an agent by (tenant_id, name)."""
|
|
116
|
+
now = _now()
|
|
117
|
+
if agent_id is None:
|
|
118
|
+
agent_id = _uuid()
|
|
119
|
+
|
|
120
|
+
# Check for existing agent with same (tenant_id, name)
|
|
121
|
+
row = await self.db.execute_fetchall(
|
|
122
|
+
"SELECT id, created_at FROM agents WHERE tenant_id = ? AND name = ?",
|
|
123
|
+
(tenant_id, name),
|
|
124
|
+
)
|
|
125
|
+
if row:
|
|
126
|
+
existing_id = row[0][0]
|
|
127
|
+
existing_created = row[0][1]
|
|
128
|
+
await self.db.execute(
|
|
129
|
+
"""UPDATE agents
|
|
130
|
+
SET source_filename = ?, function_name = ?, config_json = ?,
|
|
131
|
+
storage_path = ?, updated_at = ?
|
|
132
|
+
WHERE id = ?""",
|
|
133
|
+
(source_filename, function_name, config_json, storage_path, now, existing_id),
|
|
134
|
+
)
|
|
135
|
+
await self.db.commit()
|
|
136
|
+
return AgentRecord(
|
|
137
|
+
id=existing_id,
|
|
138
|
+
tenant_id=tenant_id,
|
|
139
|
+
name=name,
|
|
140
|
+
source_filename=source_filename,
|
|
141
|
+
function_name=function_name,
|
|
142
|
+
config_json=config_json,
|
|
143
|
+
storage_path=storage_path,
|
|
144
|
+
created_at=existing_created,
|
|
145
|
+
updated_at=now,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
await self.db.execute(
|
|
149
|
+
"""INSERT INTO agents (id, tenant_id, name, source_filename, function_name,
|
|
150
|
+
config_json, storage_path, created_at, updated_at)
|
|
151
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
152
|
+
(agent_id, tenant_id, name, source_filename, function_name,
|
|
153
|
+
config_json, storage_path, now, now),
|
|
154
|
+
)
|
|
155
|
+
await self.db.commit()
|
|
156
|
+
return AgentRecord(
|
|
157
|
+
id=agent_id,
|
|
158
|
+
tenant_id=tenant_id,
|
|
159
|
+
name=name,
|
|
160
|
+
source_filename=source_filename,
|
|
161
|
+
function_name=function_name,
|
|
162
|
+
config_json=config_json,
|
|
163
|
+
storage_path=storage_path,
|
|
164
|
+
created_at=now,
|
|
165
|
+
updated_at=now,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def get_agent(self, tenant_id: str, name: str) -> AgentRecord | None:
|
|
169
|
+
rows = await self.db.execute_fetchall(
|
|
170
|
+
"SELECT * FROM agents WHERE tenant_id = ? AND name = ?",
|
|
171
|
+
(tenant_id, name),
|
|
172
|
+
)
|
|
173
|
+
if not rows:
|
|
174
|
+
return None
|
|
175
|
+
r = rows[0]
|
|
176
|
+
return AgentRecord(
|
|
177
|
+
id=r[0], tenant_id=r[1], name=r[2], source_filename=r[3],
|
|
178
|
+
function_name=r[4], config_json=r[5], storage_path=r[6],
|
|
179
|
+
created_at=r[7], updated_at=r[8],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def list_agents(self, tenant_id: str) -> list[AgentRecord]:
|
|
183
|
+
rows = await self.db.execute_fetchall(
|
|
184
|
+
"SELECT * FROM agents WHERE tenant_id = ? ORDER BY name",
|
|
185
|
+
(tenant_id,),
|
|
186
|
+
)
|
|
187
|
+
return [
|
|
188
|
+
AgentRecord(
|
|
189
|
+
id=r[0], tenant_id=r[1], name=r[2], source_filename=r[3],
|
|
190
|
+
function_name=r[4], config_json=r[5], storage_path=r[6],
|
|
191
|
+
created_at=r[7], updated_at=r[8],
|
|
192
|
+
)
|
|
193
|
+
for r in rows
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
async def delete_agent(self, tenant_id: str, name: str) -> bool:
|
|
197
|
+
cursor = await self.db.execute(
|
|
198
|
+
"DELETE FROM agents WHERE tenant_id = ? AND name = ?",
|
|
199
|
+
(tenant_id, name),
|
|
200
|
+
)
|
|
201
|
+
await self.db.commit()
|
|
202
|
+
return cursor.rowcount > 0
|
|
203
|
+
|
|
204
|
+
# ── Runs ──────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
async def create_run(
|
|
207
|
+
self,
|
|
208
|
+
agent_id: str,
|
|
209
|
+
tenant_id: str,
|
|
210
|
+
prompt: str,
|
|
211
|
+
) -> RunRecord:
|
|
212
|
+
run_id = _uuid()
|
|
213
|
+
now = _now()
|
|
214
|
+
await self.db.execute(
|
|
215
|
+
"""INSERT INTO runs (id, agent_id, tenant_id, prompt, status, created_at)
|
|
216
|
+
VALUES (?, ?, ?, ?, 'started', ?)""",
|
|
217
|
+
(run_id, agent_id, tenant_id, prompt, now),
|
|
218
|
+
)
|
|
219
|
+
await self.db.commit()
|
|
220
|
+
return RunRecord(
|
|
221
|
+
id=run_id,
|
|
222
|
+
agent_id=agent_id,
|
|
223
|
+
tenant_id=tenant_id,
|
|
224
|
+
prompt=prompt,
|
|
225
|
+
status="started",
|
|
226
|
+
created_at=now,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def complete_run(self, run_id: str, error: str | None = None) -> None:
|
|
230
|
+
now = _now()
|
|
231
|
+
status = "error" if error else "completed"
|
|
232
|
+
await self.db.execute(
|
|
233
|
+
"UPDATE runs SET status = ?, completed_at = ?, error = ? WHERE id = ?",
|
|
234
|
+
(status, now, error, run_id),
|
|
235
|
+
)
|
|
236
|
+
await self.db.commit()
|
|
237
|
+
|
|
238
|
+
async def get_run(self, run_id: str) -> RunRecord | None:
|
|
239
|
+
rows = await self.db.execute_fetchall(
|
|
240
|
+
"SELECT * FROM runs WHERE id = ?", (run_id,),
|
|
241
|
+
)
|
|
242
|
+
if not rows:
|
|
243
|
+
return None
|
|
244
|
+
r = rows[0]
|
|
245
|
+
return RunRecord(
|
|
246
|
+
id=r[0], agent_id=r[1], tenant_id=r[2], prompt=r[3],
|
|
247
|
+
status=r[4], created_at=r[5], completed_at=r[6], error=r[7],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# ── API Keys ──────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async def store_api_key(self, key_hash: str, tenant_id: str, label: str = "") -> None:
|
|
253
|
+
await self.db.execute(
|
|
254
|
+
"""INSERT OR IGNORE INTO api_keys (key_hash, tenant_id, label, created_at)
|
|
255
|
+
VALUES (?, ?, ?, ?)""",
|
|
256
|
+
(key_hash, tenant_id, label, _now()),
|
|
257
|
+
)
|
|
258
|
+
await self.db.commit()
|
|
259
|
+
|
|
260
|
+
async def get_tenant_by_key_hash(self, key_hash: str) -> str | None:
|
|
261
|
+
rows = await self.db.execute_fetchall(
|
|
262
|
+
"SELECT tenant_id FROM api_keys WHERE key_hash = ?",
|
|
263
|
+
(key_hash,),
|
|
264
|
+
)
|
|
265
|
+
if not rows:
|
|
266
|
+
return None
|
|
267
|
+
return rows[0][0]
|
nightshift/sdk/app.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from types import FunctionType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from nightshift.sdk.config import AgentConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RegisteredAgent:
|
|
14
|
+
"""An agent function registered with NightshiftApp."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
fn: Callable[..., Any]
|
|
18
|
+
config: AgentConfig
|
|
19
|
+
module_path: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NightshiftApp:
|
|
23
|
+
"""Main application class for registering and serving agents."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._agents: dict[str, RegisteredAgent] = {}
|
|
27
|
+
|
|
28
|
+
def agent(self, config: AgentConfig | None = None, *, name: str | None = None):
|
|
29
|
+
"""Decorator to register an async generator function as an agent.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
@app.agent(AgentConfig(vcpu_count=2))
|
|
33
|
+
async def my_agent(prompt: str):
|
|
34
|
+
yield message
|
|
35
|
+
"""
|
|
36
|
+
if config is None:
|
|
37
|
+
config = AgentConfig()
|
|
38
|
+
|
|
39
|
+
def decorator(fn: FunctionType) -> FunctionType:
|
|
40
|
+
agent_name = name or fn.__name__
|
|
41
|
+
module_path = inspect.getfile(fn)
|
|
42
|
+
self._agents[agent_name] = RegisteredAgent(
|
|
43
|
+
name=agent_name,
|
|
44
|
+
fn=fn,
|
|
45
|
+
config=config,
|
|
46
|
+
module_path=module_path,
|
|
47
|
+
)
|
|
48
|
+
return fn
|
|
49
|
+
|
|
50
|
+
return decorator
|
|
51
|
+
|