diff --git a/.env.example b/.env.example index 7446591d..055f609a 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ WRENN_HOST_LISTEN_ADDR=:50051 WRENN_HOST_INTERFACE=eth0 WRENN_CP_URL=http://localhost:9725 WRENN_DEFAULT_ROOTFS_SIZE=5Gi -WRENN_FIRECRACKER_BIN=/usr/local/bin/firecracker +WRENN_CH_BIN=/usr/local/bin/cloud-hypervisor # Auth JWT_SECRET= diff --git a/.gitignore b/.gitignore index 59c36a2d..919b3cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ e2b/ .mcp.json ## Builds -builds/ +/builds/ ## Rust envd-rs/target/ @@ -55,3 +55,4 @@ internal/dashboard/static/* .dual-graph/ # Added by code-review-graph .code-review-graph/ +.mcp.json diff --git a/.woodpecker/pipeline.yml b/.woodpecker/pipeline.yml new file mode 100644 index 00000000..1484def4 --- /dev/null +++ b/.woodpecker/pipeline.yml @@ -0,0 +1,63 @@ +when: + - event: push + branch: main + +steps: + build-go: + image: python:3.13 + environment: + WRENN_API_KEY: + from_secret: wrenn_api_key + commands: + - pip install wrenn httpx + - export GO_VERSION=$$(grep '^go ' go.mod | cut -d' ' -f2) + - python .woodpecker/scripts/build_go.py + depends_on: [] + + build-rust: + image: python:3.13 + environment: + WRENN_API_KEY: + from_secret: wrenn_api_key + commands: + - pip install wrenn + - export RUST_VERSION=$$(grep '^rust-version ' envd-rs/Cargo.toml | cut -d'"' -f2) + - python .woodpecker/scripts/build_rust.py + depends_on: [] + + tag-release: + image: python:3.13 + environment: + GITEA_TOKEN: + from_secret: gitea_token + commands: + - VERSION=$$(cat VERSION_CP) + - git config user.name "R3dRum92" + - git config user.email "tksadik@omukk.dev" + - git tag "v$${VERSION}" + - git push "https://tksadik92:$${GITEA_TOKEN}@git.omukk.dev/wrenn/wrenn.git" "v$${VERSION}" + depends_on: [build-go, build-rust] + + release-notes: + image: python:3.13 + environment: + WRENN_API_KEY: + from_secret: wrenn_api_key + GITEA_TOKEN: + from_secret: gitea_token + ZHIPU_API_KEY: + from_secret: zhipu_api_key + commands: + - pip install wrenn + - python .woodpecker/scripts/release_notes.py + depends_on: [tag-release] + + publish-github: + image: python:3.13 + environment: + GITHUB_TOKEN: + from_secret: github_token + commands: + - pip install httpx + - python .woodpecker/scripts/publish_github.py + depends_on: [release-notes] diff --git a/.woodpecker/scripts/build_go.py b/.woodpecker/scripts/build_go.py new file mode 100644 index 00000000..a34ec41b --- /dev/null +++ b/.woodpecker/scripts/build_go.py @@ -0,0 +1,112 @@ +import os +import sys + +from wrenn import Capsule, StreamExitEvent, StreamStderrEvent, StreamStdoutEvent +from wrenn._git import GitCommandError + +GO_VERSION = os.getenv("GO_VERSION", "1.25.8") +REPO_URL = "https://git.omukk.dev/wrenn/wrenn.git" +REPO_DIR = "/home/wrenn-user/wrenn" +BUILDS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "builds") + + +def read_remote_version(capsule: Capsule, filename: str) -> str: + content = capsule.files.read_bytes(f"{REPO_DIR}/{filename}") + return content.decode("utf-8").strip() + + +def run(capsule: Capsule, cmd: str, timeout: int = 30) -> int: + result = capsule.commands.run(cmd, timeout=timeout) + if result.exit_code != 0: + print(f"FAIL [{cmd.split()[0]}]: exit={result.exit_code}", file=sys.stderr) + if result.stderr: + print(result.stderr.strip(), file=sys.stderr) + return result.exit_code + print(f"OK [{cmd.split()[0]}]") + return 0 + + +def clone_repo(capsule: Capsule) -> bool: + try: + capsule.git.clone(REPO_URL, REPO_DIR) + print("OK [git clone]") + return True + except GitCommandError as e: + print(f"FAIL [git clone]: {e}", file=sys.stderr) + return False + + +def build_go(capsule: Capsule) -> bool: + command = "CGO_ENABLED=1 make build-cp build-agent" + handle = capsule.commands.run( + command, + background=True, + cwd=REPO_DIR, + envs={ + "PATH": "/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + }, + ) + print(f"{command} started (pid={handle.pid}), streaming output...") + + exit_code = 0 + for event in capsule.commands.connect(handle.pid): + if isinstance(event, StreamStdoutEvent): + print(event.data, end="") + elif isinstance(event, StreamStderrEvent): + print(event.data, end="", file=sys.stderr) + elif isinstance(event, StreamExitEvent): + exit_code = event.exit_code + + if exit_code != 0: + print(f"FAIL [go build]: exit={exit_code}", file=sys.stderr) + return False + print("OK [go build]") + return True + + +def download_artifacts(capsule: Capsule) -> bool: + remote_dir = f"{REPO_DIR}/builds" + entries = capsule.files.list(remote_dir, depth=1) + files = [e for e in entries if e.type != "directory"] + + if not files: + print("FAIL [download]: no files found in builds/", file=sys.stderr) + return False + + local_dir = os.path.normpath(BUILDS_DIR) + os.makedirs(local_dir, exist_ok=True) + versions = { + "wrenn-cp": read_remote_version(capsule, "VERSION_CP"), + "wrenn-agent": read_remote_version(capsule, "VERSION_AGENT"), + } + + for entry in files: + name = entry.name or "unknown" + remote_path = f"{remote_dir}/{name}" + local_name = f"{name}-{versions[name]}" if name in versions else name + local_path = os.path.join(local_dir, local_name) + print(f"Downloading {name} as {local_name} ({entry.size or '?'} bytes)...") + + with open(local_path, "wb") as f: + for chunk in capsule.files.download_stream(remote_path): + f.write(chunk) + + print(f"OK [download {local_name}]") + + return True + + +def main() -> None: + with Capsule(template="golang", wait=True) as capsule: + print(f"Capsule: {capsule.capsule_id}") + if not clone_repo(capsule): + sys.exit(1) + if not build_go(capsule): + sys.exit(1) + if not download_artifacts(capsule): + sys.exit(1) + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/.woodpecker/scripts/build_rust.py b/.woodpecker/scripts/build_rust.py new file mode 100644 index 00000000..7c25120b --- /dev/null +++ b/.woodpecker/scripts/build_rust.py @@ -0,0 +1,105 @@ +import os +import sys + +from wrenn import Capsule, StreamExitEvent, StreamStderrEvent, StreamStdoutEvent +from wrenn._git import GitCommandError + +RUST_VERSION = os.getenv("RUST_VERSION", "1.95.0") +REPO_URL = "https://git.omukk.dev/wrenn/wrenn.git" +REPO_DIR = "/home/wrenn-user/wrenn" +BUILDS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "builds") + + +def read_envd_version(capsule: Capsule) -> str: + content = capsule.files.read_bytes(f"{REPO_DIR}/envd-rs/Cargo.toml") + for line in content.decode("utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("version ="): + return stripped.split("=", 1)[1].strip().strip('"') + print("FAIL [version]: envd-rs/Cargo.toml has no package version", file=sys.stderr) + sys.exit(1) + + +def run(capsule: Capsule, cmd: str, timeout: int = 30, envs={}) -> int: + result = capsule.commands.run(cmd, timeout=timeout, envs=envs) + if result.exit_code != 0: + print(f"FAIL [{cmd.split()[0]}]: exit={result.exit_code}", file=sys.stderr) + if result.stderr: + print(result.stderr.strip(), file=sys.stderr) + return result.exit_code + print(f"OK [{cmd.split()[0]}]") + return 0 + + +def clone_repo(capsule: Capsule) -> bool: + try: + capsule.git.clone(REPO_URL, REPO_DIR) + print("OK [git clone]") + return True + except GitCommandError as e: + print(f"FAIL [git clone]: {e}", file=sys.stderr) + return False + + +def build_rust(capsule: Capsule) -> bool: + if run(capsule, f"mkdir -p {REPO_DIR}/builds") != 0: + return False + + handle = capsule.commands.run( + "make build-envd", + background=True, + cwd=REPO_DIR, + envs={ + "PATH": "/home/wrenn-user/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + }, + ) + print(f"rust build started (pid={handle.pid}), streaming output...") + + exit_code = 0 + for event in capsule.commands.connect(handle.pid): + if isinstance(event, StreamStdoutEvent): + print(event.data, end="") + elif isinstance(event, StreamStderrEvent): + print(event.data, end="", file=sys.stderr) + elif isinstance(event, StreamExitEvent): + exit_code = event.exit_code + + if exit_code != 0: + print(f"FAIL [rust build]: exit={exit_code}", file=sys.stderr) + return False + + print("OK [rust build]") + return True + + +def download_artifacts(capsule: Capsule) -> bool: + version = read_envd_version(capsule) + remote_path = f"{REPO_DIR}/builds/envd" + local_dir = os.path.normpath(BUILDS_DIR) + local_name = f"envd-{version}" + local_path = os.path.join(local_dir, local_name) + os.makedirs(local_dir, exist_ok=True) + + print(f"Downloading envd as {local_name}...") + with open(local_path, "wb") as f: + for chunk in capsule.files.download_stream(remote_path): + f.write(chunk) + + print(f"OK [download {local_name}]") + return True + + +def main() -> None: + with Capsule(template="rust-1.95", wait=True) as capsule: + print(f"Capsule: {capsule.capsule_id}") + if not clone_repo(capsule): + sys.exit(1) + if not build_rust(capsule): + sys.exit(1) + if not download_artifacts(capsule): + sys.exit(1) + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/.woodpecker/scripts/publish_github.py b/.woodpecker/scripts/publish_github.py new file mode 100644 index 00000000..c9bf9c5c --- /dev/null +++ b/.woodpecker/scripts/publish_github.py @@ -0,0 +1,104 @@ +import os +import sys +from pathlib import Path + +import httpx + +GITHUB_REPO = "wrennhq/wrenn" +GITHUB_API = "https://api.github.com" +GITHUB_UPLOADS = "https://uploads.github.com" +BUILDS_DIR = "builds" +VERSION_FILE = "VERSION_CP" +NOTES_FILE = os.path.join(".woodpecker", "release_notes.md") + + +def main() -> None: + token = os.environ["GITHUB_TOKEN"] + + with open(VERSION_FILE) as f: + version = f.read().strip() + tag = f"v{version}" + + release_notes = "" + if os.path.exists(NOTES_FILE): + with open(NOTES_FILE) as f: + release_notes = f.read() + + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + client = httpx.Client(headers=headers, timeout=60) + + print(f"Creating GitHub release for {tag}...") + resp = client.post( + f"{GITHUB_API}/repos/{GITHUB_REPO}/releases", + json={ + "tag_name": tag, + "name": tag, + "body": release_notes, + "draft": False, + "prerelease": False, + }, + ) + if resp.status_code == 422: + print(f"WARN [create release]: release for {tag} already exists, skipping") + data = resp.json() + errors = data.get("errors", []) + if errors: + existing_url = errors[0].get("documentation_url", "") + print(f" See: {existing_url}") + client.close() + return + if resp.status_code != 201: + print(f"FAIL [create release]: {resp.status_code} {resp.text}", file=sys.stderr) + client.close() + sys.exit(1) + + release_data = resp.json() + release_id = release_data["id"] + release_url = release_data.get("html_url", "") + print(f"OK [create release] id={release_id}") + + builds_path = Path(BUILDS_DIR) + if not builds_path.exists(): + print(f"No {BUILDS_DIR}/ directory found, skipping asset upload") + client.close() + print(f"Release published: {release_url}") + return + + upload_headers = { + **headers, + "Content-Type": "application/octet-stream", + } + + for artifact in sorted(builds_path.iterdir()): + if artifact.is_dir(): + continue + print(f"Uploading {artifact.name}...") + + with open(artifact, "rb") as f: + data = f.read() + + resp = client.post( + f"{GITHUB_UPLOADS}/repos/{GITHUB_REPO}/releases/{release_id}/assets", + params={"name": artifact.name}, + headers=upload_headers, + content=data, + ) + if resp.status_code != 201: + print( + f"WARN [upload {artifact.name}]: {resp.status_code} {resp.text}", + file=sys.stderr, + ) + else: + print(f"OK [upload {artifact.name}]") + + client.close() + print(f"Release published: {release_url}") + + +if __name__ == "__main__": + main() diff --git a/.woodpecker/scripts/release_notes.py b/.woodpecker/scripts/release_notes.py new file mode 100644 index 00000000..112165f9 --- /dev/null +++ b/.woodpecker/scripts/release_notes.py @@ -0,0 +1,241 @@ +import base64 +import os +import sys + +from wrenn import Capsule + +REPO_URL = "https://git.omukk.dev/wrenn/wrenn.git" +REPO_DIR = "wrenn-releases" +CAPSULE_OUTPUT = "/tmp/release_notes.md" +LOCAL_OUTPUT = os.path.join(os.path.dirname(__file__), "..", "release_notes.md") + +DEFAULT_MODEL = "opencode/deepseek-v4-flash-free" + +RELEASE_NOTES_EXAMPLE = """ +## What's new +Sandbox HTTP proxying, terminal reliability, and auth robustness improvements. + +### Proxy +- Fixed redirect loops for apps served inside sandboxes (Python HTTP server, Jupyter, etc.) +- Proxy traffic no longer interferes with terminal and exec connections +- Services that take a moment to start up inside a sandbox are now retried instead of immediately failing + +### Terminal (PTY) +- Terminal input is no longer blocked by slow network conditions — fast typing no longer causes timeouts or disconnects +- Input bursts are coalesced into fewer round trips — lower latency under fast typing + +### Authentication +- WebSocket connections now authenticate correctly for both SDK clients (header-based) and browser clients (message-based) + +### Bug Fixes +- Fixed crash in envd when a process exits without a PTY +- Fixed goroutine leak on sandbox pause + +### Others +- Version bump +""".strip() + + +def run(capsule: Capsule, cmd: str, cwd: str | None = None, timeout: int = 30) -> int: + result = capsule.commands.run(cmd, cwd=cwd, timeout=timeout) + if result.exit_code != 0: + print(f"FAIL [{cmd.split()[0]}]: exit={result.exit_code}", file=sys.stderr) + if result.stderr: + print(result.stderr.strip(), file=sys.stderr) + return result.exit_code + print(f"OK [{cmd.split()[0]}]") + return 0 + + +def generate_release_notes( + capsule: Capsule, + output_path: str, + model: str, +) -> None: + prompt = f""" + You are inside a cloned git repository at: + + {REPO_DIR} + + Generate release notes for the latest tagged version of this software project. + + Before writing anything, inspect the repository yourself using git commands. + + You MUST determine: + 1. The latest version tag. + 2. The previous version tag, if one exists. + 3. The commits between the previous tag and the latest tag. + 4. The files and areas changed between those tags. + + Use commands like: + + git tag --sort=-version:refname + + If there are at least two tags, compare the newest tag against the previous tag: + + git log PREVIOUS_TAG..LATEST_TAG --pretty=format:'%s (%h)' + git diff PREVIOUS_TAG..LATEST_TAG --stat + git diff PREVIOUS_TAG..LATEST_TAG --name-only + + If there is only one tag, inspect the latest tag with: + + git log LATEST_TAG --pretty=format:'%s (%h)' -n 50 + git show LATEST_TAG --stat + git show LATEST_TAG --name-only + + Do not rely on any pre-injected commit list or diff summary. + You must inspect the git history yourself. + + Write the release notes in plain, friendly language that any developer can understand + without deep knowledge of the codebase. + + Avoid jargon like "goroutine", "PTY", "envd", or internal function names. + Describe what the change means for the user instead. + + Group related changes under headings that reflect what actually changed. + Only include sections that are relevant to the actual changes. + Do not include CI/CD-only changes. + + Start with: + + ## What's New + + The very next line must be a single short summary sentence. + + Keep each bullet point to one clear sentence. + + Here is an example of the style to aim for — not a template to copy: + + {RELEASE_NOTES_EXAMPLE} + + Output only the final markdown. + No intro. + No explanation. + No conversational filler. + No acknowledgments. + No "I checked the logs" text. + No thoughts. + """.strip() + + prompt_b64 = base64.b64encode(prompt.encode("utf-8")).decode("utf-8") + + write_prompt_cmd = f"echo '{prompt_b64}' | base64 -d > /tmp/oc_prompt.txt" + + result = capsule.commands.run( + write_prompt_cmd, + cwd=REPO_DIR, + timeout=10, + ) + if result.exit_code != 0: + print(f"FAIL [write prompt]: {result.stderr}", file=sys.stderr) + sys.exit(1) + + def run_opencode_with_model(target_model: str) -> int: + env = "" + if "zhipu" in target_model.lower(): + env = f"ZHIPU_API_KEY={os.environ.get('ZHIPU_API_KEY', '')}" + + raw_output_path = "/tmp/opencode_raw.txt" + cmd = ( + f"{env} " + f"~/.opencode/bin/opencode run " + f'"Read the attached file and generate the release notes. Output ONLY markdown." ' + f"--model {target_model} " + f"--file /tmp/oc_prompt.txt " + f"> {raw_output_path}" + ) + + cmd_result = capsule.commands.run(cmd, cwd=REPO_DIR, timeout=300) + if cmd_result.exit_code != 0: + print( + f"FAIL [opencode via {target_model}]: exit={cmd_result.exit_code}", + file=sys.stderr, + ) + print(f"STDOUT:\n{cmd_result.stdout}", file=sys.stderr) + print(f"STDERR:\n{cmd_result.stderr}", file=sys.stderr) + + clean_cmd = ( + f"awk 'found || /^## What.s [Nn]ew/ {{ found=1; print }}' " + f"{raw_output_path} > {output_path}" + ) + + clean_result = capsule.commands.run(clean_cmd, cwd=REPO_DIR, timeout=10) + if clean_result.exit_code != 0: + print(f"FAIL [clean output]: {clean_result.stderr}", file=sys.stderr) + return clean_result.exit_code + + check_result = capsule.commands.run( + f"grep -q '^## What.s New' {output_path}", + cwd=REPO_DIR, + timeout=10, + ) + if check_result.exit_code != 0: + print( + "FAIL: Could not find release notes heading in opencode output", + file=sys.stderr, + ) + print(cmd_result.stdout, file=sys.stderr) + print(cmd_result.stderr, file=sys.stderr) + return 1 + + return cmd_result.exit_code + + exit_status = run_opencode_with_model(model) + + if exit_status != 0: + fallback_model = "opencode/big-pickle" + if model != fallback_model: + print( + f"\n[!] Model {model} failed. Falling back to {fallback_model}...", + file=sys.stderr, + ) + exit_status = run_opencode_with_model(fallback_model) + if exit_status != 0: + print("FAIL: Fallback model also failed. Exiting.", file=sys.stderr) + sys.exit(1) + else: + sys.exit(1) + + result = capsule.commands.run(f"cat {output_path}") + print(result.stdout) + if result.stderr: + print(result.stderr) + + print(f"OK [opencode] release notes written to {output_path}") + + +def download_release_notes(capsule: Capsule) -> None: + local_path = os.path.normpath(LOCAL_OUTPUT) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + print("Downloading release notes from capsule...") + content = capsule.files.read_bytes(CAPSULE_OUTPUT) + with open(local_path, "wb") as f: + f.write(content) + + print(f"OK [download] release notes → {local_path}") + print(content.decode("utf-8", errors="replace")) + + +def main() -> None: + model = os.environ.get("OPENCODE_MODEL", DEFAULT_MODEL) + + with Capsule(template="opencode", wait=True) as capsule: + print(f"Capsule: {capsule.capsule_id}") + + capsule.git.clone( + REPO_URL, + REPO_DIR, + ) + print("OK [git clone]") + + # Note: This simply creates the directory string safely + output_path = os.path.normpath(CAPSULE_OUTPUT) + + generate_release_notes(capsule, output_path, model) + + download_release_notes(capsule) + + +if __name__ == "__main__": + main() diff --git a/CLAUDE.md b/CLAUDE.md index 4608899e..4575423d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Firecracker microVMs), run code inside them, and get output back via SDKs. Think E2B but with persistent sandboxes, pool-based pricing, and a single-binary deployment story. +Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Cloud Hypervisor microVMs), run code inside them, and get output back via SDKs. Think E2B but with persistent sandboxes, pool-based pricing, and a single-binary deployment story. ## Build & Development Commands @@ -23,11 +23,11 @@ make dev-down # Stop dev infra make dev-cp # Control plane with hot reload (if air installed) make dev-frontend # Vite dev server with HMR (port 5173) make dev-agent # Host agent (sudo required) -make dev-envd # envd in debug mode (--isnotfc, port 49983) +make dev-envd # envd in debug mode (port 49983) make check # fmt + vet + lint + test (CI order) make test # Unit tests: go test -race -v ./internal/... -make test-integration # Integration tests (require host agent + Firecracker) +make test-integration # Integration tests (require host agent + Cloud Hypervisor) make fmt # gofmt make vet # go vet make lint # golangci-lint @@ -66,11 +66,22 @@ envd is a standalone Rust binary (Tokio + Axum + connectrpc-rs). It is completel **Internal packages:** `internal/api/`, `internal/email/` -**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/` +**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/` -**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext`), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`) +**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext` + hook interfaces), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`) -The cloud repo imports this module as a Go dependency and calls `cpserver.Run(cpserver.WithExtensions(myExt))`. Each extension implements two methods: `RegisterRoutes(r chi.Router, sctx ServerContext)` to add HTTP routes, and `BackgroundWorkers(sctx ServerContext) []func(context.Context)` to add long-running goroutines. `ServerContext` carries all OSS services (DB, scheduler, auth, etc.) so extensions can use them without reimplementing anything. To expose a new OSS service to extensions, add it to `ServerContext` in `pkg/cpextension/extension.go` and populate it in `pkg/cpserver/run.go`. +The cloud repo imports this module as a Go dependency and calls `cpserver.Run(cpserver.WithExtensions(myExt))`. An extension always implements: +- `RegisterRoutes(r chi.Router, sctx ServerContext)` — adds HTTP routes. +- `BackgroundWorkers(sctx ServerContext) []func(context.Context)` — starts long-running goroutines. + +It can optionally implement any of these hook interfaces (the OSS server type-asserts at startup): +- `MiddlewareProvider` — `Middlewares(sctx) []func(http.Handler) http.Handler`, applied before OSS routes so cloud middleware can wrap them (e.g. billing gates). +- `AuthHook` — `OnSignup` (synchronous, error aborts the request with 500 `signup_hook_failed`), `OnLogin`, `OnAccountSoftDelete`, `OnAccountHardDelete` (the last three log + ignore errors). OnSignup fires after team provisioning in both email-activate and OAuth-new-signup paths. +- `SandboxEventHook` — `OnSandboxEvent(ctx, SandboxEvent)`, invoked from the unified Redis stream consumer for capsule create/pause/resume/destroy success events. Hook errors leave the message un-acked so it will be redelivered; hooks must be idempotent. + +`ServerContext` carries the initialized OSS dependencies: `Queries`, `PgPool`, `Redis`, `HostPool`, `Scheduler`, `CA`, `Audit`, `Mailer`, `OAuthRegistry`, `Channels`, `ChannelPub`, `JWTSecret`, `Sessions`, `Config`. To expose a new OSS service to extensions, add it to `ServerContext` in `pkg/cpextension/extension.go` and populate it in `pkg/cpserver/run.go`. + +**Auth helpers for extensions** (`pkg/auth/session/middleware/`, re-exported via `cpextension`): `RequireSession(sctx)`, `RequireSessionOrAPIKey(sctx)`, `RequireAdmin(sctx)`, `RequireCSRF()`, `IssueSession(w, r, sctx, userID, teamID)`, `ClearSessionCookies(w, r)`. Cookie/header names are exported as `SessionCookieName`, `CSRFCookieName`, `CSRFHeaderName`. OSS handlers (`internal/api/middleware_session.go`) are thin shims over this package — single source of truth. **pkg/ vs internal/ decision rule:** A package belongs in `pkg/` only if the cloud repo needs to import it directly. Everything else stays in `internal/`. New OSS services (e.g. email, notifications) go in `internal/` — the cloud repo accesses them through `ServerContext`, not by importing the package. Do not put a service in `pkg/` just because the cloud repo uses it. @@ -86,13 +97,13 @@ Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver. **Packages:** `internal/hostagent/`, `internal/sandbox/`, `internal/vm/`, `internal/network/`, `internal/devicemapper/`, `internal/envdclient/`, `internal/snapshot/` -**Production deployment:** `scripts/prepare-wrenn-user.sh` creates the `wrenn` system user, sets Linux capabilities (setcap) on wrenn-agent and all child binaries (iptables, losetup, dmsetup, etc.), installs an apt hook to restore capabilities after package updates, configures udev rules for `/dev/net/tun`, loads required kernel modules, and writes systemd unit files for both services. No sudo grants — all privilege is via capabilities. +**Production deployment:** `make setup-host` (→ `scripts/setup-host.sh`) prepares the host: creates the `wrenn` system user, sets Linux capabilities (setcap) on wrenn-agent and all child binaries (iptables, losetup, dmsetup, etc.), installs an apt hook to restore capabilities after package updates, configures udev rules for `/dev/net/tun`, and loads required kernel modules. No sudo grants — all privilege is via capabilities. `make install` then copies the binaries to `/usr/local/bin` and installs the systemd units from `deploy/systemd/`. Startup (`cmd/host-agent/main.go`) wires: root/capabilities check → enable IP forwarding → clean up stale dm devices → `sandbox.Manager` (containing `vm.Manager` + `network.SlotAllocator` + `devicemapper.LoopRegistry`) → `hostagent.Server` (Connect RPC handler) → HTTP server. - **RPC Server** (`internal/hostagent/server.go`): implements `hostagentv1connect.HostAgentServiceHandler`. Thin wrapper — every method delegates to `sandbox.Manager`. Maps Connect error codes on return. - **Sandbox Manager** (`internal/sandbox/manager.go`): the core orchestration layer. Maintains in-memory state in `boxes map[string]*sandboxState` (protected by `sync.RWMutex`). Each `sandboxState` holds a `models.Sandbox`, a `*network.Slot`, and an `*envdclient.Client`. Runs a TTL reaper (every 10s) that auto-destroys timed-out sandboxes. -- **VM Manager** (`internal/vm/manager.go`, `fc.go`, `config.go`): manages Firecracker processes. Uses raw HTTP API over Unix socket (`/tmp/fc-{sandboxID}.sock`), not the firecracker-go-sdk Machine type. Launches Firecracker via `unshare -m` + `ip netns exec`. Configures VM via PUT to `/boot-source`, `/drives/rootfs`, `/network-interfaces/eth0`, `/machine-config`, then starts with PUT `/actions`. +- **VM Manager** (`internal/vm/manager.go`, `ch.go`, `config.go`): manages Cloud Hypervisor processes. Uses raw HTTP API over Unix socket (`/tmp/ch-{sandboxID}.sock`). Launches Cloud Hypervisor via `unshare -m` + `ip netns exec` with `--api-socket path=...`. Configures and boots VM via `PUT /vm.create` + `PUT /vm.boot`. Snapshot restore uses `--restore source_url=file://...`. - **Network** (`internal/network/setup.go`, `allocator.go`): per-sandbox network namespace with veth pair + TAP device. See Networking section below. - **Device Mapper** (`internal/devicemapper/devicemapper.go`): CoW rootfs via device-mapper snapshots. Shared read-only loop devices per base template (refcounted `LoopRegistry`), per-sandbox sparse CoW files, dm-snapshot create/restore/remove/flatten operations. - **envd Client** (`internal/envdclient/client.go`, `health.go`): dual interface to the guest agent. Connect RPC for streaming process exec (`process.Start()` bidirectional stream). Plain HTTP for file operations (POST/GET `/files?path=...&username=root`). Health check polls `GET /health` every 100ms until ready (30s timeout). @@ -109,14 +120,14 @@ Runs as PID 1 inside the microVM via `wrenn-init.sh` (mounts procfs/sysfs/dev, s - **HTTP endpoints**: GET `/health`, GET `/metrics`, POST `/init`, POST `/snapshot/prepare`, GET/POST `/files` - **Proto codegen**: `connectrpc-build` compiles `proto/envd/*.proto` at `cargo build` time via `build.rs` — no committed stubs - **Build**: `make build-envd` → static musl binary in `builds/envd` -- **Dev**: `make dev-envd` → `cargo run -- --isnotfc --port 49983` +- **Dev**: `make dev-envd` → `cargo run -- --port 49983` ### Dashboard (Frontend) **Directory:** `frontend/` — standalone SvelteKit app (Svelte 5, runes mode) - **Stack**: SvelteKit + `adapter-static` + Tailwind CSS v4 + Bits UI (headless accessible components) -- **Package manager**: pnpm +- **Package manager**: Bun - **Routing**: SvelteKit file-based routing under `frontend/src/routes/` - **Routing layout**: `/login` and `/signup` at root, authenticated pages under `/dashboard/*` (e.g. `/dashboard/capsules`, `/dashboard/keys`) - **Build output**: `frontend/build/` — static files served by Caddy @@ -164,7 +175,7 @@ HIBERNATED → RUNNING (cold snapshot resume, slower) **Sandbox creation** (`POST /v1/capsules`): 1. API handler generates sandbox ID, inserts into DB as "pending" 2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()` -3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Firecracker with `/dev/mapper/wrenn-{id}`, configure via HTTP API, boot) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state +3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Cloud Hypervisor with `/dev/mapper/wrenn-{id}`, configure via `PUT /vm.create` + `PUT /vm.boot`) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state 4. API handler updates DB to "running" with host_ip **Command execution** (`POST /v1/capsules/{id}/exec`): @@ -183,7 +194,25 @@ HIBERNATED → RUNNING (cold snapshot resume, slower) ## REST API -Routes defined in `internal/api/server.go`, handlers in `internal/api/handlers_*.go`. OpenAPI spec embedded via `//go:embed` and served at `/openapi.yaml` (Swagger UI at `/docs`). JSON request/response. API key auth via `X-API-Key` header. Error responses: `{"error": {"code": "...", "message": "..."}}`. +Routes defined in `internal/api/server.go`, handlers in `internal/api/handlers_*.go`. OpenAPI spec embedded via `//go:embed` and served at `/openapi.yaml` (Swagger UI at `/docs`). JSON request/response. Error responses: `{"error": {"code": "...", "message": "..."}}`. + +### Authentication + +Two paths, no JWTs for user auth: + +- **SDK / server-to-server**: `X-API-Key: wrn_<32hex>` header. Keys are created via the dashboard or `POST /v1/api-keys`, SHA-256-hashed at rest, scoped to a single team. `requireSessionOrAPIKey` middleware accepts this on every capsule lifecycle route. +- **Browser (dashboard)**: opaque cookie session. `POST /v1/auth/login` (or activate / oauth-callback) sets two cookies — `wrenn_sid` (HttpOnly+Secure+SameSite=Strict) and `wrenn_csrf` (readable by JS, SameSite=Strict). All non-GET requests must echo the CSRF token in an `X-CSRF-Token` header (double-submit). Sessions live in Postgres `sessions` plus a Redis cache (`wrenn:session:{sid}`) — see `pkg/auth/session/`. + +Session semantics: +- **Idle**: 6h. Each request bumps the Redis TTL. Postgres `last_seen_at` is updated on a debounce. +- **Absolute**: 24h from `created_at` (`expires_at`). Never extended. +- **Rotation**: `POST /v1/auth/switch-team` issues a fresh SID and re-sets both cookies; old SID revoked. +- **Revocation**: password change, password add, and password reset all call `RevokeAllForUser`. Self-service is at `GET /v1/me/sessions`, `DELETE /v1/me/sessions/{id}`, `POST /v1/auth/logout`, `POST /v1/auth/logout-all`. +- **`is_admin` freshness**: the session blob caches it for display, but `requireAdmin` always re-reads Postgres so revoked admins lose access at the next admin request. + +Host JWTs (long-lived, signed by `JWT_SECRET`) are unchanged — that is the wrenn-cp ↔ wrenn-agent trust channel and has nothing to do with user auth. + +SSE / WebSocket auth: browsers send the `wrenn_sid` cookie automatically on `EventSource` and WS upgrades (same-origin via Caddy); SDKs set `X-API-Key`. No ticket exchange is involved. ## Code Generation @@ -210,9 +239,9 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → ` - **Connect RPC** (not gRPC) for all RPC communication between components - **Buf + protoc-gen-connect-go** for Go code generation; **connectrpc-build** for Rust code generation in envd -- **Raw Firecracker HTTP API** via Unix socket (not firecracker-go-sdk Machine type) +- **Raw Cloud Hypervisor HTTP API** via Unix socket (`PUT /vm.create` + `PUT /vm.boot`) - **TAP networking** (not vsock) for host-to-envd communication -- **Device-mapper snapshots** for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Firecracker gets `/dev/mapper/wrenn-{id}` +- **Device-mapper snapshots** for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Cloud Hypervisor gets `/dev/mapper/wrenn-{id}` - **PostgreSQL** via pgx/v5 + sqlc (type-safe query generation). Goose for migrations (plain SQL, up/down) - **Dashboard**: SvelteKit (Svelte 5, adapter-static) + Tailwind CSS v4 + Bits UI. Built to static files in `frontend/build/`, served by Caddy (not embedded in the Go binary) - **Lago** for billing (external service, not in this codebase) @@ -229,27 +258,28 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → ` ## Rootfs & Guest Init - **wrenn-init** (`images/wrenn-init.sh`): the PID 1 init script baked into every rootfs. Mounts virtual filesystems, sets hostname, writes `/etc/resolv.conf`, then execs envd. -- **Updating the rootfs** after changing envd or wrenn-init: `bash scripts/update-minimal-rootfs.sh`. This builds envd via `make build-envd` (Rust → static musl binary), mounts the rootfs image, copies in the new binaries, and unmounts. Defaults to `/var/lib/wrenn/images/minimal.ext4`. -- Rootfs images are minimal debootstrap — no systemd, no coreutils beyond busybox. Use `/bin/sh -c` for shell builtins inside the guest. +- **System base templates**: four built-in distro images — `minimal-ubuntu` (id 0, default), `minimal-alpine` (1), `minimal-arch` (2), `minimal-fedora` (3) — built via `images/build-{ubuntu,alpine,arch,fedora}.sh` (or `make images`). All platform-owned, protected from deletion (reserved IDs 0–1024). Same static envd + tini run on all four. Each has a `wrenn-user` with passwordless sudo. +- **Updating the rootfs** after changing envd or wrenn-init: `bash scripts/update-minimal-rootfs.sh`. Builds envd via `make build-envd` (Rust → static musl binary), then re-injects envd + wrenn-init + tini into all four system base images. +- Rootfs images are built from distro containers — no systemd (init is overridden to `wrenn-init`). Use `/bin/sh -c` for shell builtins inside the guest. ## Fixed Paths (on host machine) - Kernel: `/var/lib/wrenn/kernels/vmlinux` -- Base rootfs images: `/var/lib/wrenn/images/{template}.ext4` +- Base rootfs images: `/var/lib/wrenn/images/teams/{base36(teamID)}/{base36(templateID)}/rootfs.ext4` (system templates use the platform team, base36 all-zeros) - Sandbox clones: `/var/lib/wrenn/sandboxes/` -- Firecracker: `/usr/local/bin/firecracker` (e2b's fork of firecracker) +- Cloud Hypervisor: `/usr/local/bin/cloud-hypervisor` ## Design Context ### Users -Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Firecracker microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at. +Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Cloud Hypervisor microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at. **Primary job to be done:** Understand what's running, act on it confidently, and get back to code. ### Brand Personality **Precise. Warm. Uncompromising.** -Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Firecracker microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity. +Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Cloud Hypervisor microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity. Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous. diff --git a/Makefile b/Makefile index 87b11082..8564ef2d 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ LDFLAGS := -s -w build: build-cp build-agent build-envd build-frontend: - cd frontend && pnpm install --frozen-lockfile && pnpm build + cd frontend && bun install --frozen-lockfile && bun run build build-cp: go build -v -ldflags="$(LDFLAGS) -X main.version=$(VERSION_CP) -X main.commit=$(COMMIT)" -o $(BIN_DIR)/wrenn-cp ./cmd/control-plane @@ -59,10 +59,10 @@ dev-agent: sudo go run ./cmd/host-agent dev-frontend: - cd frontend && pnpm dev --port 5173 --host 0.0.0.0 + cd frontend && bun run dev --port 5173 --host 0.0.0.0 dev-envd: - cd envd-rs && cargo run -- --isnotfc --port 49983 + cd envd-rs && cargo run -- --port 49983 # ═══════════════════════════════════════════════════ # Database (goose) @@ -131,32 +131,24 @@ check: fmt vet lint test # ═══════════════════════════════════════════════════ # Rootfs Images # ═══════════════════════════════════════════════════ -.PHONY: images image-minimal image-python image-node +.PHONY: images rootfs-ubuntu rootfs-alpine rootfs-arch rootfs-fedora -images: build-envd image-minimal image-python image-node +# Build all four system base rootfs images (ubuntu/alpine/arch/fedora). Each +# spawns a distro container, installs the required packages + wrenn-user, then +# exports to images/teams///rootfs.ext4. Requires docker + sudo. +images: rootfs-ubuntu rootfs-alpine rootfs-arch rootfs-fedora -image-minimal: - sudo bash images/templates/minimal/build.sh +rootfs-ubuntu: + bash images/build-ubuntu.sh -image-python: - sudo bash images/templates/python312/build.sh +rootfs-alpine: + bash images/build-alpine.sh -image-node: - sudo bash images/templates/node20/build.sh +rootfs-arch: + bash images/build-arch.sh -# ═══════════════════════════════════════════════════ -# Deployment -# ═══════════════════════════════════════════════════ -.PHONY: setup-host install - -setup-host: - sudo bash scripts/setup-host.sh - -install: build - sudo cp $(BIN_DIR)/wrenn-cp /usr/local/bin/ - sudo cp $(BIN_DIR)/wrenn-agent /usr/local/bin/ - sudo cp deploy/systemd/*.service /etc/systemd/system/ - sudo systemctl daemon-reload +rootfs-fedora: + bash images/build-fedora.sh # ═══════════════════════════════════════════════════ # Clean @@ -181,7 +173,7 @@ help: @echo " make dev-cp Control plane (hot reload if air installed)" @echo " make dev-frontend Vite dev server with HMR (port 5173)" @echo " make dev-agent Host agent (sudo required)" - @echo " make dev-envd envd in debug mode (--isnotfc, port 49983)" + @echo " make dev-envd envd in debug mode (port 49983)" @echo "" @echo " make build Build all binaries → builds/" @echo " make build-frontend Build SvelteKit dashboard → frontend/build/" diff --git a/README.md b/README.md index 011e5d13..d7325b2e 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ Secure infrastructure for AI ## Prerequisites - Linux host with `/dev/kvm` access (bare metal or nested virt) -- Firecracker binary at `/usr/local/bin/firecracker` +- Cloud Hypervisor binary at `/usr/local/bin/cloud-hypervisor` - PostgreSQL - Go 1.25+ - Rust 1.88+ with `x86_64-unknown-linux-musl` target (`rustup target add x86_64-unknown-linux-musl`) -- pnpm (for frontend) +- Bun (for frontend) - Docker (for dev infra and rootfs builds) ## Build @@ -22,7 +22,7 @@ Produces three binaries: `wrenn-cp` (control plane), `wrenn-agent` (host agent), ## Host setup -The host agent needs a kernel, a minimal rootfs image, and working directories on the host machine. +The host agent needs a kernel, the system base rootfs images, and working directories on the host machine. ### Directory structure @@ -31,59 +31,74 @@ The host agent needs a kernel, a minimal rootfs image, and working directories o ├── kernels/ │ └── vmlinux # uncompressed Linux kernel (not bzImage) ├── images/ -│ └── minimal/ -│ └── rootfs.ext4 # base rootfs (all other templates snapshot from this) +│ └── teams/ +│ └── 0000000000000000000000000/ # platform team (base36 all-zeros) +│ ├── 0000000000000000000000000/rootfs.ext4 # minimal-ubuntu (id 0) +│ ├── 0000000000000000000000001/rootfs.ext4 # minimal-alpine (id 1) +│ ├── 0000000000000000000000002/rootfs.ext4 # minimal-arch (id 2) +│ └── 0000000000000000000000003/rootfs.ext4 # minimal-fedora (id 3) ├── sandboxes/ # per-sandbox CoW files (created at runtime) └── snapshots/ # pause/hibernate snapshot files (created at runtime) ``` -Create the directories: +Create the base directories (the per-template image dirs are created by the build scripts): ```bash -sudo mkdir -p /var/lib/wrenn/{kernels,images/minimal,sandboxes,snapshots} +sudo mkdir -p /var/lib/wrenn/{kernels,images,sandboxes,snapshots} ``` ### Kernel Place an uncompressed `vmlinux` kernel at `/var/lib/wrenn/kernels/vmlinux`. Versioned kernels (`vmlinux-{semver}`) are also supported — the agent picks the latest by semver. -### Minimal rootfs +### System base rootfs images -The minimal rootfs is the base image that all other templates (Python, Node, etc.) are built on top of via device-mapper snapshots. It must contain: +There are four built-in **system base templates** — one per distro — that all other +templates snapshot from via device-mapper. They are platform-owned (visible to every +team) and protected from deletion (reserved template IDs 0–1024): + +| Template | Distro | ID | +|----------|--------|----| +| `minimal-ubuntu` | `ubuntu:26.04` | 0 | +| `minimal-alpine` | `alpine:3.22` | 1 | +| `minimal-arch` | `archlinux:base` | 2 | +| `minimal-fedora` | `fedora:45` | 3 | + +`minimal-ubuntu` is the default template for new sandboxes and builds. The same +statically-linked `envd` + `tini` run on all four regardless of the distro's libc +(glibc on Ubuntu/Arch/Fedora, musl on Alpine). + +Each image contains these packages plus a `wrenn-user` account with passwordless `sudo`: | Package | Why | |---------|-----| | `socat` | Bidirectional relay for port forwarding | | `chrony` | Time sync from KVM PTP clock (`/dev/ptp0`) | -| `tini` | PID 1 zombie reaper (injected by build script, not apt) | +| `iproute2` (`iproute` on Fedora) | `ip` for guest network setup in `wrenn-init` | +| `tini` | PID 1 zombie reaper | | `sudo` | User privilege management inside the guest | | `wget` | HTTP fetching | | `curl` | HTTP client | | `ca-certificates` | TLS certificate verification | +| `git` | Version control | -**To build a rootfs from a Docker container:** +**To build all four images** (each spawns a distro container, installs the packages + +`wrenn-user`, builds `envd`, injects `wrenn-init` + `tini`, and exports to the +team-scoped path). Requires Docker + sudo: -1. Create and configure a container with the required packages: - ```bash - docker run -it --name wrenn-minimal debian:bookworm bash - # Inside the container: - apt update && apt install -y socat chrony sudo wget curl ca-certificates - exit - ``` +```bash +make images +``` -2. Export to a rootfs image (builds envd, injects wrenn-init + tini, shrinks to minimum size): - ```bash - sudo bash scripts/rootfs-from-container.sh wrenn-minimal minimal - ``` +Or build a single distro: `make rootfs-ubuntu` / `rootfs-alpine` / `rootfs-arch` / `rootfs-fedora`. -**To update an existing rootfs** after changing envd or `wrenn-init.sh`: +**To update the images** after changing `envd` or `wrenn-init.sh` (rebuilds `envd` once, +then re-injects `envd` + `wrenn-init` + `tini` into every system base image): ```bash bash scripts/update-minimal-rootfs.sh ``` -This rebuilds envd via `make build-envd` and copies the fresh binaries into the mounted rootfs image. - ### IP forwarding ```bash @@ -120,14 +135,7 @@ make check # fmt + vet + lint + test Hosts must be registered with the control plane before they can serve sandboxes. -1. **Create a host record** (via API or dashboard): - ```bash - curl -X POST http://localhost:8000/v1/hosts \ - -H "Authorization: Bearer $JWT_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"type": "regular"}' - ``` - This returns a `registration_token` (valid for 1 hour). +1. **Create a host record** in the dashboard (admin only — host management is not exposed over the SDK / API keys). Sign in at `/login`, open the admin hosts page, and click **Add host**. The dashboard returns a `registration_token` valid for 1 hour. 2. **Start the host agent** with the registration token and its externally-reachable address: ```bash @@ -143,13 +151,152 @@ Hosts must be registered with the control plane before they can serve sandboxes. sudo ./builds/wrenn-agent --address :50051 ``` -4. **If registration fails** (e.g., network error after token was consumed), regenerate a token: - ```bash - curl -X POST http://localhost:8000/v1/hosts/$HOST_ID/token \ - -H "Authorization: Bearer $JWT_TOKEN" - ``` - Then restart the agent with the new token. +4. **If registration fails** (e.g., network error after token was consumed), regenerate a token from the dashboard host detail page, then restart the agent with the new token. The agent sends heartbeats to the control plane every 30 seconds. +## Notification channels + +Teams can subscribe to lifecycle events via webhook, Discord, Slack, Teams, Google Chat, Telegram, or Matrix. All providers consume the same event stream (durable Redis stream `wrenn:events`, consumer group `wrenn-channels-v1`, at-least-once delivery with two retries at 10s / 30s). + +### Subscribable event types + +| Event | Emitted on | Has outcome | +|-------|-----------|-------------| +| `capsule.create` | First boot of a sandbox | yes | +| `capsule.pause` | Manual pause, TTL auto-pause, or reconciler-detected pause | yes | +| `capsule.resume` | Unpause (any subsequent boot after `capsule.create`) | yes | +| `capsule.destroy` | Stop / destroy, including system cleanup-on-error | yes | +| `template.snapshot.create` | Snapshot taken from a running sandbox | yes | +| `template.snapshot.delete` | Snapshot deletion (including cleanup-on-error) | yes | +| `host.up` | Host agent comes online | no | +| `host.down` | Host agent crashes or misses heartbeats | no | + +Subscribing to an event type delivers **both success and failure**. The `outcome` field on the payload (`success` or `error`) distinguishes them. `error` events carry an `error` string with the failure reason. + +The transient `capsule.state.changed` event (intermediate transitions like `starting`, `pausing`, `resuming`) is **not** subscribable — it is delivered to the dashboard via SSE only and never written to the durable stream. + +### Event payload + +All channels receive the same canonical JSON shape: + +```json +{ + "event": "capsule.pause", + "outcome": "success", + "timestamp": "2026-05-19T14:23:01Z", + "team_id": "tm_...", + "actor": { + "type": "user", + "id": "usr_...", + "name": "alice@example.com" + }, + "resource": { + "id": "sb_a1b2c3d4", + "type": "sandbox" + }, + "metadata": { + "reason": "ttl_expired" + }, + "error": "" +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `event` | string | Event type (see table above) | +| `outcome` | `"success"` \| `"error"` \| `""` | Omitted for host.up/host.down | +| `timestamp` | RFC3339 UTC | When the event was published | +| `team_id` | string | Owning team | +| `actor.type` | `"user"` \| `"api_key"` \| `"system"` | System = TTL reaper, reconciler, cleanup-on-error | +| `actor.id` | string | User ID, API key ID, or empty for system | +| `actor.name` | string | Display name (email for user, label for api_key) | +| `resource.id` | string | Sandbox ID, snapshot ID, or host ID | +| `resource.type` | `"sandbox"` \| `"snapshot"` \| `"host"` | | +| `metadata` | object\ | Event-specific context (e.g., `reason`, `from`/`to`, `inferred`) | +| `error` | string | Failure reason when `outcome == "error"` | + +`metadata` keys you may observe: + +- `reason` — `ttl_expired` (auto-pause), `orphaned` (reconciler cleanup), `cleanup_after_create_error`, `restored_after_host_recovery`, `host_state_sync`, `transient_timeout`, `transient_timeout_inferred` +- `inferred` — `"true"` when the reconciler derived the event from host state, not a direct host callback + +### Webhook delivery + +Webhook channels receive a raw `POST` with the JSON payload as the body. + +Headers: + +| Header | Value | +|--------|-------| +| `Content-Type` | `application/json` | +| `X-Wrenn-Delivery` | UUID, unique per delivery attempt | +| `X-Wrenn-Timestamp` | RFC3339 UTC, used for signature verification | +| `X-WRENN-SIGNATURE` | `sha256=` HMAC over `.` using the channel's signing secret | + +The signing secret is shown **once** at channel creation. Verify signatures by computing `HMAC-SHA256(secret, timestamp + "." + body)` and comparing to the header (constant-time compare). Reject deliveries where `X-Wrenn-Timestamp` is outside your acceptable clock skew window. Redirects are not followed. + +Any non-2xx response triggers retry (10s, then 30s). After three total failures the event is dropped (logged on the control plane). + +### Other providers + +Discord, Slack, Teams, Google Chat, Telegram, and Matrix receive a formatted text message — the same fields, rendered as human-readable text — not the JSON payload. Use webhook if you need the structured event. + +## Extending the control plane + +The OSS control plane is designed to be embedded by a private cloud distribution without forking. Import this module, implement the `Extension` interface from `pkg/cpextension`, and pass it to `cpserver.Run`: + +```go +import ( + "git.omukk.dev/wrenn/wrenn/pkg/cpextension" + "git.omukk.dev/wrenn/wrenn/pkg/cpserver" +) + +func main() { + cpserver.Run( + cpserver.WithVersion("cloud-1.0.0"), + cpserver.WithExtensions(&myExtension{}), + ) +} +``` + +Every extension implements two methods: + +```go +RegisterRoutes(r chi.Router, sctx cpextension.ServerContext) +BackgroundWorkers(sctx cpextension.ServerContext) []func(context.Context) +``` + +`ServerContext` exposes the initialized OSS services so extensions never re-implement them: `Queries`, `PgPool`, `Redis`, `HostPool`, `Scheduler`, `CA`, `Audit`, `Mailer`, `OAuthRegistry`, `Channels`, `ChannelPub`, `JWTSecret`, `Sessions`, `Config`. + +### Optional hook interfaces + +An extension can also implement any subset of these — the OSS server type-asserts at startup: + +| Interface | When it fires | Failure semantics | +|---|---|---| +| `MiddlewareProvider` | Wraps every OSS route before registration | n/a | +| `AuthHook.OnSignup(ctx, userID, teamID, email)` | After team provisioning on email-activate or OAuth-new-signup | Error aborts signup with 500 `signup_hook_failed` (billing customer creation must succeed) | +| `AuthHook.OnLogin(ctx, userID)` | After a successful login or OAuth callback | Error logged, login still succeeds | +| `AuthHook.OnAccountSoftDelete(ctx, userID)` | After `DELETE /v1/me` commits | Error logged, request still succeeds | +| `AuthHook.OnAccountHardDelete(ctx, userID)` | After the 15-day cleanup goroutine purges a soft-deleted account | Error logged, cleanup continues | +| `SandboxEventHook.OnSandboxEvent(ctx, ev)` | Capsule create/pause/resume/destroy success, from the Redis stream consumer | Error leaves the message un-acked — hooks **must** be idempotent | +| `LimitsProvider.EffectiveLimits(ctx, teamID)` | `POST /v1/capsules` consults before scheduling | Returns 402 (`concurrent_sandbox_limit` / `vcpu_limit` / `memory_limit`) when over | +| `UsageProvider.CurrentUsage(ctx, teamID)` | Feeds `LimitsProvider` checks; falls back to OSS DB-backed default | Error → 402 `usage_unavailable` | + +### Auth middleware helpers + +For extensions that gate their own routes: + +```go +r.With(cpextension.RequireSession(sctx)).Get("/billing", handler) +r.With(cpextension.RequireSessionOrAPIKey(sctx)).Get("/usage", handler) +r.With(cpextension.RequireSession(sctx), cpextension.RequireAdmin(sctx)).Get("/admin/exports", handler) + +// Issue a session from a custom flow (e.g. invite-accept): +sess, err := cpextension.IssueSession(w, r, sctx, userID, teamID) +``` + +Cookie/header names are exported as `cpextension.SessionCookieName`, `CSRFCookieName`, `CSRFHeaderName`. + See `CLAUDE.md` for full architecture documentation. diff --git a/VERSION_AGENT b/VERSION_AGENT index b1e80bb2..0ea3a944 100644 --- a/VERSION_AGENT +++ b/VERSION_AGENT @@ -1 +1 @@ -0.1.3 +0.2.0 diff --git a/VERSION_CP b/VERSION_CP index c946ee61..0ea3a944 100644 --- a/VERSION_CP +++ b/VERSION_CP @@ -1 +1 @@ -0.1.6 +0.2.0 diff --git a/cmd/host-agent/main.go b/cmd/host-agent/main.go index d49d9e02..e9a9655b 100644 --- a/cmd/host-agent/main.go +++ b/cmd/host-agent/main.go @@ -24,6 +24,7 @@ import ( "git.omukk.dev/wrenn/wrenn/internal/layout" "git.omukk.dev/wrenn/wrenn/internal/network" "git.omukk.dev/wrenn/wrenn/internal/sandbox" + "git.omukk.dev/wrenn/wrenn/internal/vm" "git.omukk.dev/wrenn/wrenn/pkg/auth" "git.omukk.dev/wrenn/wrenn/pkg/logging" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" @@ -63,8 +64,12 @@ func main() { os.Exit(1) } - // Clean up stale resources from a previous crash. + // Clean up stale resources from a previous crash. Order matters: + // kill stale CH processes first — they hold dm-snapshot devices open and + // would otherwise cause "Device or resource busy" on dmsetup remove. + vm.CleanupStaleProcesses() devicemapper.CleanupStaleDevices() + devicemapper.LogLoopState() network.CleanupStaleNamespaces() listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051") @@ -126,27 +131,45 @@ func main() { } slog.Info("resolved kernel", "version", kernelVersion, "path", kernelPath) - // Detect firecracker version. - fcBin := envOrDefault("WRENN_FIRECRACKER_BIN", "/usr/local/bin/firecracker") - fcVersion, err := sandbox.DetectFirecrackerVersion(fcBin) + // Detect cloud-hypervisor version. + chBin := envOrDefault("WRENN_CH_BIN", "/usr/local/bin/cloud-hypervisor") + chVersion, err := sandbox.DetectCHVersion(chBin) if err != nil { - slog.Error("failed to detect firecracker version", "error", err) + slog.Error("failed to detect cloud-hypervisor version", "error", err) os.Exit(1) } - slog.Info("resolved firecracker", "version", fcVersion, "path", fcBin) + slog.Info("resolved cloud-hypervisor", "version", chVersion, "path", chBin) cfg := sandbox.Config{ WrennDir: rootDir, DefaultRootfsSizeMB: defaultRootfsSizeMB, KernelPath: kernelPath, KernelVersion: kernelVersion, - FirecrackerBin: fcBin, - FirecrackerVersion: fcVersion, + VMMBin: chBin, + VMMVersion: chVersion, AgentVersion: version, } + // Remove any *.staging-* / *.trash-* directories left behind by a + // previous Pause that crashed before completing the atomic swap. Must + // run before any Resume so we don't race with a sandbox restoration. + sandbox.CleanupOrphanPauseDirs(rootDir) + mgr := sandbox.New(cfg) + // Set up lifecycle event callback sender so autonomous events + // (auto-pause, auto-destroy) are pushed to the CP proactively. + cb := hostagent.NewCallbackSender(cpURL, credsFile, creds.HostID) + mgr.SetEventSender(hostagent.NewEventSender(cb)) + + // Restore paused sandboxes from disk so ListSandboxes reports them as + // 'paused' immediately. Without this, the CP's HostMonitor would mark + // every paused-on-disk sandbox 'stopped' via the missing→stopped + // reconcile path on the first ListSandboxes after agent restart. + // Must run before HTTP server starts serving (an early Create would + // race the slot reservation). + mgr.RestorePausedSandboxes() + mgr.StartTTLReaper(ctx) // httpServer is declared here so the shutdown func can reference it. @@ -190,10 +213,22 @@ func main() { shutdownOnce.Do(func() { slog.Info("shutting down", "reason", reason) cancel() - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + // Shutdown pauses every running sandbox in parallel (PauseAll uses + // a worker pool). Per-sandbox Pause can take 10–30s (memory loader + // wait + ch.snapshot of guest RAM). 5 minutes is enough headroom for + // a busy host while still bounded so a wedged sandbox can't keep the + // process alive indefinitely — a second signal force-exits anyway. + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer shutdownCancel() + // Order matters: mgr.Shutdown FIRST so it runs to completion + // before httpServer.Shutdown unblocks main's Serve and lets the + // process exit. mgr.Shutdown internally flips a draining flag + // that rejects new Create/Resume RPCs with Unavailable so any + // in-flight HTTP handlers can't add sandboxes after PauseAll + // snapshotted state. User-initiated Pauses already running are + // awaited by PauseAll/Destroy's lifecycleMu serialization. mgr.Shutdown(shutdownCtx) - sandbox.ShrinkMinimalImage(rootDir) + sandbox.ShrinkSystemImages(rootDir) if err := httpServer.Shutdown(shutdownCtx); err != nil { slog.Error("http server shutdown error", "error", err) } @@ -226,8 +261,9 @@ func main() { func() { doShutdown("host deleted from CP") }, - // onCredsRefreshed: hot-swap the TLS certificate after a JWT refresh. + // onCredsRefreshed: hot-swap the TLS certificate and update callback JWT. func(tf *hostagent.TokenFile) { + cb.UpdateJWT(tf.JWT) if tf.CertPEM == "" || tf.KeyPEM == "" { return } @@ -239,12 +275,16 @@ func main() { }, ) - // Graceful shutdown on SIGINT/SIGTERM. + // Graceful shutdown on SIGINT/SIGTERM. A second signal force-exits + // so the operator can always kill the process if shutdown hangs. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { sig := <-sigCh - doShutdown("signal: " + sig.String()) + go doShutdown("signal: " + sig.String()) + sig = <-sigCh + slog.Error("received second signal, force exiting", "signal", sig.String()) + os.Exit(1) }() slog.Info("host agent starting", "addr", listenAddr, "host_id", creds.HostID, "version", version, "commit", commit) @@ -286,7 +326,7 @@ func checkPrivileges() error { name string }{ {1, "CAP_DAC_OVERRIDE"}, // /dev/loop*, /dev/mapper/*, /dev/net/tun - {5, "CAP_KILL"}, // SIGTERM/SIGKILL to Firecracker processes + {5, "CAP_KILL"}, // SIGTERM/SIGKILL to cloud-hypervisor processes {12, "CAP_NET_ADMIN"}, // netlink, iptables, routing, TAP/veth {13, "CAP_NET_RAW"}, // raw sockets (iptables) {19, "CAP_SYS_PTRACE"}, // reading /proc/self/ns/net (netns.Get) diff --git a/db/migrations/20260518200117_add_sessions.sql b/db/migrations/20260518200117_add_sessions.sql new file mode 100644 index 00000000..a106967a --- /dev/null +++ b/db/migrations/20260518200117_add_sessions.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX sessions_user_id_idx ON sessions(user_id); +CREATE INDEX sessions_expires_at_idx ON sessions(expires_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS sessions; +-- +goose StatementEnd diff --git a/db/migrations/20260519231056_hash_session_ids.sql b/db/migrations/20260519231056_hash_session_ids.sql new file mode 100644 index 00000000..689966a0 --- /dev/null +++ b/db/migrations/20260519231056_hash_session_ids.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +-- Session IDs are now stored as sha256(raw_sid) hex so a DB/Redis dump +-- cannot be replayed as session cookies. Existing sessions hold raw SIDs +-- in id; they are unrecoverable under the new scheme and must be wiped. +-- Users will need to log in again after this migration. +TRUNCATE TABLE sessions; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- Down: nothing to do schematically. Hashed rows remain but will never +-- match a raw cookie under the old code path; safest is to wipe again. +TRUNCATE TABLE sessions; +-- +goose StatementEnd diff --git a/db/migrations/20260522154716_seed_system_base_templates.sql b/db/migrations/20260522154716_seed_system_base_templates.sql new file mode 100644 index 00000000..382cd311 --- /dev/null +++ b/db/migrations/20260522154716_seed_system_base_templates.sql @@ -0,0 +1,49 @@ +-- +goose Up + +-- Replace the old all-zeros "minimal" base template with the four system base +-- templates (ubuntu/alpine/arch/fedora). All are platform-owned (team_id +-- all-zeros) with reserved template IDs 0..3, default user wrenn-user. +-- +-- Template IDs are well-known: the all-zeros UUID + low byte = {0,1,2,3}. +-- On disk each lives at images/teams/{base36(0)}/{base36(id)}/rootfs.ext4. + +-- 0 → minimal-ubuntu (was "minimal"). +UPDATE templates +SET name = 'minimal-ubuntu', + default_user = 'wrenn-user' +WHERE id = '00000000-0000-0000-0000-000000000000'; + +-- Seed the row if it did not already exist (fresh DBs). +INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user) +VALUES ('00000000-0000-0000-0000-000000000000', 'minimal-ubuntu', 'base', 1, 512, 0, + '00000000-0000-0000-0000-000000000000', 'wrenn-user') +ON CONFLICT (id) DO NOTHING; + +-- 1 → minimal-alpine, 2 → minimal-arch, 3 → minimal-fedora. +INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user) +VALUES + ('00000000-0000-0000-0000-000000000001', 'minimal-alpine', 'base', 1, 512, 0, + '00000000-0000-0000-0000-000000000000', 'wrenn-user'), + ('00000000-0000-0000-0000-000000000002', 'minimal-arch', 'base', 1, 512, 0, + '00000000-0000-0000-0000-000000000000', 'wrenn-user'), + ('00000000-0000-0000-0000-000000000003', 'minimal-fedora', 'base', 1, 512, 0, + '00000000-0000-0000-0000-000000000000', 'wrenn-user') +ON CONFLICT (id) DO NOTHING; + +-- Point the sandboxes.template column default at the new default base template. +ALTER TABLE sandboxes ALTER COLUMN template SET DEFAULT 'minimal-ubuntu'; + +-- +goose Down + +ALTER TABLE sandboxes ALTER COLUMN template SET DEFAULT 'minimal'; + +DELETE FROM templates WHERE id IN ( + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003' +); + +UPDATE templates +SET name = 'minimal', + default_user = 'root' +WHERE id = '00000000-0000-0000-0000-000000000000'; diff --git a/db/queries/hosts.sql b/db/queries/hosts.sql index 3e133cb9..d77b9c7e 100644 --- a/db/queries/hosts.sql +++ b/db/queries/hosts.sql @@ -13,7 +13,36 @@ SELECT * FROM hosts ORDER BY created_at DESC; SELECT * FROM hosts WHERE type = $1 ORDER BY created_at DESC; -- name: ListHostsByTeam :many -SELECT * FROM hosts WHERE team_id = $1 AND type = 'byoc' ORDER BY created_at DESC; +-- Returns hosts by team with per-host sandbox resource consumption aggregated. +-- Follows the same aggregation pattern as ListHostsAdmin and GetHostsWithLoad. +SELECT + h.id, + h.type, + h.team_id, + h.provider, + h.availability_zone, + h.arch, + h.cpu_cores, + h.memory_mb, + h.disk_gb, + h.address, + h.status, + h.last_heartbeat_at, + h.metadata, + h.created_by, + h.created_at, + h.updated_at, + COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus, + COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb, + COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb, + COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_memory_mb, + COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_disk_mb +FROM hosts h +LEFT JOIN sandboxes s ON s.host_id = h.id + AND s.status IN ('running', 'paused', 'starting', 'pending') +WHERE h.team_id = $1 AND h.type = 'byoc' +GROUP BY h.id +ORDER BY h.created_at DESC; -- name: ListHostsByStatus :many SELECT * FROM hosts WHERE status = $1 ORDER BY created_at DESC; @@ -101,8 +130,6 @@ SELECT h.created_by, h.created_at, h.updated_at, - h.cert_fingerprint, - h.cert_expires_at, COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus, COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb, COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb, @@ -125,5 +152,37 @@ SET last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $1; +-- name: ListHostsAdmin :many +-- Returns all hosts with per-host sandbox resource consumption aggregated. +-- Unlike GetHostsWithLoad, this returns ALL hosts (not just online) so admins +-- can see resource usage across the entire fleet including offline/pending hosts. +SELECT + h.id, + h.type, + h.team_id, + h.provider, + h.availability_zone, + h.arch, + h.cpu_cores, + h.memory_mb, + h.disk_gb, + h.address, + h.status, + h.last_heartbeat_at, + h.metadata, + h.created_by, + h.created_at, + h.updated_at, + COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus, + COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb, + COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb, + COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_memory_mb, + COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_disk_mb +FROM hosts h +LEFT JOIN sandboxes s ON s.host_id = h.id + AND s.status IN ('running', 'paused', 'starting', 'pending') +GROUP BY h.id +ORDER BY h.created_at DESC; + -- name: MarkHostUnreachable :exec UPDATE hosts SET status = 'unreachable', updated_at = NOW() WHERE id = $1; diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index 7355823c..8d1da602 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -42,6 +42,13 @@ ORDER BY ts ASC; DELETE FROM sandbox_metric_points WHERE sandbox_id = $1; +-- name: GetLatestSandboxMetricPoint :one +SELECT ts, cpu_pct, mem_bytes, disk_bytes +FROM sandbox_metric_points +WHERE sandbox_id = $1 +ORDER BY ts DESC +LIMIT 1; + -- name: DeleteSandboxMetricPointsByTier :exec DELETE FROM sandbox_metric_points WHERE sandbox_id = $1 AND tier = $2; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index 2bf5db7d..772ce0e3 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -72,7 +72,7 @@ ORDER BY created_at DESC; UPDATE sandboxes SET status = 'missing', last_updated = NOW() -WHERE host_id = $1 AND status IN ('running', 'starting', 'pending'); +WHERE host_id = $1 AND status IN ('running', 'starting', 'pending', 'pausing', 'resuming', 'stopping'); -- name: UpdateSandboxMetadata :exec UPDATE sandboxes @@ -80,10 +80,42 @@ SET metadata = $2, last_updated = NOW() WHERE id = $1; --- name: BulkRestoreRunning :exec --- Called by the reconciler when a host comes back online and its sandboxes are --- confirmed alive. Restores only sandboxes that are in 'missing' state. +-- name: UpdateSandboxRunningIf :one +-- Conditionally transition a sandbox to running only if the current status +-- matches the expected value. Prevents races where a user destroys a sandbox +-- while the create/resume goroutine is still in-flight. UPDATE sandboxes -SET status = 'running', +SET status = 'running', + host_ip = $3, + guest_ip = $4, + started_at = $5, + last_active_at = $5, + last_updated = NOW() +WHERE id = $1 AND status = $2 +RETURNING *; + +-- name: UpdateSandboxStatusIf :one +-- Atomically update status only when the current status matches the expected value. +-- Prevents background goroutines from overwriting a status that has since changed +-- (e.g. user destroyed a sandbox while Create was in-flight). +UPDATE sandboxes +SET status = $3, + last_updated = NOW() +WHERE id = $1 AND status = $2 +RETURNING *; + +-- name: BulkRestoreMissingToStatus :exec +-- Called by the reconciler when a host comes back online and its sandboxes are +-- confirmed alive. Restores only sandboxes currently in 'missing' state to the +-- given target status (typically 'running' or 'paused' based on the live state +-- reported by the host agent's ListSandboxes RPC). +UPDATE sandboxes +SET status = $2, last_updated = NOW() WHERE id = ANY($1::uuid[]) AND status = 'missing'; + +-- name: UpdateSandboxDiskSize :exec +UPDATE sandboxes +SET disk_size_mb = $2, + last_updated = NOW() +WHERE id = $1; diff --git a/db/queries/sessions.sql b/db/queries/sessions.sql new file mode 100644 index 00000000..a43c2a97 --- /dev/null +++ b/db/queries/sessions.sql @@ -0,0 +1,28 @@ +-- name: InsertSession :one +INSERT INTO sessions (id, user_id, team_id, csrf_token, user_agent, ip_address, expires_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: GetSession :one +SELECT * FROM sessions WHERE id = $1; + +-- name: TouchSession :exec +UPDATE sessions SET last_seen_at = NOW() WHERE id = $1; + +-- name: UpdateSessionTeam :exec +UPDATE sessions SET team_id = $2 WHERE id = $1; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = $1; + +-- name: DeleteSessionForUser :exec +DELETE FROM sessions WHERE id = $1 AND user_id = $2; + +-- name: ListSessionsByUserID :many +SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC; + +-- name: DeleteSessionsByUserID :many +DELETE FROM sessions WHERE user_id = $1 RETURNING id; + +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at < NOW(); diff --git a/db/queries/teams.sql b/db/queries/teams.sql index f4de8087..a4b2c568 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -66,7 +66,9 @@ SELECT COALESCE(owner_u.name, '') AS owner_name, COALESCE(owner_u.email, '') AS owner_email, (SELECT COUNT(*) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting'))::int AS active_sandbox_count, - (SELECT COUNT(*) FROM channels c WHERE c.team_id = t.id)::int AS channel_count + (SELECT COUNT(*) FROM channels c WHERE c.team_id = t.id)::int AS channel_count, + COALESCE((SELECT SUM(s.vcpus) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting')), 0)::int AS running_vcpus, + COALESCE((SELECT SUM(s.memory_mb) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting')), 0)::int AS running_memory_mb FROM teams t LEFT JOIN users_teams owner_ut ON owner_ut.team_id = t.id AND owner_ut.role = 'owner' LEFT JOIN users owner_u ON owner_u.id = owner_ut.user_id diff --git a/db/queries/templates.sql b/db/queries/templates.sql index 7c50ea64..7d058612 100644 --- a/db/queries/templates.sql +++ b/db/queries/templates.sql @@ -42,6 +42,9 @@ DELETE FROM templates WHERE name = $1 AND team_id = $2; -- Bulk delete all templates owned by a team (for team soft-delete cleanup). DELETE FROM templates WHERE team_id = $1; +-- name: UpdateTemplateSize :exec +UPDATE templates SET size_bytes = $2 WHERE id = $1; + -- name: ListTemplatesByTeamOnly :many -- List templates owned by a specific team (NOT including platform templates). SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC; diff --git a/envd-rs/Cargo.lock b/envd-rs/Cargo.lock index 11207846..d48d2a39 100644 --- a/envd-rs/Cargo.lock +++ b/envd-rs/Cargo.lock @@ -17,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -113,6 +122,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.9" @@ -280,6 +295,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -486,17 +512,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "either" version = "1.15.0" @@ -514,7 +529,7 @@ dependencies = [ [[package]] name = "envd" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-stream", "axum", @@ -522,6 +537,7 @@ dependencies = [ "buffa", "buffa-types", "bytes", + "chrono", "clap", "connectrpc", "connectrpc-build", @@ -537,7 +553,6 @@ dependencies = [ "mime_guess", "nix", "notify", - "reqwest", "serde", "serde_json", "sha2", @@ -889,7 +904,6 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", ] [[package]] @@ -898,103 +912,37 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", - "futures-channel", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", "tokio", "tower-service", - "tracing", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "cc", ] [[package]] @@ -1003,27 +951,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1065,22 +992,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1105,9 +1016,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1171,12 +1082,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "lock_api" version = "0.4.14" @@ -1326,6 +1231,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1405,15 +1319,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1509,38 +1414,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1723,12 +1596,6 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strsim" version = "0.11.1" @@ -1757,20 +1624,6 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "sysinfo" @@ -1828,16 +1681,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tokio" version = "1.52.1" @@ -1911,14 +1754,12 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", - "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", - "tower", "tower-layer", "tower-service", "tracing", @@ -2011,12 +1852,6 @@ dependencies = [ "tracing-serde", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.0" @@ -2041,24 +1876,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -2087,15 +1904,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2122,9 +1930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2133,21 +1941,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.70" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2155,9 +1953,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2168,9 +1966,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2209,16 +2007,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2485,56 +2273,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zeroize" version = "1.8.2" @@ -2555,39 +2293,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/envd-rs/Cargo.toml b/envd-rs/Cargo.toml index 35655f24..f741acfb 100644 --- a/envd-rs/Cargo.toml +++ b/envd-rs/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "envd" -version = "0.2.1" +version = "0.3.0" edition = "2024" -rust-version = "1.88" +rust-version = "1.95" [dependencies] # Async runtime @@ -53,12 +53,12 @@ notify = "7" # Compression flate2 = "1" -# HTTP client (MMDS polling) -reqwest = { version = "0.12", default-features = false, features = ["json"] } - # Directory walking walkdir = "2" +# Time parsing (RFC3339 → epoch nanos for clock fix) +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } + # Misc libc = "0.2" bytes = "1" diff --git a/envd-rs/README.md b/envd-rs/README.md index 3a82d2d7..ca17281c 100644 --- a/envd-rs/README.md +++ b/envd-rs/README.md @@ -1,6 +1,6 @@ # envd (Rust) -Wrenn guest agent daemon — runs as PID 1 inside Firecracker microVMs. Provides process management, filesystem operations, file transfer, port forwarding, and VM lifecycle control over Connect RPC and HTTP. +Wrenn guest agent daemon — runs as PID 1 inside Cloud Hypervisor microVMs. Provides process management, filesystem operations, file transfer, port forwarding, and VM lifecycle control over Connect RPC and HTTP. Rust rewrite of `envd/` (Go). Drop-in replacement — same wire protocol, same endpoints, same CLI flags. @@ -50,7 +50,7 @@ cargo build Run locally (outside a VM): ```bash -./target/debug/envd --isnotfc --port 49983 +./target/debug/envd --port 49983 ``` ### Via Makefile (from repo root) @@ -64,7 +64,6 @@ make build-envd-go # Go version (for comparison) ``` --port Listen port [default: 49983] ---isnotfc Not running inside Firecracker (disables MMDS, cgroups) --version Print version and exit --commit Print git commit and exit --cmd Spawn a process at startup (e.g. --cmd "/bin/bash") @@ -81,7 +80,7 @@ make build-envd-go # Go version (for comparison) | GET | `/metrics` | System metrics (CPU, memory, disk) | | GET | `/envs` | Current environment variables | | POST | `/init` | Host agent init (token, env, mounts) | -| POST | `/snapshot/prepare` | Quiesce before Firecracker snapshot | +| POST | `/snapshot/prepare` | Quiesce before Cloud Hypervisor snapshot | | GET | `/files` | Download file (gzip, range support) | | POST | `/files` | Upload file(s) via multipart | @@ -108,7 +107,7 @@ src/ ├── util.rs # AtomicMax ├── auth/ # Token, signing, middleware ├── crypto/ # SHA-256, SHA-512, HMAC -├── host/ # MMDS polling, system metrics +├── host/ # System metrics ├── http/ # Axum handlers (health, init, snapshot, files, encoding) ├── permissions/ # Path resolution, user lookup, chown ├── rpc/ # Connect RPC services @@ -129,13 +128,15 @@ src/ After building the static binary, copy it into the rootfs: ```bash -bash scripts/update-debug-rootfs.sh [rootfs_path] +bash scripts/update-minimal-rootfs.sh [rootfs_path] ``` -Or manually: +With no argument it updates all four system base images; pass a path to target one. + +Or manually (example path: the minimal-ubuntu image, platform team + template id 0): ```bash -sudo mount -o loop /var/lib/wrenn/images/minimal.ext4 /mnt -sudo cp target/x86_64-unknown-linux-musl/release/envd /mnt/usr/bin/envd +sudo mount -o loop /var/lib/wrenn/images/teams/0000000000000000000000000/0000000000000000000000000/rootfs.ext4 /mnt +sudo cp target/x86_64-unknown-linux-musl/release/envd /mnt/usr/local/bin/envd sudo umount /mnt ``` diff --git a/envd-rs/src/config.rs b/envd-rs/src/config.rs index c2dac435..be89725f 100644 --- a/envd-rs/src/config.rs +++ b/envd-rs/src/config.rs @@ -9,8 +9,3 @@ pub const WRENN_RUN_DIR: &str = "/run/wrenn"; pub const KILOBYTE: u64 = 1024; pub const MEGABYTE: u64 = 1024 * KILOBYTE; - -pub const MMDS_ADDRESS: &str = "169.254.169.254"; -pub const MMDS_POLL_INTERVAL: Duration = Duration::from_millis(50); -pub const MMDS_TOKEN_EXPIRATION_SECS: u64 = 60; -pub const MMDS_ACCESS_TOKEN_CLIENT_TIMEOUT: Duration = Duration::from_secs(10); diff --git a/envd-rs/src/conntracker.rs b/envd-rs/src/conntracker.rs index 15c974c5..cde052dc 100644 --- a/envd-rs/src/conntracker.rs +++ b/envd-rs/src/conntracker.rs @@ -1,24 +1,14 @@ use std::collections::HashSet; use std::sync::Mutex; -/// Tracks active TCP connections for snapshot/restore lifecycle. -/// -/// Before snapshot: close idle connections, record active ones. -/// After restore: close all pre-snapshot connections (zombie TCP sockets). -/// -/// In Rust/axum, we don't have Go's ConnState callback. Instead we track -/// connections via a tower middleware that registers connection IDs. -/// For the initial implementation, we track by a simple connection counter -/// and rely on axum's graceful shutdown mechanics. +/// Tracks active TCP connections. pub struct ConnTracker { inner: Mutex, } struct ConnTrackerInner { active: HashSet, - pre_snapshot: Option>, next_id: u64, - keepalives_enabled: bool, } impl ConnTracker { @@ -26,9 +16,7 @@ impl ConnTracker { Self { inner: Mutex::new(ConnTrackerInner { active: HashSet::new(), - pre_snapshot: None, next_id: 0, - keepalives_enabled: true, }), } } @@ -44,37 +32,6 @@ impl ConnTracker { pub fn remove_connection(&self, id: u64) { let mut inner = self.inner.lock().unwrap(); inner.active.remove(&id); - if let Some(ref mut pre) = inner.pre_snapshot { - pre.remove(&id); - } - } - - pub fn prepare_for_snapshot(&self) { - let mut inner = self.inner.lock().unwrap(); - inner.keepalives_enabled = false; - inner.pre_snapshot = Some(inner.active.clone()); - tracing::info!( - active_connections = inner.active.len(), - "snapshot: recorded pre-snapshot connections, keep-alives disabled" - ); - } - - pub fn restore_after_snapshot(&self) { - let mut inner = self.inner.lock().unwrap(); - if let Some(pre) = inner.pre_snapshot.take() { - let zombie_count = pre.len(); - for id in &pre { - inner.active.remove(id); - } - if zombie_count > 0 { - tracing::info!(zombie_count, "restore: closed zombie connections"); - } - } - inner.keepalives_enabled = true; - } - - pub fn keepalives_enabled(&self) -> bool { - self.inner.lock().unwrap().keepalives_enabled } #[cfg(test)] @@ -110,91 +67,4 @@ mod tests { ct.remove_connection(999); assert_eq!(ct.active_count(), 0); } - - #[test] - fn prepare_disables_keepalives() { - let ct = ConnTracker::new(); - assert!(ct.keepalives_enabled()); - ct.register_connection(); - ct.prepare_for_snapshot(); - assert!(!ct.keepalives_enabled()); - } - - #[test] - fn restore_removes_zombies_and_reenables_keepalives() { - let ct = ConnTracker::new(); - let id0 = ct.register_connection(); - let id1 = ct.register_connection(); - ct.prepare_for_snapshot(); - ct.restore_after_snapshot(); - assert!(ct.keepalives_enabled()); - // Both pre-snapshot connections removed as zombies - assert_eq!(ct.active_count(), 0); - // IDs don't matter anymore, but remove shouldn't panic - ct.remove_connection(id0); - ct.remove_connection(id1); - } - - #[test] - fn restore_without_prepare_is_noop() { - let ct = ConnTracker::new(); - let _id = ct.register_connection(); - ct.restore_after_snapshot(); - assert!(ct.keepalives_enabled()); - assert_eq!(ct.active_count(), 1); - } - - #[test] - fn connection_closed_before_restore_not_zombie() { - let ct = ConnTracker::new(); - let id0 = ct.register_connection(); - let _id1 = ct.register_connection(); - ct.prepare_for_snapshot(); - // Close id0 during snapshot window - ct.remove_connection(id0); - assert_eq!(ct.active_count(), 1); - ct.restore_after_snapshot(); - // id1 was zombie (still active at restore), id0 already gone - assert_eq!(ct.active_count(), 0); - } - - #[test] - fn post_snapshot_connection_survives_restore() { - let ct = ConnTracker::new(); - ct.register_connection(); - ct.prepare_for_snapshot(); - // New connection after snapshot - let _post = ct.register_connection(); - ct.restore_after_snapshot(); - // Pre-snapshot connection removed, post-snapshot survives - assert_eq!(ct.active_count(), 1); - } - - #[test] - fn full_lifecycle() { - let ct = ConnTracker::new(); - let _a = ct.register_connection(); - let b = ct.register_connection(); - let _c = ct.register_connection(); - assert_eq!(ct.active_count(), 3); - assert!(ct.keepalives_enabled()); - - ct.prepare_for_snapshot(); - assert!(!ct.keepalives_enabled()); - - let d = ct.register_connection(); - ct.remove_connection(b); - - ct.restore_after_snapshot(); - assert!(ct.keepalives_enabled()); - // a and c were zombies, b removed before restore, d is post-snapshot - assert_eq!(ct.active_count(), 1); - ct.remove_connection(d); - assert_eq!(ct.active_count(), 0); - - // Can reuse tracker after restore - let e = ct.register_connection(); - assert_eq!(ct.active_count(), 1); - assert!(e > d); - } } diff --git a/envd-rs/src/host/metrics.rs b/envd-rs/src/host/metrics.rs deleted file mode 100644 index 671d1a6f..00000000 --- a/envd-rs/src/host/metrics.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::ffi::CString; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde::Serialize; - -#[derive(Serialize)] -pub struct Metrics { - pub ts: i64, - pub cpu_count: u32, - pub cpu_used_pct: f32, - pub mem_total_mib: u64, - pub mem_used_mib: u64, - pub mem_total: u64, - pub mem_used: u64, - pub disk_used: u64, - pub disk_total: u64, -} - -pub fn get_metrics() -> Result { - use sysinfo::System; - - let mut sys = System::new(); - sys.refresh_memory(); - sys.refresh_cpu_all(); - - std::thread::sleep(std::time::Duration::from_millis(100)); - sys.refresh_cpu_all(); - - let cpu_count = sys.cpus().len() as u32; - let cpu_used_pct = sys.global_cpu_usage(); - let cpu_used_pct_rounded = if cpu_used_pct > 0.0 { - (cpu_used_pct * 100.0).round() / 100.0 - } else { - 0.0 - }; - - let mem_total = sys.total_memory(); - let mem_used = sys.used_memory(); - - let (disk_total, disk_used) = disk_stats("/")?; - - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - Ok(Metrics { - ts, - cpu_count, - cpu_used_pct: cpu_used_pct_rounded, - mem_total_mib: mem_total / 1024 / 1024, - mem_used_mib: mem_used / 1024 / 1024, - mem_total, - mem_used, - disk_used, - disk_total, - }) -} - -fn disk_stats(path: &str) -> Result<(u64, u64), String> { - let c_path = CString::new(path).unwrap(); - let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; - let ret = unsafe { libc::statfs(c_path.as_ptr(), &mut stat) }; - if ret != 0 { - return Err(format!("statfs failed: {}", std::io::Error::last_os_error())); - } - - let block = stat.f_bsize as u64; - let total = stat.f_blocks * block; - let available = stat.f_bavail * block; - - Ok((total, total - available)) -} diff --git a/envd-rs/src/host/mmds.rs b/envd-rs/src/host/mmds.rs deleted file mode 100644 index e2bf5bbc..00000000 --- a/envd-rs/src/host/mmds.rs +++ /dev/null @@ -1,120 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use dashmap::DashMap; -use serde::Deserialize; -use tokio_util::sync::CancellationToken; - -use crate::config::{MMDS_ADDRESS, MMDS_POLL_INTERVAL, MMDS_TOKEN_EXPIRATION_SECS, WRENN_RUN_DIR}; - -#[derive(Debug, Clone, Deserialize)] -pub struct MMDSOpts { - #[serde(rename = "instanceID")] - pub sandbox_id: String, - #[serde(rename = "envID")] - pub template_id: String, - #[serde(rename = "address", default)] - pub logs_collector_address: String, - #[serde(rename = "accessTokenHash", default)] - pub access_token_hash: String, -} - -async fn get_mmds_token(client: &reqwest::Client) -> Result { - let resp = client - .put(format!("http://{MMDS_ADDRESS}/latest/api/token")) - .header( - "X-metadata-token-ttl-seconds", - MMDS_TOKEN_EXPIRATION_SECS.to_string(), - ) - .send() - .await - .map_err(|e| format!("mmds token request failed: {e}"))?; - - let token = resp.text().await.map_err(|e| format!("mmds token read: {e}"))?; - if token.is_empty() { - return Err("mmds token is an empty string".into()); - } - Ok(token) -} - -async fn get_mmds_opts(client: &reqwest::Client, token: &str) -> Result { - let resp = client - .get(format!("http://{MMDS_ADDRESS}")) - .header("X-metadata-token", token) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| format!("mmds opts request failed: {e}"))?; - - resp.json::() - .await - .map_err(|e| format!("mmds opts parse: {e}")) -} - -pub async fn get_access_token_hash() -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .no_proxy() - .build() - .map_err(|e| format!("http client: {e}"))?; - - let token = get_mmds_token(&client).await?; - let opts = get_mmds_opts(&client, &token).await?; - Ok(opts.access_token_hash) -} - -/// Polls MMDS every 50ms until metadata is available. -/// Stores sandbox_id and template_id in env_vars and writes to /run/wrenn/ files. -pub async fn poll_for_opts( - env_vars: Arc>, - cancel: CancellationToken, -) -> Option { - let client = reqwest::Client::builder() - .no_proxy() - .build() - .ok()?; - - let mut interval = tokio::time::interval(MMDS_POLL_INTERVAL); - - loop { - tokio::select! { - _ = cancel.cancelled() => { - tracing::warn!("context cancelled while waiting for mmds opts"); - return None; - } - _ = interval.tick() => { - let token = match get_mmds_token(&client).await { - Ok(t) => t, - Err(e) => { - tracing::debug!(error = %e, "mmds token poll"); - continue; - } - }; - - let opts = match get_mmds_opts(&client, &token).await { - Ok(o) => o, - Err(e) => { - tracing::debug!(error = %e, "mmds opts poll"); - continue; - } - }; - - env_vars.insert("WRENN_SANDBOX_ID".into(), opts.sandbox_id.clone()); - env_vars.insert("WRENN_TEMPLATE_ID".into(), opts.template_id.clone()); - - let run_dir = std::path::Path::new(WRENN_RUN_DIR); - if let Err(e) = std::fs::create_dir_all(run_dir) { - tracing::error!(error = %e, "mmds: failed to create run dir"); - } - if let Err(e) = std::fs::write(run_dir.join(".WRENN_SANDBOX_ID"), &opts.sandbox_id) { - tracing::error!(error = %e, "mmds: failed to write .WRENN_SANDBOX_ID"); - } - if let Err(e) = std::fs::write(run_dir.join(".WRENN_TEMPLATE_ID"), &opts.template_id) { - tracing::error!(error = %e, "mmds: failed to write .WRENN_TEMPLATE_ID"); - } - - return Some(opts); - } - } - } -} diff --git a/envd-rs/src/host/mod.rs b/envd-rs/src/host/mod.rs deleted file mode 100644 index a8ba613f..00000000 --- a/envd-rs/src/host/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod metrics; -pub mod mmds; diff --git a/envd-rs/src/http/health.rs b/envd-rs/src/http/health.rs index 39d61c99..5108faae 100644 --- a/envd-rs/src/http/health.rs +++ b/envd-rs/src/http/health.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::sync::atomic::Ordering; use axum::Json; use axum::extract::State; @@ -10,14 +9,6 @@ use serde_json::json; use crate::state::AppState; pub async fn get_health(State(state): State>) -> impl IntoResponse { - if state - .needs_restore - .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed) - .is_ok() - { - post_restore_recovery(&state); - } - tracing::trace!("health check"); ( @@ -25,17 +16,3 @@ pub async fn get_health(State(state): State>) -> impl IntoResponse Json(json!({ "version": state.version })), ) } - -fn post_restore_recovery(state: &AppState) { - tracing::info!("restore: post-restore recovery (no GC needed in Rust)"); - - state.snapshot_in_progress.store(false, std::sync::atomic::Ordering::Release); - - state.conn_tracker.restore_after_snapshot(); - tracing::info!("restore: zombie connections closed"); - - if let Some(ref ps) = state.port_subsystem { - ps.restart(); - tracing::info!("restore: port subsystem restarted"); - } -} diff --git a/envd-rs/src/http/init.rs b/envd-rs/src/http/init.rs index 840cab0d..17dd3b01 100644 --- a/envd-rs/src/http/init.rs +++ b/envd-rs/src/http/init.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::sync::Arc; -use std::sync::atomic::Ordering; use axum::Json; use axum::extract::State; @@ -8,20 +7,29 @@ use axum::http::{StatusCode, header}; use axum::response::IntoResponse; use serde::Deserialize; -use crate::crypto; -use crate::host::mmds; use crate::state::AppState; #[derive(Deserialize, Default)] -#[serde(rename_all = "camelCase")] pub struct InitRequest { + #[serde(rename = "access_token")] pub access_token: Option, + #[serde(rename = "defaultUser")] pub default_user: Option, + #[serde(rename = "defaultWorkdir")] pub default_workdir: Option, + #[serde(rename = "envVars")] pub env_vars: Option>, + #[serde(rename = "hyperloop_ip")] pub hyperloop_ip: Option, pub timestamp: Option, + #[serde(rename = "volume_mounts")] pub volume_mounts: Option>, + pub sandbox_id: Option, + pub template_id: Option, + /// New lifecycle identifier for this resume. When it changes between + /// /init calls, envd treats the call as a post-resume hook: port + /// forwarder is restarted and NFS mounts are refreshed. + pub lifecycle_id: Option, } #[derive(Deserialize)] @@ -30,7 +38,7 @@ pub struct VolumeMount { pub path: String, } -/// POST /init — called by host agent after boot and after every resume. +/// POST /init — called by host agent after boot. pub async fn post_init( State(state): State>, body: Option>, @@ -45,12 +53,71 @@ pub async fn post_init( } } - // Idempotent timestamp check + // Post-resume lifecycle hook: restart port forwarder so socat children + // are reaped + respawned against the new wall clock and any rotated + // listeners. Must run BEFORE the stale-timestamp early-return so a + // resume with an out-of-order timestamp still refreshes the subsystem. + let lifecycle_changed = if let Some(ref new_id) = init_req.lifecycle_id { + state.bump_lifecycle(new_id) + } else { + false + }; + if lifecycle_changed { + // Each new lifecycle (i.e. a snapshot restore) requires a fresh memory + // preload pass — pages materialised before the previous pause are now + // back in the source memory-ranges file as the host re-restored them + // lazily. Reset the flags so the next POST /memory/preload kicks off + // a new loader instead of returning the stale "already-done". + use std::sync::atomic::Ordering; + state.mem_preload_cancel.store(false, Ordering::SeqCst); + state.mem_preload_done.store(false, Ordering::SeqCst); + state.mem_preload_started.store(false, Ordering::SeqCst); + state.mem_preload_regions.store(0, Ordering::SeqCst); + state.mem_preload_pages.store(0, Ordering::SeqCst); + state.mem_preload_bytes.store(0, Ordering::SeqCst); + state.mem_preload_elapsed_us.store(0, Ordering::SeqCst); + state.mem_preload_source.store(0, Ordering::SeqCst); + *state.mem_preload_error.lock().unwrap() = None; + + if let Some(ref port_sub) = state.port_subsystem { + tracing::info!("lifecycle changed, restarting port subsystem"); + port_sub.restart(); + } + // Force chrony to step the clock immediately. chronyd is launched by + // wrenn-init.sh and disciplines against PHC (/dev/ptp0), so the host + // wall time is already available — `makestep` just bypasses chrony's + // normal slewing and snaps the clock in one go. Best effort. + tokio::spawn(async { + match tokio::process::Command::new("chronyc") + .args(["makestep"]) + .output() + .await + { + Ok(out) if out.status.success() => { + tracing::info!("chronyc makestep ok"); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + tracing::warn!(stderr = %stderr, "chronyc makestep failed"); + } + Err(e) => { + tracing::warn!(error = %e, "chronyc makestep spawn failed"); + } + } + }); + } + + // Idempotent timestamp check. Run after lifecycle handling so a + // stale-timestamp /init still gets to refresh ports + step clock. + // No userspace clock_settime here — chrony owns time discipline. if let Some(ref ts_str) = init_req.timestamp { - if let Ok(ts) = chrono_parse_to_nanos(ts_str) { + if let Ok(ts) = parse_timestamp_to_nanos(ts_str) { if !state.last_set_time.set_to_greater(ts) { - // Stale request, skip data updates - return trigger_restore_and_respond(&state).await; + return ( + StatusCode::NO_CONTENT, + [(header::CACHE_CONTROL, "no-store")], + ) + .into_response(); } } } @@ -90,56 +157,40 @@ pub async fn post_init( } } - // Hyperloop /etc/hosts setup + // Hyperloop /etc/hosts setup. Awaited so callers that immediately + // resolve events.wrenn.local see the entry. Cheap (two file ops). if let Some(ref ip) = init_req.hyperloop_ip { - let ip = ip.clone(); - let env_vars = Arc::clone(&state.defaults.env_vars); - tokio::spawn(async move { - setup_hyperloop(&ip, &env_vars).await; - }); + setup_hyperloop(ip, &state.defaults.env_vars).await; } - // NFS mounts + // NFS mounts. Awaited in parallel so callers that immediately access the + // mount path don't race the mount(2). Previously these were detached via + // tokio::spawn, which let /init return success before mounts existed. if let Some(ref mounts) = init_req.volume_mounts { - for mount in mounts { - let target = mount.nfs_target.clone(); - let path = mount.path.clone(); - tokio::spawn(async move { + let futs = mounts.iter().map(|m| { + let target = m.nfs_target.clone(); + let path = m.path.clone(); + async move { setup_nfs(&target, &path).await; - }); - } - } - - // Re-poll MMDS in background - if state.is_fc { - let env_vars = Arc::clone(&state.defaults.env_vars); - let cancel = tokio_util::sync::CancellationToken::new(); - let cancel_clone = cancel.clone(); - tokio::spawn(async move { - tokio::time::timeout(std::time::Duration::from_secs(60), async { - mmds::poll_for_opts(env_vars, cancel_clone).await; - }) - .await - .ok(); + } }); + futures::future::join_all(futs).await; } - trigger_restore_and_respond(&state).await -} - -async fn trigger_restore_and_respond(state: &AppState) -> axum::response::Response { - // Safety net: if health check's postRestoreRecovery hasn't run yet - if state - .needs_restore - .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed) - .is_ok() - { - post_restore_recovery(state); + // Set sandbox/template metadata from request body. + if let Some(ref id) = init_req.sandbox_id { + tracing::debug!(sandbox_id = %id, "setting sandbox ID from init request"); + // SAFETY: envd is single-threaded at init time; no concurrent env reads. + unsafe { std::env::set_var("WRENN_SANDBOX_ID", id) }; + write_run_file(".WRENN_SANDBOX_ID", id); + state.defaults.env_vars.insert("WRENN_SANDBOX_ID".into(), id.clone()); } - - state.conn_tracker.restore_after_snapshot(); - if let Some(ref ps) = state.port_subsystem { - ps.restart(); + if let Some(ref id) = init_req.template_id { + tracing::debug!(template_id = %id, "setting template ID from init request"); + // SAFETY: envd is single-threaded at init time; no concurrent env reads. + unsafe { std::env::set_var("WRENN_TEMPLATE_ID", id) }; + write_run_file(".WRENN_TEMPLATE_ID", id); + state.defaults.env_vars.insert("WRENN_TEMPLATE_ID".into(), id.clone()); } ( @@ -149,46 +200,13 @@ async fn trigger_restore_and_respond(state: &AppState) -> axum::response::Respon .into_response() } -fn post_restore_recovery(state: &AppState) { - tracing::info!("restore: post-restore recovery (no GC needed in Rust)"); - - state.snapshot_in_progress.store(false, std::sync::atomic::Ordering::Release); - - state.conn_tracker.restore_after_snapshot(); - - if let Some(ref ps) = state.port_subsystem { - ps.restart(); - tracing::info!("restore: port subsystem restarted"); - } -} - async fn validate_init_access_token(state: &AppState, request_token: &str) -> Result<(), String> { // Fast path: matches existing token if state.access_token.is_set() && !request_token.is_empty() && state.access_token.equals(request_token) { return Ok(()); } - // Check MMDS hash - if state.is_fc { - if let Ok(mmds_hash) = mmds::get_access_token_hash().await { - if !mmds_hash.is_empty() { - if request_token.is_empty() { - let empty_hash = crypto::sha512::hash_access_token(""); - if mmds_hash == empty_hash { - return Ok(()); - } - } else { - let token_hash = crypto::sha512::hash_access_token(request_token); - if mmds_hash == token_hash { - return Ok(()); - } - } - return Err("access token validation failed".into()); - } - } - } - - // First-time setup: no existing token and no MMDS + // First-time setup: no existing token if !state.access_token.is_set() { return Ok(()); } @@ -268,14 +286,27 @@ async fn setup_nfs(nfs_target: &str, path: &str) { } } -fn chrono_parse_to_nanos(ts: &str) -> Result { - // Parse RFC3339 timestamp to nanoseconds since epoch - // Simple approach: parse as seconds + fractional - let secs = ts.parse::().ok(); - if let Some(s) = secs { - return Ok((s * 1_000_000_000.0) as i64); +fn write_run_file(name: &str, value: &str) { + let dir = std::path::Path::new("/run/wrenn"); + if let Err(e) = std::fs::create_dir_all(dir) { + tracing::warn!(error = %e, "failed to create /run/wrenn"); + return; + } + if let Err(e) = std::fs::write(dir.join(name), value) { + tracing::warn!(error = %e, name, "failed to write run file"); + } +} + +/// Parses a host-provided timestamp into nanoseconds since the Unix epoch. +/// Accepts either RFC3339 (`2026-05-17T16:13:03.123456Z`) or a float-seconds +/// string (legacy callers). +fn parse_timestamp_to_nanos(ts: &str) -> Result { + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) { + return Ok(parsed.timestamp_nanos_opt().ok_or(())?); + } + if let Ok(secs) = ts.parse::() { + return Ok((secs * 1_000_000_000.0) as i64); } - // Try RFC3339 format - // For now, fall back to allowing the update Err(()) } + diff --git a/envd-rs/src/http/memory.rs b/envd-rs/src/http/memory.rs new file mode 100644 index 00000000..6bd4c677 --- /dev/null +++ b/envd-rs/src/http/memory.rs @@ -0,0 +1,350 @@ +// POST /memory/preload — guest-side helper that materialises every physical +// RAM page so a subsequent ch.snapshot writes a self-contained memory-ranges +// file. Required after a restore with memory_restore_mode=ondemand: pages +// that were never demand-faulted live only in the source memory-ranges file +// and would become holes (read back as zero) in the new snapshot. +// +// Trigger is one-byte-per-page reads through /dev/mem (fallback /proc/kcore +// PT_LOAD segments). The guest kernel walks its direct map → accesses the +// physical page → host kernel handles the EPT entry → CH's userfaultfd +// handler fills the page from the source memory-ranges file. +// +// Wire protocol: +// POST /memory/preload — starts the loader (idempotent) and returns +// the current status JSON immediately +// GET /memory/preload — returns the current status JSON +// POST /memory/preload/cancel — signals the loader to stop early +// +// Returning immediately avoids any HTTP-level header timeout in the caller +// while materialisation (hundreds of MiB at one byte per page) runs in a +// background blocking thread. + +use std::fs; +use std::io::{Read, Seek, SeekFrom}; +use std::os::unix::fs::FileExt; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Instant; + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::Serialize; + +use crate::state::AppState; + +const PAGE_SIZE: u64 = 4096; + +#[derive(Serialize, Clone)] +pub struct PreloadStatus { + /// One of: "idle", "running", "done", "failed", "cancelled". + pub state: &'static str, + pub regions: u64, + pub pages: u64, + pub bytes: u64, + pub elapsed_sec: f64, + pub source: &'static str, + pub error: Option, +} + +pub async fn post_memory_preload(State(state): State>) -> impl IntoResponse { + // First caller wins the CAS and spawns the loader; subsequent callers + // just report the existing status. + let we_start = state + .mem_preload_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok(); + + if we_start { + let state_clone = Arc::clone(&state); + // Detached blocking thread — no axum task lifetime ties it to the + // request, so the connection can close immediately without aborting + // materialisation. Lifecycle bump on the next /init clears the flags + // for a fresh run after a restore. + std::thread::spawn(move || { + let started = Instant::now(); + match preload_blocking(&state_clone) { + Ok((source, regions, pages, bytes)) => { + let elapsed = started.elapsed().as_secs_f64(); + state_clone + .mem_preload_regions + .store(regions, Ordering::SeqCst); + state_clone.mem_preload_pages.store(pages, Ordering::SeqCst); + state_clone.mem_preload_bytes.store(bytes, Ordering::SeqCst); + state_clone + .mem_preload_elapsed_us + .store((elapsed * 1_000_000.0) as u64, Ordering::SeqCst); + set_source(&state_clone, source); + *state_clone.mem_preload_error.lock().unwrap() = None; + state_clone.mem_preload_done.store(true, Ordering::SeqCst); + tracing::info!( + regions, + pages, + bytes, + elapsed_sec = elapsed, + source, + "memory preload complete" + ); + } + Err(e) => { + let elapsed = started.elapsed().as_secs_f64(); + state_clone + .mem_preload_elapsed_us + .store((elapsed * 1_000_000.0) as u64, Ordering::SeqCst); + *state_clone.mem_preload_error.lock().unwrap() = Some(e.clone()); + state_clone.mem_preload_done.store(true, Ordering::SeqCst); + tracing::warn!(error = %e, "memory preload failed"); + } + } + }); + } + + let status = read_status(&state); + (StatusCode::OK, Json(status)) +} + +pub async fn get_memory_preload(State(state): State>) -> impl IntoResponse { + (StatusCode::OK, Json(read_status(&state))) +} + +pub async fn post_memory_preload_cancel(State(state): State>) -> impl IntoResponse { + state.mem_preload_cancel.store(true, Ordering::SeqCst); + StatusCode::NO_CONTENT +} + +fn read_status(state: &AppState) -> PreloadStatus { + let started = state.mem_preload_started.load(Ordering::SeqCst); + let done = state.mem_preload_done.load(Ordering::SeqCst); + let cancelled = state.mem_preload_cancel.load(Ordering::SeqCst); + let error = state.mem_preload_error.lock().unwrap().clone(); + + let lane = if !started { + "idle" + } else if !done { + "running" + } else if let Some(_) = &error { + "failed" + } else if cancelled { + "cancelled" + } else { + "done" + }; + + PreloadStatus { + state: lane, + regions: state.mem_preload_regions.load(Ordering::SeqCst), + pages: state.mem_preload_pages.load(Ordering::SeqCst), + bytes: state.mem_preload_bytes.load(Ordering::SeqCst), + elapsed_sec: state.mem_preload_elapsed_us.load(Ordering::SeqCst) as f64 / 1_000_000.0, + source: get_source(state), + error, + } +} + +fn set_source(state: &AppState, src: &'static str) { + let code: u8 = match src { + "/dev/mem" => 1, + "/proc/kcore" => 2, + _ => 0, + }; + state.mem_preload_source.store(code, Ordering::SeqCst); +} + +fn get_source(state: &AppState) -> &'static str { + match state.mem_preload_source.load(Ordering::SeqCst) { + 1 => "/dev/mem", + 2 => "/proc/kcore", + _ => "", + } +} + +fn preload_blocking(state: &AppState) -> Result<(&'static str, u64, u64, u64), String> { + let ranges = parse_system_ram_ranges().map_err(|e| format!("iomem: {e}"))?; + if ranges.is_empty() { + return Err("no System RAM ranges found in /proc/iomem".into()); + } + + let mut pages: u64 = 0; + let mut bytes: u64 = 0; + + match preload_via_devmem(&ranges, state, &mut pages, &mut bytes) { + Ok(()) => Ok(("/dev/mem", ranges.len() as u64, pages, bytes)), + Err(devmem_err) => { + tracing::warn!( + error = %devmem_err, + "/dev/mem preload failed, falling back to /proc/kcore" + ); + pages = 0; + bytes = 0; + preload_via_kcore(state, &mut pages, &mut bytes) + .map_err(|e| format!("/dev/mem: {devmem_err}; /proc/kcore: {e}"))?; + Ok(("/proc/kcore", ranges.len() as u64, pages, bytes)) + } + } +} + +fn parse_system_ram_ranges() -> std::io::Result> { + let data = fs::read_to_string("/proc/iomem")?; + let mut out = Vec::new(); + for line in data.lines() { + if line.starts_with(|c: char| c.is_whitespace()) { + continue; + } + let Some((range, label)) = line.split_once(" : ") else { + continue; + }; + if label.trim() != "System RAM" { + continue; + } + let Some((start, end)) = range.split_once('-') else { + continue; + }; + let start = u64::from_str_radix(start.trim(), 16).ok(); + let end = u64::from_str_radix(end.trim(), 16).ok(); + if let (Some(s), Some(e)) = (start, end) { + out.push((s, e.saturating_add(1))); + } + } + Ok(out) +} + +fn preload_via_devmem( + ranges: &[(u64, u64)], + state: &AppState, + pages: &mut u64, + bytes: &mut u64, +) -> std::io::Result<()> { + let f = fs::File::open("/dev/mem")?; + let mut buf = [0u8; 1]; + for (start, end) in ranges { + let mut off = *start; + while off < *end { + if state.mem_preload_cancel.load(Ordering::SeqCst) { + return Ok(()); + } + f.read_at(&mut buf, off)?; + *pages += 1; + *bytes += PAGE_SIZE; + // Publish progress so GET /memory/preload reports useful numbers + // while the loader is still running. + if *pages % 1024 == 0 { + state.mem_preload_pages.store(*pages, Ordering::SeqCst); + state.mem_preload_bytes.store(*bytes, Ordering::SeqCst); + } + off = off.saturating_add(PAGE_SIZE); + } + } + Ok(()) +} + +// Read /proc/kcore's direct-map segment to materialise physical RAM. The +// direct map's PT_LOAD covers the kernel's *maximum* possible direct-map +// region (64TB on x86_64), not just the present physical RAM — iterating the +// whole segment would loop for billions of pages. Bound the walk to the sum +// of System RAM ranges from /proc/iomem; sequential reads through the +// segment touch consecutive physical pages 1:1, which is what we need. +fn preload_via_kcore(state: &AppState, pages: &mut u64, bytes: &mut u64) -> std::io::Result<()> { + let ram_ranges = parse_system_ram_ranges()?; + if ram_ranges.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "no System RAM ranges to bound kcore walk", + )); + } + let total_ram_bytes: u64 = ram_ranges.iter().map(|(s, e)| e - s).sum(); + + let mut f = fs::File::open("/proc/kcore")?; + let segments = parse_kcore_pt_load(&mut f)?; + if segments.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "no PT_LOAD segments in /proc/kcore", + )); + } + + // Pick the direct map: highest-vaddr kernel-space segment large enough + // to plausibly cover RAM. KASLR randomises the base, so don't hardcode it. + // Kernel virtual addresses start at 0xffff800000000000 on x86_64; vmalloc + // / modules sit above the direct map and are usually smaller. + const KERNEL_SPACE_MIN: u64 = 0xffff_8000_0000_0000; + let direct_map = segments + .iter() + .filter(|s| s.vaddr >= KERNEL_SPACE_MIN && s.file_size >= total_ram_bytes) + .min_by_key(|s| s.vaddr) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "no PT_LOAD segment large enough for {} bytes of RAM in /proc/kcore", + total_ram_bytes + ), + ) + })?; + + let mut buf = [0u8; 1]; + let read_bytes = total_ram_bytes.min(direct_map.file_size); + let start = direct_map.file_offset; + let end = start.saturating_add(read_bytes); + let mut off = start; + while off < end { + if state.mem_preload_cancel.load(Ordering::SeqCst) { + return Ok(()); + } + // Reads into MMIO holes within the direct map can fail; ignore so the + // loop keeps making progress over the present RAM ranges either side. + if f.read_at(&mut buf, off).is_ok() { + *pages += 1; + *bytes += PAGE_SIZE; + if *pages % 256 == 0 { + state.mem_preload_pages.store(*pages, Ordering::SeqCst); + state.mem_preload_bytes.store(*bytes, Ordering::SeqCst); + } + } + off = off.saturating_add(PAGE_SIZE); + } + Ok(()) +} + +struct KcoreSegment { + file_offset: u64, + file_size: u64, + vaddr: u64, +} + +fn parse_kcore_pt_load(f: &mut fs::File) -> std::io::Result> { + let mut hdr = [0u8; 64]; + f.seek(SeekFrom::Start(0))?; + f.read_exact(&mut hdr)?; + + if &hdr[0..4] != b"\x7fELF" || hdr[4] != 2 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "not an ELF64 file", + )); + } + + let e_phoff = u64::from_le_bytes(hdr[32..40].try_into().unwrap()); + let e_phentsize = u16::from_le_bytes(hdr[54..56].try_into().unwrap()) as u64; + let e_phnum = u16::from_le_bytes(hdr[56..58].try_into().unwrap()) as u64; + + let mut out = Vec::new(); + let mut entry = vec![0u8; e_phentsize as usize]; + for i in 0..e_phnum { + f.seek(SeekFrom::Start(e_phoff + i * e_phentsize))?; + f.read_exact(&mut entry)?; + let p_type = u32::from_le_bytes(entry[0..4].try_into().unwrap()); + if p_type != 1 { + continue; + } + let p_offset = u64::from_le_bytes(entry[8..16].try_into().unwrap()); + let p_vaddr = u64::from_le_bytes(entry[16..24].try_into().unwrap()); + let p_filesz = u64::from_le_bytes(entry[32..40].try_into().unwrap()); + out.push(KcoreSegment { + file_offset: p_offset, + file_size: p_filesz, + vaddr: p_vaddr, + }); + } + Ok(out) +} diff --git a/envd-rs/src/http/mod.rs b/envd-rs/src/http/mod.rs index d74c3d21..841d6f50 100644 --- a/envd-rs/src/http/mod.rs +++ b/envd-rs/src/http/mod.rs @@ -4,6 +4,7 @@ pub mod error; pub mod files; pub mod health; pub mod init; +pub mod memory; pub mod metrics; pub mod snapshot; @@ -50,6 +51,14 @@ pub fn router(state: Arc) -> Router { .route("/envs", get(envs::get_envs)) .route("/init", post(init::post_init)) .route("/snapshot/prepare", post(snapshot::post_snapshot_prepare)) + .route( + "/memory/preload", + get(memory::get_memory_preload).post(memory::post_memory_preload), + ) + .route( + "/memory/preload/cancel", + post(memory::post_memory_preload_cancel), + ) .route("/files", get(files::get_files).post(files::post_files)) .layer(cors) .with_state(state) diff --git a/envd-rs/src/http/snapshot.rs b/envd-rs/src/http/snapshot.rs index e507d8fa..d52e0cfc 100644 --- a/envd-rs/src/http/snapshot.rs +++ b/envd-rs/src/http/snapshot.rs @@ -1,47 +1,58 @@ use std::sync::Arc; -use std::sync::atomic::Ordering; use axum::extract::State; use axum::http::{StatusCode, header}; use axum::response::IntoResponse; +use nix::unistd::sync; use crate::state::AppState; -/// POST /snapshot/prepare — quiesce subsystems before Firecracker snapshot. -/// -/// In Rust there is no GC dance. We just: -/// 1. Drop page cache to shrink snapshot size -/// 2. Stop port subsystem -/// 3. Close idle connections via conntracker -/// 4. Set needs_restore flag +/// POST /snapshot/prepare — called by the host agent immediately before it +/// invokes vm.pause + vm.snapshot. The handler quiesces guest state so the +/// resulting snapshot is clean: outstanding writes are flushed to disk, the +/// VFS page cache is dropped (so the dm-snapshot CoW is the source of truth), +/// and the port forwarder is stopped to prevent socat children from being +/// frozen mid-handshake. pub async fn post_snapshot_prepare(State(state): State>) -> impl IntoResponse { - // Drop page cache BEFORE blocking the reclaimer — avoids snapshotting - // gigabytes of stale cache that inflates the memory dump on disk. - // "1" = pagecache only (keep dentries/inodes for faster resume). - if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "1") { - tracing::warn!(error = %e, "snapshot/prepare: drop_caches failed"); - } else { - tracing::info!("snapshot/prepare: page cache dropped"); + // Stop port forwarder + scanner so no socat process is captured in the + // snapshot with a half-open TCP connection. /init on resume restarts it. + if let Some(ref port_sub) = state.port_subsystem { + port_sub.stop(); } - // Block memory reclaimer — prevents drop_caches from running mid-freeze - // which would corrupt kernel page table state. - state.snapshot_in_progress.store(true, Ordering::Release); + // sync(2) flushes the in-memory FS state. Done before drop_caches so the + // pages we drop are clean. + sync(); - if let Some(ref ps) = state.port_subsystem { - ps.stop(); - tracing::info!("snapshot/prepare: port subsystem stopped"); + // Drop the VFS page cache + dentries/inodes. Reduces snapshot size by + // ensuring CH only persists memory pages that the guest actually needs. + if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") { + tracing::warn!(error = %e, "drop_caches (first pass) failed (continuing)"); } - state.conn_tracker.prepare_for_snapshot(); - tracing::info!("snapshot/prepare: connections prepared"); + // Best-effort fstrim on the rootfs so unused blocks are returned to the + // dm-snapshot, keeping CoW size minimal. + let _ = tokio::process::Command::new("fstrim") + .arg("/") + .output() + .await; - // Sync filesystem buffers so dirty pages are flushed before freeze. - unsafe { libc::sync(); } + // Second drop_caches pass after fstrim: fstrim re-reads superblock / + // group descriptor pages that we just evicted, putting them back in the + // page cache. A second pass drops those and any other late readers (e.g. + // sync flushers). + sync(); + if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") { + tracing::warn!(error = %e, "drop_caches (second pass) failed (continuing)"); + } - state.needs_restore.store(true, Ordering::Release); - tracing::info!("snapshot/prepare: ready for freeze"); + // Free-page reporting drains asynchronously: the balloon driver hands + // freed pages to the host in batches and CH punches holes in the backing + // memfile. Without a brief settle window most of the pages freed by the + // drop_caches passes above would still be present in the snapshot. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + tracing::info!("snapshot/prepare: quiesced"); ( StatusCode::NO_CONTENT, [(header::CACHE_CONTROL, "no-store")], diff --git a/envd-rs/src/main.rs b/envd-rs/src/main.rs index 9e33fecf..3b147fb7 100644 --- a/envd-rs/src/main.rs +++ b/envd-rs/src/main.rs @@ -6,7 +6,6 @@ mod config; mod conntracker; mod crypto; mod execcontext; -mod host; mod http; mod logging; mod permissions; @@ -22,7 +21,6 @@ use std::sync::Arc; use clap::Parser; use tokio::net::TcpListener; -use tokio_util::sync::CancellationToken; use config::{DEFAULT_PORT, DEFAULT_USER, WRENN_RUN_DIR}; use execcontext::Defaults; @@ -44,9 +42,6 @@ struct Cli { #[arg(long, default_value_t = DEFAULT_PORT)] port: u16, - #[arg(long = "isnotfc", default_value_t = false)] - is_not_fc: bool, - #[arg(long)] version: bool, @@ -73,35 +68,22 @@ async fn main() { return; } - let use_json = !cli.is_not_fc; - logging::init(use_json); + logging::init(true); if let Err(e) = fs::create_dir_all(WRENN_RUN_DIR) { tracing::error!(error = %e, "failed to create wrenn run directory"); } let defaults = Defaults::new(DEFAULT_USER); - let is_fc_str = if cli.is_not_fc { "false" } else { "true" }; defaults .env_vars - .insert("WRENN_SANDBOX".into(), is_fc_str.into()); + .insert("WRENN_SANDBOX".into(), "true".into()); let wrenn_sandbox_path = Path::new(WRENN_RUN_DIR).join(".WRENN_SANDBOX"); - if let Err(e) = fs::write(&wrenn_sandbox_path, is_fc_str.as_bytes()) { + if let Err(e) = fs::write(&wrenn_sandbox_path, b"true") { tracing::error!(error = %e, "failed to write sandbox file"); } - let cancel = CancellationToken::new(); - - // MMDS polling (only in FC mode) - if !cli.is_not_fc { - let env_vars = Arc::clone(&defaults.env_vars); - let cancel_clone = cancel.clone(); - tokio::spawn(async move { - host::mmds::poll_for_opts(env_vars, cancel_clone).await; - }); - } - // Cgroup manager let cgroup_manager: Arc = match cgroups::Cgroup2Manager::new( @@ -143,14 +125,12 @@ async fn main() { defaults, VERSION.to_string(), COMMIT.to_string(), - !cli.is_not_fc, Some(Arc::clone(&port_subsystem)), ); // Memory reclaimer — drop page cache when available memory is low. - // Firecracker balloon device can only reclaim pages the guest kernel freed. - // Pauses during snapshot/prepare to avoid corrupting kernel page table state. - if !cli.is_not_fc { + // The balloon device can only reclaim pages the guest kernel freed. + { let state_for_reclaimer = Arc::clone(&state); std::thread::spawn(move || memory_reclaimer(state_for_reclaimer)); } @@ -188,7 +168,6 @@ async fn main() { } port_subsystem.stop(); - cancel.cancel(); } fn spawn_initial_command(cmd: &str, state: &AppState) { @@ -231,19 +210,15 @@ fn spawn_initial_command(cmd: &str, state: &AppState) { } } -fn memory_reclaimer(state: Arc) { - use std::sync::atomic::Ordering; +fn memory_reclaimer(_state: Arc) { + use std::time::Duration; - const CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + const CHECK_INTERVAL: Duration = Duration::from_secs(10); const DROP_THRESHOLD_PCT: u64 = 80; loop { std::thread::sleep(CHECK_INTERVAL); - if state.snapshot_in_progress.load(Ordering::Acquire) { - continue; - } - let mut sys = sysinfo::System::new(); sys.refresh_memory(); let total = sys.total_memory(); @@ -255,10 +230,6 @@ fn memory_reclaimer(state: Arc) { let used_pct = ((total - available) * 100) / total; if used_pct >= DROP_THRESHOLD_PCT { - if state.snapshot_in_progress.load(Ordering::Acquire) { - continue; - } - if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") { tracing::debug!(error = %e, "drop_caches failed"); } else { diff --git a/envd-rs/src/port/scanner.rs b/envd-rs/src/port/scanner.rs index ea8d3be9..ea613e57 100644 --- a/envd-rs/src/port/scanner.rs +++ b/envd-rs/src/port/scanner.rs @@ -57,7 +57,9 @@ impl Scanner { pub async fn scan_and_broadcast(&self, cancel: CancellationToken) { loop { - let conns = read_tcp_connections(); + let conns = tokio::task::spawn_blocking(read_tcp_connections) + .await + .unwrap_or_default(); { let subs = self.subs.read().unwrap(); diff --git a/envd-rs/src/rpc/process_handler.rs b/envd-rs/src/rpc/process_handler.rs index 8c7e07b6..a548e7e7 100644 --- a/envd-rs/src/rpc/process_handler.rs +++ b/envd-rs/src/rpc/process_handler.rs @@ -57,7 +57,11 @@ impl ProcessHandle { } pub fn send_signal(&self, sig: Signal) -> Result<(), ConnectError> { - signal::kill(Pid::from_raw(self.pid as i32), sig).map_err(|e| { + // Signal the whole process group (negative pid), not just the immediate + // /bin/sh wrapper. Otherwise children the process spawned are orphaned + // and keep running. Both spawn paths make the process a group leader + // (setsid for pty, setpgid for pipe), so pgid == pid. + signal::kill(Pid::from_raw(-(self.pid as i32)), sig).map_err(|e| { ConnectError::new(ErrorCode::Internal, format!("error sending signal: {e}")) }) } @@ -165,11 +169,22 @@ pub fn spawn_process( env.push((k.clone(), v.clone())); } + // Reset the child's nice value only when envd itself was started at an + // elevated nice value (delta > 0 means raising the nice number / lowering + // priority, which is permitted for non-root processes). A non-root process + // cannot improve its priority, so skip the `nice` wrapper otherwise — it + // would fail with EPERM ("cannot set niceness: permission denied") for + // commands run as a non-root user. Writing 100 to the process's own + // oom_score_adj is always permitted (raising the score). let nice_delta = 0 - current_nice(); - let oom_script = format!( - r#"echo 100 > /proc/$$/oom_score_adj && exec /usr/bin/nice -n {} "${{@}}""#, - nice_delta - ); + let oom_script = if nice_delta > 0 { + format!( + r#"echo 100 > /proc/$$/oom_score_adj && exec /usr/bin/nice -n {} "${{@}}""#, + nice_delta + ) + } else { + r#"echo 100 > /proc/$$/oom_score_adj && exec "$@""#.to_string() + }; let mut wrapper_args = vec![ "-c".to_string(), oom_script, @@ -264,7 +279,7 @@ pub fn spawn_process( let end_rx = handle.subscribe_end(); let data_tx_clone = data_tx.clone(); - std::thread::spawn(move || { + let pty_reader = std::thread::spawn(move || { let mut master = master_clone; let mut buf = vec![0u8; PTY_CHUNK_SIZE]; loop { @@ -282,7 +297,11 @@ pub fn spawn_process( let handle_for_waiter = Arc::clone(&handle); std::thread::spawn(move || { let mut child = child; - let end_event = match child.wait() { + let status = child.wait(); + // Drain the pty to EOF before publishing the end event so trailing + // output is never lost to a process-exit/pty-read race. + let _ = pty_reader.join(); + let end_event = match status { Ok(s) => EndEvent { exit_code: s.code().unwrap_or(-1), exited: s.code().is_some(), @@ -320,6 +339,11 @@ pub fn spawn_process( unsafe { command.pre_exec(move || { + // Become a process-group leader so SendSignal can kill the + // whole group, not just this wrapper. The pty path gets this + // for free via setsid(). + nix::unistd::setpgid(Pid::from_raw(0), Pid::from_raw(0)) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; libc::setgid(gid); libc::setuid(uid); Ok(()) @@ -349,9 +373,11 @@ pub fn spawn_process( let data_rx = handle.subscribe_data(); let end_rx = handle.subscribe_end(); + let mut output_readers: Vec> = Vec::new(); + if let Some(mut out) = stdout { let tx = data_tx.clone(); - std::thread::spawn(move || { + output_readers.push(std::thread::spawn(move || { let mut buf = vec![0u8; STD_CHUNK_SIZE]; loop { match out.read(&mut buf) { @@ -362,12 +388,12 @@ pub fn spawn_process( Err(_) => break, } } - }); + })); } if let Some(mut err_pipe) = stderr { let tx = data_tx.clone(); - std::thread::spawn(move || { + output_readers.push(std::thread::spawn(move || { let mut buf = vec![0u8; STD_CHUNK_SIZE]; loop { match err_pipe.read(&mut buf) { @@ -378,13 +404,19 @@ pub fn spawn_process( Err(_) => break, } } - }); + })); } let end_tx_clone = end_tx.clone(); let handle_for_waiter = Arc::clone(&handle); std::thread::spawn(move || { - let end_event = match child.wait() { + let status = child.wait(); + // Drain stdout/stderr to EOF before publishing the end event so + // trailing output is never lost to a process-exit/pipe-read race. + for reader in output_readers { + let _ = reader.join(); + } + let end_event = match status { Ok(s) => EndEvent { exit_code: s.code().unwrap_or(-1), exited: s.code().is_some(), @@ -414,6 +446,8 @@ fn current_nice() -> i32 { if *libc::__errno_location() != 0 { return 0; } - 20 - prio + // getpriority(PRIO_PROCESS, 0) returns the nice value directly, + // in the range [-20, 19]; the normal default is 0. + prio } } diff --git a/envd-rs/src/rpc/process_service.rs b/envd-rs/src/rpc/process_service.rs index 3d53cd70..e3816862 100644 --- a/envd-rs/src/rpc/process_service.rs +++ b/envd-rs/src/rpc/process_service.rs @@ -14,14 +14,14 @@ use crate::state::AppState; pub struct ProcessServiceImpl { state: Arc, - processes: DashMap>, + processes: Arc>>, } impl ProcessServiceImpl { pub fn new(state: Arc) -> Self { Self { state, - processes: DashMap::new(), + processes: Arc::new(DashMap::new()), } } @@ -131,11 +131,21 @@ impl ProcessServiceImpl { self.processes.insert(spawned.handle.pid, Arc::clone(&spawned.handle)); - let processes = self.processes.clone(); + let processes = Arc::clone(&self.processes); let pid = spawned.handle.pid; + // Subscribe before checking cached_end so the prune cannot be lost to a + // race: a short-lived process can exit and broadcast its end event + // before this task runs. A broadcast receiver only sees messages sent + // after subscribe(), so a late subscribe would miss the event forever + // (recv() never returns Closed either — the handle keeps end_tx alive + // until it leaves the map, which only this task does). The waiter sets + // ended before sending end_tx, so cached_end() is a reliable fallback. let mut cleanup_end_rx = spawned.handle.subscribe_end(); + let already_ended = spawned.handle.cached_end().is_some(); tokio::spawn(async move { - let _ = cleanup_end_rx.recv().await; + if !already_ended { + let _ = cleanup_end_rx.recv().await; + } processes.remove(&pid); }); @@ -199,12 +209,28 @@ impl Process for ProcessServiceImpl { match data { Ok(ev) => yield Ok(make_data_start_response(ev)), Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Data channel closed: the process ended and its + // handle was dropped. The end event is published + // before the handle drop, so it is still buffered + // — emit it rather than losing the exit code. + if let Ok(end) = end_rx.try_recv() { + yield Ok(make_end_start_response(end)); + } + break; + } } } end = end_rx.recv() => { - while let Ok(ev) = data_rx.try_recv() { - yield Ok(make_data_start_response(ev)); + // Process ended. The waiter joins the output readers + // before sending this event, so every byte is already + // in the data channel — drain it fully before the end. + loop { + match data_rx.try_recv() { + Ok(ev) => yield Ok(make_data_start_response(ev)), + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => break, + } } if let Ok(end) = end { yield Ok(make_end_start_response(end)); @@ -268,15 +294,35 @@ impl Process for ProcessServiceImpl { }); } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Data channel closed: the process ended and + // its handle was dropped. The end event is + // published before the handle drop, so it is + // still buffered — emit it rather than losing + // the exit code. + if let Ok(end) = end_rx.try_recv() { + yield Ok(ConnectResponse { + event: buffa::MessageField::some(make_end_event(end)), + ..Default::default() + }); + } + break; + } } } end = end_rx.recv() => { - while let Ok(ev) = data_rx.try_recv() { - yield Ok(ConnectResponse { - event: buffa::MessageField::some(make_data_event(ev)), - ..Default::default() - }); + // Process ended. The waiter joins the output readers + // before sending this event, so every byte is already + // in the data channel — drain it fully before the end. + loop { + match data_rx.try_recv() { + Ok(ev) => yield Ok(ConnectResponse { + event: buffa::MessageField::some(make_data_event(ev)), + ..Default::default() + }), + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => break, + } } if let Ok(end) = end { yield Ok(ConnectResponse { diff --git a/envd-rs/src/state.rs b/envd-rs/src/state.rs index 33d170ad..4b10ccb6 100644 --- a/envd-rs/src/state.rs +++ b/envd-rs/src/state.rs @@ -1,5 +1,5 @@ -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; use crate::auth::token::SecureToken; use crate::conntracker::ConnTracker; @@ -11,15 +11,32 @@ pub struct AppState { pub defaults: Defaults, pub version: String, pub commit: String, - pub is_fc: bool, - pub needs_restore: AtomicBool, pub last_set_time: AtomicMax, pub access_token: SecureToken, pub conn_tracker: ConnTracker, pub port_subsystem: Option>, pub cpu_used_pct: AtomicU32, pub cpu_count: AtomicU32, - pub snapshot_in_progress: AtomicBool, + + /// Memory preload coordination. The host agent POSTs /memory/preload after + /// a snapshot restore to materialise every physical page (so the next + /// ch.snapshot writes a self-contained memory-ranges). `mem_preload_started` + /// ensures only one loader runs; `mem_preload_done` lets concurrent callers + /// rendezvous; `mem_preload_cancel` lets a teardown abort the loader. + pub mem_preload_started: AtomicBool, + pub mem_preload_done: AtomicBool, + pub mem_preload_cancel: AtomicBool, + pub mem_preload_regions: AtomicU64, + pub mem_preload_pages: AtomicU64, + pub mem_preload_bytes: AtomicU64, + pub mem_preload_elapsed_us: AtomicU64, + /// 0 = unset, 1 = /dev/mem, 2 = /proc/kcore. + pub mem_preload_source: AtomicU8, + pub mem_preload_error: Mutex>, + + /// Last lifecycle ID seen on /init. Used to detect post-resume calls so + /// envd can refresh port forwarders and remount NFS volumes. + lifecycle_id: Mutex>, } impl AppState { @@ -27,22 +44,28 @@ impl AppState { defaults: Defaults, version: String, commit: String, - is_fc: bool, port_subsystem: Option>, ) -> Arc { let state = Arc::new(Self { defaults, version, commit, - is_fc, - needs_restore: AtomicBool::new(false), last_set_time: AtomicMax::new(), access_token: SecureToken::new(), conn_tracker: ConnTracker::new(), port_subsystem, cpu_used_pct: AtomicU32::new(0), cpu_count: AtomicU32::new(0), - snapshot_in_progress: AtomicBool::new(false), + mem_preload_started: AtomicBool::new(false), + mem_preload_done: AtomicBool::new(false), + mem_preload_cancel: AtomicBool::new(false), + mem_preload_regions: AtomicU64::new(0), + mem_preload_pages: AtomicU64::new(0), + mem_preload_bytes: AtomicU64::new(0), + mem_preload_elapsed_us: AtomicU64::new(0), + mem_preload_source: AtomicU8::new(0), + mem_preload_error: Mutex::new(None), + lifecycle_id: Mutex::new(None), }); let state_clone = Arc::clone(&state); @@ -60,6 +83,20 @@ impl AppState { pub fn cpu_count(&self) -> u32 { self.cpu_count.load(Ordering::Relaxed) } + + /// Records a new lifecycle ID, returning true if it changed (i.e. this + /// is the first /init since a resume). First-ever call returns false: + /// boot-time /init doesn't need port-subsystem restart since the + /// subsystem hasn't been started yet by anything else. + pub fn bump_lifecycle(&self, new_id: &str) -> bool { + let mut guard = self.lifecycle_id.lock().unwrap(); + let changed = match guard.as_deref() { + Some(existing) => existing != new_id, + None => false, + }; + *guard = Some(new_id.to_owned()); + changed + } } fn cpu_sampler(state: Arc) { @@ -70,6 +107,7 @@ fn cpu_sampler(state: Arc) { loop { std::thread::sleep(std::time::Duration::from_secs(1)); + sys.refresh_cpu_all(); let pct = sys.global_cpu_usage(); diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 00000000..33f2af29 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,379 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "frontend", + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "chart.js": "^4.5.1", + "shiki": "^4.0.2", + }, + "devDependencies": { + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", + "@fontsource/alice": "^5.2.8", + "@fontsource/instrument-serif": "^5.2.8", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.1", + "bits-ui": "^2.16.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^6.0.2", + "vite": "^8.0.8", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "2.8.1" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "1.7.5", "@floating-ui/utils": "0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + + "@fontsource-variable/manrope": ["@fontsource-variable/manrope@5.2.8", "", {}, "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw=="], + + "@fontsource/alice": ["@fontsource/alice@5.2.8", "", {}, "sha512-EDpK9aFXsaRKdyZpgFu8d5+zmE07yIaFxqVeKrYQJjdQpEhWDZA+naLflHwQQmMbLMJK3a4X/RAm5MCScT93NA=="], + + "@fontsource/instrument-serif": ["@fontsource/instrument-serif@5.2.8", "", {}, "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="], + + "@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "0.5.21" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "0.10.1" }, "peerDependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + + "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4", "hast-util-to-html": "9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "oniguruma-to-es": "4.3.5" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + + "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + + "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + + "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "8.16.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "2.57.1" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.57.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "@sveltejs/acorn-typescript": "1.0.9", "@types/cookie": "0.6.0", "acorn": "8.16.0", "cookie": "0.6.0", "devalue": "5.7.1", "esm-env": "1.2.2", "kleur": "4.1.5", "magic-string": "0.30.21", "mrmime": "2.0.1", "set-cookie-parser": "3.1.0", "sirv": "3.0.2" }, "optionalDependencies": { "typescript": "6.0.2" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "7.0.0", "svelte": "5.55.3", "vite": "8.0.8" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "4.3.1", "magic-string": "0.30.21", "obug": "2.1.1", "vitefu": "1.1.3" }, "peerDependencies": { "svelte": "5.55.3", "vite": "8.0.8" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], + + "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "enhanced-resolve": "5.20.1", "jiti": "2.6.1", "lightningcss": "1.32.0", "magic-string": "0.30.21", "source-map-js": "1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "8.0.8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bits-ui": ["bits-ui@2.17.3", "", { "dependencies": { "@floating-ui/core": "1.7.5", "@floating-ui/dom": "1.7.6", "esm-env": "1.2.2", "runed": "0.35.1", "svelte-toolbelt": "0.10.6", "tabbable": "6.4.0" }, "peerDependencies": { "@internationalized/date": "3.12.0", "svelte": "5.55.3" } }, "sha512-Bef41uY9U2jaBJHPhcPvmBNkGec5Wx2z6eioDsTmsaR2vH4QoaOcPi75gzCG3+/2TNr6v/qBwzgWNPYCxNtrEA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "0.3.4" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.2" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="], + + "fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "3.0.4", "@types/unist": "3.0.3", "ccount": "2.0.1", "comma-separated-tokens": "2.0.3", "hast-util-whitespace": "3.0.0", "html-void-elements": "3.0.0", "mdast-util-to-hast": "13.2.1", "property-information": "7.1.0", "space-separated-tokens": "2.0.2", "stringify-entities": "4.0.4", "zwitch": "2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "3.0.4" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "1.0.8" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@ungap/structured-clone": "1.3.0", "devlop": "1.1.0", "micromark-util-sanitize-uri": "2.0.1", "trim-lines": "3.0.1", "unist-util-position": "5.0.0", "unist-util-visit": "5.1.0", "vfile": "6.0.3" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "2.1.1", "micromark-util-encode": "2.0.1", "micromark-util-symbol": "2.0.1" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "0.12.1", "regex": "6.1.0", "regex-recursion": "6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], + + "runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "2.0.3", "esm-env": "1.2.2", "lz-string": "1.5.0" }, "optionalDependencies": { "@sveltejs/kit": "2.57.1" }, "peerDependencies": { "svelte": "5.55.3" } }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "1.2.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "1.0.0-next.29", "mrmime": "2.0.1", "totalist": "3.0.1" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "2.1.0", "character-entities-legacy": "3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svelte": ["svelte@5.55.3", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.9", "@types/estree": "1.0.8", "@types/trusted-types": "2.0.7", "acorn": "8.16.0", "aria-query": "5.3.1", "axobject-query": "4.1.0", "clsx": "2.1.1", "devalue": "5.7.1", "esm-env": "1.2.2", "esrap": "2.2.5", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.21", "zimmerframe": "1.1.4" } }, "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ=="], + + "svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "chokidar": "4.0.3", "fdir": "6.5.0", "picocolors": "1.1.1", "sade": "1.8.1" }, "peerDependencies": { "svelte": "5.55.3", "typescript": "6.0.2" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="], + + "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "2.1.1", "runed": "0.35.1", "style-to-object": "1.0.14" }, "peerDependencies": { "svelte": "5.55.3" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], + + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-is": "6.0.1", "unist-util-visit-parents": "6.0.2" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-is": "6.0.1" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "vfile-message": "4.0.3" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "1.32.0", "picomatch": "4.0.4", "postcss": "8.5.9", "rolldown": "1.0.0-rc.15", "tinyglobby": "0.2.16" }, "optionalDependencies": { "fsevents": "2.3.3", "jiti": "2.6.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + + "vitefu": ["vitefu@1.1.3", "", { "optionalDependencies": { "vite": "8.0.8" } }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml deleted file mode 100644 index 35215704..00000000 --- a/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,1564 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0 - '@xterm/addon-web-links': - specifier: ^0.12.0 - version: 0.12.0 - '@xterm/xterm': - specifier: ^6.0.0 - version: 6.0.0 - chart.js: - specifier: ^4.5.1 - version: 4.5.1 - shiki: - specifier: ^4.0.2 - version: 4.0.2 - devDependencies: - '@fontsource-variable/jetbrains-mono': - specifier: ^5.2.8 - version: 5.2.8 - '@fontsource-variable/manrope': - specifier: ^5.2.8 - version: 5.2.8 - '@fontsource/alice': - specifier: ^5.2.8 - version: 5.2.8 - '@fontsource/instrument-serif': - specifier: ^5.2.8 - version: 5.2.8 - '@sveltejs/adapter-static': - specifier: ^3.0.10 - version: 3.0.10(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1))) - '@sveltejs/kit': - specifier: ^2.50.2 - version: 2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)) - '@sveltejs/vite-plugin-svelte': - specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)) - '@tailwindcss/vite': - specifier: ^4.2.1 - version: 4.2.2(vite@8.0.8(jiti@2.6.1)) - bits-ui: - specifier: ^2.16.3 - version: 2.17.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3) - svelte: - specifier: ^5.51.0 - version: 5.55.3 - svelte-check: - specifier: ^4.4.2 - version: 4.4.6(picomatch@4.0.4)(svelte@5.55.3)(typescript@6.0.2) - tailwindcss: - specifier: ^4.2.1 - version: 4.2.2 - typescript: - specifier: ^6.0.2 - version: 6.0.2 - vite: - specifier: ^8.0.8 - version: 8.0.8(jiti@2.6.1) - -packages: - - '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - - '@fontsource-variable/jetbrains-mono@5.2.8': - resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} - - '@fontsource-variable/manrope@5.2.8': - resolution: {integrity: sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==} - - '@fontsource/alice@5.2.8': - resolution: {integrity: sha512-EDpK9aFXsaRKdyZpgFu8d5+zmE07yIaFxqVeKrYQJjdQpEhWDZA+naLflHwQQmMbLMJK3a4X/RAm5MCScT93NA==} - - '@fontsource/instrument-serif@5.2.8': - resolution: {integrity: sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==} - - '@internationalized/date@3.12.0': - resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@kurkle/color@0.3.4': - resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} - - '@napi-rs/wasm-runtime@1.1.3': - resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.124.0': - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} - - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - - '@rolldown/binding-android-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} - - '@shikijs/core@4.0.2': - resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} - engines: {node: '>=20'} - - '@shikijs/engine-javascript@4.0.2': - resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} - engines: {node: '>=20'} - - '@shikijs/engine-oniguruma@4.0.2': - resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} - engines: {node: '>=20'} - - '@shikijs/langs@4.0.2': - resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} - engines: {node: '>=20'} - - '@shikijs/primitive@4.0.2': - resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} - engines: {node: '>=20'} - - '@shikijs/themes@4.0.2': - resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} - engines: {node: '>=20'} - - '@shikijs/types@4.0.2': - resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} - engines: {node: '>=20'} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@sveltejs/acorn-typescript@1.0.9': - resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} - peerDependencies: - acorn: ^8.9.0 - - '@sveltejs/adapter-static@3.0.10': - resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} - peerDependencies: - '@sveltejs/kit': ^2.0.0 - - '@sveltejs/kit@2.57.1': - resolution: {integrity: sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==} - engines: {node: '>=18.13'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.0.0 - '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: ^5.3.3 || ^6.0.0 - vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - typescript: - optional: true - - '@sveltejs/vite-plugin-svelte@7.0.0': - resolution: {integrity: sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==} - engines: {node: ^20.19 || ^22.12 || >=24} - peerDependencies: - svelte: ^5.46.4 - vite: ^8.0.0-beta.7 || ^8.0.0 - - '@swc/helpers@0.5.21': - resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} - - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} - - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/addon-web-links@0.12.0': - resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} - - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - aria-query@5.3.1: - resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} - engines: {node: '>= 0.4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - bits-ui@2.17.3: - resolution: {integrity: sha512-Bef41uY9U2jaBJHPhcPvmBNkGec5Wx2z6eioDsTmsaR2vH4QoaOcPi75gzCG3+/2TNr6v/qBwzgWNPYCxNtrEA==} - engines: {node: '>=20'} - peerDependencies: - '@internationalized/date': ^3.8.1 - svelte: ^5.33.0 - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - chart.js@4.5.1: - resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} - engines: {pnpm: '>=8'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} - engines: {node: '>= 0.6'} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - devalue@5.7.1: - resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} - engines: {node: '>=10.13.0'} - - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - - esrap@2.2.5: - resolution: {integrity: sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==} - peerDependencies: - '@typescript-eslint/types': ^8.2.0 - peerDependenciesMeta: - '@typescript-eslint/types': - optional: true - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - - inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - oniguruma-parser@0.12.1: - resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - - oniguruma-to-es@4.3.5: - resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} - engines: {node: ^10 || ^12 || >=14} - - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} - - regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - - regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - - rolldown@1.0.0-rc.15: - resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - runed@0.35.1: - resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} - peerDependencies: - '@sveltejs/kit': ^2.21.0 - svelte: ^5.7.0 - peerDependenciesMeta: - '@sveltejs/kit': - optional: true - - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} - - shiki@4.0.2: - resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} - engines: {node: '>=20'} - - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - - svelte-check@4.4.6: - resolution: {integrity: sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==} - engines: {node: '>= 18.0.0'} - hasBin: true - peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: '>=5.0.0' - - svelte-toolbelt@0.10.6: - resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.30.2 - - svelte@5.55.3: - resolution: {integrity: sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==} - engines: {node: '>=18'} - - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} - engines: {node: '>=14.17'} - hasBin: true - - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - - vite@8.0.8: - resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitefu@1.1.3: - resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - vite: - optional: true - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@emnapi/core@1.9.2': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.9.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - - '@floating-ui/utils@0.2.11': {} - - '@fontsource-variable/jetbrains-mono@5.2.8': {} - - '@fontsource-variable/manrope@5.2.8': {} - - '@fontsource/alice@5.2.8': {} - - '@fontsource/instrument-serif@5.2.8': {} - - '@internationalized/date@3.12.0': - dependencies: - '@swc/helpers': 0.5.21 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@kurkle/color@0.3.4': {} - - '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@oxc-project/types@0.124.0': {} - - '@polka/url@1.0.0-next.29': {} - - '@rolldown/binding-android-arm64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.15': {} - - '@shikijs/core@4.0.2': - dependencies: - '@shikijs/primitive': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.5 - - '@shikijs/engine-oniguruma@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - - '@shikijs/primitive@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/themes@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - - '@shikijs/types@4.0.2': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/vscode-textmate@10.0.2': {} - - '@standard-schema/spec@1.1.0': {} - - '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': - dependencies: - acorn: 8.16.0 - - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))': - dependencies: - '@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)) - - '@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1))': - dependencies: - '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)) - '@types/cookie': 0.6.0 - acorn: 8.16.0 - cookie: 0.6.0 - devalue: 5.7.1 - esm-env: 1.2.2 - kleur: 4.1.5 - magic-string: 0.30.21 - mrmime: 2.0.1 - set-cookie-parser: 3.1.0 - sirv: 3.0.2 - svelte: 5.55.3 - vite: 8.0.8(jiti@2.6.1) - optionalDependencies: - typescript: 6.0.2 - - '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1))': - dependencies: - deepmerge: 4.3.1 - magic-string: 0.30.21 - obug: 2.1.1 - svelte: 5.55.3 - vite: 8.0.8(jiti@2.6.1) - vitefu: 1.1.3(vite@8.0.8(jiti@2.6.1)) - - '@swc/helpers@0.5.21': - dependencies: - tslib: 2.8.1 - - '@tailwindcss/node@4.2.2': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.2 - - '@tailwindcss/oxide-android-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide@4.2.2': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - - '@tailwindcss/vite@4.2.2(vite@8.0.8(jiti@2.6.1))': - dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 8.0.8(jiti@2.6.1) - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/cookie@0.6.0': {} - - '@types/estree@1.0.8': {} - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/trusted-types@2.0.7': {} - - '@types/unist@3.0.3': {} - - '@ungap/structured-clone@1.3.0': {} - - '@xterm/addon-fit@0.11.0': {} - - '@xterm/addon-web-links@0.12.0': {} - - '@xterm/xterm@6.0.0': {} - - acorn@8.16.0: {} - - aria-query@5.3.1: {} - - axobject-query@4.1.0: {} - - bits-ui@2.17.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3): - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/dom': 1.7.6 - '@internationalized/date': 3.12.0 - esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3) - svelte: 5.55.3 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3) - tabbable: 6.4.0 - transitivePeerDependencies: - - '@sveltejs/kit' - - ccount@2.0.1: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - chart.js@4.5.1: - dependencies: - '@kurkle/color': 0.3.4 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - clsx@2.1.1: {} - - comma-separated-tokens@2.0.3: {} - - cookie@0.6.0: {} - - deepmerge@4.3.1: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - devalue@5.7.1: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - enhanced-resolve@5.20.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.2 - - esm-env@1.2.2: {} - - esrap@2.2.5: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.3: - optional: true - - graceful-fs@4.2.11: {} - - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - html-void-elements@3.0.0: {} - - inline-style-parser@0.2.7: {} - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - jiti@2.6.1: {} - - kleur@4.1.5: {} - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - locate-character@3.0.0: {} - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-encode@2.0.1: {} - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - mri@1.2.0: {} - - mrmime@2.0.1: {} - - nanoid@3.3.11: {} - - obug@2.1.1: {} - - oniguruma-parser@0.12.1: {} - - oniguruma-to-es@4.3.5: - dependencies: - oniguruma-parser: 0.12.1 - regex: 6.1.0 - regex-recursion: 6.0.2 - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss@8.5.9: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - property-information@7.1.0: {} - - readdirp@4.1.2: {} - - regex-recursion@6.0.2: - dependencies: - regex-utilities: 2.3.0 - - regex-utilities@2.3.0: {} - - regex@6.1.0: - dependencies: - regex-utilities: 2.3.0 - - rolldown@1.0.0-rc.15: - dependencies: - '@oxc-project/types': 0.124.0 - '@rolldown/pluginutils': 1.0.0-rc.15 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-x64': 1.0.0-rc.15 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 - - runed@0.35.1(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3): - dependencies: - dequal: 2.0.3 - esm-env: 1.2.2 - lz-string: 1.5.0 - svelte: 5.55.3 - optionalDependencies: - '@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)) - - sade@1.8.1: - dependencies: - mri: 1.2.0 - - set-cookie-parser@3.1.0: {} - - shiki@4.0.2: - dependencies: - '@shikijs/core': 4.0.2 - '@shikijs/engine-javascript': 4.0.2 - '@shikijs/engine-oniguruma': 4.0.2 - '@shikijs/langs': 4.0.2 - '@shikijs/themes': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - - source-map-js@1.2.1: {} - - space-separated-tokens@2.0.2: {} - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - style-to-object@1.0.14: - dependencies: - inline-style-parser: 0.2.7 - - svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.3)(typescript@6.0.2): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.4) - picocolors: 1.1.1 - sade: 1.8.1 - svelte: 5.55.3 - typescript: 6.0.2 - transitivePeerDependencies: - - picomatch - - svelte-toolbelt@0.10.6(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3): - dependencies: - clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3)(typescript@6.0.2)(vite@8.0.8(jiti@2.6.1)))(svelte@5.55.3) - style-to-object: 1.0.14 - svelte: 5.55.3 - transitivePeerDependencies: - - '@sveltejs/kit' - - svelte@5.55.3: - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@types/estree': 1.0.8 - '@types/trusted-types': 2.0.7 - acorn: 8.16.0 - aria-query: 5.3.1 - axobject-query: 4.1.0 - clsx: 2.1.1 - devalue: 5.7.1 - esm-env: 1.2.2 - esrap: 2.2.5 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.21 - zimmerframe: 1.1.4 - transitivePeerDependencies: - - '@typescript-eslint/types' - - tabbable@6.4.0: {} - - tailwindcss@4.2.2: {} - - tapable@2.3.2: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - totalist@3.0.1: {} - - trim-lines@3.0.1: {} - - tslib@2.8.1: {} - - typescript@6.0.2: {} - - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - - vite@8.0.8(jiti@2.6.1): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.9 - rolldown: 1.0.0-rc.15 - tinyglobby: 0.2.16 - optionalDependencies: - fsevents: 2.3.3 - jiti: 2.6.1 - - vitefu@1.1.3(vite@8.0.8(jiti@2.6.1)): - optionalDependencies: - vite: 8.0.8(jiti@2.6.1) - - zimmerframe@1.1.4: {} - - zwitch@2.0.4: {} diff --git a/frontend/src/lib/api/admin-capsules.ts b/frontend/src/lib/api/admin-capsules.ts index 337ee0ae..b5be629b 100644 --- a/frontend/src/lib/api/admin-capsules.ts +++ b/frontend/src/lib/api/admin-capsules.ts @@ -18,7 +18,9 @@ export async function destroyAdminCapsule(id: string): Promise> return apiFetch('DELETE', `/api/v1/admin/capsules/${id}`); } -export async function snapshotAdminCapsule(id: string, name?: string): Promise> { +// Async: returns 202 with the capsule now in the "snapshotting" state. The +// template lands later (watch template.snapshot.create or poll templates). +export async function snapshotAdminCapsule(id: string, name?: string): Promise> { return apiFetch('POST', `/api/v1/admin/capsules/${id}/snapshot`, { name }); } @@ -35,6 +37,7 @@ export async function listPlatformTemplates(): Promise> { size_bytes: t.size_bytes, created_at: t.created_at, platform: true, + protected: t.protected, })); return { ok: true, data: snapshots }; } diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 1a3ede9c..6c810ce9 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -1,9 +1,10 @@ export type AuthResponse = { - token: string; user_id: string; team_id: string; email: string; name: string; + role: string; + is_admin: boolean; }; export type SignupResponse = { @@ -30,6 +31,7 @@ async function authFetch(url: string, body: Record> { if (params.archive) { // Use multipart when an archive file is provided. @@ -72,6 +97,8 @@ export type AdminTemplate = { size_bytes: number; team_id: string; created_at: string; + /** True for built-in system base templates, which cannot be deleted. */ + protected: boolean; }; export async function listAdminTemplates(): Promise> { diff --git a/frontend/src/lib/api/capsules.ts b/frontend/src/lib/api/capsules.ts index 3e8f7f37..59d41d20 100644 --- a/frontend/src/lib/api/capsules.ts +++ b/frontend/src/lib/api/capsules.ts @@ -1,18 +1,51 @@ import { apiFetch, type ApiResult } from '$lib/api/client'; +// Mirror of the backend state machine. Keep in sync with the `status` enum +// on the Capsule schema in internal/api/openapi.yaml. +export type CapsuleStatus = + | 'pending' + | 'starting' + | 'running' + | 'pausing' + | 'paused' + | 'snapshotting' + | 'resuming' + | 'stopping' + | 'hibernated' + | 'stopped' + | 'missing' + | 'error'; + +// States from which a user may resume the capsule. +export const RESUMABLE_STATUSES: ReadonlySet = new Set([ + 'paused', + 'hibernated' +]); + +// Transient states where lifecycle actions should be disabled. +export const TRANSIENT_STATUSES: ReadonlySet = new Set([ + 'pending', + 'starting', + 'pausing', + 'snapshotting', + 'resuming', + 'stopping' +]); + export type Capsule = { id: string; - status: string; + status: CapsuleStatus; template: string; vcpus: number; memory_mb: number; timeout_sec: number; - guest_ip?: string; - host_ip?: string; created_at: string; started_at?: string; last_active_at?: string; last_updated: string; + metadata?: Record; + disk_size_mb: number; + disk_used_mb?: number; }; @@ -55,9 +88,14 @@ export type Snapshot = { size_bytes: number; created_at: string; platform: boolean; + /** True for built-in system base templates, which cannot be deleted. */ + protected?: boolean; }; -export async function createSnapshot(capsuleId: string, name?: string): Promise> { +// Snapshots are async: the call returns 202 with the capsule now in the +// "snapshotting" state. The resulting template arrives later via the +// template.snapshot.create SSE event (or by polling listSnapshots). +export async function createSnapshot(capsuleId: string, name?: string): Promise> { return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name }); } diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index 130a9a85..4a565819 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -22,14 +22,14 @@ export const PROVIDERS = [ ] as const; export const EVENT_TYPES = [ - { value: 'capsule.created', group: 'Capsule' }, - { value: 'capsule.running', group: 'Capsule' }, - { value: 'capsule.paused', group: 'Capsule' }, - { value: 'capsule.destroyed', group: 'Capsule' }, - { value: 'template.snapshot.created', group: 'Template' }, - { value: 'template.snapshot.deleted', group: 'Template' }, - { value: 'host.up', group: 'Host' }, - { value: 'host.down', group: 'Host' } + { value: 'capsule.create', group: 'Capsule', label: 'Capsule create' }, + { value: 'capsule.pause', group: 'Capsule', label: 'Capsule pause' }, + { value: 'capsule.resume', group: 'Capsule', label: 'Capsule resume' }, + { value: 'capsule.destroy', group: 'Capsule', label: 'Capsule destroy' }, + { value: 'template.snapshot.create', group: 'Template', label: 'Snapshot create' }, + { value: 'template.snapshot.delete', group: 'Template', label: 'Snapshot delete' }, + { value: 'host.up', group: 'Host', label: 'Host up' }, + { value: 'host.down', group: 'Host', label: 'Host down' } ] as const; export async function listChannels(): Promise> { diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d6e64592..ba966df6 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,44 +1,70 @@ -import { auth } from '$lib/auth.svelte'; +import { goto } from '$app/navigation'; +import { auth, readCSRFToken } from '$lib/auth.svelte'; export type ApiResult = { ok: true; data: T } | { ok: false; error: string }; +async function parseResponse(res: Response): Promise> { + if (res.status === 204 || res.status === 202) { + const text = await res.text(); + if (!text) return { ok: true, data: undefined as T }; + const data = JSON.parse(text); + return { ok: true, data: data as T }; + } + + if (res.status === 401) { + auth.clearUser(); + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + goto('/login', { replaceState: true }); + return new Promise>(() => {}); + } + } + + const data = await res.json(); + if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' }; + return { ok: true, data: data as T }; +} + +function attachCSRF(headers: Record, method: string): void { + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return; + const token = readCSRFToken(); + if (token) headers['X-CSRF-Token'] = token; +} + export async function apiFetch(method: string, path: string, body?: unknown): Promise> { try { const headers: Record = { 'Content-Type': 'application/json' }; - if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + attachCSRF(headers, method); const res = await fetch(path, { method, headers, + credentials: 'same-origin', body: body ? JSON.stringify(body) : undefined }); - if (res.status === 204) return { ok: true, data: undefined as T }; - - const data = await res.json(); - if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' }; - return { ok: true, data: data as T }; + return await parseResponse(res); } catch { return { ok: false, error: 'Unable to connect to the server' }; } } -export async function apiFetchMultipart(method: string, path: string, formData: FormData): Promise> { +export async function apiFetchMultipart( + method: string, + path: string, + formData: FormData +): Promise> { try { const headers: Record = {}; - if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + attachCSRF(headers, method); const res = await fetch(path, { method, headers, + credentials: 'same-origin', body: formData }); - if (res.status === 204) return { ok: true, data: undefined as T }; - - const data = await res.json(); - if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' }; - return { ok: true, data: data as T }; + return await parseResponse(res); } catch { return { ok: false, error: 'Unable to connect to the server' }; } diff --git a/frontend/src/lib/api/events.ts b/frontend/src/lib/api/events.ts new file mode 100644 index 00000000..f5b3c5ee --- /dev/null +++ b/frontend/src/lib/api/events.ts @@ -0,0 +1,169 @@ +import type { Capsule } from '$lib/api/capsules'; + +// Mirror the SSE event names emitted by pkg/events. Keep in sync with the +// `SSEEvent.event` enum in internal/api/openapi.yaml. +export type SSEEventKind = + | 'connected' + | 'capsule.create' + | 'capsule.pause' + | 'capsule.resume' + | 'capsule.destroy' + | 'capsule.state.changed' + | 'template.snapshot.create' + | 'template.snapshot.delete' + | 'host.up' + | 'host.down'; + +export type SSEEventOutcome = 'success' | 'error'; + +export type SSEEvent = { + event: SSEEventKind; + outcome?: SSEEventOutcome; + timestamp: string; + team_id: string; + actor: { type: string; id?: string; name?: string }; + resource: { id: string; type: string }; + metadata?: Record; + error?: string; + sandbox?: Capsule | null; +}; + +function isSSEEvent(x: unknown): x is SSEEvent { + if (!x || typeof x !== 'object') return false; + const o = x as Record; + return ( + typeof o.event === 'string' && + typeof o.resource === 'object' && + o.resource !== null + ); +} + +export type SSEEventHandler = (event: SSEEvent) => void; + +export type EventStreamConnection = { + close: () => void; +}; + +/** + * Connects to the SSE event stream. Returns a handle to close the connection. + * Automatically reconnects on disconnect with exponential backoff. The + * browser sends the wrenn_sid cookie automatically on EventSource so no + * ticket exchange is required. + */ +export function connectEventStream( + onEvent: SSEEventHandler, + opts?: { admin?: boolean } +): EventStreamConnection { + let closed = false; + let eventSource: EventSource | null = null; + let reconnectTimeout: ReturnType | null = null; + let backoff = 1000; + + function scheduleReconnect() { + if (closed) return; + if (reconnectTimeout) return; + reconnectTimeout = setTimeout(() => { + reconnectTimeout = null; + connect(); + }, backoff); + backoff = Math.min(backoff * 2, 30000); + } + + function reconnectNow() { + if (closed) return; + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + eventSource?.close(); + eventSource = null; + backoff = 1000; + connect(); + } + + function connect() { + if (closed) return; + + const isAdmin = opts?.admin ?? false; + const url = isAdmin ? '/api/v1/admin/events/stream' : '/api/v1/events/stream'; + + eventSource = new EventSource(url, { withCredentials: true }); + + eventSource.onopen = () => { + backoff = 1000; + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + }; + + eventSource.onerror = () => { + eventSource?.close(); + eventSource = null; + scheduleReconnect(); + }; + + eventSource.addEventListener('capsule.create', handleEvent); + eventSource.addEventListener('capsule.pause', handleEvent); + eventSource.addEventListener('capsule.resume', handleEvent); + eventSource.addEventListener('capsule.destroy', handleEvent); + eventSource.addEventListener('capsule.state.changed', handleEvent); + eventSource.addEventListener('template.snapshot.create', handleEvent); + eventSource.addEventListener('template.snapshot.delete', handleEvent); + eventSource.addEventListener('host.up', handleEvent); + eventSource.addEventListener('host.down', handleEvent); + } + + function handleEvent(e: MessageEvent) { + try { + const parsed = JSON.parse(e.data); + if (!isSSEEvent(parsed)) { + console.warn('SSE event failed shape validation, dropping', parsed); + return; + } + onEvent(parsed); + } catch { + // Ignore malformed messages. + } + } + + function isDisconnected() { + return !eventSource || eventSource.readyState !== EventSource.OPEN; + } + + function handleOnline() { + if (isDisconnected()) reconnectNow(); + } + + function handleVisibility() { + if (typeof document !== 'undefined' && document.visibilityState === 'visible' && isDisconnected()) { + reconnectNow(); + } + } + + if (typeof window !== 'undefined') { + window.addEventListener('online', handleOnline); + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibility); + } + + function close() { + closed = true; + eventSource?.close(); + eventSource = null; + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + if (typeof window !== 'undefined') { + window.removeEventListener('online', handleOnline); + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibility); + } + } + + connect(); + return { close }; +} diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index 7d066ecb..da5842db 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -1,5 +1,5 @@ -import { auth } from '$lib/auth.svelte'; -import { type ApiResult } from '$lib/api/client'; +import { readCSRFToken } from '$lib/auth.svelte'; +import { apiFetch, type ApiResult } from '$lib/api/client'; export type FileEntry = { name: string; @@ -20,10 +20,6 @@ export type ListDirResponse = { const MAX_READABLE_SIZE = 10 * 1024 * 1024; // 10 MB -/** - * Whether a file can be previewed as text in the browser. - * Binary/unreadable extensions and files > 10 MB should be downloaded instead. - */ const BINARY_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.svg', '.mp3', '.mp4', '.wav', '.ogg', '.flac', '.avi', '.mkv', '.mov', '.webm', @@ -53,23 +49,8 @@ export function formatFileSize(bytes: number): string { return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`; } -export async function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise> { - try { - const headers: Record = { 'Content-Type': 'application/json' }; - if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - - const res = await fetch(`${basePath}/${capsuleId}/files/list`, { - method: 'POST', - headers, - body: JSON.stringify({ path, depth }), - }); - - const data = await res.json(); - if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Failed to list directory' }; - return { ok: true, data: data as ListDirResponse }; - } catch { - return { ok: false, error: 'Unable to connect to the server' }; - } +export function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise> { + return apiFetch('POST', `${basePath}/${capsuleId}/files/list`, { path, depth }); } export async function readFile( @@ -78,13 +59,18 @@ export async function readFile( signal?: AbortSignal, basePath = '/api/v1/capsules', ): Promise> { + // /files/read returns raw bytes (potentially binary) so we cannot route it + // through apiFetch which assumes JSON. We still inject the CSRF token via + // the shared cookie reader. try { const headers: Record = { 'Content-Type': 'application/json' }; - if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + const csrf = readCSRFToken(); + if (csrf) headers['X-CSRF-Token'] = csrf; const res = await fetch(`${basePath}/${capsuleId}/files/read`, { method: 'POST', headers, + credentials: 'same-origin', body: JSON.stringify({ path }), signal, }); @@ -117,11 +103,13 @@ export async function downloadFile( basePath = '/api/v1/capsules', ): Promise { const headers: Record = { 'Content-Type': 'application/json' }; - if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + const csrf = readCSRFToken(); + if (csrf) headers['X-CSRF-Token'] = csrf; const res = await fetch(`${basePath}/${capsuleId}/files/read`, { method: 'POST', headers, + credentials: 'same-origin', body: JSON.stringify({ path }), signal, }); @@ -136,6 +124,5 @@ export async function downloadFile( document.body.appendChild(a); a.click(); a.remove(); - // Delay revocation so the browser has time to start the download setTimeout(() => URL.revokeObjectURL(url), 5000); } diff --git a/frontend/src/lib/api/hosts.ts b/frontend/src/lib/api/hosts.ts index 031b7f02..15c2c104 100644 --- a/frontend/src/lib/api/hosts.ts +++ b/frontend/src/lib/api/hosts.ts @@ -17,8 +17,15 @@ export type Host = { created_by: string; created_at: string; updated_at: string; + running_vcpus: number; + running_memory_mb: number; + running_disk_mb: number; + paused_memory_mb: number; + paused_disk_mb: number; }; +export type AdminHost = Host; + export type CreateHostParams = { type: 'regular' | 'byoc'; team_id?: string; @@ -35,6 +42,10 @@ export async function listHosts(): Promise<{ ok: true; data: Host[] } | { ok: fa return apiFetch('GET', '/api/v1/hosts'); } +export async function listAdminHosts(): Promise<{ ok: true; data: AdminHost[] } | { ok: false; error: string }> { + return apiFetch('GET', '/api/v1/admin/hosts'); +} + export async function createHost( params: CreateHostParams ): Promise<{ ok: true; data: CreateHostResult } | { ok: false; error: string }> { diff --git a/frontend/src/lib/api/me.ts b/frontend/src/lib/api/me.ts index 1396d9db..0987943d 100644 --- a/frontend/src/lib/api/me.ts +++ b/frontend/src/lib/api/me.ts @@ -1,13 +1,26 @@ import { apiFetch, type ApiResult } from '$lib/api/client'; -import type { AuthResponse } from '$lib/api/auth'; export type MeResponse = { + user_id: string; + team_id: string; name: string; email: string; + role: string; + is_admin: boolean; has_password: boolean; providers: string[]; }; +export type SessionRow = { + id: string; + user_agent: string; + ip_address: string; + created_at: string; + last_seen_at: string; + expires_at: string; + current: boolean; +}; + export type ChangePasswordBody = { current_password?: string; new_password: string; @@ -17,7 +30,7 @@ export type ChangePasswordBody = { export const getMe = (): Promise> => apiFetch('GET', '/api/v1/me'); -export const updateName = (name: string): Promise> => +export const updateName = (name: string): Promise> => apiFetch('PATCH', '/api/v1/me', { name }); export const changePassword = (body: ChangePasswordBody): Promise> => @@ -40,3 +53,15 @@ export const disconnectProvider = (provider: string): Promise> = export const deleteAccount = (confirmation: string): Promise> => apiFetch('DELETE', '/api/v1/me', { confirmation }); + +export const listSessions = (): Promise> => + apiFetch('GET', '/api/v1/me/sessions'); + +export const revokeSession = (id: string): Promise> => + apiFetch('DELETE', `/api/v1/me/sessions/${id}`); + +export const logout = (): Promise> => + apiFetch('POST', '/api/v1/auth/logout'); + +export const logoutAll = (): Promise> => + apiFetch('POST', '/api/v1/auth/logout-all'); diff --git a/frontend/src/lib/api/team.ts b/frontend/src/lib/api/team.ts index 2cebb8e7..e2e0ebbd 100644 --- a/frontend/src/lib/api/team.ts +++ b/frontend/src/lib/api/team.ts @@ -1,4 +1,5 @@ import { apiFetch, type ApiResult } from '$lib/api/client'; +import type { AuthResponse } from '$lib/api/auth'; export type TeamMember = { user_id: string; @@ -42,9 +43,7 @@ export async function createTeam(name: string): Promise> return apiFetch('POST', '/api/v1/teams', { name }); } -export async function switchTeam( - teamId: string -): Promise> { +export async function switchTeam(teamId: string): Promise> { return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId }); } @@ -98,6 +97,8 @@ export type AdminTeam = { owner_email: string; active_sandbox_count: number; channel_count: number; + running_vcpus: number; + running_memory_mb: number; }; export type AdminTeamsResponse = { diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index d39a0f41..bb909699 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -1,35 +1,22 @@ import { goto } from '$app/navigation'; -const STORAGE_KEYS = { - token: 'wrenn_token', - userId: 'wrenn_user_id', - teamId: 'wrenn_team_id', - email: 'wrenn_email', - name: 'wrenn_name' -} as const; +// Cookie-backed session auth. The browser holds the opaque session via the +// httpOnly `wrenn_sid` cookie (set by the server on login). JS never reads +// the session id; identity state is hydrated from GET /v1/me on app boot +// and after login/team-switch. -function isTokenExpired(token: string): boolean { - try { - const payload = token.split('.')[1]; - const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); - const { exp } = JSON.parse(decoded); - return Date.now() / 1000 >= exp; - } catch { - return true; - } -} - -function decodeJWTPayload(token: string): Record { - try { - const payload = token.split('.')[1]; - return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); - } catch { - return {}; - } -} +export type Me = { + user_id: string; + team_id: string; + email: string; + name: string; + role: string; + is_admin: boolean; + has_password?: boolean; + providers?: string[]; +}; function createAuth() { - let token = $state(null); let userId = $state(null); let teamId = $state(null); let email = $state(null); @@ -38,33 +25,58 @@ function createAuth() { let role = $state('member'); let initialized = $state(false); - // Initialize from localStorage synchronously at module load. - if (typeof window !== 'undefined') { - const stored = localStorage.getItem(STORAGE_KEYS.token); - if (stored && !isTokenExpired(stored)) { - token = stored; - userId = localStorage.getItem(STORAGE_KEYS.userId); - teamId = localStorage.getItem(STORAGE_KEYS.teamId); - email = localStorage.getItem(STORAGE_KEYS.email); - name = localStorage.getItem(STORAGE_KEYS.name); - const payload = decodeJWTPayload(stored); - isAdmin = Boolean(payload.is_admin); - role = String(payload.role || 'member'); - } else if (stored) { - // Expired — clean up. - for (const key of Object.values(STORAGE_KEYS)) { - localStorage.removeItem(key); + function setUser(data: Me) { + userId = data.user_id; + teamId = data.team_id; + email = data.email; + name = data.name; + role = data.role || 'member'; + isAdmin = Boolean(data.is_admin); + } + + function clearUser() { + userId = null; + teamId = null; + email = null; + name = null; + isAdmin = false; + role = 'member'; + } + + async function init(): Promise { + if (typeof window === 'undefined') { + initialized = true; + return; + } + try { + const res = await fetch('/api/v1/me', { credentials: 'same-origin' }); + if (res.ok) { + const data = (await res.json()) as Me; + setUser(data); + } else { + clearUser(); } + } catch { + clearUser(); } initialized = true; } - const isAuthenticated = $derived(token !== null && !isTokenExpired(token)); + async function logout(): Promise { + try { + await fetch('/api/v1/auth/logout', { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRF-Token': readCSRFToken() ?? '' } + }); + } catch { + /* best effort */ + } + clearUser(); + await goto('/login'); + } return { - get token() { - return token; - }, get userId() { return userId; }, @@ -84,45 +96,26 @@ function createAuth() { return role; }, get isAuthenticated() { - return isAuthenticated; + return userId !== null; }, get initialized() { return initialized; }, - login(data: { token: string; user_id: string; team_id: string; email: string; name: string }) { - token = data.token; - userId = data.user_id; - teamId = data.team_id; - email = data.email; - name = data.name; - const payload = decodeJWTPayload(data.token); - isAdmin = Boolean(payload.is_admin); - role = String(payload.role || 'member'); - - localStorage.setItem(STORAGE_KEYS.token, data.token); - localStorage.setItem(STORAGE_KEYS.userId, data.user_id); - localStorage.setItem(STORAGE_KEYS.teamId, data.team_id); - localStorage.setItem(STORAGE_KEYS.email, data.email); - localStorage.setItem(STORAGE_KEYS.name, data.name); - }, - - logout() { - token = null; - userId = null; - teamId = null; - email = null; - name = null; - isAdmin = false; - role = 'member'; - - for (const key of Object.values(STORAGE_KEYS)) { - localStorage.removeItem(key); - } - - goto('/login'); - } + setUser, + clearUser, + init, + logout }; } +// readCSRFToken returns the value of the wrenn_csrf cookie, or null if absent. +// Exported so client.ts can attach the X-CSRF-Token header without duplicating +// the parser. +export function readCSRFToken(): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(/(?:^|;\s*)wrenn_csrf=([^;]+)/); + return match ? decodeURIComponent(match[1]) : null; +} + export const auth = createAuth(); diff --git a/frontend/src/lib/build-console-ws.svelte.ts b/frontend/src/lib/build-console-ws.svelte.ts new file mode 100644 index 00000000..d8bcf643 --- /dev/null +++ b/frontend/src/lib/build-console-ws.svelte.ts @@ -0,0 +1,192 @@ +// build-console-ws.svelte.ts — WebSocket client for the live admin build +// console. Connects to GET /v1/admin/builds/{id}/stream, which replays the +// completed-step history then live-tails events. The client maps events to a +// reactive step list and forwards raw PTY output to a terminal writer. + +import { buildStreamUrl, type BuildStreamEvent } from '$lib/api/builds'; + +export type StepStatus = 'running' | 'success' | 'failed'; + +export type BuildStep = { + step: number; + phase: string; + cmd: string; + status: StepStatus; + exit: number | null; + elapsedMs: number | null; +}; + +export type ConsoleConnState = 'connecting' | 'connected' | 'closed' | 'error'; + +const RECONNECT_DELAY = 1500; + +// ANSI truecolor escapes matching the Wrenn palette. +const dim = (s: string) => `\x1b[38;2;107;104;98m${s}\x1b[0m`; // text-tertiary +const sage = (s: string) => `\x1b[38;2;137;167;133m${s}\x1b[0m`; // accent-mid +const red = (s: string) => `\x1b[38;2;207;129;114m${s}\x1b[0m`; // red + +// Binary-safe base64 decode for raw PTY bytes. +function decodeBase64(b64: string): string { + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +function isTerminal(status: string): boolean { + return status === 'success' || status === 'failed' || status === 'cancelled'; +} + +/** + * createBuildConsole wires a build's event WebSocket to reactive state. + * Call connect() with a terminal write function once the terminal exists, + * and disconnect() on teardown. + */ +export function createBuildConsole(buildId: string) { + let connState = $state('connecting'); + let steps = $state([]); + let buildStatus = $state(''); + let currentStep = $state(0); + let totalSteps = $state(0); + let errorMessage = $state(null); + + let ws: WebSocket | null = null; + let writeTerm: ((text: string) => void) | null = null; + let reconnectTimer: ReturnType | null = null; + let disposed = false; + + function upsertStep(step: number, patch: Partial) { + const idx = steps.findIndex((s) => s.step === step); + if (idx === -1) { + steps = [ + ...steps, + { + step, + phase: patch.phase ?? '', + cmd: patch.cmd ?? '', + status: patch.status ?? 'running', + exit: patch.exit ?? null, + elapsedMs: patch.elapsedMs ?? null + } + ].sort((a, b) => a.step - b.step); + } else { + // Immutable replace so the reactive array re-renders the step list. + steps = steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)); + } + } + + function summaryLine(status: string): string { + if (status === 'success') return `\r\n${sage('● build succeeded')}\r\n`; + if (status === 'failed') return `\r\n${red('● build failed')}\r\n`; + return `\r\n${dim('● build ' + status)}\r\n`; + } + + function handle(ev: BuildStreamEvent) { + switch (ev.type) { + case 'step-start': + upsertStep(ev.step ?? 0, { + phase: ev.phase, + cmd: ev.cmd, + status: 'running', + exit: null, + elapsedMs: null + }); + writeTerm?.(`\r\n${sage('▸')} ${dim('step ' + ev.step)} ${ev.cmd ?? ''}\r\n`); + break; + case 'output': + if (ev.data) writeTerm?.(decodeBase64(ev.data)); + break; + case 'step-end': { + const ok = ev.ok ?? false; + upsertStep(ev.step ?? 0, { + phase: ev.phase, + cmd: ev.cmd, + status: ok ? 'success' : 'failed', + exit: ev.exit ?? 0, + elapsedMs: ev.elapsed_ms ?? 0 + }); + if (typeof ev.step === 'number' && ev.step > currentStep) currentStep = ev.step; + break; + } + case 'build-status': + if (ev.status) buildStatus = ev.status; + if (typeof ev.total_steps === 'number' && ev.total_steps > 0) totalSteps = ev.total_steps; + if (typeof ev.current_step === 'number' && ev.current_step > currentStep) { + currentStep = ev.current_step; + } + if (ev.error) errorMessage = ev.error; + if (ev.status && isTerminal(ev.status)) writeTerm?.(summaryLine(ev.status)); + break; + case 'ping': + break; + } + } + + function open() { + connState = 'connecting'; + ws = new WebSocket(buildStreamUrl(buildId)); + + ws.onopen = () => { + connState = 'connected'; + }; + + ws.onmessage = (e) => { + try { + handle(JSON.parse(e.data) as BuildStreamEvent); + } catch { + // ignore malformed frames + } + }; + + ws.onclose = () => { + if (disposed) return; + // A finished build closes cleanly; nothing more to stream. + if (isTerminal(buildStatus)) { + connState = 'closed'; + return; + } + // Unexpected drop mid-build: reconnect and resume from history. + connState = 'connecting'; + writeTerm?.(`\r\n${dim('[reconnecting...]')}\r\n`); + reconnectTimer = setTimeout(open, RECONNECT_DELAY); + }; + + ws.onerror = () => { + if (!disposed) connState = 'error'; + }; + } + + return { + get connState() { + return connState; + }, + get steps() { + return steps; + }, + get buildStatus() { + return buildStatus; + }, + get currentStep() { + return currentStep; + }, + get totalSteps() { + return totalSteps; + }, + get errorMessage() { + return errorMessage; + }, + + /** connect opens the WebSocket; write receives terminal output. */ + connect(write: (text: string) => void) { + if (disposed) return; + writeTerm = write; + open(); + }, + + /** disconnect tears down the WebSocket and cancels any reconnect. */ + disconnect() { + disposed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + ws?.close(); + ws = null; + } + }; +} diff --git a/frontend/src/lib/components/BuildConsole.svelte b/frontend/src/lib/components/BuildConsole.svelte new file mode 100644 index 00000000..e36642bb --- /dev/null +++ b/frontend/src/lib/components/BuildConsole.svelte @@ -0,0 +1,198 @@ + + +
+ +
+ {buildId} + + {bc.currentStep}/{stepTotal} + +
+ {#if bc.connState === 'connected'} + + + + + {:else if bc.connState === 'connecting'} + + {:else} + + {/if} + {connLabel(bc.connState)} +
+ + +
+
+
+
+ +
+ + {#if bc.errorMessage} +
+ {bc.errorMessage} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/BuildStepList.svelte b/frontend/src/lib/components/BuildStepList.svelte new file mode 100644 index 00000000..8d4ca966 --- /dev/null +++ b/frontend/src/lib/components/BuildStepList.svelte @@ -0,0 +1,126 @@ + + +
+ + Steps + + {#if steps.length > 0} + + {steps.length} + + {/if} +
+ +
+ {#each steps as s (s.step)} + {@const [kw, rest] = splitInstruction(s.cmd)} +
+
+ {#if s.status === 'running'} + + + + + {:else if s.status === 'success'} + + {:else} + + {/if} + + {s.step} + +
+ {#if s.exit !== null && s.exit !== 0} + + exit {s.exit} + + {/if} + {#if s.elapsedMs !== null} + + {formatMs(s.elapsedMs)} + + {/if} +
+ + {kw}{#if rest} + {rest} + {/if} + +
+ {/each} + + {#if steps.length === 0} +
+ Waiting for the first step... +
+ {/if} +
diff --git a/frontend/src/lib/components/CreateCapsuleDialog.svelte b/frontend/src/lib/components/CreateCapsuleDialog.svelte index 36d893f4..6467599d 100644 --- a/frontend/src/lib/components/CreateCapsuleDialog.svelte +++ b/frontend/src/lib/components/CreateCapsuleDialog.svelte @@ -11,7 +11,7 @@ }; let { open, onclose, oncreated, templateSource = 'team' }: Props = $props(); - let createForm = $state({ template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 }); + let createForm = $state({ template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, timeout_sec: 0 }); let creating = $state(false); let createError = $state(null); @@ -116,17 +116,20 @@ async function handleCreate() { creating = true; createError = null; - const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule; - const result = await creator(createForm); - if (result.ok) { - createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 }; - templateQuery = 'minimal'; - oncreated?.(result.data); - onclose(); - } else { - createError = result.error; + try { + const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule; + const result = await creator(createForm); + if (result.ok) { + createForm = { template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, timeout_sec: 0 }; + templateQuery = 'minimal-ubuntu'; + onclose(); + oncreated?.(result.data); + } else { + createError = result.error; + } + } finally { + creating = false; } - creating = false; } diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 47d2960f..3b4bddd2 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -100,7 +100,7 @@ teamPopoverOpen = false; const result = await switchTeam(teamId); if (result.ok) { - auth.login(result.data); + auth.setUser(result.data); window.location.reload(); } } @@ -113,7 +113,7 @@ if (result.ok) { const switchResult = await switchTeam(result.data.id); if (switchResult.ok) { - auth.login(switchResult.data); + auth.setUser(switchResult.data); window.location.reload(); } else { createTeamError = switchResult.error; diff --git a/frontend/src/lib/components/SnapshotDialog.svelte b/frontend/src/lib/components/SnapshotDialog.svelte index 3db648db..0237167b 100644 --- a/frontend/src/lib/components/SnapshotDialog.svelte +++ b/frontend/src/lib/components/SnapshotDialog.svelte @@ -1,14 +1,38 @@ +{#snippet defaultDescription()} +

The capsule moves to a snapshotting state while its memory and disk are written to a new template, then returns to running. This runs in the background; you'll be notified when it completes.

+{/snippet} + {#if open}
@@ -59,24 +88,13 @@
-

Capture snapshot

+

{title}

{capsuleId}

- {#if pauseFirst} -
- - - - - -

This capsule will be paused first, then its full state (memory + disk) will be captured.

-
- {:else} -

The capsule's current state (memory + disk) will be captured and stored as a reusable snapshot.

- {/if} + {@render (description ?? defaultDescription)()} {#if error}
@@ -86,7 +104,7 @@
- + optional
{ if (e.key === 'Enter' && !snapshotting) handleConfirm(); }} /> -

Leave blank to use an auto-generated name.

+

{hint}

@@ -118,9 +136,9 @@ - Capturing... + {pendingLabel} {:else} - Capture snapshot + {confirmLabel} {/if}
diff --git a/frontend/src/lib/components/TerminalTab.svelte b/frontend/src/lib/components/TerminalTab.svelte index f1e96374..3f17c81d 100644 --- a/frontend/src/lib/components/TerminalTab.svelte +++ b/frontend/src/lib/components/TerminalTab.svelte @@ -255,7 +255,7 @@ const int = internals.get(id); if (!int) return; - if (!auth.token) { + if (!auth.isAuthenticated) { updateSession(id, { state: 'error', errorMessage: 'Not authenticated' }); return; } @@ -263,13 +263,12 @@ const display = sessions.find(s => s.id === id); const tag = reconnectTag ?? display?.ptyTag; + // Browser sends wrenn_sid cookie on the WS upgrade automatically (same-origin). const ws = new WebSocket(getWsUrl()); int.ws = ws; updateSession(id, { state: 'connecting', errorMessage: null }); ws.onopen = () => { - // Send auth as the first message (JWT no longer in URL). - wsSend(ws, JSON.stringify({ type: 'auth', token: auth.token })); const { cols, rows } = int.term; const msg: Record = { type: tag ? 'connect' : 'start', diff --git a/frontend/src/lib/lifecycle-toasts.ts b/frontend/src/lib/lifecycle-toasts.ts new file mode 100644 index 00000000..be1185a6 --- /dev/null +++ b/frontend/src/lib/lifecycle-toasts.ts @@ -0,0 +1,39 @@ +import type { SSEEvent } from '$lib/api/events'; +import { toast } from '$lib/toast.svelte'; + +// Terminal copy per lifecycle verb. Success and failure are paired so the two +// can never drift apart. +const VERBS: Record = { + 'capsule.create': { done: 'Capsule created', failed: 'Capsule failed to start' }, + 'capsule.pause': { done: 'Capsule paused', failed: 'Capsule failed to pause' }, + 'capsule.resume': { done: 'Capsule resumed', failed: 'Capsule failed to resume' }, + 'capsule.destroy': { done: 'Capsule destroyed', failed: 'Capsule failed to destroy' } +}; + +/** + * Surfaces lifecycle outcomes as toasts. Only system-actor events with an + * outcome are terminal: the user-actor events published at request-accept time + * carry a premature outcome (the operation has only been accepted, not yet + * completed) and are skipped, so each operation toasts exactly once. + */ +export function lifecycleToast(event: SSEEvent): void { + if (event.actor?.type !== 'system' || !event.outcome) return; + + if (event.event === 'template.snapshot.create') { + const name = event.resource?.id; + if (event.outcome === 'success') { + toast.success(name ? `Snapshot "${name}" captured` : 'Snapshot captured'); + } else { + toast.error(event.error ? `Snapshot failed: ${event.error}` : 'Snapshot failed'); + } + return; + } + + const verb = VERBS[event.event]; + if (!verb) return; + if (event.outcome === 'success') { + toast.success(verb.done); + } else { + toast.error(event.error ? `${verb.failed}: ${event.error}` : verb.failed); + } +} diff --git a/frontend/src/lib/sse.svelte.ts b/frontend/src/lib/sse.svelte.ts new file mode 100644 index 00000000..68207e55 --- /dev/null +++ b/frontend/src/lib/sse.svelte.ts @@ -0,0 +1,75 @@ +import { connectEventStream, type SSEEvent, type EventStreamConnection } from '$lib/api/events'; +import { auth } from '$lib/auth.svelte'; + +type SSEListener = (event: SSEEvent) => void; + +let connection: EventStreamConnection | null = null; +let adminConnection: EventStreamConnection | null = null; +let listeners = new Set(); +let adminListeners = new Set(); +let started = false; +let adminStarted = false; + +function dispatch(event: SSEEvent) { + for (const fn of listeners) { + fn(event); + } +} + +function adminDispatch(event: SSEEvent) { + for (const fn of adminListeners) { + fn(event); + } +} + +function ensureConnected() { + if (connection || !auth.isAuthenticated) return; + connection = connectEventStream(dispatch); +} + +function ensureAdminConnected() { + if (adminConnection || !auth.isAuthenticated) return; + adminConnection = connectEventStream(adminDispatch, { admin: true }); +} + +export function startSSE() { + if (started) return; + started = true; + ensureConnected(); +} + +export function stopSSE() { + started = false; + connection?.close(); + connection = null; + listeners.clear(); +} + +export function startAdminSSE() { + if (adminStarted) return; + adminStarted = true; + ensureAdminConnected(); +} + +export function stopAdminSSE() { + adminStarted = false; + adminConnection?.close(); + adminConnection = null; + adminListeners.clear(); +} + +export function subscribeSSE(fn: SSEListener): () => void { + listeners.add(fn); + ensureConnected(); + return () => { + listeners.delete(fn); + }; +} + +export function subscribeAdminSSE(fn: SSEListener): () => void { + adminListeners.add(fn); + ensureAdminConnected(); + return () => { + adminListeners.delete(fn); + }; +} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 72b205e3..e1265943 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,3 +1,15 @@ -// Static site generation — all pages prerendered +// Static site generation — all pages prerendered. +import { browser } from '$app/environment'; +import { auth } from '$lib/auth.svelte'; + export const prerender = true; export const ssr = false; + +// Bootstrap auth state once for the whole app. Children load functions can +// then read auth.isAuthenticated synchronously without an async race. +export async function load() { + if (!browser) return; + if (!auth.initialized) { + await auth.init(); + } +} diff --git a/frontend/src/routes/activate/+page.svelte b/frontend/src/routes/activate/+page.svelte index 14bea927..2ab0cb83 100644 --- a/frontend/src/routes/activate/+page.svelte +++ b/frontend/src/routes/activate/+page.svelte @@ -28,7 +28,7 @@ done = true; teams.reset(); - auth.login(result.data); + auth.setUser(result.data); goto('/dashboard'); }); diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index de91ec9b..47eb57ae 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -1,6 +1,8 @@
diff --git a/frontend/src/routes/admin/capsules/+page.svelte b/frontend/src/routes/admin/capsules/+page.svelte index b0f19233..19de44e6 100644 --- a/frontend/src/routes/admin/capsules/+page.svelte +++ b/frontend/src/routes/admin/capsules/+page.svelte @@ -10,6 +10,8 @@ destroyAdminCapsule, } from '$lib/api/admin-capsules'; import type { Capsule } from '$lib/api/capsules'; + import { subscribeAdminSSE } from '$lib/sse.svelte'; + import type { SSEEvent } from '$lib/api/events'; const REFRESH_INTERVAL = 15; const SPIN_DURATION = 600; @@ -147,8 +149,10 @@ function statusColor(status: string): string { switch (status) { case 'running': return 'var(--color-accent)'; - case 'paused': return 'var(--color-amber)'; + case 'paused': case 'hibernated': return 'var(--color-amber)'; case 'error': return 'var(--color-red)'; + case 'pending': case 'starting': case 'resuming': case 'pausing': case 'stopping': + return 'var(--color-blue)'; default: return 'var(--color-text-muted)'; } } @@ -156,8 +160,10 @@ function statusBg(status: string): string { switch (status) { case 'running': return 'rgba(94,140,88,0.12)'; - case 'paused': return 'rgba(212,167,60,0.12)'; + case 'paused': case 'hibernated': return 'rgba(212,167,60,0.12)'; case 'error': return 'rgba(207,129,114,0.12)'; + case 'pending': case 'starting': case 'resuming': case 'pausing': case 'stopping': + return 'rgba(90,159,212,0.12)'; default: return 'rgba(255,255,255,0.05)'; } } @@ -165,8 +171,10 @@ function statusBorder(status: string): string { switch (status) { case 'running': return 'rgba(94,140,88,0.3)'; - case 'paused': return 'rgba(212,167,60,0.3)'; + case 'paused': case 'hibernated': return 'rgba(212,167,60,0.3)'; case 'error': return 'rgba(207,129,114,0.3)'; + case 'pending': case 'starting': case 'resuming': case 'pausing': case 'stopping': + return 'rgba(90,159,212,0.3)'; default: return 'rgba(255,255,255,0.08)'; } } @@ -188,6 +196,33 @@ return `${Math.floor(seconds / 86400)}d ago`; } + function handleSSEEvent(event: SSEEvent) { + if (!event.resource || event.resource.type !== 'sandbox') return; + + const sandboxId = event.resource.id; + + if (event.event === 'capsule.destroy') { + capsules = capsules.filter((c) => c.id !== sandboxId); + return; + } + + if (event.sandbox) { + const idx = capsules.findIndex((c) => c.id === sandboxId); + if (idx >= 0) { + capsules[idx] = event.sandbox; + capsules = capsules; + } else if (event.event === 'capsule.create') { + capsules = [event.sandbox, ...capsules]; + newCapsuleId = sandboxId; + setTimeout(() => { newCapsuleId = null; }, 1600); + } + return; + } + + // Server-side hydration failed; refetch list so badges don't go stale. + void fetchCapsules(); + } + function handleVisibility() { if (document.hidden) { stopAutoRefresh(); @@ -197,14 +232,18 @@ } } + let unsubscribe: (() => void) | null = null; + onMount(() => { fetchCapsules(); startAutoRefresh(); + unsubscribe = subscribeAdminSSE(handleSSEEvent); document.addEventListener('visibilitychange', handleVisibility); }); onDestroy(() => { stopAutoRefresh(); + unsubscribe?.(); document.removeEventListener('visibilitychange', handleVisibility); }); @@ -418,7 +457,8 @@
{:else} {#each filteredCapsules as capsule, i (capsule.id)} - {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : capsule.status === 'error' ? 'bg-[var(--color-red)]' : 'bg-[var(--color-text-muted)]'} + {@const isTransient = ['pending', 'starting', 'resuming', 'pausing', 'stopping'].includes(capsule.status)} + {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : (capsule.status === 'paused' || capsule.status === 'hibernated') ? 'bg-[var(--color-amber)]' : capsule.status === 'error' ? 'bg-[var(--color-red)]' : isTransient ? 'bg-[var(--color-blue)]' : 'bg-[var(--color-text-muted)]'}
- {:else if capsule.status === 'paused'} + {:else if capsule.status === 'paused' || capsule.status === 'hibernated'} {:else if capsule.status === 'error'} + {:else if isTransient} + + + + {:else} {/if} @@ -484,7 +529,7 @@
- {#if capsule.status === 'running' || capsule.status === 'paused'} + {#if capsule.status === 'running' || capsule.status === 'paused' || capsule.status === 'hibernated'} + {/if} + {#if canDestroy} - -
-
-
-
-{/if} + { showSnapshot = false; }} + onsnapshot={(updated) => { toast.success('Snapshot started'); capsule = updated; }} + snapshotFn={snapshotAdminCapsule} + title="Snapshot as platform template" + label="Template name" + placeholder="e.g. python-3.12, node-22-dev" + hint="Leave blank for an auto-generated name. Each snapshot needs a unique name." + confirmLabel="Snapshot" + pendingLabel="Snapshotting..." + description={adminSnapshotDescription} +/> ('platform'); // All hosts fetched once - let allHosts = $state([]); + let allHosts = $state([]); let loading = $state(true); let error = $state(null); @@ -29,7 +30,7 @@ let byocHosts = $derived(allHosts.filter((h) => h.type === 'byoc')); let byocPage = $state(0); - type TeamGroup = { teamId: string | null; teamName: string; hosts: Host[] }; + type TeamGroup = { teamId: string | null; teamName: string; hosts: AdminHost[] }; let byocGroups = $derived.by(() => { const map = new Map(); @@ -56,6 +57,14 @@ let onlineCount = $derived(allHosts.filter((h) => h.status === 'online').length); let pendingCount = $derived(allHosts.filter((h) => h.status === 'pending').length); let totalCount = $derived(allHosts.length); + let totalCpuCores = $derived(allHosts.reduce((sum, h) => sum + (h.cpu_cores ?? 0), 0)); + let totalMemoryMb = $derived(allHosts.reduce((sum, h) => sum + (h.memory_mb ?? 0), 0)); + let totalRunningVcpus = $derived(allHosts.reduce((sum, h) => sum + h.running_vcpus, 0)); + let totalRunningMemoryMb = $derived(allHosts.reduce((sum, h) => sum + h.running_memory_mb, 0)); + + function formatMem(mb: number): string { + return mb >= 1024 ? `${(mb / 1024).toFixed(0)} GB` : `${mb} MB`; + } // Create dialog (platform hosts) let showCreate = $state(false); @@ -69,7 +78,7 @@ let checkmarkVisible = $state(false); // Delete confirmation - let deleteTarget = $state(null); + let deleteTarget = $state(null); let deletePreviewCapsules = $state([]); let deletePreviewLoading = $state(false); let deleting = $state(false); @@ -81,7 +90,7 @@ async function fetchHosts() { loading = true; error = null; - const result = await listHosts(); + const result = await listAdminHosts(); if (result.ok) { allHosts = result.data; } else { @@ -114,7 +123,7 @@ creating = false; } - async function openDeleteConfirm(host: Host) { + async function openDeleteConfirm(host: AdminHost) { deleteTarget = host; deleteError = null; deletePreviewCapsules = []; @@ -187,7 +196,7 @@
{totalCount} - total + hosts
@@ -203,6 +212,18 @@ pending
{/if} + {#if totalCpuCores > 0} +
+ {totalRunningVcpus}/{totalCpuCores} + vCPU used +
+ {/if} + {#if totalMemoryMb > 0} +
+ {formatMem(totalRunningMemoryMb)} + RAM of {formatMem(totalMemoryMb)} +
+ {/if}
{/if} @@ -312,6 +333,9 @@ Host Status Specs + vCPU + Memory + Disk Last Heartbeat @@ -329,6 +353,15 @@
+ +
+ + +
+ + +
+
@@ -342,7 +375,7 @@ {/snippet} -{#snippet hostsTable(hosts: Host[], _showTeam: boolean)} +{#snippet hostsTable(hosts: AdminHost[], _showTeam: boolean)} {#if hosts.length === 0} {@render emptyState('platform')} {:else} @@ -353,6 +386,9 @@ Host Status Specs + vCPU + Memory + Disk Last Heartbeat @@ -393,6 +429,15 @@ {formatSpecs(host)} + + {host.running_vcpus > 0 ? `${host.running_vcpus} / ${host.cpu_cores ?? '—'}` : '—'} + + + {host.running_memory_mb > 0 ? `${formatMem(host.running_memory_mb)} / ${host.memory_mb ? formatMem(host.memory_mb) : '—'}` : '—'} + + + {host.running_disk_mb > 0 ? formatMem(host.running_disk_mb) : '—'} + {host.last_heartbeat_at ? timeAgo(host.last_heartbeat_at) : '—'} diff --git a/frontend/src/routes/admin/teams/+page.svelte b/frontend/src/routes/admin/teams/+page.svelte index 90a8db28..4cd806ce 100644 --- a/frontend/src/routes/admin/teams/+page.svelte +++ b/frontend/src/routes/admin/teams/+page.svelte @@ -34,6 +34,11 @@ // Stats let byocCount = $derived(teams.filter((t) => t.is_byoc).length); let totalActiveSandboxes = $derived(teams.reduce((sum, t) => sum + t.active_sandbox_count, 0)); + let totalVcpus = $derived(teams.reduce((sum, t) => sum + t.running_vcpus, 0)); + let totalMemoryMb = $derived(teams.reduce((sum, t) => sum + t.running_memory_mb, 0)); + function formatMem(mb: number): string { + return mb >= 1024 ? `${(mb / 1024).toFixed(0)} GB` : `${mb} MB`; + } async function fetchTeams(page: number = 1) { const wasEmpty = teams.length === 0; @@ -106,7 +111,7 @@