creatureos 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.
- creatureos/__init__.py +3 -0
- creatureos/__main__.py +7 -0
- creatureos/cli.py +604 -0
- creatureos/codex_cli.py +199 -0
- creatureos/config.py +236 -0
- creatureos/service.py +12586 -0
- creatureos/static/alien-picker-icon.svg +1 -0
- creatureos/static/alien-scene.svg +58 -0
- creatureos/static/apple-touch-icon.png +0 -0
- creatureos/static/console.css +5349 -0
- creatureos/static/creature_os.css +9636 -0
- creatureos/static/creature_os.js +3074 -0
- creatureos/static/creatureos-favicon.png +0 -0
- creatureos/static/creatureos-icon-192.png +0 -0
- creatureos/static/creatureos-icon-512.png +0 -0
- creatureos/static/creatureos-mark.png +0 -0
- creatureos/static/expanse-bridge-scene.svg +361 -0
- creatureos/static/expanse-hubble-xdf.jpg +0 -0
- creatureos/static/favicon-16x16.png +0 -0
- creatureos/static/favicon-32x32.png +0 -0
- creatureos/static/favicon.ico +0 -0
- creatureos/static/monsters-picker-icon.svg +1 -0
- creatureos/static/monsters-scene.svg +172 -0
- creatureos/static/ocean-picker-icon.svg +1 -0
- creatureos/static/ocean-scene.svg +99 -0
- creatureos/static/ocean-source-algae.svg +1 -0
- creatureos/static/ocean-source-clam-shell.svg +3 -0
- creatureos/static/ocean-source-coral.svg +1 -0
- creatureos/static/ocean-source-crab.svg +1 -0
- creatureos/static/ocean-source-fish-muted-flip.png +0 -0
- creatureos/static/ocean-source-fish-muted.png +0 -0
- creatureos/static/ocean-source-hammerhead-muted.png +0 -0
- creatureos/static/ocean-source-octopus-muted.png +0 -0
- creatureos/static/ocean-source-oyster-shell.svg +11 -0
- creatureos/static/ocean-source-seahorse.svg +77 -0
- creatureos/static/ocean-source-seaweed-tall.svg +66 -0
- creatureos/static/ocean-source-seaweed.svg +40 -0
- creatureos/static/ocean-source-shark-muted.png +0 -0
- creatureos/static/ocean-source-starfish.svg +1 -0
- creatureos/static/ocean-source-whale.svg +61 -0
- creatureos/static/shell.js +405 -0
- creatureos/static/site.webmanifest +19 -0
- creatureos/static/spooky-bone.svg +7 -0
- creatureos/static/spooky-flying-ghost.svg +9 -0
- creatureos/static/spooky-gate.svg +1487 -0
- creatureos/static/spooky-hasty-grave.svg +1 -0
- creatureos/static/spooky-midnight-graveyard.svg +2681 -0
- creatureos/static/spooky-picker-icon.svg +1 -0
- creatureos/static/spooky-scene.svg +88 -0
- creatureos/static/spooky-tombstone.svg +1 -0
- creatureos/static/spooky-zombie-hand.svg +1 -0
- creatureos/static/tech-picker-icon.svg +3 -0
- creatureos/static/tech-scene.svg +90 -0
- creatureos/static/tech-web-delivery-drone.svg +1 -0
- creatureos/static/tech-web-tracked-robot.svg +1 -0
- creatureos/static/tech-web-walking-scout.svg +1 -0
- creatureos/static/woodlands-beaver-soft.svg +100 -0
- creatureos/static/woodlands-birds.svg +7 -0
- creatureos/static/woodlands-branches-dark.svg +356 -0
- creatureos/static/woodlands-conifer-deep.svg +157 -0
- creatureos/static/woodlands-conifer-soft.svg +157 -0
- creatureos/static/woodlands-deer-soft.svg +119 -0
- creatureos/static/woodlands-eagle-soft.svg +105 -0
- creatureos/static/woodlands-fox-soft.svg +61 -0
- creatureos/static/woodlands-owl-soft.svg +99 -0
- creatureos/static/woodlands-rabbit-soft.svg +117 -0
- creatureos/static/woodlands-scene.svg +60 -0
- creatureos/static/woodlands-trees-mid-clipped-strong.png +0 -0
- creatureos/storage.py +2465 -0
- creatureos/templates/index.html +1443 -0
- creatureos/templates/layouts/console_base.html +88 -0
- creatureos/web.py +931 -0
- creatureos-0.1.0.dist-info/METADATA +248 -0
- creatureos-0.1.0.dist-info/RECORD +78 -0
- creatureos-0.1.0.dist-info/WHEEL +5 -0
- creatureos-0.1.0.dist-info/entry_points.txt +2 -0
- creatureos-0.1.0.dist-info/licenses/LICENSE +201 -0
- creatureos-0.1.0.dist-info/top_level.txt +1 -0
creatureos/__init__.py
ADDED
creatureos/__main__.py
ADDED
creatureos/cli.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from urllib import request as urlrequest
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
from . import config
|
|
20
|
+
from . import service
|
|
21
|
+
from . import storage
|
|
22
|
+
|
|
23
|
+
SERVER_WATCH_INTERVAL_SECONDS = 1.0
|
|
24
|
+
SERVER_RESTART_BACKOFF_SECONDS = 1.0
|
|
25
|
+
SERVER_SHUTDOWN_GRACE_SECONDS = 5.0
|
|
26
|
+
SERVER_READY_TIMEOUT_SECONDS = 45.0
|
|
27
|
+
SERVER_READY_POLL_INTERVAL_SECONDS = 0.5
|
|
28
|
+
SERVER_HEALTH_CHECK_INTERVAL_SECONDS = 5.0
|
|
29
|
+
SERVER_HEALTH_FAILURE_LIMIT = 3
|
|
30
|
+
SERVER_HEALTH_REQUEST_TIMEOUT_SECONDS = 5.0
|
|
31
|
+
SERVE_BIND_MODE_ENV = "CREATURE_OS_SERVE_BIND_MODE"
|
|
32
|
+
SERVE_BIND_MODE_DEFAULT = "default"
|
|
33
|
+
SERVE_BIND_MODE_TAILSCALE = "tailscale"
|
|
34
|
+
_TAILSCALE_IPV4_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _HiddenSubparsersAction(argparse._SubParsersAction):
|
|
38
|
+
def add_parser(self, name, **kwargs):
|
|
39
|
+
hidden = bool(kwargs.pop("hidden", False))
|
|
40
|
+
parser = super().add_parser(name, **kwargs)
|
|
41
|
+
if hidden and self._choices_actions:
|
|
42
|
+
self._choices_actions.pop()
|
|
43
|
+
return parser
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(description="CreatureOS runtime")
|
|
48
|
+
parser.register("action", "parsers", _HiddenSubparsersAction)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--workspace",
|
|
51
|
+
default="",
|
|
52
|
+
help="Workspace directory CreatureOS should inspect. Defaults to the current directory.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--data-dir",
|
|
56
|
+
default="",
|
|
57
|
+
help="Runtime state directory. Defaults to a user-local CreatureOS state directory.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--db-path",
|
|
61
|
+
default="",
|
|
62
|
+
help="SQLite path override. Defaults to <data-dir>/creature_os.sqlite3.",
|
|
63
|
+
)
|
|
64
|
+
subparsers = parser.add_subparsers(
|
|
65
|
+
dest="command",
|
|
66
|
+
required=True,
|
|
67
|
+
metavar="{init-db,run-creature,run-due,create-creature,send-message,create-conversation,spawn-conversation,delete-creature,serve}",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
subparsers.add_parser("init-db")
|
|
71
|
+
run_creature = subparsers.add_parser("run-creature")
|
|
72
|
+
run_creature.add_argument("slug")
|
|
73
|
+
run_creature.add_argument("--trigger", default="manual")
|
|
74
|
+
run_creature.add_argument("--force-message", action="store_true")
|
|
75
|
+
run_creature.add_argument("--conversation-id", type=int)
|
|
76
|
+
run_creature.add_argument("--allow-code-changes", action="store_true")
|
|
77
|
+
|
|
78
|
+
run_due = subparsers.add_parser("run-due")
|
|
79
|
+
run_due.add_argument("--force-message", action="store_true")
|
|
80
|
+
|
|
81
|
+
create_creature = subparsers.add_parser("create-creature")
|
|
82
|
+
create_creature.add_argument("display_name")
|
|
83
|
+
create_creature.add_argument("concern")
|
|
84
|
+
create_creature.add_argument("--public-prompt", default="")
|
|
85
|
+
create_creature.add_argument("--slug", default="")
|
|
86
|
+
|
|
87
|
+
send_message = subparsers.add_parser("send-message")
|
|
88
|
+
send_message.add_argument("slug")
|
|
89
|
+
send_message.add_argument("conversation_id", type=int)
|
|
90
|
+
send_message.add_argument("body")
|
|
91
|
+
|
|
92
|
+
create_conversation = subparsers.add_parser("create-conversation")
|
|
93
|
+
create_conversation.add_argument("slug")
|
|
94
|
+
create_conversation.add_argument("--title", default="")
|
|
95
|
+
|
|
96
|
+
spawn_conversation = subparsers.add_parser("spawn-conversation")
|
|
97
|
+
spawn_conversation.add_argument("slug")
|
|
98
|
+
spawn_conversation.add_argument("run_id", type=int)
|
|
99
|
+
|
|
100
|
+
delete_creature = subparsers.add_parser("delete-creature")
|
|
101
|
+
delete_creature.add_argument("slug")
|
|
102
|
+
|
|
103
|
+
serve = subparsers.add_parser("serve")
|
|
104
|
+
serve.add_argument("--force-scan", action="store_true", help="Clear the cached onboarding environment scan before starting the server")
|
|
105
|
+
serve.add_argument(
|
|
106
|
+
"--tailscale",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Serve on localhost plus the detected Tailscale IPv4. Falls back to localhost if Tailscale is unavailable.",
|
|
109
|
+
)
|
|
110
|
+
serve_worker = subparsers.add_parser("serve-worker", help=argparse.SUPPRESS, hidden=True)
|
|
111
|
+
serve_worker.add_argument("--force-scan", action="store_true", help=argparse.SUPPRESS)
|
|
112
|
+
serve_worker.add_argument("--tailscale", action="store_true", help=argparse.SUPPRESS)
|
|
113
|
+
return parser
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _normalize_bind_mode(value: str | None) -> str:
|
|
117
|
+
cleaned = str(value or "").strip().lower()
|
|
118
|
+
return SERVE_BIND_MODE_TAILSCALE if cleaned == SERVE_BIND_MODE_TAILSCALE else SERVE_BIND_MODE_DEFAULT
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _detect_tailscale_ipv4() -> str:
|
|
122
|
+
override = os.getenv("CREATURE_OS_TAILSCALE_IP", "").strip()
|
|
123
|
+
candidates: list[str] = [override] if override else []
|
|
124
|
+
if not candidates:
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["tailscale", "ip", "-4"],
|
|
128
|
+
check=False,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
timeout=2,
|
|
132
|
+
)
|
|
133
|
+
candidates = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
134
|
+
except Exception:
|
|
135
|
+
candidates = []
|
|
136
|
+
for candidate in candidates:
|
|
137
|
+
try:
|
|
138
|
+
ip = ipaddress.ip_address(candidate)
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
if ip.version == 4 and ip in _TAILSCALE_IPV4_NETWORK:
|
|
142
|
+
return str(ip)
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _server_display_urls(*, bind_mode: str) -> list[str]:
|
|
147
|
+
normalized_mode = _normalize_bind_mode(bind_mode)
|
|
148
|
+
urls = [f"http://127.0.0.1:{config.port()}/"]
|
|
149
|
+
if normalized_mode == SERVE_BIND_MODE_TAILSCALE:
|
|
150
|
+
tailscale_ip = _detect_tailscale_ipv4()
|
|
151
|
+
if tailscale_ip:
|
|
152
|
+
urls.append(f"http://{tailscale_ip}:{config.port()}/")
|
|
153
|
+
return urls
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_server_pid(
|
|
157
|
+
*,
|
|
158
|
+
supervisor_pid: int,
|
|
159
|
+
source_revision: str,
|
|
160
|
+
started_at: str,
|
|
161
|
+
worker_pid: int | None = None,
|
|
162
|
+
bind_mode: str = SERVE_BIND_MODE_DEFAULT,
|
|
163
|
+
) -> None:
|
|
164
|
+
urls = _server_display_urls(bind_mode=bind_mode)
|
|
165
|
+
config.server_pid_path().write_text(
|
|
166
|
+
json.dumps(
|
|
167
|
+
{
|
|
168
|
+
"pid": supervisor_pid,
|
|
169
|
+
"worker_pid": int(worker_pid or 0),
|
|
170
|
+
"url": urls[0] if urls else config.app_url(),
|
|
171
|
+
"urls": urls,
|
|
172
|
+
"bind_mode": _normalize_bind_mode(bind_mode),
|
|
173
|
+
"started_at": started_at,
|
|
174
|
+
"source_revision": source_revision,
|
|
175
|
+
"static_version": config.static_version(source_revision),
|
|
176
|
+
},
|
|
177
|
+
sort_keys=True,
|
|
178
|
+
),
|
|
179
|
+
encoding="utf-8",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _remove_server_pid_if_owned(owner_pid: int) -> None:
|
|
184
|
+
pid_path = config.server_pid_path()
|
|
185
|
+
if not pid_path.exists():
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
payload = json.loads(pid_path.read_text(encoding="utf-8"))
|
|
189
|
+
except Exception:
|
|
190
|
+
payload = {}
|
|
191
|
+
if int(payload.get("pid") or 0) == owner_pid:
|
|
192
|
+
pid_path.unlink(missing_ok=True)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _terminate_worker(worker: subprocess.Popen[bytes] | None) -> None:
|
|
196
|
+
if worker is None or worker.poll() is not None:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
worker.terminate()
|
|
200
|
+
worker.wait(timeout=SERVER_SHUTDOWN_GRACE_SECONDS)
|
|
201
|
+
return
|
|
202
|
+
except subprocess.TimeoutExpired:
|
|
203
|
+
pass
|
|
204
|
+
except ProcessLookupError:
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
worker.kill()
|
|
208
|
+
worker.wait(timeout=1)
|
|
209
|
+
except Exception:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _launch_server_worker(source_revision: str, *, bind_mode: str) -> tuple[subprocess.Popen[bytes], str]:
|
|
214
|
+
booted_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
215
|
+
env = os.environ.copy()
|
|
216
|
+
env["CREATURE_OS_SOURCE_REVISION"] = source_revision
|
|
217
|
+
env["CREATURE_OS_STATIC_VERSION"] = config.static_version(source_revision)
|
|
218
|
+
env["CREATURE_OS_SERVER_BOOTED_AT"] = booted_at
|
|
219
|
+
env["CREATURE_OS_SUPERVISOR_PID"] = str(os.getpid())
|
|
220
|
+
env[SERVE_BIND_MODE_ENV] = _normalize_bind_mode(bind_mode)
|
|
221
|
+
worker_args = [config.python_bin(), "-m", "creatureos.cli", "serve-worker"]
|
|
222
|
+
if _normalize_bind_mode(bind_mode) == SERVE_BIND_MODE_TAILSCALE:
|
|
223
|
+
worker_args.append("--tailscale")
|
|
224
|
+
worker = subprocess.Popen(
|
|
225
|
+
worker_args,
|
|
226
|
+
cwd=str(config.package_root().parent),
|
|
227
|
+
env=env,
|
|
228
|
+
)
|
|
229
|
+
return worker, booted_at
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _create_listening_socket(host: str) -> socket.socket:
|
|
233
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
234
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
235
|
+
sock.bind((host, config.port()))
|
|
236
|
+
sock.listen(socket.SOMAXCONN)
|
|
237
|
+
sock.set_inheritable(True)
|
|
238
|
+
return sock
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _server_health_url() -> str:
|
|
242
|
+
return f"http://127.0.0.1:{config.port()}/healthz"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _fetch_server_health(*, timeout_seconds: float = SERVER_HEALTH_REQUEST_TIMEOUT_SECONDS) -> dict[str, object] | None:
|
|
246
|
+
try:
|
|
247
|
+
with urlrequest.urlopen(_server_health_url(), timeout=timeout_seconds) as response:
|
|
248
|
+
if int(getattr(response, "status", 200) or 200) != 200:
|
|
249
|
+
return None
|
|
250
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
251
|
+
except Exception:
|
|
252
|
+
return None
|
|
253
|
+
if str(payload.get("status") or "").strip().lower() != "ok":
|
|
254
|
+
return None
|
|
255
|
+
return payload
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _remove_server_ready_file() -> None:
|
|
259
|
+
config.server_ready_path().unlink(missing_ok=True)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _load_server_ready_payload() -> dict[str, object] | None:
|
|
263
|
+
path = config.server_ready_path()
|
|
264
|
+
if not path.exists():
|
|
265
|
+
return None
|
|
266
|
+
try:
|
|
267
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
268
|
+
except Exception:
|
|
269
|
+
return None
|
|
270
|
+
return payload if isinstance(payload, dict) else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _server_ready_matches(
|
|
274
|
+
payload: dict[str, object] | None,
|
|
275
|
+
*,
|
|
276
|
+
source_revision: str,
|
|
277
|
+
worker_pid: int | None = None,
|
|
278
|
+
booted_at: str = "",
|
|
279
|
+
) -> bool:
|
|
280
|
+
if not isinstance(payload, dict):
|
|
281
|
+
return False
|
|
282
|
+
if str(payload.get("status") or "").strip().lower() != "ready":
|
|
283
|
+
return False
|
|
284
|
+
if str(payload.get("source_revision") or "").strip() != str(source_revision).strip():
|
|
285
|
+
return False
|
|
286
|
+
if worker_pid is not None and int(payload.get("worker_pid") or 0) != int(worker_pid):
|
|
287
|
+
return False
|
|
288
|
+
if booted_at and str(payload.get("booted_at") or "").strip() != str(booted_at).strip():
|
|
289
|
+
return False
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _server_health_matches(
|
|
294
|
+
payload: dict[str, object] | None,
|
|
295
|
+
*,
|
|
296
|
+
source_revision: str,
|
|
297
|
+
worker_pid: int | None = None,
|
|
298
|
+
booted_at: str = "",
|
|
299
|
+
) -> bool:
|
|
300
|
+
if not isinstance(payload, dict):
|
|
301
|
+
return False
|
|
302
|
+
if str(payload.get("source_revision") or "").strip() != str(source_revision).strip():
|
|
303
|
+
return False
|
|
304
|
+
if worker_pid is not None and int(payload.get("worker_pid") or 0) != int(worker_pid):
|
|
305
|
+
return False
|
|
306
|
+
if booted_at and str(payload.get("booted_at") or "").strip() != str(booted_at).strip():
|
|
307
|
+
return False
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _wait_for_worker_ready(
|
|
312
|
+
worker: subprocess.Popen[bytes],
|
|
313
|
+
*,
|
|
314
|
+
source_revision: str,
|
|
315
|
+
booted_at: str,
|
|
316
|
+
timeout_seconds: float = SERVER_READY_TIMEOUT_SECONDS,
|
|
317
|
+
) -> bool:
|
|
318
|
+
deadline = time.monotonic() + max(1.0, timeout_seconds)
|
|
319
|
+
while time.monotonic() < deadline:
|
|
320
|
+
if worker.poll() is not None:
|
|
321
|
+
return False
|
|
322
|
+
payload = _load_server_ready_payload()
|
|
323
|
+
if _server_ready_matches(payload, source_revision=source_revision, worker_pid=worker.pid, booted_at=booted_at):
|
|
324
|
+
return True
|
|
325
|
+
time.sleep(SERVER_READY_POLL_INTERVAL_SECONDS)
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _run_server_worker(*, force_scan: bool = False, bind_mode: str = SERVE_BIND_MODE_DEFAULT) -> int:
|
|
330
|
+
if force_scan:
|
|
331
|
+
storage.delete_meta(service.ONBOARDING_ENVIRONMENT_KEY)
|
|
332
|
+
storage.delete_meta(service.ONBOARDING_BRIEFING_KEY)
|
|
333
|
+
normalized_mode = _normalize_bind_mode(bind_mode)
|
|
334
|
+
if normalized_mode == SERVE_BIND_MODE_TAILSCALE:
|
|
335
|
+
tailscale_ip = _detect_tailscale_ipv4()
|
|
336
|
+
listen_hosts = ["127.0.0.1"]
|
|
337
|
+
if tailscale_ip and tailscale_ip not in listen_hosts:
|
|
338
|
+
listen_hosts.append(tailscale_ip)
|
|
339
|
+
sockets: list[socket.socket] = []
|
|
340
|
+
try:
|
|
341
|
+
for host in listen_hosts:
|
|
342
|
+
sockets.append(_create_listening_socket(host))
|
|
343
|
+
print("CreatureOS dual-bind mode:", flush=True)
|
|
344
|
+
print(f" Local: http://127.0.0.1:{config.port()}", flush=True)
|
|
345
|
+
if tailscale_ip:
|
|
346
|
+
print(f" Tailscale: http://{tailscale_ip}:{config.port()}", flush=True)
|
|
347
|
+
else:
|
|
348
|
+
print(" Tailscale: not detected, staying local-only", flush=True)
|
|
349
|
+
uvicorn_config = uvicorn.Config(
|
|
350
|
+
"creatureos.web:app",
|
|
351
|
+
host="127.0.0.1",
|
|
352
|
+
port=config.port(),
|
|
353
|
+
reload=False,
|
|
354
|
+
access_log=False,
|
|
355
|
+
log_config=None,
|
|
356
|
+
)
|
|
357
|
+
server = uvicorn.Server(uvicorn_config)
|
|
358
|
+
return 0 if asyncio.run(server.serve(sockets=sockets)) is not False else 1
|
|
359
|
+
finally:
|
|
360
|
+
for sock in sockets:
|
|
361
|
+
try:
|
|
362
|
+
sock.close()
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
uvicorn.run(
|
|
366
|
+
"creatureos.web:app",
|
|
367
|
+
host=config.host(),
|
|
368
|
+
port=config.port(),
|
|
369
|
+
reload=False,
|
|
370
|
+
access_log=False,
|
|
371
|
+
log_config=None,
|
|
372
|
+
)
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _run_server_supervisor(*, force_scan: bool = False, bind_mode: str = SERVE_BIND_MODE_DEFAULT) -> int:
|
|
377
|
+
lock_path = config.server_lock_path()
|
|
378
|
+
pid_path = config.server_pid_path()
|
|
379
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
lock_file = lock_path.open("a+", encoding="utf-8")
|
|
381
|
+
try:
|
|
382
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
383
|
+
except OSError:
|
|
384
|
+
existing_pid = ""
|
|
385
|
+
if pid_path.exists():
|
|
386
|
+
try:
|
|
387
|
+
existing_pid = str(json.loads(pid_path.read_text(encoding="utf-8")).get("pid") or "").strip()
|
|
388
|
+
except Exception:
|
|
389
|
+
existing_pid = ""
|
|
390
|
+
detail = f" (pid {existing_pid})" if existing_pid else ""
|
|
391
|
+
print(f"CreatureOS server is already running for this habitat{detail}.", flush=True)
|
|
392
|
+
try:
|
|
393
|
+
lock_file.close()
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
return 1
|
|
397
|
+
|
|
398
|
+
worker: subprocess.Popen[bytes] | None = None
|
|
399
|
+
normalized_bind_mode = _normalize_bind_mode(bind_mode)
|
|
400
|
+
os.environ[SERVE_BIND_MODE_ENV] = normalized_bind_mode
|
|
401
|
+
current_revision = config.server_source_revision()
|
|
402
|
+
worker_started_at = ""
|
|
403
|
+
state = {"shutdown": False, "reload": False}
|
|
404
|
+
launch_force_scan = bool(force_scan)
|
|
405
|
+
last_health_probe_at = 0.0
|
|
406
|
+
consecutive_health_failures = 0
|
|
407
|
+
launch_count = 0
|
|
408
|
+
|
|
409
|
+
def _supervisor_args(*, include_force_scan: bool = False) -> list[str]:
|
|
410
|
+
args = [config.python_bin(), "-m", "creatureos.cli", "serve"]
|
|
411
|
+
if normalized_bind_mode == SERVE_BIND_MODE_TAILSCALE:
|
|
412
|
+
args.append("--tailscale")
|
|
413
|
+
if include_force_scan:
|
|
414
|
+
args.append("--force-scan")
|
|
415
|
+
return args
|
|
416
|
+
|
|
417
|
+
def _handle_shutdown(signum: int, frame) -> None: # type: ignore[no-untyped-def]
|
|
418
|
+
state["shutdown"] = True
|
|
419
|
+
_terminate_worker(worker)
|
|
420
|
+
|
|
421
|
+
def _handle_reload(signum: int, frame) -> None: # type: ignore[no-untyped-def]
|
|
422
|
+
state["reload"] = True
|
|
423
|
+
|
|
424
|
+
def _reexec_supervisor(*, include_force_scan: bool = False) -> None:
|
|
425
|
+
os.execvpe(
|
|
426
|
+
config.python_bin(),
|
|
427
|
+
_supervisor_args(include_force_scan=include_force_scan),
|
|
428
|
+
os.environ.copy(),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
signal.signal(signal.SIGTERM, _handle_shutdown)
|
|
432
|
+
signal.signal(signal.SIGINT, _handle_shutdown)
|
|
433
|
+
if hasattr(signal, "SIGHUP"):
|
|
434
|
+
signal.signal(signal.SIGHUP, _handle_reload)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
print("404: Humans not found.", flush=True)
|
|
438
|
+
print(f"Creatures awakening at {config.app_url().rstrip('/')}", flush=True)
|
|
439
|
+
print(f"Working root: {config.workspace_root()} ({config.workspace_root_source()})", flush=True)
|
|
440
|
+
print(f"Data dir: {config.data_dir()} ({config.data_dir_source()})", flush=True)
|
|
441
|
+
print(f"Database: {config.db_path()}", flush=True)
|
|
442
|
+
if config.workspace_root_source() == "cwd":
|
|
443
|
+
print(
|
|
444
|
+
"Tip: pass --workspace PATH or set CREATURE_OS_WORKSPACE_ROOT to keep creature file work anchored to a predictable directory.",
|
|
445
|
+
flush=True,
|
|
446
|
+
)
|
|
447
|
+
print("Onboarding scans look across likely work directories on this machine.", flush=True)
|
|
448
|
+
if normalized_bind_mode == SERVE_BIND_MODE_TAILSCALE:
|
|
449
|
+
for url in _server_display_urls(bind_mode=normalized_bind_mode):
|
|
450
|
+
print(f"Listening on {url.rstrip('/')}", flush=True)
|
|
451
|
+
_remove_server_ready_file()
|
|
452
|
+
if launch_force_scan:
|
|
453
|
+
storage.delete_meta(service.ONBOARDING_ENVIRONMENT_KEY)
|
|
454
|
+
storage.delete_meta(service.ONBOARDING_BRIEFING_KEY)
|
|
455
|
+
while not state["shutdown"]:
|
|
456
|
+
if state["reload"]:
|
|
457
|
+
_terminate_worker(worker)
|
|
458
|
+
print("Reload requested; restarting supervisor.", flush=True)
|
|
459
|
+
_reexec_supervisor(include_force_scan=launch_force_scan)
|
|
460
|
+
|
|
461
|
+
latest_revision = config.server_source_revision()
|
|
462
|
+
if latest_revision != current_revision:
|
|
463
|
+
_terminate_worker(worker)
|
|
464
|
+
print("Source revision changed; restarting supervisor.", flush=True)
|
|
465
|
+
_reexec_supervisor(include_force_scan=launch_force_scan)
|
|
466
|
+
|
|
467
|
+
if worker is None or worker.poll() is not None:
|
|
468
|
+
if state["shutdown"]:
|
|
469
|
+
break
|
|
470
|
+
if worker is not None:
|
|
471
|
+
print(
|
|
472
|
+
f"Worker exited with code {worker.returncode}; restarting in {SERVER_RESTART_BACKOFF_SECONDS:.1f}s.",
|
|
473
|
+
flush=True,
|
|
474
|
+
)
|
|
475
|
+
time.sleep(SERVER_RESTART_BACKOFF_SECONDS)
|
|
476
|
+
current_revision = config.server_source_revision()
|
|
477
|
+
_remove_server_ready_file()
|
|
478
|
+
worker, worker_started_at = _launch_server_worker(current_revision, bind_mode=normalized_bind_mode)
|
|
479
|
+
launch_count += 1
|
|
480
|
+
print(
|
|
481
|
+
f"Launching worker #{launch_count} ({normalized_bind_mode}) for revision {current_revision[:12]}.",
|
|
482
|
+
flush=True,
|
|
483
|
+
)
|
|
484
|
+
if not _wait_for_worker_ready(worker, source_revision=current_revision, booted_at=worker_started_at):
|
|
485
|
+
print(
|
|
486
|
+
f"Worker #{launch_count} failed to become ready within {SERVER_READY_TIMEOUT_SECONDS:.0f}s.",
|
|
487
|
+
flush=True,
|
|
488
|
+
)
|
|
489
|
+
_terminate_worker(worker)
|
|
490
|
+
worker = None
|
|
491
|
+
continue
|
|
492
|
+
launch_force_scan = False
|
|
493
|
+
consecutive_health_failures = 0
|
|
494
|
+
last_health_probe_at = time.monotonic()
|
|
495
|
+
_write_server_pid(
|
|
496
|
+
supervisor_pid=os.getpid(),
|
|
497
|
+
worker_pid=worker.pid,
|
|
498
|
+
source_revision=current_revision,
|
|
499
|
+
started_at=worker_started_at,
|
|
500
|
+
bind_mode=normalized_bind_mode,
|
|
501
|
+
)
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
now = time.monotonic()
|
|
505
|
+
if now - last_health_probe_at >= SERVER_HEALTH_CHECK_INTERVAL_SECONDS:
|
|
506
|
+
last_health_probe_at = now
|
|
507
|
+
payload = _fetch_server_health()
|
|
508
|
+
if _server_health_matches(payload, source_revision=current_revision, worker_pid=worker.pid, booted_at=worker_started_at):
|
|
509
|
+
consecutive_health_failures = 0
|
|
510
|
+
else:
|
|
511
|
+
consecutive_health_failures += 1
|
|
512
|
+
print(
|
|
513
|
+
f"Health probe failed ({consecutive_health_failures}/{SERVER_HEALTH_FAILURE_LIMIT}); waiting for recovery.",
|
|
514
|
+
flush=True,
|
|
515
|
+
)
|
|
516
|
+
if consecutive_health_failures >= SERVER_HEALTH_FAILURE_LIMIT:
|
|
517
|
+
print("Worker stayed unhealthy; restarting it.", flush=True)
|
|
518
|
+
_terminate_worker(worker)
|
|
519
|
+
worker = None
|
|
520
|
+
consecutive_health_failures = 0
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
time.sleep(SERVER_WATCH_INTERVAL_SECONDS)
|
|
524
|
+
return 0
|
|
525
|
+
finally:
|
|
526
|
+
_terminate_worker(worker)
|
|
527
|
+
_remove_server_ready_file()
|
|
528
|
+
try:
|
|
529
|
+
_remove_server_pid_if_owned(os.getpid())
|
|
530
|
+
except Exception:
|
|
531
|
+
pass
|
|
532
|
+
try:
|
|
533
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
try:
|
|
537
|
+
lock_file.close()
|
|
538
|
+
except Exception:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def main() -> int:
|
|
543
|
+
parser = build_parser()
|
|
544
|
+
args = parser.parse_args()
|
|
545
|
+
with config.override_runtime_paths(
|
|
546
|
+
workspace_root=args.workspace or None,
|
|
547
|
+
data_dir=args.data_dir or None,
|
|
548
|
+
db_path=args.db_path or None,
|
|
549
|
+
):
|
|
550
|
+
if args.command == "init-db":
|
|
551
|
+
storage.init_db()
|
|
552
|
+
print(config.db_path())
|
|
553
|
+
return 0
|
|
554
|
+
if args.command == "run-creature":
|
|
555
|
+
result = service.run_creature(
|
|
556
|
+
args.slug,
|
|
557
|
+
trigger_type=args.trigger,
|
|
558
|
+
force_message=bool(args.force_message),
|
|
559
|
+
conversation_id=args.conversation_id,
|
|
560
|
+
allow_code_changes=bool(args.allow_code_changes),
|
|
561
|
+
)
|
|
562
|
+
print(json.dumps(result, indent=2))
|
|
563
|
+
return 0
|
|
564
|
+
if args.command == "run-due":
|
|
565
|
+
result = service.run_due_creatures(force_message=bool(args.force_message))
|
|
566
|
+
print(json.dumps(result, indent=2))
|
|
567
|
+
return 0
|
|
568
|
+
if args.command == "create-creature":
|
|
569
|
+
result = service.create_creature(
|
|
570
|
+
display_name=args.display_name,
|
|
571
|
+
concern=args.concern,
|
|
572
|
+
public_prompt=args.public_prompt,
|
|
573
|
+
slug=args.slug or None,
|
|
574
|
+
)
|
|
575
|
+
print(json.dumps(result, indent=2))
|
|
576
|
+
return 0
|
|
577
|
+
if args.command == "send-message":
|
|
578
|
+
result = service.send_user_message(args.slug, args.conversation_id, args.body)
|
|
579
|
+
print(json.dumps(result, indent=2))
|
|
580
|
+
return 0
|
|
581
|
+
if args.command == "create-conversation":
|
|
582
|
+
result = service.create_conversation(args.slug, title=args.title or None)
|
|
583
|
+
print(json.dumps(result, indent=2))
|
|
584
|
+
return 0
|
|
585
|
+
if args.command == "spawn-conversation":
|
|
586
|
+
result = service.spawn_conversation_from_run(args.slug, args.run_id)
|
|
587
|
+
print(json.dumps(result, indent=2))
|
|
588
|
+
return 0
|
|
589
|
+
if args.command == "delete-creature":
|
|
590
|
+
service.delete_creature(args.slug)
|
|
591
|
+
print(json.dumps({"deleted": args.slug}, indent=2))
|
|
592
|
+
return 0
|
|
593
|
+
if args.command == "serve":
|
|
594
|
+
bind_mode = SERVE_BIND_MODE_TAILSCALE if bool(args.tailscale) else _normalize_bind_mode(os.getenv(SERVE_BIND_MODE_ENV))
|
|
595
|
+
return _run_server_supervisor(force_scan=bool(args.force_scan), bind_mode=bind_mode)
|
|
596
|
+
if args.command == "serve-worker":
|
|
597
|
+
bind_mode = SERVE_BIND_MODE_TAILSCALE if bool(args.tailscale) else _normalize_bind_mode(os.getenv(SERVE_BIND_MODE_ENV))
|
|
598
|
+
return _run_server_worker(force_scan=bool(args.force_scan), bind_mode=bind_mode)
|
|
599
|
+
parser.error(f"Unknown command: {args.command}")
|
|
600
|
+
return 2
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
if __name__ == "__main__":
|
|
604
|
+
raise SystemExit(main())
|