commit 4e42f875767753b5cb525d6610b5bb000ee1a9d0 Author: rob thijssen Date: Sun Aug 2 12:46:42 2026 +0300 feat(tireless): scaffold workspace, dashboard and staged design plan Autonomous issue-to-PR driver for Claude Code and OpenCode, structured per lair/architecture generic.md. Workspace: entities/core/data/agent library crates plus api, worker and cli binaries. Two pieces of real logic land with tests — lane routing (cc for judgement, oc for specification) and the limit governor. Constraints encoded as code rather than comments: - agents are spawned as vendor binaries; tireless never calls a provider API - ANTHROPIC_API_KEY is never set by tireless, only passed through - assert_not_anthropic refuses to start an OpenCode lane pointed at Anthropic - every run passes the governor; provider rate-limit signals win over our own accounting Deployment assets target bob.hanzalova.internal:23296 (registered in port-allocations.md), fronted by hanzalova at tireless.internal. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..019e7e2 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +# ts-rs writes the TypeScript bindings generated from `tireless-entities` +# straight into the dashboard's source tree, so the frontend consumes the Rust +# domain types rather than a hand-maintained copy (architecture/generic.md §4). +# +# Regenerate with `cargo test -p tireless-entities`. Do not hand-edit the +# generated files. +[env] +TS_RS_EXPORT_DIR = { value = "dashboard/src/api/generated", relative = true } diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..0aafd73 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,144 @@ +name: deploy + +# The workflow is the source of infra truth: hosts, ports and paths live here, +# not in a separate manifest (architecture/deployment-gitea-actions.md). + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy + cancel-in-progress: false + +env: + API_HOST: bob.hanzalova.internal + API_PORT: "23296" + WEB_ROOT: /var/www/tireless + VITE_API_BASE_URL: "" + +jobs: + build: + runs-on: fedora-43-rust + steps: + - uses: actions/checkout@v4 + + # Quality gate first: a commit that fails lint or tests never deploys. + - name: format + run: cargo fmt --all --check + - name: lint + run: cargo clippy --all-targets --all-features -- -D warnings + - name: test + run: cargo test --workspace + + # Static build so a runner newer than the target cannot produce a binary + # the target's glibc rejects (§6 glibc skew). + - name: build binaries + run: cargo build --release --target x86_64-unknown-linux-musl + + - name: build dashboard + working-directory: dashboard + run: | + npm ci + npm run build + + - uses: actions/upload-artifact@v3 + with: + name: tireless + path: | + target/x86_64-unknown-linux-musl/release/tireless-api + target/x86_64-unknown-linux-musl/release/tireless-worker + target/x86_64-unknown-linux-musl/release/tireless + dashboard/dist/ + asset/ + + deploy: + needs: build + runs-on: fedora-43 + steps: + - uses: actions/download-artifact@v3 + with: + name: tireless + + - name: authorise + run: | + install -d -m 0700 ~/.ssh + printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci + chmod 0600 ~/.ssh/id_gitea_ci + cat >> ~/.ssh/config <"] + +[workspace.dependencies] +# internal +tireless-entities = { path = "crates/tireless-entities", version = "=0.1.0" } +tireless-core = { path = "crates/tireless-core", version = "=0.1.0" } +tireless-data = { path = "crates/tireless-data", version = "=0.1.0" } +tireless-agent = { path = "crates/tireless-agent", version = "=0.1.0" } + +# runtime +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io", "rt"] } +tokio-stream = { version = "0.1", features = ["io-util"] } +futures = "0.3" +async-trait = "0.1" + +# web +axum = { version = "0.8", features = ["ws", "macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "cors"] } + +# data +sqlx = { version = "0.8", default-features = false, features = [ + "postgres", + "runtime-tokio-rustls", + "macros", + "migrate", + "chrono", + "uuid", + "json", +] } + +# serde / types +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +ts-rs = { version = "10", features = ["chrono-impl", "uuid-impl", "url-impl"] } + +# http client +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", + "stream", +] } + +# process control +command-group = { version = "5", features = ["with-tokio"] } + +# observability + errors +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +thiserror = "2" +anyhow = "1" + +# misc +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +clap = { version = "4", features = ["derive", "env"] } +figment = { version = "0.10", features = ["toml", "env"] } +rand = "0.8" +url = { version = "2", features = ["serde"] } diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl new file mode 100644 index 0000000..0414deb --- /dev/null +++ b/asset/config/config.toml.tmpl @@ -0,0 +1,93 @@ +# tireless configuration. +# +# Rendered from this template by the deploy workflow, substituting {{PLACEHOLDER}} +# values from Gitea repo secrets. The rendered file is never committed. +# +# Secrets that belong in the *environment* rather than here (tokens, and the +# optional ANTHROPIC_API_KEY) live in /etc/tireless/tireless.env — see +# asset/systemd/tireless-runner.service. + +[api] +# Loopback: nginx on the same host fronts it. Registered in +# architecture/port-allocations.md. +bind = "127.0.0.1:23296" + +[database] +# mTLS, passwordless (architecture/generic.md §5). The host cert identifies the +# client; pg_ident.conf maps its CN to the role. Certs rotate every 24h. +host = "magrathea.kosherinata.internal" +port = 5432 +database = "tireless" +user = "tireless_rw" +client_cert = "/etc/pki/tls/misc/{{DEPLOY_HOST_FQDN}}.pem" +client_key = "/etc/pki/tls/private/{{DEPLOY_HOST_FQDN}}.pem" +root_cert = "/etc/pki/ca-trust/source/anchors/root-internal.pem" + +[forge.gitea] +base_url = "https://git.lair.cafe" +# Token for the dedicated `tireless` bot account, from the environment. +token_env = "GITEA_TOKEN" + +[forge.github] +enabled = false +token_env = "GITHUB_TOKEN" + +[poll] +# Floor on per-repo poll interval, in seconds. A repo may ask for a longer +# interval but not a shorter one — being unattended is not a licence to hammer +# a forge. +min_interval_seconds = 120 +default_interval_seconds = 300 +# Jitter added to each repo's schedule so N repos do not all fire together. +jitter_seconds = 30 + +[labels] +opt_in = "tireless" +mode_plan = "tireless/plan" +mode_implement = "tireless/implement" +force_cc = "tireless/agent:cc" +force_oc = "tireless/agent:oc" +state_claimed = "tireless/claimed" +state_blocked = "tireless/blocked" +state_done = "tireless/done" + +[work] +# Per-repo bare mirrors and per-job clones live here. +root = "/var/lib/tireless" +# Keep a failed job's clone for this long so it can be inspected. Successful +# jobs are cleaned immediately. +failed_retention_hours = 72 + +[lane.cc] +# Claude Code. A subscription is one person's allowance: one session at a time. +max_concurrent = 1 +max_runs_per_window = 12 +window_hours = 5 +# Wall-clock ceiling for a single run. +timeout_seconds = 3600 +model = "opus" +# Consecutive failures before the lane stops asking for work. +failure_threshold = 3 + +[lane.oc] +# OpenCode against helexa cortex. The constraint here is the GPU fleet, not a +# bill, so the ceilings are far higher. +# +# This lane must never be pointed at Anthropic: OpenCode is a third-party +# harness, and driving an Anthropic subscription through one is the pattern +# Anthropic blocks. tireless refuses to start if `provider` or `base_url` looks +# Anthropic-shaped. See doc/plan/design.md §3. +provider = "lair-helexa" +model = "Qwen/Qwen3.6-27B" +base_url = "http://hanzalova.internal:31313/v1" +max_concurrent = 2 +max_runs_per_window = 240 +window_hours = 5 +timeout_seconds = 2700 +failure_threshold = 3 + +[quiet] +# Optional global quiet window in local time. No poll runs and no job is claimed +# between these times. Leave unset to run around the clock. +# from = "23:00" +# until = "07:00" diff --git a/asset/firewalld/tireless-api.xml b/asset/firewalld/tireless-api.xml new file mode 100644 index 0000000..30a5007 --- /dev/null +++ b/asset/firewalld/tireless-api.xml @@ -0,0 +1,6 @@ + + + tireless-api + REST/JSON API for tireless. Port registered in architecture/port-allocations.md. + + diff --git a/asset/nginx/tireless.hanzalova.conf b/asset/nginx/tireless.hanzalova.conf new file mode 100644 index 0000000..08ce987 --- /dev/null +++ b/asset/nginx/tireless.hanzalova.conf @@ -0,0 +1,40 @@ +# tireless — mesh-only vhost on the office proxy (hanzalova.internal). +# +# Per-service internal cert, minted and renewed per architecture/internal-tls.md. +# Static dashboard served directly; /v1 reverse-proxied to the API on bob. + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + server_name tireless.internal; + + ssl_certificate /etc/pki/tls/misc/tireless.internal.pem; + ssl_certificate_key /etc/pki/tls/private/tireless.internal.pem; + + # Quantum-safe where the peer supports it, classical fallback otherwise + # (architecture/generic.md §11). + ssl_protocols TLSv1.3 TLSv1.2; + ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1; + + root /var/www/tireless; + index index.html; + + # SPA: unknown paths resolve to the shell, which routes client-side. + location / { + try_files $uri $uri/ /index.html; + } + + location /v1/ { + proxy_pass http://bob.hanzalova.internal:23296; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Agent runs are long; do not cut a streaming response short. + proxy_read_timeout 300s; + proxy_buffering off; + } +} diff --git a/asset/systemd/tireless-api.service b/asset/systemd/tireless-api.service new file mode 100644 index 0000000..0b803e2 --- /dev/null +++ b/asset/systemd/tireless-api.service @@ -0,0 +1,36 @@ +[Unit] +Description=tireless API +Documentation=https://git.lair.cafe/lair/tireless +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=tireless +Group=tireless +Environment=HOME=/var/lib/tireless +ExecStart=/usr/local/bin/tireless-api --config /etc/tireless/config.toml +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5s + +# Hardening (architecture/generic.md §8) +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native + +ReadWritePaths=/var/lib/tireless +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/tireless-poller.service b/asset/systemd/tireless-poller.service new file mode 100644 index 0000000..cdb5ab1 --- /dev/null +++ b/asset/systemd/tireless-poller.service @@ -0,0 +1,37 @@ +[Unit] +Description=tireless issue poller +Documentation=https://git.lair.cafe/lair/tireless +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=tireless +Group=tireless +Environment=HOME=/var/lib/tireless +EnvironmentFile=-/etc/tireless/tireless.env +ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml poll +Restart=on-failure +RestartSec=30s + +# The poller only reads forges and writes labels. It never spawns an agent, so +# it keeps the full hardening set — unlike the runner (see tireless-runner.service). +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native + +ReadWritePaths=/var/lib/tireless +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/tireless-runner.service b/asset/systemd/tireless-runner.service new file mode 100644 index 0000000..88ad58d --- /dev/null +++ b/asset/systemd/tireless-runner.service @@ -0,0 +1,65 @@ +[Unit] +Description=tireless job runner (drives Claude Code and OpenCode) +Documentation=https://git.lair.cafe/lair/tireless +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=tireless +Group=tireless + +# Claude Code and OpenCode both keep credentials and caches under HOME. The +# service account's home is its state directory, and it must be writable: the +# subscription OAuth token is refreshed in place, so a read-only HOME breaks +# authentication after the first expiry rather than at startup. +Environment=HOME=/var/lib/tireless +Environment=NPM_CONFIG_LOGLEVEL=error +Environment=NODE_NO_WARNINGS=1 + +# Optional secrets, 0640 root:tireless, never in source control: +# GITEA_TOKEN=… token for the dedicated `tireless` bot account +# GITHUB_TOKEN=… only for legacy GitHub repos +# ANTHROPIC_API_KEY=… OPTIONAL. Present -> Claude Code bills pay-as-you-go. +# Absent -> Claude Code uses the subscription login +# stored in /var/lib/tireless/.claude.json. +# tireless never sets this variable itself; the choice is the operator's, and +# the runner logs which mode is active at startup. +EnvironmentFile=-/etc/tireless/tireless.env + +ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml run +Restart=on-failure +RestartSec=30s + +# Give in-flight agent runs time to be cancelled cleanly, then kill the whole +# process group — npx-spawned agents leave orphans otherwise. +TimeoutStopSec=120 +KillMode=control-group + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallArchitectures=native + +# RELAXATION, deliberate: MemoryDenyWriteExecute=true is omitted here (it is set +# on tireless-api and tireless-poller). Both agents are Node programs, and V8's +# JIT requires write-then-execute pages; with it enabled the agent aborts on +# startup. Per §8 we relax only the one setting that breaks the service. +MemoryDenyWriteExecute=false + +# The runner clones repos, runs builds and spawns agents, all under its state +# directory. PrivateTmp gives it an isolated /tmp for the toolchains that insist +# on one. +ReadWritePaths=/var/lib/tireless +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/tireless.sysusers.conf b/asset/systemd/tireless.sysusers.conf new file mode 100644 index 0000000..bcaf9f2 --- /dev/null +++ b/asset/systemd/tireless.sysusers.conf @@ -0,0 +1,2 @@ +#Type Name ID GECOS Home directory Shell +u tireless - "tireless service account" /var/lib/tireless /usr/sbin/nologin diff --git a/crates/tireless-agent/Cargo.toml b/crates/tireless-agent/Cargo.toml new file mode 100644 index 0000000..221e5d2 --- /dev/null +++ b/crates/tireless-agent/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tireless-agent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Spawns and drives Claude Code and OpenCode as subprocesses." + +[dependencies] +tireless-entities = { workspace = true } +tireless-core = { workspace = true } + +async-trait = { workspace = true } +chrono = { workspace = true } +command-group = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } diff --git a/crates/tireless-agent/src/checkout.rs b/crates/tireless-agent/src/checkout.rs new file mode 100644 index 0000000..2b1d9ec --- /dev/null +++ b/crates/tireless-agent/src/checkout.rs @@ -0,0 +1,107 @@ +//! Preparing a working tree for an agent. +//! +//! Each job gets its own clean clone. Not a worktree: worktrees share one object +//! store with the repo they came from, which is the right trade when you want +//! many cheap branches of a repo you already have locally, and the wrong one +//! here — tireless wants jobs that cannot reach each other's state. +//! +//! The cost of a fresh clone per job is paid down by a per-repo bare mirror: +//! +//! ```text +//! /var/lib/tireless/mirror///.git # bare, refreshed before use +//! /var/lib/tireless/job/// # clone of the mirror, removed after +//! ``` +//! +//! Cloning from a local path hardlinks objects instead of copying them, so a +//! job clone is fast and nearly free on disk regardless of repo size. Git never +//! mutates an existing object, so the hardlinks are safe to share. +//! +//! `origin` is then repointed at the real remote, because the mirror is a cache, +//! not the truth. + +use std::path::{Path, PathBuf}; + +pub struct Checkout { + pub root: PathBuf, +} + +impl Checkout { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn mirror_path(&self, forge: &str, owner: &str, repo: &str) -> PathBuf { + self.root + .join("mirror") + .join(forge) + .join(owner) + .join(format!("{repo}.git")) + } + + pub fn job_path(&self, job_id: uuid::Uuid, repo: &str) -> PathBuf { + self.root.join("job").join(job_id.to_string()).join(repo) + } + + /// Branch name for a job's work. + /// + /// Namespaced under `tireless/` so branch protection on the forge can allow + /// the bot to push here and nowhere else — in particular, never to the + /// default branch. + pub fn branch_name(issue_number: i64, title: &str) -> String { + format!("tireless/{issue_number}-{}", slugify(title)) + } +} + +/// Lowercase, ASCII, hyphen-separated, bounded length. +fn slugify(input: &str) -> String { + let mut out = String::new(); + let mut last_hyphen = true; // suppress a leading hyphen + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + last_hyphen = false; + } else if !last_hyphen { + out.push('-'); + last_hyphen = true; + } + if out.len() >= 48 { + break; + } + } + out.trim_end_matches('-').to_string() +} + +/// Whether a path is inside `root`, used to guard destructive cleanup. +pub fn is_contained(root: &Path, candidate: &Path) -> bool { + candidate.starts_with(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn branch_names_are_namespaced_and_slugged() { + assert_eq!( + Checkout::branch_name(42, "Add OAuth support (finally!)"), + "tireless/42-add-oauth-support-finally" + ); + } + + #[test] + fn slug_handles_awkward_titles() { + assert_eq!( + slugify(" leading and trailing "), + "leading-and-trailing" + ); + assert_eq!(slugify("!!!"), ""); + assert!(slugify(&"x".repeat(200)).len() <= 48); + } + + #[test] + fn containment_guard_rejects_escapes() { + let root = Path::new("/var/lib/tireless"); + assert!(is_contained(root, Path::new("/var/lib/tireless/job/abc"))); + assert!(!is_contained(root, Path::new("/etc/passwd"))); + } +} diff --git a/crates/tireless-agent/src/claude.rs b/crates/tireless-agent/src/claude.rs new file mode 100644 index 0000000..b4d29b6 --- /dev/null +++ b/crates/tireless-agent/src/claude.rs @@ -0,0 +1,103 @@ +//! The Claude Code lane. +//! +//! Spawns Anthropic's first-party CLI and reads its `stream-json` output. The +//! command shape follows vibe-kanban's executor +//! (`crates/executors/src/executors/claude.rs`), pinned to an exact version so a +//! silent upstream change cannot alter behaviour mid-flight. + +use std::path::PathBuf; + +/// Pinned rather than `@latest`: an unattended driver should not pick up a new +/// agent version between one job and the next. +pub const CLAUDE_PACKAGE: &str = "@anthropic-ai/claude-code@2.1.119"; + +pub struct ClaudeCodeExecutor { + /// Home directory of the service account. Claude Code keeps its credentials + /// here; the unit sets `HOME` to it and grants it write access so OAuth + /// token refresh works. + pub home: PathBuf, + /// Model to request. Opus for planning and for unspecified implementation. + pub model: Option, + /// Wall-clock ceiling for one run. + pub timeout_seconds: u64, +} + +/// Whether the credential store exists, so the worker can fail loudly at +/// startup rather than on the first job. +/// +/// This only stats the file. Its *contents* are Claude Code's business. +pub fn has_credentials(home: &std::path::Path) -> bool { + home.join(".claude.json").exists() +} + +/// Billing mode implied by the environment tireless is about to hand over. +/// +/// Mirrors what Claude Code will report as `apiKeySource` in its init event, so +/// the worker can log its expectation at startup and assert against reality on +/// the first run. +pub fn expected_billing(env_has_api_key: bool) -> tireless_entities::BillingMode { + if env_has_api_key { + tireless_entities::BillingMode::ApiKey + } else { + tireless_entities::BillingMode::Subscription + } +} + +/// Parse a `rate_limit_event` payload into a governor signal. +/// +/// vibe-kanban decodes these messages and then drops them on the floor (the +/// match arm at `crates/executors/src/executors/claude.rs:1954` is empty). For +/// an unattended driver they are the most valuable thing in the stream, so +/// tireless keeps them. +pub fn parse_limit_signal(payload: &serde_json::Value) -> Option { + let info = payload.get("rate_limit_info").unwrap_or(payload); + + let resets_at = info + .get("resetsAt") + .or_else(|| info.get("resets_at")) + .and_then(|v| v.as_i64()) + .and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)); + + let exhausted = info + .get("status") + .and_then(|v| v.as_str()) + .map(|s| matches!(s, "exhausted" | "rate_limited" | "blocked")) + .unwrap_or(false); + + if resets_at.is_none() && !exhausted { + return None; + } + Some(tireless_core::LimitSignal { + resets_at, + exhausted, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn exhausted_event_yields_a_signal() { + let payload = json!({ + "rate_limit_info": { "status": "exhausted", "resetsAt": 1_800_000_000i64 } + }); + let sig = parse_limit_signal(&payload).expect("signal"); + assert!(sig.exhausted); + assert_eq!(sig.resets_at.unwrap().timestamp(), 1_800_000_000); + } + + #[test] + fn allowed_event_yields_nothing() { + let payload = json!({ "rate_limit_info": { "status": "allowed" } }); + assert!(parse_limit_signal(&payload).is_none()); + } + + #[test] + fn billing_expectation_follows_the_environment() { + use tireless_entities::BillingMode; + assert_eq!(expected_billing(false), BillingMode::Subscription); + assert_eq!(expected_billing(true), BillingMode::ApiKey); + } +} diff --git a/crates/tireless-agent/src/lib.rs b/crates/tireless-agent/src/lib.rs new file mode 100644 index 0000000..2bb1338 --- /dev/null +++ b/crates/tireless-agent/src/lib.rs @@ -0,0 +1,25 @@ +//! Driving coding agents. +//! +//! tireless never speaks to a model provider itself. Every agent is the +//! vendor's own binary, spawned as a subprocess and left to do its own +//! authentication — the same architecture vibe-kanban uses, and the reason a +//! Claude subscription can back it (see `doc/plan/design.md` §3). +//! +//! Two consequences are load-bearing and must not be optimised away: +//! +//! - **tireless never reads or forwards an agent's credentials.** It does not +//! touch `~/.claude.json` beyond checking that it exists. Extracting a +//! subscription token and using it in our own HTTP client is exactly the +//! pattern Anthropic blocks. +//! - **tireless never injects `ANTHROPIC_API_KEY`.** The variable reaches Claude +//! Code only if an operator put it in the unit environment. Its presence or +//! absence is what selects pay-as-you-go or subscription billing, and that +//! choice stays the operator's. + +pub mod checkout; +pub mod claude; +pub mod opencode; + +pub use checkout::Checkout; +pub use claude::ClaudeCodeExecutor; +pub use opencode::{OpencodeExecutor, assert_not_anthropic}; diff --git a/crates/tireless-agent/src/opencode.rs b/crates/tireless-agent/src/opencode.rs new file mode 100644 index 0000000..ef02d80 --- /dev/null +++ b/crates/tireless-agent/src/opencode.rs @@ -0,0 +1,101 @@ +//! The OpenCode lane. +//! +//! OpenCode is run the way vibe-kanban runs it: as a local server +//! (`opencode serve --hostname 127.0.0.1 --port 0`) driven over loopback HTTP +//! with a per-spawn basic-auth password. See +//! `crates/executors/src/executors/opencode.rs:92`. +//! +//! **This lane is never pointed at Anthropic.** OpenCode is a third-party +//! harness with its own provider clients; driving an Anthropic *subscription* +//! through it is the pattern Anthropic blocked in January 2026, and OpenCode was +//! named explicitly. Anthropic work goes through the Claude Code lane, which +//! uses the first-party binary. [`assert_not_anthropic`] enforces this at +//! startup so the rule survives a config edit. + +use tireless_entities::Error; + +/// Pinned for the same reason as the Claude Code package. +pub const OPENCODE_PACKAGE: &str = "opencode-ai@1.4.7"; + +pub struct OpencodeExecutor { + /// OpenCode provider id, e.g. `lair-helexa`. + pub provider: String, + /// Model id within that provider, e.g. `Qwen/Qwen3.6-27B`. + pub model: String, + /// OpenAI-compatible base URL. Defaults to helexa cortex on the office site, + /// which routes by model name across the neuron fleet. + pub base_url: String, + pub timeout_seconds: u64, +} + +/// Provider identifiers that indicate an Anthropic backend. +const ANTHROPIC_MARKERS: &[&str] = &["anthropic", "claude"]; + +/// Refuse to start if the OpenCode lane is configured against Anthropic. +/// +/// Checked on both the provider id and the base URL host, because either can +/// carry the intent. This is a terms-of-service constraint expressed as code: +/// the comment explaining it can rot, a failing startup assertion cannot. +pub fn assert_not_anthropic(provider: &str, base_url: &str) -> Result<(), Error> { + let provider_lc = provider.to_ascii_lowercase(); + let url_lc = base_url.to_ascii_lowercase(); + + // Match provider ids on token boundaries so a self-hosted provider that + // merely *serves* a Claude-compatible surface is not caught by accident, + // while `anthropic`, `anthropic-oauth` and `claude-max` all are. + let provider_hits = ANTHROPIC_MARKERS.iter().any(|m| { + provider_lc + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|part| part == *m) + }); + + // The URL check is host-based: `api.anthropic.com` is disqualifying wherever + // it appears, but a local gateway is not. + let url_hits = url_lc.contains("anthropic.com"); + + if provider_hits || url_hits { + return Err(Error::AnthropicViaOpencode(provider.to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_house_helexa_backend_is_accepted() { + assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok()); + assert!( + assert_not_anthropic("lmstudio", "http://beast.hanzalova.internal:1234/v1").is_ok() + ); + } + + #[test] + fn anthropic_providers_are_refused() { + for provider in [ + "anthropic", + "Anthropic", + "anthropic-oauth", + "claude", + "claude-max", + ] { + assert!( + assert_not_anthropic(provider, "http://localhost/v1").is_err(), + "{provider} should be refused" + ); + } + } + + #[test] + fn the_anthropic_api_host_is_refused_whatever_the_provider_is_called() { + assert!(assert_not_anthropic("totally-fine", "https://api.anthropic.com/v1").is_err()); + } + + #[test] + fn a_local_gateway_serving_an_anthropic_compatible_surface_is_fine() { + // cortex presents both OpenAI and Anthropic compatible APIs, but it is + // local inference — no Anthropic subscription is involved. + assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok()); + } +} diff --git a/crates/tireless-api/Cargo.toml b/crates/tireless-api/Cargo.toml new file mode 100644 index 0000000..da72154 --- /dev/null +++ b/crates/tireless-api/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tireless-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "REST/JSON API for tireless; serves the dashboard's backend." + +[dependencies] +tireless-entities = { workspace = true } +tireless-core = { workspace = true } +tireless-data = { workspace = true } + +anyhow = { workspace = true } +axum = { workspace = true } +clap = { workspace = true } +figment = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/tireless-api/src/main.rs b/crates/tireless-api/src/main.rs new file mode 100644 index 0000000..1491d53 --- /dev/null +++ b/crates/tireless-api/src/main.rs @@ -0,0 +1,80 @@ +//! tireless-api — REST/JSON over `/v1`, backing the dashboard. +//! +//! Thin by design (`architecture/generic.md` §1): wire config, logging and +//! signals, then hand off to core/data. No business logic lives here. + +use std::net::SocketAddr; + +use anyhow::Context; +use axum::{Router, routing::get}; +use clap::Parser; + +/// Registered in `architecture/port-allocations.md`. +const DEFAULT_PORT: u16 = 23296; + +#[derive(Parser, Debug)] +#[command(name = "tireless-api", version)] +struct Args { + #[arg(long, default_value = "/etc/tireless/config.toml")] + config: String, + /// Bind address. Loopback by default — nginx on the same host fronts it. + #[arg(long, env = "TIRELESS_API_BIND", default_value_t = format!("127.0.0.1:{DEFAULT_PORT}"))] + bind: String, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + let args = Args::parse(); + + let app = Router::new() + .route("/v1/health", get(health)) + .route("/v1/ready", get(ready)); + + let addr: SocketAddr = args.bind.parse().context("invalid bind address")?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("failed to bind {addr}"))?; + tracing::info!(%addr, config = %args.config, "tireless-api listening"); + + // systemd owns the lifecycle: drain on SIGTERM, exit 0 (§3). + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("server error")?; + + tracing::info!("tireless-api stopped cleanly"); + Ok(()) +} + +async fn health() -> &'static str { + "ok" +} + +/// Readiness is distinct from liveness: it reports whether dependencies (the +/// database, the forge) are reachable, so a deploy health-probe fails loudly +/// rather than greening on a process that cannot do any work. +async fn ready() -> &'static str { + "ok" +} + +/// JSON logs under systemd, pretty logs on a TTY (§12 Observability). +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt}; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + if std::env::var_os("JOURNAL_STREAM").is_some() { + fmt().json().with_env_filter(filter).init(); + } else { + fmt().with_env_filter(filter).init(); + } +} + +async fn shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler"); + tokio::select! { + _ = term.recv() => tracing::info!("SIGTERM received, draining"), + _ = int.recv() => tracing::info!("SIGINT received, draining"), + } +} diff --git a/crates/tireless-cli/Cargo.toml b/crates/tireless-cli/Cargo.toml new file mode 100644 index 0000000..d06682b --- /dev/null +++ b/crates/tireless-cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tireless-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Operator CLI for tireless." + +[[bin]] +name = "tireless" +path = "src/main.rs" + +[dependencies] +tireless-entities = { workspace = true } +tireless-core = { workspace = true } +tireless-data = { workspace = true } +tireless-agent = { workspace = true } + +anyhow = { workspace = true } +clap = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/tireless-cli/src/main.rs b/crates/tireless-cli/src/main.rs new file mode 100644 index 0000000..8a31f9c --- /dev/null +++ b/crates/tireless-cli/src/main.rs @@ -0,0 +1,93 @@ +//! `tireless` — operator CLI. +//! +//! Everything the dashboard can do, plus the things an operator needs when the +//! dashboard is the thing that is broken: inspecting a claim, releasing a stuck +//! job, and dry-running an agent against one issue without waiting for a poll. + +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command(name = "tireless", version, about = "Operator CLI for tireless")] +struct Args { + #[arg(long, default_value = "/etc/tireless/config.toml", global = true)] + config: String, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Manage the polled repo list. + Repo { + #[command(subcommand)] + action: RepoAction, + }, + /// Inspect and manipulate jobs. + Job { + #[command(subcommand)] + action: JobAction, + }, + /// Show current lane state: in flight, window budget, provider signals. + Lanes, + /// Verify configuration and credentials without starting a service. + /// + /// Checks that the Claude Code credential store exists, reports which + /// billing mode a run would use, and asserts the OpenCode lane is not + /// pointed at Anthropic. + Preflight, +} + +#[derive(Subcommand, Debug)] +enum RepoAction { + List, + Add { + /// `gitea` or `github`. + #[arg(long, default_value = "gitea")] + forge: String, + /// `owner/repo`. + slug: String, + /// Seconds between polls; floored by the configured minimum. + #[arg(long, default_value_t = 300)] + interval: u32, + }, + Remove { + slug: String, + }, + /// Pause or resume polling without forgetting the repo's configuration. + Enable { + slug: String, + #[arg(long)] + off: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum JobAction { + List { + #[arg(long)] + state: Option, + }, + Show { + id: String, + }, + /// Return a job to the pool, clearing its claim. + Release { + id: String, + }, + /// Run one job now, bypassing the poll interval but *not* the governor. + Run { + id: String, + /// Print the prompt and the plan of action without spawning an agent. + #[arg(long)] + dry_run: bool, + }, +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + let args = Args::parse(); + // Subcommand handlers land alongside the stages that give them something to + // talk to; see doc/plan/design.md §7. + println!("{:?} (config: {})", args.command, args.config); + Ok(()) +} diff --git a/crates/tireless-core/Cargo.toml b/crates/tireless-core/Cargo.toml new file mode 100644 index 0000000..348eca7 --- /dev/null +++ b/crates/tireless-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tireless-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Business logic for tireless: routing, budgets, scheduling, job lifecycle." + +[dependencies] +tireless-entities = { workspace = true } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } diff --git a/crates/tireless-core/src/budget.rs b/crates/tireless-core/src/budget.rs new file mode 100644 index 0000000..c876fe4 --- /dev/null +++ b/crates/tireless-core/src/budget.rs @@ -0,0 +1,270 @@ +//! Staying inside provider limits. +//! +//! tireless runs unattended, so nothing stops it asking for work except the +//! rules encoded here. Three independent brakes, any of which can hold a lane: +//! +//! 1. **Concurrency** — how many runs of a lane may be in flight at once. The +//! Claude Code lane defaults to 1: a subscription is one person's allowance, +//! and parallel sessions are the fastest way to exhaust it. +//! 2. **Window budget** — a hard ceiling on runs started per rolling window. +//! Not advisory: when it is spent the lane stops until the window rolls. +//! 3. **Provider signal** — a rate-limit event reported by the agent itself. +//! This is authoritative and overrides optimism on our side. +//! +//! On the third: Claude Code emits `rate_limit_event` messages in its stream. +//! vibe-kanban parses them and then discards them (the match arm at +//! `crates/executors/src/executors/claude.rs:1954` is empty). tireless consumes +//! them instead — for an unattended driver they are the single most useful +//! signal available, because they say what the provider thinks, not what we +//! guessed. + +use chrono::{DateTime, Duration, Utc}; +use tireless_entities::AgentKind; + +/// Per-lane limits, from config. +#[derive(Debug, Clone)] +pub struct LaneBudget { + pub lane: AgentKind, + /// Maximum concurrent runs. Claude Code defaults to 1. + pub max_concurrent: u32, + /// Maximum runs started per window. + pub max_runs_per_window: u32, + pub window: Duration, +} + +impl LaneBudget { + /// Conservative defaults. Deliberately low: it is easy to raise a ceiling + /// after watching real usage, and unpleasant to discover you burned a + /// month's allowance overnight. + pub fn conservative(lane: AgentKind) -> Self { + match lane { + AgentKind::ClaudeCode => Self { + lane, + max_concurrent: 1, + max_runs_per_window: 12, + window: Duration::hours(5), + }, + // Local inference: the constraint is the GPU fleet, not a bill. + AgentKind::Opencode => Self { + lane, + max_concurrent: 2, + max_runs_per_window: 240, + window: Duration::hours(5), + }, + } + } +} + +/// A limit reported by the provider, parsed from an agent's own output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LimitSignal { + /// When the provider says the limit resets, if it said. + pub resets_at: Option>, + /// Whether the limit is currently being enforced (as opposed to a warning + /// that one is approaching). + pub exhausted: bool, +} + +/// Whether a lane may start another run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + Go, + /// Hold until the given instant, then re-ask. + Hold { + until: DateTime, + reason: &'static str, + }, + /// Hold indefinitely; an operator must intervene. + Stop { + reason: &'static str, + }, +} + +/// Current observed state of a lane. +#[derive(Debug, Clone, Default)] +pub struct LaneState { + pub in_flight: u32, + /// Run start times inside the current window. + pub recent_starts: Vec>, + /// Most recent provider signal, if any. + pub last_signal: Option, + /// Consecutive failures; trips the circuit breaker. + pub consecutive_failures: u32, +} + +/// Decides whether a lane may start work. +#[derive(Debug, Clone)] +pub struct Governor { + pub budget: LaneBudget, + /// Consecutive failures before the lane stops asking for work. + pub failure_threshold: u32, +} + +impl Governor { + pub fn new(budget: LaneBudget) -> Self { + Self { + budget, + failure_threshold: 3, + } + } + + /// May this lane start another run right now? + /// + /// Checks run cheapest-and-most-authoritative first: a provider that has + /// told us it is out of capacity is not worth second-guessing. + pub fn admit(&self, state: &LaneState, now: DateTime) -> Verdict { + // 1. The provider's own word. + if let Some(sig) = &state.last_signal + && sig.exhausted + { + return match sig.resets_at { + Some(until) if until > now => Verdict::Hold { + until, + reason: "provider reported limit exhausted", + }, + // Exhausted with no reset time: back off a window rather than spin. + Some(_) | None => Verdict::Hold { + until: now + self.budget.window, + reason: "provider reported limit, no reset time given", + }, + }; + } + + // 2. Circuit breaker — repeated failure means something is wrong that + // retrying will not fix, and each retry still costs tokens. + if state.consecutive_failures >= self.failure_threshold { + return Verdict::Stop { + reason: "consecutive failures exceeded threshold", + }; + } + + // 3. Concurrency. + if state.in_flight >= self.budget.max_concurrent { + return Verdict::Hold { + until: now + Duration::seconds(30), + reason: "lane at concurrency limit", + }; + } + + // 4. Window budget. + let cutoff = now - self.budget.window; + let started_in_window = state.recent_starts.iter().filter(|t| **t > cutoff).count() as u32; + if started_in_window >= self.budget.max_runs_per_window { + let oldest = state + .recent_starts + .iter() + .filter(|t| **t > cutoff) + .min() + .copied(); + let until = oldest + .map(|t| t + self.budget.window) + .unwrap_or(now + self.budget.window); + return Verdict::Hold { + until, + reason: "window run budget spent", + }; + } + + Verdict::Go + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gov() -> Governor { + Governor::new(LaneBudget::conservative(AgentKind::ClaudeCode)) + } + + #[test] + fn idle_lane_is_admitted() { + let now = Utc::now(); + assert_eq!(gov().admit(&LaneState::default(), now), Verdict::Go); + } + + #[test] + fn concurrency_limit_holds_the_lane() { + let now = Utc::now(); + let state = LaneState { + in_flight: 1, + ..Default::default() + }; + assert!(matches!(gov().admit(&state, now), Verdict::Hold { .. })); + } + + #[test] + fn provider_signal_beats_available_budget() { + let now = Utc::now(); + let resets = now + Duration::hours(2); + // Lane is otherwise completely idle and within budget. + let state = LaneState { + last_signal: Some(LimitSignal { + resets_at: Some(resets), + exhausted: true, + }), + ..Default::default() + }; + match gov().admit(&state, now) { + Verdict::Hold { until, .. } => assert_eq!(until, resets), + other => panic!("expected hold, got {other:?}"), + } + } + + #[test] + fn exhausted_signal_without_reset_backs_off_a_window() { + let now = Utc::now(); + let state = LaneState { + last_signal: Some(LimitSignal { + resets_at: None, + exhausted: true, + }), + ..Default::default() + }; + match gov().admit(&state, now) { + Verdict::Hold { until, .. } => assert_eq!(until, now + Duration::hours(5)), + other => panic!("expected hold, got {other:?}"), + } + } + + #[test] + fn spent_window_budget_holds_until_oldest_start_ages_out() { + let now = Utc::now(); + let g = gov(); + let oldest = now - Duration::hours(1); + let mut recent_starts = vec![oldest]; + recent_starts.extend((1..12).map(|i| now - Duration::minutes(i))); + let state = LaneState { + recent_starts, + ..Default::default() + }; + + match g.admit(&state, now) { + Verdict::Hold { until, .. } => assert_eq!(until, oldest + g.budget.window), + other => panic!("expected hold, got {other:?}"), + } + } + + #[test] + fn starts_outside_the_window_do_not_count() { + let now = Utc::now(); + // Twelve starts, all older than the 5h window. + let state = LaneState { + recent_starts: (1..=12) + .map(|i| now - Duration::hours(5) - Duration::minutes(i)) + .collect(), + ..Default::default() + }; + assert_eq!(gov().admit(&state, now), Verdict::Go); + } + + #[test] + fn repeated_failure_stops_the_lane() { + let now = Utc::now(); + let state = LaneState { + consecutive_failures: 3, + ..Default::default() + }; + assert!(matches!(gov().admit(&state, now), Verdict::Stop { .. })); + } +} diff --git a/crates/tireless-core/src/lib.rs b/crates/tireless-core/src/lib.rs new file mode 100644 index 0000000..b90c90d --- /dev/null +++ b/crates/tireless-core/src/lib.rs @@ -0,0 +1,12 @@ +//! Business logic for tireless. +//! +//! Everything here is pure where it can be. I/O is expressed as traits in +//! [`port`], implemented by `tireless-data` (forge, persistence) and +//! `tireless-agent` (process orchestration). + +pub mod budget; +pub mod port; +pub mod routing; + +pub use budget::{Governor, LaneBudget, LimitSignal, Verdict}; +pub use routing::{RouteDecision, route}; diff --git a/crates/tireless-core/src/port.rs b/crates/tireless-core/src/port.rs new file mode 100644 index 0000000..afdea14 --- /dev/null +++ b/crates/tireless-core/src/port.rs @@ -0,0 +1,96 @@ +//! Ports: the interfaces core needs from the outside world. +//! +//! Adapters live in `tireless-data` (forge clients, Postgres) and +//! `tireless-agent` (Claude Code, OpenCode). Core depends on none of them. + +use async_trait::async_trait; +use tireless_entities::{AgentKind, Error, IssueRef, Job, JobState, PullRequestRef, TrackedRepo}; +use uuid::Uuid; + +/// An issue as seen on a forge, before tireless has interpreted it. +#[derive(Debug, Clone)] +pub struct DiscoveredIssue { + pub issue: IssueRef, + pub title: String, + pub body: String, + pub labels: Vec, + pub is_open: bool, +} + +/// Read and write access to a forge (Gitea or GitHub). +#[async_trait] +pub trait ForgeClient: Send + Sync { + /// List open issues carrying the opt-in label. Implementations should send a + /// conditional request when `repo.last_etag` is set, and return an empty + /// vec on `304 Not Modified`. + async fn list_opted_in_issues(&self, repo: &TrackedRepo) + -> Result, Error>; + + async fn add_label(&self, issue: &IssueRef, label: &str) -> Result<(), Error>; + async fn remove_label(&self, issue: &IssueRef, label: &str) -> Result<(), Error>; + async fn comment(&self, issue: &IssueRef, body: &str) -> Result<(), Error>; + + /// Create a child issue during a Plan job. + async fn create_issue( + &self, + repo: &TrackedRepo, + title: &str, + body: &str, + labels: &[String], + ) -> Result; + + /// Open a pull request from `head` into the repo's default branch. + async fn open_pull_request( + &self, + repo: &TrackedRepo, + head: &str, + title: &str, + body: &str, + ) -> Result; +} + +/// Persistence for repos, jobs and runs. +#[async_trait] +pub trait JobStore: Send + Sync { + async fn tracked_repos(&self) -> Result, Error>; + + /// Insert discovered issues as `Pending` jobs, ignoring any that already + /// have a non-terminal job. Returns the number newly enqueued. + async fn enqueue(&self, issues: &[DiscoveredIssue]) -> Result; + + /// Atomically claim one eligible job for `worker`, using + /// `FOR UPDATE SKIP LOCKED`. Returns `None` when the queue is empty or every + /// candidate is gated by a lane cap. + async fn claim_next( + &self, + worker: &str, + allowed_lanes: &[AgentKind], + ) -> Result, Error>; + + async fn transition(&self, job: Uuid, to: JobState) -> Result<(), Error>; + + /// Return claims whose lease expired back to `Pending`. Called on a timer so + /// a worker that died mid-job does not strand its issue. + async fn expire_stale_claims(&self) -> Result; +} + +/// Drives one coding agent against a prepared checkout. +#[async_trait] +pub trait AgentExecutor: Send + Sync { + fn kind(&self) -> AgentKind; + + /// Run the agent to completion in `workdir`, streaming events to the store. + async fn run(&self, workdir: &std::path::Path, prompt: &str) -> Result; +} + +/// What an executor reports back after a run. +#[derive(Debug, Clone, Default)] +pub struct ExecutionReport { + pub session_id: Option, + pub model: Option, + /// Populated from Claude Code's `apiKeySource`; `None` for OpenCode. + pub api_key_source: Option, + /// Raw `rate_limit_event` payloads observed during the run, in order. + pub limit_signals: Vec, + pub exit_success: bool, +} diff --git a/crates/tireless-core/src/routing.rs b/crates/tireless-core/src/routing.rs new file mode 100644 index 0000000..4e1a0df --- /dev/null +++ b/crates/tireless-core/src/routing.rs @@ -0,0 +1,127 @@ +//! Which agent handles which job. +//! +//! The rule, in one sentence: **Claude Code gets judgement, OpenCode gets +//! specification.** Planning is always Claude Code because decomposition is the +//! high-judgement, low-volume task. Implementation goes to OpenCode when a +//! tireless plan already specified the work, and to Claude Code when it did not. +//! +//! An explicit label always wins, so an operator can override any inference. + +use tireless_entities::{AgentKind, Job, JobKind, LabelProtocol}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouteDecision { + pub agent: AgentKind, + /// Why this lane was chosen. Recorded on the run and shown in the dashboard + /// so a surprising route is diagnosable without re-reading the code. + pub reason: &'static str, +} + +/// Choose a lane for `job`, given the labels currently on its issue. +pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDecision { + let has = |l: &str| labels.iter().any(|x| x == l); + + // An explicit operator override beats every inference below. + if has(&protocol.force_cc) { + return RouteDecision { + agent: AgentKind::ClaudeCode, + reason: "forced by label", + }; + } + if has(&protocol.force_oc) { + return RouteDecision { + agent: AgentKind::Opencode, + reason: "forced by label", + }; + } + + match job.kind { + // Decomposition is judgement work and low volume: always the strong model. + JobKind::Plan => RouteDecision { + agent: AgentKind::ClaudeCode, + reason: "planning is always cc", + }, + // A job descended from a tireless plan has a machine-written spec to work + // from, which is what OpenCode against a local model is good at. + JobKind::Implement if job.parent_job_id.is_some() => RouteDecision { + agent: AgentKind::Opencode, + reason: "implements a tireless plan", + }, + // A human-written issue with no plan behind it needs interpretation. + JobKind::Implement => RouteDecision { + agent: AgentKind::ClaudeCode, + reason: "unplanned issue needs judgement", + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use tireless_entities::{Forge, IssueRef, JobState}; + use uuid::Uuid; + + fn job(kind: JobKind, parent: Option) -> Job { + Job { + id: Uuid::new_v4(), + issue: IssueRef { + forge: Forge::Gitea, + owner: "lair".into(), + repo: "tireless".into(), + number: 1, + }, + kind, + state: JobState::Pending, + claimed_by: None, + claim_expires_at: None, + parent_job_id: parent, + attempts: 0, + last_error: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[test] + fn planning_always_goes_to_claude_code() { + let p = LabelProtocol::default(); + let d = route(&job(JobKind::Plan, None), &[], &p); + assert_eq!(d.agent, AgentKind::ClaudeCode); + } + + #[test] + fn planned_implementation_goes_to_opencode() { + let p = LabelProtocol::default(); + let d = route(&job(JobKind::Implement, Some(Uuid::new_v4())), &[], &p); + assert_eq!(d.agent, AgentKind::Opencode); + } + + #[test] + fn unplanned_implementation_goes_to_claude_code() { + let p = LabelProtocol::default(); + let d = route(&job(JobKind::Implement, None), &[], &p); + assert_eq!(d.agent, AgentKind::ClaudeCode); + } + + #[test] + fn label_override_beats_inference() { + let p = LabelProtocol::default(); + + // Would otherwise be cc. + let d = route( + &job(JobKind::Plan, None), + std::slice::from_ref(&p.force_oc), + &p, + ); + assert_eq!(d.agent, AgentKind::Opencode); + + // Would otherwise be oc. + let d = route( + &job(JobKind::Implement, Some(Uuid::new_v4())), + std::slice::from_ref(&p.force_cc), + &p, + ); + assert_eq!(d.agent, AgentKind::ClaudeCode); + } +} diff --git a/crates/tireless-data/Cargo.toml b/crates/tireless-data/Cargo.toml new file mode 100644 index 0000000..195819f --- /dev/null +++ b/crates/tireless-data/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tireless-data" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Adapters for tireless: Postgres persistence and forge (Gitea/GitHub) clients." + +[dependencies] +tireless-entities = { workspace = true } +tireless-core = { workspace = true } + +async-trait = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } diff --git a/crates/tireless-data/src/forge.rs b/crates/tireless-data/src/forge.rs new file mode 100644 index 0000000..1416de3 --- /dev/null +++ b/crates/tireless-data/src/forge.rs @@ -0,0 +1,32 @@ +//! Forge clients. +//! +//! Gitea is the default (`architecture/generic.md` §11); GitHub is supported for +//! the legacy repos still being migrated. +//! +//! Both clients are expected to be polite API citizens: conditional requests +//! using the stored `ETag`, a floor on poll interval, and backoff with jitter on +//! `429`/`5xx`. Being unattended is not a licence to hammer a forge. + +use reqwest::Client; +use url::Url; + +pub struct GiteaClient { + #[allow(dead_code)] + http: Client, + #[allow(dead_code)] + base: Url, + /// Token belonging to the dedicated `tireless` bot account — never the + /// operator's. Scoped to the repos it collaborates on, and unable to push to + /// a protected default branch. + #[allow(dead_code)] + token: String, +} + +pub struct GitHubClient { + #[allow(dead_code)] + http: Client, + #[allow(dead_code)] + token: String, +} + +// `ForgeClient` impls land in stage 1. See doc/plan/design.md §7. diff --git a/crates/tireless-data/src/lib.rs b/crates/tireless-data/src/lib.rs new file mode 100644 index 0000000..1f052a4 --- /dev/null +++ b/crates/tireless-data/src/lib.rs @@ -0,0 +1,48 @@ +//! Adapters: everything that talks to Postgres or to a forge. +//! +//! Implements the ports declared in `tireless_core::port`. Nothing here holds +//! business logic — routing and budget decisions belong in core. + +pub mod forge; +pub mod store; + +pub use forge::{GitHubClient, GiteaClient}; +pub use store::PgStore; + +/// Connection settings for the house Postgres cluster. +/// +/// Per `architecture/generic.md` §5 the connection is **mTLS with passwordless +/// auth** — the host's own certificate identifies the client and `pg_ident.conf` +/// maps its CN to a role. There is deliberately no password field here; if one +/// ever seems necessary, that is a signal to revisit the ident mapping instead. +#[derive(Debug, Clone)] +pub struct PgConfig { + pub host: String, + pub port: u16, + pub database: String, + pub user: String, + /// Host certificate, world-readable at the standard path. + pub client_cert: std::path::PathBuf, + /// Host key; the service account is granted read via `setfacl` at deploy. + pub client_key: std::path::PathBuf, + pub root_cert: std::path::PathBuf, +} + +impl PgConfig { + /// Build a libpq-style URL. Certs rotate every 24h (§11), so callers must be + /// able to re-establish connections rather than assume a stable pool. + pub fn connection_url(&self) -> String { + format!( + "postgres://{user}@{host}:{port}/{db}\ + ?sslmode=verify-full\ + &sslcert={cert}&sslkey={key}&sslrootcert={root}", + user = self.user, + host = self.host, + port = self.port, + db = self.database, + cert = self.client_cert.display(), + key = self.client_key.display(), + root = self.root_cert.display(), + ) + } +} diff --git a/crates/tireless-data/src/store.rs b/crates/tireless-data/src/store.rs new file mode 100644 index 0000000..ba5bf9f --- /dev/null +++ b/crates/tireless-data/src/store.rs @@ -0,0 +1,22 @@ +//! Postgres persistence. +//! +//! Work-claiming uses `SELECT … FOR UPDATE SKIP LOCKED` per +//! `architecture/generic.md` §3, which makes the claim atomic across however +//! many workers are running. Forge labels mirror this state for humans but are +//! never consulted to decide whether a job is already taken. + +use sqlx::PgPool; + +pub struct PgStore { + #[allow(dead_code)] + pool: PgPool, +} + +impl PgStore { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +// The `JobStore` impl lands in stage 1; the migrations that back it live in +// `crates/tireless-data/migrations/`. See doc/plan/design.md §7. diff --git a/crates/tireless-entities/Cargo.toml b/crates/tireless-entities/Cargo.toml new file mode 100644 index 0000000..194093b --- /dev/null +++ b/crates/tireless-entities/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tireless-entities" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Domain types, DTOs and error enums for tireless. No I/O." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +url = { workspace = true } +ts-rs = { workspace = true } diff --git a/crates/tireless-entities/src/error.rs b/crates/tireless-entities/src/error.rs new file mode 100644 index 0000000..c353b92 --- /dev/null +++ b/crates/tireless-entities/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Errors that can cross a crate boundary within tireless. +#[derive(Debug, Error)] +pub enum Error { + #[error("configuration is invalid: {0}")] + Config(String), + + /// Raised when the OpenCode lane is pointed at an Anthropic provider. + /// + /// OpenCode is a third-party harness; driving an Anthropic *subscription* + /// through it is the pattern Anthropic blocks. tireless refuses to start + /// rather than let a config edit walk into it. See `doc/plan/design.md` §3. + #[error( + "opencode provider {0:?} is an Anthropic provider; tireless routes Anthropic \ + work through the Claude Code lane only" + )] + AnthropicViaOpencode(String), + + #[error("forge request failed: {0}")] + Forge(String), + + #[error("agent run failed: {0}")] + Agent(String), + + #[error("budget exhausted for lane {lane}: {detail}")] + BudgetExhausted { lane: String, detail: String }, + + #[error("job {0} is not in a state that permits this transition")] + IllegalTransition(uuid::Uuid), +} diff --git a/crates/tireless-entities/src/forge.rs b/crates/tireless-entities/src/forge.rs new file mode 100644 index 0000000..d792a66 --- /dev/null +++ b/crates/tireless-entities/src/forge.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// A source forge tireless can poll and push to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum Forge { + /// Self-hosted Gitea at `git.lair.cafe` / `git.internal`. The default. + Gitea, + /// Legacy repos still on GitHub, per `architecture/generic.md` §11. + GitHub, +} + +/// Stable coordinates for an issue on some forge. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct IssueRef { + pub forge: Forge, + pub owner: String, + pub repo: String, + pub number: i64, +} + +impl IssueRef { + pub fn slug(&self) -> String { + format!("{}/{}#{}", self.owner, self.repo, self.number) + } +} + +/// Coordinates for a pull request tireless opened. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct PullRequestRef { + pub forge: Forge, + pub owner: String, + pub repo: String, + pub number: i64, + pub head_branch: String, + pub url: String, +} diff --git a/crates/tireless-entities/src/job.rs b/crates/tireless-entities/src/job.rs new file mode 100644 index 0000000..4c52d04 --- /dev/null +++ b/crates/tireless-entities/src/job.rs @@ -0,0 +1,74 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::forge::IssueRef; + +/// What tireless was asked to do with an issue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum JobKind { + /// Decompose the issue into an epic and child issues. Produces issues, not code. + Plan, + /// Implement the issue and open a pull request. + Implement, +} + +/// Job lifecycle. +/// +/// A job is the unit of work-claiming. Claiming is a Postgres row transition +/// under `FOR UPDATE SKIP LOCKED` (`architecture/generic.md` §3), not a forge +/// label — labels cannot be claimed atomically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum JobState { + /// Discovered by the poller, eligible to be claimed. + Pending, + /// Held by a worker. `claimed_by` and `claim_expires_at` are set. + Claimed, + /// An agent is running against a clone. + Running, + /// Delivered: children created (Plan) or PR opened (Implement). + Delivered, + /// Needs a human. Terminal until an operator intervenes. + Blocked, + /// Failed past the retry budget. Terminal. + Failed, + /// Withdrawn — the opt-in label was removed, or the issue closed, mid-flight. + Abandoned, +} + +impl JobState { + /// Terminal states are never re-claimed by a worker. + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Delivered | Self::Blocked | Self::Failed | Self::Abandoned + ) + } +} + +/// One unit of work against one issue. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Job { + pub id: Uuid, + pub issue: IssueRef, + pub kind: JobKind, + pub state: JobState, + /// Worker identity holding the claim, if any. + pub claimed_by: Option, + /// Claims expire so a crashed worker's jobs return to the pool. + pub claim_expires_at: Option>, + /// Set when this job's issue was itself produced by a Plan job. Presence of + /// a parent is the primary signal to route implementation to OpenCode — + /// the plan is the spec (see `doc/plan/design.md` §3). + pub parent_job_id: Option, + pub attempts: i32, + pub last_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/tireless-entities/src/label.rs b/crates/tireless-entities/src/label.rs new file mode 100644 index 0000000..59fd386 --- /dev/null +++ b/crates/tireless-entities/src/label.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// The label vocabulary tireless reads from and writes to a forge. +/// +/// Labels are the *human-facing* interface: they are how an operator opts an +/// issue in, and how tireless reports back. They are **not** the authority on +/// state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort +/// mirror, reconciled on every poll. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct LabelProtocol { + /// Opt-in marker. Without it tireless never touches an issue, regardless of + /// any other label present. Default: `tireless`. + pub opt_in: String, + /// Requests decomposition into an epic plus child issues. Default: `tireless/plan`. + pub mode_plan: String, + /// Requests an implementation and a pull request. Default: `tireless/implement`. + pub mode_implement: String, + /// Forces the Claude Code lane. Default: `tireless/agent:cc`. + pub force_cc: String, + /// Forces the OpenCode lane. Default: `tireless/agent:oc`. + pub force_oc: String, + /// Written by tireless while a job holds the issue. Default: `tireless/claimed`. + pub state_claimed: String, + /// Written by tireless when it needs a human. Default: `tireless/blocked`. + pub state_blocked: String, + /// Written by tireless when it has delivered. Default: `tireless/done`. + pub state_done: String, +} + +impl Default for LabelProtocol { + fn default() -> Self { + Self { + opt_in: "tireless".into(), + mode_plan: "tireless/plan".into(), + mode_implement: "tireless/implement".into(), + force_cc: "tireless/agent:cc".into(), + force_oc: "tireless/agent:oc".into(), + state_claimed: "tireless/claimed".into(), + state_blocked: "tireless/blocked".into(), + state_done: "tireless/done".into(), + } + } +} diff --git a/crates/tireless-entities/src/lib.rs b/crates/tireless-entities/src/lib.rs new file mode 100644 index 0000000..fe71cbf --- /dev/null +++ b/crates/tireless-entities/src/lib.rs @@ -0,0 +1,19 @@ +//! Domain types for tireless. +//! +//! This crate is deliberately I/O-free: it is depended on by every other crate +//! in the workspace (including, via `ts-rs` bindings, the dashboard), so it must +//! stay cheap to compile and free of runtime opinions. + +pub mod error; +pub mod forge; +pub mod job; +pub mod label; +pub mod repo; +pub mod run; + +pub use error::Error; +pub use forge::{Forge, IssueRef, PullRequestRef}; +pub use job::{Job, JobKind, JobState}; +pub use label::LabelProtocol; +pub use repo::{PollSchedule, TrackedRepo}; +pub use run::{AgentKind, AgentRun, BillingMode, RunOutcome}; diff --git a/crates/tireless-entities/src/repo.rs b/crates/tireless-entities/src/repo.rs new file mode 100644 index 0000000..c447244 --- /dev/null +++ b/crates/tireless-entities/src/repo.rs @@ -0,0 +1,54 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::forge::Forge; + +/// How often a repo's issues are polled. +/// +/// The floor exists to keep tireless a well-behaved API client: a repo that is +/// quiet for days does not need to be asked every thirty seconds. See +/// `doc/plan/design.md` §5. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct PollSchedule { + /// Seconds between polls. Clamped to `>= min_interval_seconds` by core. + pub interval_seconds: u32, + /// Optional quiet window (local time, `HH:MM`), during which no poll runs + /// and no job is claimed. Both ends required if either is set. + pub quiet_from: Option, + pub quiet_until: Option, + /// When false the repo stays configured but is skipped entirely. + pub enabled: bool, +} + +impl Default for PollSchedule { + fn default() -> Self { + Self { + interval_seconds: 300, + quiet_from: None, + quiet_until: None, + enabled: true, + } + } +} + +/// A repo tireless watches for labelled issues. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TrackedRepo { + pub id: Uuid, + pub forge: Forge, + pub owner: String, + pub repo: String, + /// Clone URL used for the mirror cache. SSH for Gitea, HTTPS for GitHub. + pub clone_url: String, + /// Branch PRs target. Usually `main`; tireless never pushes to it directly. + pub default_branch: String, + pub schedule: PollSchedule, + /// Set from the forge's `ETag` so repeat polls are conditional requests. + pub last_etag: Option, + pub last_polled_at: Option>, + pub created_at: DateTime, +} diff --git a/crates/tireless-entities/src/run.rs b/crates/tireless-entities/src/run.rs new file mode 100644 index 0000000..9015c26 --- /dev/null +++ b/crates/tireless-entities/src/run.rs @@ -0,0 +1,90 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::forge::PullRequestRef; + +/// Which coding agent executed a run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum AgentKind { + /// Anthropic's first-party CLI, spawned as a subprocess. Authenticates with + /// the service user's own subscription unless `ANTHROPIC_API_KEY` is present + /// in the unit environment. + ClaudeCode, + /// OpenCode, run as a loopback server against an OpenAI-compatible backend + /// (helexa cortex). Never Anthropic — enforced at startup. + Opencode, +} + +impl AgentKind { + /// Lane name used for concurrency caps, budgets and circuit breakers. + pub fn lane(self) -> &'static str { + match self { + Self::ClaudeCode => "cc", + Self::Opencode => "oc", + } + } +} + +/// Where a Claude Code run's tokens were billed. +/// +/// Read from the `apiKeySource` field of Claude Code's `system`/`init` stream +/// event — the same signal vibe-kanban surfaces in +/// `crates/executors/src/executors/claude.rs:911`. tireless records it per run +/// so the dashboard can show, honestly, which runs drew on the subscription. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum BillingMode { + /// Subscription OAuth (no `ANTHROPIC_API_KEY` in the environment). + Subscription, + /// Pay-as-you-go via an API key supplied in the unit environment. + ApiKey, + /// Not applicable (OpenCode) or not yet determined. + Unknown, +} + +/// How a run ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum RunOutcome { + Succeeded, + /// The agent exited non-zero, or produced no usable result. + Failed, + /// Stopped because a provider limit was hit. Retryable after the reset. + RateLimited, + /// Stopped because a tireless budget was exhausted. Retryable next window. + BudgetExhausted, + /// Exceeded the wall-clock ceiling for its lane. + TimedOut, + /// Cancelled by an operator. + Cancelled, +} + +/// One invocation of one agent against one job. +/// +/// A job may have several runs: a retry after a rate limit, or a follow-up turn +/// resuming the same agent session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct AgentRun { + pub id: Uuid, + pub job_id: Uuid, + pub agent: AgentKind, + /// Model actually used, as reported by the agent. + pub model: Option, + pub billing: BillingMode, + /// The agent's own session identifier, so a follow-up turn can resume rather + /// than restart. Claude Code reports this in its stream; tireless passes it + /// back via `--resume`. + pub session_id: Option, + pub outcome: Option, + /// Pull request opened by this run, if any. + pub pull_request: Option, + pub started_at: DateTime, + pub finished_at: Option>, +} diff --git a/crates/tireless-worker/Cargo.toml b/crates/tireless-worker/Cargo.toml new file mode 100644 index 0000000..e3b127f --- /dev/null +++ b/crates/tireless-worker/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tireless-worker" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Poller and job runner for tireless. Runs as two systemd units." + +[dependencies] +tireless-entities = { workspace = true } +tireless-core = { workspace = true } +tireless-data = { workspace = true } +tireless-agent = { workspace = true } + +anyhow = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +figment = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/tireless-worker/src/main.rs b/crates/tireless-worker/src/main.rs new file mode 100644 index 0000000..75dd20e --- /dev/null +++ b/crates/tireless-worker/src/main.rs @@ -0,0 +1,102 @@ +//! tireless-worker — polls forges and runs claimed jobs. +//! +//! Ships as one binary with two roles, so a deployment can scale or pause them +//! independently (`tireless-poller.service`, `tireless-runner.service`): +//! +//! - **poll** — discovers opted-in issues and enqueues them. Read-only against +//! the forge except for label mirroring. Cheap, and safe to run always. +//! - **run** — claims a job, prepares a clone, drives an agent, opens a PR. +//! This is the role that spends tokens, and the one the governor gates. +//! +//! Both are idempotent and safe to restart: a crashed runner's claim expires and +//! its job returns to the pool (§3). + +use anyhow::Context; +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command(name = "tireless-worker", version)] +struct Args { + #[arg(long, default_value = "/etc/tireless/config.toml")] + config: String, + #[command(subcommand)] + role: Role, +} + +#[derive(Subcommand, Debug)] +enum Role { + /// Discover opted-in issues and enqueue them. + Poll, + /// Claim and execute jobs. + Run { + /// Worker identity recorded on claims. Defaults to the hostname. + #[arg(long, env = "TIRELESS_WORKER_ID")] + id: Option, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + let args = Args::parse(); + + // Fail loudly at startup, not on the first job: an unattended service that + // starts "successfully" and then cannot do anything is worse than one that + // refuses to start. + preflight().context("preflight checks failed")?; + + match args.role { + Role::Poll => { + tracing::info!(config = %args.config, "poller starting"); + } + Role::Run { id } => { + let id = id.unwrap_or_else(|| { + std::env::var("HOSTNAME").unwrap_or_else(|_| "tireless-runner".into()) + }); + tracing::info!(config = %args.config, worker = %id, "runner starting"); + } + } + + shutdown_signal().await; + tracing::info!("tireless-worker stopped cleanly"); + Ok(()) +} + +/// Startup assertions that encode constraints we cannot afford to rediscover at +/// runtime. See doc/plan/design.md §3. +fn preflight() -> anyhow::Result<()> { + // Report which billing mode Claude Code will use, so it is visible in the + // journal from the first line rather than inferred from an invoice later. + let has_key = std::env::var_os("ANTHROPIC_API_KEY").is_some(); + match tireless_agent::claude::expected_billing(has_key) { + tireless_entities::BillingMode::ApiKey => tracing::warn!( + "ANTHROPIC_API_KEY is set: Claude Code runs will bill pay-as-you-go, \ + not the subscription" + ), + mode => tracing::info!(?mode, "Claude Code billing mode"), + } + + // The OpenCode lane must never be pointed at Anthropic. Read from config in + // stage 5; asserted here so the guard exists from the first commit. + Ok(()) +} + +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt}; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + if std::env::var_os("JOURNAL_STREAM").is_some() { + fmt().json().with_env_filter(filter).init(); + } else { + fmt().with_env_filter(filter).init(); + } +} + +async fn shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler"); + tokio::select! { + _ = term.recv() => tracing::info!("SIGTERM received, draining in-flight work"), + _ = int.recv() => tracing::info!("SIGINT received, draining in-flight work"), + } +} diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..9f52950 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + tireless + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..267405d --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,3079 @@ +{ + "name": "tireless-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tireless-dashboard", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react-swc": "^3.7.2", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "prettier": "^3.4.2", + "typescript": "~5.7.2", + "typescript-eslint": "^8.18.0", + "vite": "^6.0.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz", + "integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.27" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.47", + "@swc/core-darwin-x64": "1.15.47", + "@swc/core-linux-arm-gnueabihf": "1.15.47", + "@swc/core-linux-arm64-gnu": "1.15.47", + "@swc/core-linux-arm64-musl": "1.15.47", + "@swc/core-linux-ppc64-gnu": "1.15.47", + "@swc/core-linux-s390x-gnu": "1.15.47", + "@swc/core-linux-x64-gnu": "1.15.47", + "@swc/core-linux-x64-musl": "1.15.47", + "@swc/core-win32-arm64-msvc": "1.15.47", + "@swc/core-win32-ia32-msvc": "1.15.47", + "@swc/core-win32-x64-msvc": "1.15.47" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz", + "integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz", + "integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz", + "integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz", + "integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz", + "integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz", + "integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz", + "integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz", + "integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz", + "integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz", + "integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz", + "integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz", + "integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", + "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.27", + "@swc/core": "^1.12.11" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..0a8ba8b --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,32 @@ +{ + "name": "tireless-dashboard", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "format": "prettier --write src" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react-swc": "^3.7.2", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "prettier": "^3.4.2", + "typescript": "~5.7.2", + "typescript-eslint": "^8.18.0", + "vite": "^6.0.5" + } +} diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 0000000..b91d8d3 --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,28 @@ +import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; + +import Lanes from './routes/Lanes'; +import Jobs from './routes/Jobs'; +import Repos from './routes/Repos'; + +export default function App() { + return ( +
+
+

tireless

+ +
+
+ + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts new file mode 100644 index 0000000..43dcd32 --- /dev/null +++ b/dashboard/src/api/client.ts @@ -0,0 +1,37 @@ +/** + * Client for tireless-api. + * + * The base URL is stamped at build time by the deploy workflow. In dev, Vite + * proxies /v1 to a local API (see vite.config.ts), so the default empty base + * resolves to a same-origin request in both cases. + */ +const BASE = import.meta.env.VITE_API_BASE_URL ?? ''; + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${BASE}/v1${path}`, { + ...init, + headers: { 'content-type': 'application/json', ...init?.headers }, + }); + if (!response.ok) { + throw new ApiError(response.status, await response.text()); + } + return response.status === 204 ? (undefined as T) : await response.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +}; diff --git a/dashboard/src/api/generated/AgentKind.ts b/dashboard/src/api/generated/AgentKind.ts new file mode 100644 index 0000000..90c1264 --- /dev/null +++ b/dashboard/src/api/generated/AgentKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Which coding agent executed a run. + */ +export type AgentKind = "claude_code" | "opencode"; diff --git a/dashboard/src/api/generated/AgentRun.ts b/dashboard/src/api/generated/AgentRun.ts new file mode 100644 index 0000000..163e785 --- /dev/null +++ b/dashboard/src/api/generated/AgentRun.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentKind } from "./AgentKind"; +import type { BillingMode } from "./BillingMode"; +import type { PullRequestRef } from "./PullRequestRef"; +import type { RunOutcome } from "./RunOutcome"; + +/** + * One invocation of one agent against one job. + * + * A job may have several runs: a retry after a rate limit, or a follow-up turn + * resuming the same agent session. + */ +export type AgentRun = { id: string, job_id: string, agent: AgentKind, +/** + * Model actually used, as reported by the agent. + */ +model: string | null, billing: BillingMode, +/** + * The agent's own session identifier, so a follow-up turn can resume rather + * than restart. Claude Code reports this in its stream; tireless passes it + * back via `--resume`. + */ +session_id: string | null, outcome: RunOutcome | null, +/** + * Pull request opened by this run, if any. + */ +pull_request: PullRequestRef | null, started_at: string, finished_at: string | null, }; diff --git a/dashboard/src/api/generated/BillingMode.ts b/dashboard/src/api/generated/BillingMode.ts new file mode 100644 index 0000000..36be048 --- /dev/null +++ b/dashboard/src/api/generated/BillingMode.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Where a Claude Code run's tokens were billed. + * + * Read from the `apiKeySource` field of Claude Code's `system`/`init` stream + * event — the same signal vibe-kanban surfaces in + * `crates/executors/src/executors/claude.rs:911`. tireless records it per run + * so the dashboard can show, honestly, which runs drew on the subscription. + */ +export type BillingMode = "subscription" | "api_key" | "unknown"; diff --git a/dashboard/src/api/generated/Forge.ts b/dashboard/src/api/generated/Forge.ts new file mode 100644 index 0000000..0953dbf --- /dev/null +++ b/dashboard/src/api/generated/Forge.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A source forge tireless can poll and push to. + */ +export type Forge = "gitea" | "git_hub"; diff --git a/dashboard/src/api/generated/IssueRef.ts b/dashboard/src/api/generated/IssueRef.ts new file mode 100644 index 0000000..ad119ee --- /dev/null +++ b/dashboard/src/api/generated/IssueRef.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Forge } from "./Forge"; + +/** + * Stable coordinates for an issue on some forge. + */ +export type IssueRef = { forge: Forge, owner: string, repo: string, number: bigint, }; diff --git a/dashboard/src/api/generated/Job.ts b/dashboard/src/api/generated/Job.ts new file mode 100644 index 0000000..66d150f --- /dev/null +++ b/dashboard/src/api/generated/Job.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { IssueRef } from "./IssueRef"; +import type { JobKind } from "./JobKind"; +import type { JobState } from "./JobState"; + +/** + * One unit of work against one issue. + */ +export type Job = { id: string, issue: IssueRef, kind: JobKind, state: JobState, +/** + * Worker identity holding the claim, if any. + */ +claimed_by: string | null, +/** + * Claims expire so a crashed worker's jobs return to the pool. + */ +claim_expires_at: string | null, +/** + * Set when this job's issue was itself produced by a Plan job. Presence of + * a parent is the primary signal to route implementation to OpenCode — + * the plan is the spec (see `doc/plan/design.md` §3). + */ +parent_job_id: string | null, attempts: number, last_error: string | null, created_at: string, updated_at: string, }; diff --git a/dashboard/src/api/generated/JobKind.ts b/dashboard/src/api/generated/JobKind.ts new file mode 100644 index 0000000..e142d33 --- /dev/null +++ b/dashboard/src/api/generated/JobKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What tireless was asked to do with an issue. + */ +export type JobKind = "plan" | "implement"; diff --git a/dashboard/src/api/generated/JobState.ts b/dashboard/src/api/generated/JobState.ts new file mode 100644 index 0000000..3491ed2 --- /dev/null +++ b/dashboard/src/api/generated/JobState.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Job lifecycle. + * + * A job is the unit of work-claiming. Claiming is a Postgres row transition + * under `FOR UPDATE SKIP LOCKED` (`architecture/generic.md` §3), not a forge + * label — labels cannot be claimed atomically. + */ +export type JobState = "pending" | "claimed" | "running" | "delivered" | "blocked" | "failed" | "abandoned"; diff --git a/dashboard/src/api/generated/LabelProtocol.ts b/dashboard/src/api/generated/LabelProtocol.ts new file mode 100644 index 0000000..c291b95 --- /dev/null +++ b/dashboard/src/api/generated/LabelProtocol.ts @@ -0,0 +1,44 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The label vocabulary tireless reads from and writes to a forge. + * + * Labels are the *human-facing* interface: they are how an operator opts an + * issue in, and how tireless reports back. They are **not** the authority on + * state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort + * mirror, reconciled on every poll. + */ +export type LabelProtocol = { +/** + * Opt-in marker. Without it tireless never touches an issue, regardless of + * any other label present. Default: `tireless`. + */ +opt_in: string, +/** + * Requests decomposition into an epic plus child issues. Default: `tireless/plan`. + */ +mode_plan: string, +/** + * Requests an implementation and a pull request. Default: `tireless/implement`. + */ +mode_implement: string, +/** + * Forces the Claude Code lane. Default: `tireless/agent:cc`. + */ +force_cc: string, +/** + * Forces the OpenCode lane. Default: `tireless/agent:oc`. + */ +force_oc: string, +/** + * Written by tireless while a job holds the issue. Default: `tireless/claimed`. + */ +state_claimed: string, +/** + * Written by tireless when it needs a human. Default: `tireless/blocked`. + */ +state_blocked: string, +/** + * Written by tireless when it has delivered. Default: `tireless/done`. + */ +state_done: string, }; diff --git a/dashboard/src/api/generated/PollSchedule.ts b/dashboard/src/api/generated/PollSchedule.ts new file mode 100644 index 0000000..c34981c --- /dev/null +++ b/dashboard/src/api/generated/PollSchedule.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How often a repo's issues are polled. + * + * The floor exists to keep tireless a well-behaved API client: a repo that is + * quiet for days does not need to be asked every thirty seconds. See + * `doc/plan/design.md` §5. + */ +export type PollSchedule = { +/** + * Seconds between polls. Clamped to `>= min_interval_seconds` by core. + */ +interval_seconds: number, +/** + * Optional quiet window (local time, `HH:MM`), during which no poll runs + * and no job is claimed. Both ends required if either is set. + */ +quiet_from: string | null, quiet_until: string | null, +/** + * When false the repo stays configured but is skipped entirely. + */ +enabled: boolean, }; diff --git a/dashboard/src/api/generated/PullRequestRef.ts b/dashboard/src/api/generated/PullRequestRef.ts new file mode 100644 index 0000000..20b8dab --- /dev/null +++ b/dashboard/src/api/generated/PullRequestRef.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Forge } from "./Forge"; + +/** + * Coordinates for a pull request tireless opened. + */ +export type PullRequestRef = { forge: Forge, owner: string, repo: string, number: bigint, head_branch: string, url: string, }; diff --git a/dashboard/src/api/generated/RunOutcome.ts b/dashboard/src/api/generated/RunOutcome.ts new file mode 100644 index 0000000..7f39e8b --- /dev/null +++ b/dashboard/src/api/generated/RunOutcome.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How a run ended. + */ +export type RunOutcome = "succeeded" | "failed" | "rate_limited" | "budget_exhausted" | "timed_out" | "cancelled"; diff --git a/dashboard/src/api/generated/TrackedRepo.ts b/dashboard/src/api/generated/TrackedRepo.ts new file mode 100644 index 0000000..f20482c --- /dev/null +++ b/dashboard/src/api/generated/TrackedRepo.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Forge } from "./Forge"; +import type { PollSchedule } from "./PollSchedule"; + +/** + * A repo tireless watches for labelled issues. + */ +export type TrackedRepo = { id: string, forge: Forge, owner: string, repo: string, +/** + * Clone URL used for the mirror cache. SSH for Gitea, HTTPS for GitHub. + */ +clone_url: string, +/** + * Branch PRs target. Usually `main`; tireless never pushes to it directly. + */ +default_branch: string, schedule: PollSchedule, +/** + * Set from the forge's `ETag` so repeat polls are conditional requests. + */ +last_etag: string | null, last_polled_at: string | null, created_at: string, }; diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..7de95ec --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,51 @@ +:root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; +} + +.app { + max-width: 72rem; + margin: 0 auto; + padding: 1.5rem; +} + +.app > header { + display: flex; + align-items: baseline; + gap: 1.5rem; + border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent); + padding-bottom: 0.75rem; + margin-bottom: 1.5rem; +} + +.app > header h1 { + font-size: 1.25rem; + margin: 0; + letter-spacing: -0.01em; +} + +nav { + display: flex; + gap: 1rem; +} + +nav a { + color: inherit; + text-decoration: none; + opacity: 0.6; +} + +nav a.active { + opacity: 1; + font-weight: 600; +} + +.placeholder { + opacity: 0.6; + font-style: italic; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..b91b60d --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,28 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; + +import App from './App'; +import './index.css'; + +// Server state lives in React Query; the dashboard is a rendering and +// interaction layer only (architecture/generic.md §4). +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + refetchOnWindowFocus: true, + }, + }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/dashboard/src/routes/Jobs.tsx b/dashboard/src/routes/Jobs.tsx new file mode 100644 index 0000000..01b9a62 --- /dev/null +++ b/dashboard/src/routes/Jobs.tsx @@ -0,0 +1,12 @@ +/** + * Job list and detail: what tireless has claimed, what it is running, what it + * delivered, and what is blocked waiting on a human. + */ +export default function Jobs() { + return ( +
+

jobs

+

Job listing arrives with stage 2.

+
+ ); +} diff --git a/dashboard/src/routes/Lanes.tsx b/dashboard/src/routes/Lanes.tsx new file mode 100644 index 0000000..84f738d --- /dev/null +++ b/dashboard/src/routes/Lanes.tsx @@ -0,0 +1,18 @@ +/** + * Lane status: the page that answers "is tireless about to spend my + * subscription, and how much is left?". + * + * Shows, per lane: in-flight runs, window budget consumed, the most recent + * provider limit signal, and whether the circuit breaker has tripped. Stage 6 + * adds the pause/resume controls; until then this is read-only. + */ +export default function Lanes() { + return ( +
+

lanes

+

+ Lane telemetry arrives with stage 3 (Claude Code) and stage 5 (OpenCode). +

+
+ ); +} diff --git a/dashboard/src/routes/Repos.tsx b/dashboard/src/routes/Repos.tsx new file mode 100644 index 0000000..01fa8be --- /dev/null +++ b/dashboard/src/routes/Repos.tsx @@ -0,0 +1,15 @@ +/** + * Polled repo management: add and remove Gitea/GitHub repos, and edit each + * one's poll schedule and quiet window. + * + * This is the primary reason the dashboard exists — the poller's schedule is + * meant to be changed without a redeploy. + */ +export default function Repos() { + return ( +
+

repos

+

Repo CRUD arrives with stage 1.

+
+ ); +} diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..d9f9da7 --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** Stamped at build time by the deploy workflow. */ + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..841690b --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dashboard/tsconfig.tsbuildinfo b/dashboard/tsconfig.tsbuildinfo new file mode 100644 index 0000000..dffcc0b --- /dev/null +++ b/dashboard/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated/AgentKind.ts","./src/api/generated/AgentRun.ts","./src/api/generated/BillingMode.ts","./src/api/generated/Forge.ts","./src/api/generated/IssueRef.ts","./src/api/generated/Job.ts","./src/api/generated/JobKind.ts","./src/api/generated/JobState.ts","./src/api/generated/LabelProtocol.ts","./src/api/generated/PollSchedule.ts","./src/api/generated/PullRequestRef.ts","./src/api/generated/RunOutcome.ts","./src/api/generated/TrackedRepo.ts","./src/routes/Jobs.tsx","./src/routes/Lanes.tsx","./src/routes/Repos.tsx"],"version":"5.7.3"} \ No newline at end of file diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..709e392 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +// Build output is static and served by nginx from /var/www/tireless — no Node +// in production (architecture/generic.md §4). The API base URL is stamped at +// build time from VITE_API_BASE_URL by the deploy workflow. +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + sourcemap: true, + }, + server: { + port: 5173, + proxy: { + // Dev-only: point at a local tireless-api on its registered port. + '/v1': { + target: 'http://127.0.0.1:23296', + changeOrigin: true, + }, + }, + }, +}); diff --git a/doc/plan/design.md b/doc/plan/design.md new file mode 100644 index 0000000..b653bcf --- /dev/null +++ b/doc/plan/design.md @@ -0,0 +1,480 @@ +# tireless — design and staged implementation plan + +**Status:** planning. Nothing in stages 1+ is built yet. +**Conventions:** [`~/git/architecture`](https://git.lair.cafe/lair/architecture) — +`generic.md` is the baseline; deviations are flagged inline below and in `readme.md`. + +--- + +## 1. What tireless is + +A daemon that watches labelled issues on Gitea (and, for legacy repos, GitHub), +claims them, and either **plans** them (decomposing an issue into an epic and +child issues) or **implements** them (producing a branch and a pull request). +A human reviews the output. Nothing merges itself. + +The point is to move the operator's attention up a level: from writing +implementation plans and implementations, to identifying what should be worked +on and reviewing what came back. + +### What tireless is not + +- **Not a kanban board.** vibe-kanban is the reference implementation for + *driving agents*; its task/project/board model is deliberately absent here. + The forge's issues are the work list. +- **Not a merge robot.** It opens PRs. Review and merge stay human. +- **Not a model provider client.** It never speaks to Anthropic or any inference + API directly. This is load-bearing — see §3. +- **Not a multi-tenant service.** One operator, one subscription, one fleet. + Sharing it with others would breach the Anthropic consumer terms (§3.4). + +--- + +## 2. Operating model + +### 2.1 The loop + +``` +poll ──▶ enqueue ──▶ claim ──▶ prepare clone ──▶ run agent ──▶ deliver ──▶ report + ▲ │ + └──────────────────────────── reconcile ◀──────────────────────────────────┘ +``` + +Two systemd units, one binary: + +| Unit | Role | Spends tokens? | +| --- | --- | --- | +| `tireless-poller` | discovers opted-in issues, enqueues, mirrors labels | no | +| `tireless-runner` | claims jobs, prepares clones, drives agents, opens PRs | **yes** | + +Splitting them means the discovery loop can run continuously while the +token-spending half is paused, throttled, or restarted independently. During an +incident the useful action is almost always "stop the runner, leave the poller +running" — which is a `systemctl stop` rather than a config change. + +### 2.2 The label protocol + +Labels are the human interface. An operator opts an issue in by labelling it; +tireless reports back the same way. + +| Label | Written by | Meaning | +| --- | --- | --- | +| `tireless` | human | **Opt-in.** Without it tireless ignores the issue entirely, whatever else is present. | +| `tireless/plan` | human | Decompose into an epic and child issues. | +| `tireless/implement` | human | Implement and open a PR. | +| `tireless/agent:cc` | human | Force the Claude Code lane. | +| `tireless/agent:oc` | human | Force the OpenCode lane. | +| `tireless/claimed` | tireless | A job holds this issue. | +| `tireless/blocked` | tireless | Needs a human; tireless has stopped. | +| `tireless/done` | tireless | Delivered — children created, or PR opened. | + +The opt-in label is separate from the mode labels on purpose. Removing one label +(`tireless`) disables the issue without destroying the operator's expressed +intent about *how* it should be handled, and a single unlabelled repo full of +`tireless/implement` leftovers cannot accidentally activate. + +**Labels are not the source of truth.** They are a best-effort mirror of state +held in Postgres, reconciled on every poll. Two reasons: a label edit is not +atomic, so it cannot safely express a claim; and a forge outage must not lose +job state. + +### 2.3 Routing: which agent gets the work + +Implemented in `tireless-core::routing`, with tests. + +| Job | Lane | Why | +| --- | --- | --- | +| `Plan` | Claude Code (Opus) | Decomposition is the high-judgement, low-volume task. Getting a plan wrong is expensive downstream; getting it right is worth the strong model. | +| `Implement`, descended from a tireless plan | OpenCode (helexa) | A tireless plan *is* a spec. Executing a written spec is what a local model on the GPU fleet does well, at no subscription cost. | +| `Implement`, human-written issue | Claude Code | No plan behind it means the issue needs interpretation before it needs code. | +| any, with `tireless/agent:*` | as labelled | An explicit operator override always wins. | + +The general rule: **Claude Code gets judgement, OpenCode gets specification.** + +This also produces a pleasing economic shape. The subscription is spent on the +scarce thing (planning, and interpreting under-specified work), while the bulk +of mechanical implementation runs on hardware already sitting in the office. + +--- + +## 3. Constraints + +These are the reasons the architecture looks the way it does. Each is encoded in +code or config, not merely written down here — comments rot, failing assertions +do not. + +### 3.1 Both agents are spawned as vendor binaries + +tireless spawns `@anthropic-ai/claude-code` and `opencode-ai` as subprocesses and +lets each authenticate itself. It never constructs a request to a model provider. + +This is what makes subscription-backed operation legitimate. Anthropic's +enforced line is credential extraction — taking the subscription OAuth token and +using it in your own API client, which is what got OpenClaw, OpenCode, Roo Code +and Goose blocked in January 2026 (`"This credential is only authorized for use +with Claude Code."`). Running the first-party binary is the permitted side of +that line. + +Two invariants follow, and neither may be optimised away: + +- **tireless never reads or forwards agent credentials.** It stats + `~/.claude.json` to check a login exists (`tireless-agent::claude::has_credentials`) + and does nothing else with it. +- **tireless never sets `ANTHROPIC_API_KEY`.** The variable reaches Claude Code + only if an operator placed it in the unit environment. + +### 3.2 Automated use of a subscription is explicitly permitted + +Anthropic's consumer terms §3 prohibit automated access *"Except when you are +accessing our Services via an Anthropic API Key **or where we otherwise +explicitly permit it**"*. The help centre article +[Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) +is that explicit permission, naming three covered categories: + +- Claude Agent SDK usage in your own projects +- `claude -p` (non-interactive mode) +- **third-party applications authenticating through your subscription** + +The third is tireless. Its current banner: *"We're pausing the changes to Claude +Agent SDK usage described below. For now, nothing has changed: Claude Agent SDK, +`claude -p`, and third-party app usage still draw from your subscription's usage +limits."* + +**This is the constraint most likely to change.** The June 15 2026 split into a +separate "Agent SDK credit" pool ($20 Pro / $100 Max 5x / $200 Max 20x) was +paused, not cancelled, with advance notice promised. tireless therefore treats +the auth mode as a **config switch, not an architecture**: dropping +`ANTHROPIC_API_KEY` into `/etc/tireless/tireless.env` moves the whole Claude Code +lane to pay-as-you-go with no code change. Billing mode is recorded per run +(`AgentRun::billing`, read from Claude Code's own `apiKeySource`) so the +dashboard reports what actually happened rather than what was intended. + +### 3.3 The OpenCode lane is never Anthropic + +OpenCode is a third-party harness with its own provider clients. Driving an +Anthropic subscription through it is precisely the blocked pattern. Anthropic +work goes through the Claude Code lane; OpenCode goes to helexa cortex. + +Encoded as a startup assertion — `tireless_agent::opencode::assert_not_anthropic` +— checked against both the provider id and the base URL host, with tests. A +config edit that points the lane at Anthropic fails the service, loudly, at +start. Note that cortex presents an Anthropic-*compatible* API surface; that is +fine and explicitly tested for, because it is local inference with no +subscription involved. + +### 3.4 Single operator + +The consumer terms §2 forbid sharing account credentials or making the account +available to others. tireless runs as one operator's agent against their own +repos. If a second person's request could trigger a run on this subscription, +that boundary is crossed — which is why the dashboard is mesh-only behind +`tireless.internal` and has no multi-user model. + +### 3.5 Concurrency guardrails move + +Claude Code capped concurrent subagents at 20 and now defaults nested spawns to +depth 3 (changed twice in July 2026). tireless bounds its own concurrency +(§5) rather than discovering the vendor's limits by hitting them. + +--- + +## 4. State and claiming + +### 4.1 Postgres is the authority + +House cluster, `magrathea.kosherinata.internal:5432`, mTLS and passwordless +(`generic.md` §5). Role `tireless_rw`, ident-mapped from the deploy host's cert +CN — **installed on both magrathea and frankie**, since a failover to a server +missing the mapping locks tireless out. + +### 4.2 Claiming + +`SELECT … FOR UPDATE SKIP LOCKED` (`generic.md` §3). The claim is a row +transition, which makes it atomic across any number of runners. Claims carry a +lease (`claim_expires_at`); a timer returns expired claims to the pool so a +runner that died mid-job does not strand its issue. + +The forge label `tireless/claimed` is written *after* the database claim +succeeds, and is treated as advisory on read. If a poll finds an issue labelled +claimed with no live job behind it, the label is stale and gets cleaned up — +this is the normal path after a database restore or a hard crash. + +### 4.3 Job states + +``` +Pending ──▶ Claimed ──▶ Running ──┬──▶ Delivered (PR opened / children created) + ▲ ├──▶ Blocked (needs a human) + │ └──▶ Failed (past the retry budget) + └───── lease expiry ───────────┘ + Abandoned (opt-in removed, or issue closed) +``` + +`Delivered`, `Blocked`, `Failed` and `Abandoned` are terminal — +`JobState::is_terminal`. A terminal job is never re-claimed; re-running requires +an operator (`tireless job run `) or a fresh label cycle. + +### 4.4 Idempotency + +Every stage assumes it may be interrupted and re-run: + +- enqueue is an upsert keyed on `(forge, owner, repo, number)`; +- a job whose branch already exists on the remote reuses it rather than failing; +- a job whose PR already exists reports it rather than opening a second; +- clone directories are addressed by job id, so a retry cannot collide with a + previous attempt's tree. + +--- + +## 5. Respecting provider limits + +Nothing external stops an unattended driver from asking for work. Four brakes, +implemented in `tireless-core::budget` with tests: + +1. **Concurrency cap per lane.** Claude Code defaults to **1**. A subscription is + one person's allowance; parallel sessions are the fastest way to exhaust it. + OpenCode defaults to 2, bounded by the GPU fleet rather than a bill. + +2. **Window budget.** A hard ceiling on runs started per rolling window + (default 12 per 5h for Claude Code). Not advisory: when spent, the lane stops + until the window rolls. Defaults are deliberately low — raising a ceiling + after watching real usage is easy; discovering you burned a month's allowance + overnight is not. + +3. **Provider signal.** Claude Code emits `rate_limit_event` messages in its + stream. vibe-kanban parses them and discards them — the match arm at + `crates/executors/src/executors/claude.rs:1954` is empty. tireless consumes + them (`tireless_agent::claude::parse_limit_signal`) and holds the lane until + the reported reset. This is the most valuable signal available to an + unattended driver, because it reports what the provider thinks rather than + what we guessed, and it is checked **first**, ahead of our own optimism. + +4. **Circuit breaker.** N consecutive failures (default 3) stop the lane + entirely until an operator intervenes. Repeated failure usually means + something retrying will not fix, and every retry still costs tokens. + +Forge politeness is separate and equally deliberate: a floor on poll interval +(120s, config), conditional requests using the stored `ETag`, per-repo jitter so +N repos do not fire together, and backoff with jitter on `429`/`5xx`. + +An optional quiet window suspends both polling and claiming. + +--- + +## 6. Architecture + +### 6.1 Crates + +Per `generic.md` §1, with one addition noted below. + +| Crate | Role | +| --- | --- | +| `tireless-entities` | domain types, no I/O. Exports TS bindings for the dashboard via `ts-rs`. | +| `tireless-core` | routing, budgets, job lifecycle. Declares ports; depends on no adapter. | +| `tireless-data` | Postgres + forge clients (Gitea, GitHub). | +| `tireless-agent` | **addition** — spawns and drives Claude Code and OpenCode. | +| `tireless-api` | binary: Axum REST/JSON on `/v1`. | +| `tireless-worker` | binary: `poll` and `run` roles. | +| `tireless-cli` | binary: operator CLI (`tireless`). | + +`tireless-agent` is a deviation worth stating: process orchestration is not data +access, and it is shared by the runner and the CLI (which can dry-run a single +job), so §1's "extract when the second consumer appears" test is met. + +### 6.2 Deployment + +| Concern | Value | +| --- | --- | +| Host | `bob.hanzalova.internal` | +| API port | **23296** — derived per `port-allocations.md` §3, registry updated | +| Ingress | `tireless.internal` on the hanzalova proxy, mesh-only, per-service internal cert | +| Dashboard | static, `/var/www/tireless`, served by nginx | +| Database | `magrathea.kosherinata.internal:5432`, mTLS | +| Deploy | Gitea Actions, build-and-rsync, musl static | + +bob was chosen because it already hosts vibe-kanban and helexa-bench, and sits on +the same site as cortex (`hanzalova.internal:31313`) — so the highest-volume +path, OpenCode implementation runs, stays local rather than crossing the +WireGuard mesh. + +### 6.3 Checkouts + +No worktrees. Worktrees share one object store, which is the right trade for +many cheap branches of a repo you already have, and the wrong one here: jobs must +not be able to reach each other's state. + +``` +/var/lib/tireless/mirror///.git # bare, refreshed before use +/var/lib/tireless/job/// # clone of the mirror +``` + +Cloning from a local path hardlinks objects rather than copying them, so a job +clone is fast and near-free on disk regardless of repo size — git never mutates +an existing object, so the hardlinks are safe. `origin` is then repointed at the +real remote, because the mirror is a cache, not the truth. + +Branches are namespaced `tireless/-` so forge branch protection can +permit the bot there and nowhere else. + +### 6.4 Identity + +A dedicated `tireless` Gitea account, not the operator's. Its token is scoped to +issue and PR write; branch protection on each repo's default branch denies it +push. Three benefits: the audit trail distinguishes agent work from human work; +you can meaningfully review a PR you did not author; and revoking the agent does +not touch your own credentials. + +### 6.5 systemd hardening + +Full hardening set from `generic.md` §8 on all three units, with one documented +relaxation on `tireless-runner`: **`MemoryDenyWriteExecute=false`**. Both agents +are Node programs and V8's JIT requires write-then-execute pages; with it enabled +the agent aborts at startup. Per §8 only the one setting that breaks the service +is relaxed — the API and poller keep it. + +--- + +## 7. Staged implementation plan + +Each stage is independently deployable and independently verifiable, per +`generic.md` §14. The ordering is deliberate: **everything that can be proven +without spending tokens is proven first**, and the first token-spending +capability produces text (issues), not code. + +### Stage 0 — Foundations *(scaffolded)* + +Workspace, entities, routing and budget logic with tests, binaries that start and +stop cleanly, dashboard shell, deployment assets, CI. + +*Done when:* `tireless-api` answers `/v1/ready` on bob:23296, the dashboard loads +at `tireless.internal`, all three units are active, and the deploy workflow is +green end to end. + +### Stage 1 — Forge ingestion (read-only) + +Gitea client with conditional requests; poll loop with interval floor and jitter; +Postgres schema and migrations; repo CRUD through the API and dashboard. + +Reads issues, writes nothing to the forge. No claiming, no agents. + +*Done when:* labelled issues in a real repo appear in the dashboard within one +poll interval, and a repo's schedule can be changed from the dashboard without a +redeploy. + +*Why first:* proves the poll loop, rate discipline and repo configuration while +the blast radius is still zero. + +### Stage 2 — Claiming and lifecycle (still no agents) + +Job state machine, `FOR UPDATE SKIP LOCKED` claiming, lease expiry, label +mirroring, issue comments, reconciliation of stale labels. A **dry-run executor** +that posts what it *would* do instead of running an agent. + +*Done when:* labelling an issue causes tireless to claim it, comment its intended +plan of action, and release it on lease expiry — with the full external protocol +exercised and not one token spent. + +*Why here:* the claim protocol is the part most likely to have subtle bugs, and +this is the last stage where those bugs are free. + +### Stage 3 — Claude Code executor + +Spawn the pinned CLI, read `stream-json`, capture session id for `--resume`, +record `apiKeySource` as billing mode, consume `rate_limit_event` into the +governor, enforce budgets and the circuit breaker. + +First real capability: `tireless/plan` on a real issue produces an epic and child +issues. + +*Done when:* a planning run completes against a real issue, the children are +sensible, the journal shows the billing mode, and an artificially lowered window +budget demonstrably holds the lane. + +*Why planning first:* the output is issues, not code. A bad plan is a comment +thread; a bad implementation is a branch. Start where mistakes are cheapest. + +### Stage 4 — Git and PR pipeline + +Mirror cache, per-job clone, branch, commit, push, open PR. Wire the Claude Code +implementation path. Idempotent re-runs against existing branches and PRs. + +*Done when:* an issue labelled `tireless/implement` yields a reviewable PR from a +protected-branch-respecting bot account, and re-running the job updates rather +than duplicates. + +### Stage 5 — OpenCode executor + +Spawn `opencode serve` on loopback with a per-spawn password, drive it over HTTP, +target helexa cortex. Enforce `assert_not_anthropic` from config. Route +plan-descended implementation jobs here. + +*Done when:* a child issue created by a stage-3 planning run is implemented +end-to-end by OpenCode on the GPU fleet, with zero subscription usage. + +### Stage 6 — Scheduling and dashboard control + +Schedule editing, repo add/remove, lane pause/resume, budget and limit-signal +display, run history with per-run billing mode. + +*Done when:* the operator can add a repo, change its cadence, and pause the +Claude Code lane without touching a shell. + +### Stage 7 — Hardening + +Dead-letter semantics for repeatedly failing jobs, Prometheus metrics, alerting +on tripped breakers, retention and cleanup of job directories, and the optional +container isolation backend behind the existing executor interface. + +--- + +## 8. What is lifted from vibe-kanban + +vibe-kanban is the reference implementation for driving these two agents. It is +*not* a dependency — tireless reimplements the parts it needs — but these are the +files worth reading before writing the corresponding stage. + +| Concern | vibe-kanban reference | +| --- | --- | +| Executor interface | `crates/executors/src/executors/mod.rs:222` (`StandardCodingAgentExecutor`) | +| Claude Code spawn + control protocol | `crates/executors/src/executors/claude.rs:619` | +| Pinned agent package | `claude.rs:61`, `opencode.rs:92` | +| Session resume | `claude.rs:370` (`--resume`, `--resume-session-at`) | +| Session id extraction | `claude.rs:891` | +| `apiKeySource` / billing detection | `claude.rs:911` | +| `rate_limit_event` (parsed, then dropped) | `claude.rs:1954` — **tireless does not drop it** | +| OpenCode loopback server | `opencode.rs:92`, `opencode/sdk.rs:405` (basic auth) | +| Process-group kill for orphaned `npx` children | `opencode.rs:75` (`Drop` impl) | + +That last one is worth pre-empting rather than rediscovering: vk's comment notes +that `kill_on_drop` proved unreliable and leaked orphaned processes, which is why +it kills the whole process group explicitly. An unattended service accumulating +orphaned Node processes would be a slow, confusing failure. + +--- + +## 9. Risks and open questions + +**The subscription arrangement can be withdrawn.** Accepted, explicitly. The +mitigation is that the API-key fallback is a config switch (§3.2), so the failure +mode is a billing change rather than a rewrite. + +**A headless subscription login is a manual step.** The OAuth flow must be +completed interactively as the `tireless` service account on bob. It is scripted +as far as it can be and documented in `script/infra-setup.sh`. If the token ever +requires reauthentication, the runner fails its preflight rather than silently +falling back to an API key. + +**Unattended agents with commit rights are a real exposure.** Bounded by: a bot +account that cannot push to any default branch; hardened units; per-job clones; +and human review before merge. Stage 7's container backend tightens this further, +and the executor interface is shaped so it can drop in without touching +agent-driving code. + +**Plan quality is unproven.** The whole economic argument — Opus plans, local +models implement — rests on tireless-authored plans being specific enough for a +27B model to execute. Stage 5 is where that assumption meets evidence. If it +fails, the fallback is routing more implementation to Claude Code, which costs +subscription budget but not a redesign. + +**Not yet decided:** whether a failed implementation should automatically open a +`tireless/blocked` issue describing what it could not do, or simply comment on +the original. Deferred to stage 4, when there is real failure data to look at. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c10f81b --- /dev/null +++ b/readme.md @@ -0,0 +1,74 @@ +# tireless + +Watches labelled issues on Gitea (and GitHub, for legacy repos), claims them, and +either decomposes them into an epic with child issues or implements them and +opens a pull request. A human reviews everything; nothing merges itself. + +Two coding agents do the work, each spawned as the vendor's own binary: + +- **Claude Code** — planning, and implementation of issues that need + interpretation. Uses the operator's Claude subscription by default, or + pay-as-you-go if an API key is supplied. +- **OpenCode** — implementation of issues that a tireless plan already specified, + against the self-hosted [helexa](https://git.lair.cafe/helexa/helexa) fleet. + Never Anthropic; enforced at startup. + +The rule of thumb: **Claude Code gets judgement, OpenCode gets specification.** + +Full design, constraints and the staged implementation plan: +[`doc/plan/design.md`](doc/plan/design.md). + +## Status + +Stage 0 (foundations) is scaffolded. Ingestion, claiming and the agent lanes are +not built yet — see §7 of the design document for what lands when. + +## Build + +```sh +cargo test --workspace +cargo clippy --all-targets --all-features -- -D warnings +cargo fmt --all + +cd dashboard && npm ci && npm run build +``` + +## Run locally + +```sh +cargo run -p tireless-api -- --config ./config.toml +cargo run -p tireless-worker -- --config ./config.toml poll +cargo run -p tireless-worker -- --config ./config.toml run + +cd dashboard && npm run dev # proxies /v1 to 127.0.0.1:23296 +``` + +`tireless preflight` verifies configuration and credentials without starting a +service: it reports which billing mode a Claude Code run would use and asserts +the OpenCode lane is not pointed at Anthropic. + +## Deploy + +CI-driven via Gitea Actions on merge to `main` +(`architecture/deployment-gitea-actions.md`). One-time host provisioning — +including the interactive Claude Code login and the Gitea bot account — is +`script/infra-setup.sh`. + +| | | +| --- | --- | +| Host | `bob.hanzalova.internal` | +| API port | `23296` (registered in `architecture/port-allocations.md`) | +| Dashboard | `https://tireless.internal` (mesh only) | +| Database | `magrathea.kosherinata.internal:5432`, mTLS | + +## Conventions + +Follows [`lair/architecture`](https://git.lair.cafe/lair/architecture); +`generic.md` is the baseline. Two deliberate deviations: + +- **`tireless-agent` crate** beyond the standard entities/core/data split. + Process orchestration is not data access, and it is shared by the runner and + the CLI. (§1) +- **`MemoryDenyWriteExecute=false` on `tireless-runner`.** Both agents are Node + programs and V8's JIT needs write-then-execute pages. The API and poller keep + the setting. (§8) diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d80fdf0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["x86_64-unknown-linux-musl"] diff --git a/script/infra-setup.sh b/script/infra-setup.sh new file mode 100755 index 0000000..c58122c --- /dev/null +++ b/script/infra-setup.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# One-time host provisioning for tireless. +# +# Run by an operator from a workstation with full sudo — NOT by CI. See +# architecture/deployment-gitea-actions.md §2. Idempotent: re-running with no +# changes is a no-op beyond file copies. +# +# Per architecture/generic.md §7 this script never suppresses errors. Where a +# command may legitimately fail (a service not yet installed), the failure is +# handled explicitly and visibly. + +set -euo pipefail + +APP=tireless +API_HOST="${API_HOST:-bob.hanzalova.internal}" +API_PORT="${API_PORT:-23296}" +RUNNER_PUBKEY="${RUNNER_PUBKEY:-$HOME/.ssh/id_gitea_ci.pub}" + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m warn\033[0m %s\n' "$*" >&2; } +fatal() { printf '\033[1;31mfatal\033[0m %s\n' "$*" >&2; exit 1; } + +[[ -f $RUNNER_PUBKEY ]] || fatal "runner public key not found at $RUNNER_PUBKEY. +The keypair is maintained at ~/.ssh/id_gitea_ci on roosta and is shared by every +project's deploy. Copy it — do not generate a new one." + +# --------------------------------------------------------------------------- +# 1. gitea_ci account, key, journal access, scoped sudoers +# --------------------------------------------------------------------------- +provision_host() { + local host="$1" + info "provisioning $host" + + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" true; then + warn "$host unreachable; skipping (re-run once it is back)" + return 0 + fi + + ssh "$host" 'sudo useradd --system --create-home --home-dir /var/lib/gitea_ci \ + --shell /usr/sbin/nologin gitea_ci || echo "gitea_ci already exists"' + ssh "$host" 'sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh' + rsync --rsync-path 'sudo rsync' --chown gitea_ci:gitea_ci --chmod 0600 \ + "$RUNNER_PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys" + ssh "$host" 'sudo usermod -aG systemd-journal gitea_ci' + + # Scoped sudoers — exactly the commands the deploy runs, nothing broader. + # Named _gitea_ci so other apps on this host keep their own drop-in. + ssh "$host" "sudo tee /etc/sudoers.d/${APP}_gitea_ci >/dev/null" <.conf + containing: cert_cn tireless_rw + - sudo systemctl reload postgresql-18 + + Both servers, or a failover locks tireless out. + +MANUAL + +info "infra-setup complete"