diff --git a/.env.example b/.env.example index d5bbd871..4bc3f6cb 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ # # Then choose your target: # make dev # local hot-reload stack -# docker compose -f docker-compose.local.yml up -d # single-host / LAN deploy +# docker compose up -d # single-host / LAN deploy # # The production self-hosted compose (deploy/compose/compose.selfhost.yml) # has its own template at deploy/compose/.env.example for image-tag / @@ -58,12 +58,6 @@ PARSAR_PG_PORT=15432 PARSAR_HOST_IP= # Set to parsar:local after running: make docker-build PARSAR_IMAGE=parsar PARSAR_IMAGE_TAG=local PARSAR_SERVER_IMAGE=parsar:local -# Shared token between parsar-server and the compose-resident local runtime. -# install.sh generates this automatically for normal installs. -PARSAR_SHARED_RUNTIME_TOKEN= -# Internal WebSocket URL advertised to compose-resident daemon runtimes. -PARSAR_AGENT_DAEMON_WS_URL=ws://parsar-server:8080/agent-daemon/ws - # ----------------------------------------------------------------------------- # Feishu Bot (optional — see docs/deploy/lan-deploy.md) # ----------------------------------------------------------------------------- @@ -71,53 +65,12 @@ PARSAR_AGENT_DAEMON_WS_URL=ws://parsar-server:8080/agent-daemon/ws PARSAR_FEISHU_DEFAULT_BOT_OPEN_ID= # ----------------------------------------------------------------------------- -# Cloud sandbox — e2b.app (optional; powers the "Cloud isolation" agent mode) -# ----------------------------------------------------------------------------- -# Both values are required to enable cloud isolation. With either empty the -# server logs a warning at boot and cloud-isolation agents fail fast, while -# local-device agents keep working. -# -# Build the template first (writes only gitignored build artifacts): -# make e2b-template -# then paste the template id it reports here. Rebuilding the same template -# name keeps the id, so this only changes on first creation. -AGENT_DAEMON_SANDBOX_TEMPLATE= -PARSAR_E2B_API_KEY= - -# Optional larger tier, selected per agent via agents.config.sandbox_size="xl". -# An agent asking for a size with no template configured degrades to the -# standard one with a warning rather than failing the acquire. -AGENT_DAEMON_SANDBOX_TEMPLATE_XL= - -# Optional deployment-wide sandbox lifetime. Uses Go duration syntax (for -# example 30m, 1h, or 24h) and defaults to 1h. An agent may override this with -# agents.config.sandbox_ttl. Parsar does not impose a provider-specific maximum; -# configure a value supported by your sandbox provider and account. -AGENT_DAEMON_SANDBOX_TTL= - -# Deployment-wide default for periodic best-effort renewal. An agent may -# override this with agents.config.sandbox_auto_renew. Renewal requires a TTL -# longer than the five-minute scan interval. Enabled by default so a -# continuously-used agent's sandbox is not reaped mid-conversation; set to -# false to opt out (e.g. for a provider that does not support renewal). -AGENT_DAEMON_SANDBOX_AUTO_RENEW=true - -# Deprecated compatibility setting. Used only when the duration above is -# empty; prefer AGENT_DAEMON_SANDBOX_TTL for new deployments. -AGENT_DAEMON_SANDBOX_TTL_HOURS= - -# Self-hosted / proxied e2b only. Leave empty for e2b.app. -PARSAR_E2B_API_BASE_URL= -PARSAR_E2B_SANDBOX_HOST= -# PEM of a private CA, when the sandbox gateway serves a non-public cert. -PARSAR_E2B_CA_CERT= - -# IMPORTANT for local testing: the daemon runs INSIDE the cloud sandbox and -# dials back to PARSAR_PUBLIC_URL, so a loopback URL can never work — the -# sandbox resolves 127.0.0.1 to itself and pairing times out. Expose the dev -# server through a tunnel and set PARSAR_PUBLIC_URL to that hostname, e.g. -# ngrok http 18080 -# PARSAR_PUBLIC_URL=https://.ngrok-free.dev +# Core execution — configured independently from the product. +# See docs/deploy/product-core.md. Docker/E2B and model credentials belong to Core. +# Native development: absolute JSON file containing workspace/project/key-file bindings. +PARSAR_CORE_WORKSPACES_FILE= +# Compose deployment: directory mounted at /run/secrets/core. +PARSAR_CORE_CONFIG_DIR= # ----------------------------------------------------------------------------- # Network proxy (optional — only if your host needs a proxy for internet) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c71ec5f0..a36ec698 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,12 +99,9 @@ that issue instead of expanding the PR. Continue with independent issues. at runtime. - **Web**: Vite + React SPA, eventually served directly by the Go server. - **API**: OpenAPI-first. -- **Connector MVP**: Agent Daemon Connector (`connector_type=agent_daemon`, - adapter determined by `project_agents.config.agent_kind` — `opencode`, - `claude_code`, …) plus the HTTP Agent Connector. -- Agent Daemon runs are dispatched to the `parsar-daemon` runtime bound to - the agent (`project_agents.runtime_id`); the daemon's internal adapter - picks which CLI actually executes. +- **Execution client**: the product registers only `connector_type=agents_api` + and uses `packages/agents-client/v1` with the official OpenAI Go SDK. + Core owns daemon connections, engine selection and environments. ## Architecture boundaries @@ -119,8 +116,7 @@ and example application; its feature backlog must not dictate the execution service's public protocol or internal model. Agents API must build, deploy and run without the Parsar product service, frontend or database. An optional Compose deployment may install both services with one PostgreSQL instance, but separate -databases, credentials and migrations. The existing product keeps its execution -path until an explicit client cutover. +databases, credentials and migrations. The product uses Core exclusively; it has no native daemon or HTTP Agent fallback. #### Design and compatibility requirements @@ -2514,41 +2510,22 @@ or filesystem isolation. Automatic installation remains separate. ### Install and image freshness -- The root `docker-compose.yml` must be directly runnable with - `docker compose up -d`. Do not require `install.sh` to pre-generate `.env` - values for mandatory services to boot. If a service needs a local-only - shared secret, the compose file must provide a clearly documented dev-only - default and allow production/Dokploy installs to override it with a stable - random value. -- The one-command installer is both install and upgrade path. Default GHCR - images must be pulled before `docker compose up` so `:latest` does not - silently reuse a stale local image after `main` changes. -- After pulling, the installer prepares its server data mount for the image's - actual UID/GID and verifies writability before starting services. Only the - preparation container runs as root; the server retains its configured user. -- Default Compose keeps Claude Code configuration and native session files in - `/root/.parsar/claude-code`, on the runtime's existing persistent home volume. - The first installer upgrade stops the old runtime, backs up its legacy - `~/.claude/` and `~/.claude.json` under the install directory, and migrates - them before container replacement. Conflicting history aborts the upgrade. - Do not remove the old container or its volumes before this migration. - Direct Compose/Dokploy users must run `./install.sh migrate-runtime-history` - followed by their existing Compose global options (such as `-p`, `-f`, and - `--env-file`) once before upgrading. This migration-only command does not - rewrite `.env`, change data mounts/secrets, pull images, or start services. - Then use the original Compose upgrade command. Wait for active runs to finish - before upgrading; the runtime is stopped during migration. - Backups contain private session/configuration data; retain them securely until - resume is verified. Already-deleted native histories cannot be reconstructed. -- `install.sh` may still write stable random overrides such as - `PARSAR_MASTER_KEY` and `PARSAR_SHARED_RUNTIME_TOKEN` for safer local - installs, but raw Compose/Dokploy deployments must not depend on those - installer-only side effects. -- Keep `install.sh` a thin Compose wrapper. Its CLI is limited to installation - location, web bind/port, image overrides, and validation. Uncommon deployment - settings belong in the Compose environment rather than new installer flags. - The one-time history migration subcommand passes existing Compose global - options through unchanged so raw deployments retain their configuration. +- Configure product-to-Core access with `PARSAR_CORE_WORKSPACES_FILE`: each + workspace maps to a distinct Core project and caller key file. See + [product deployment](docs/deploy/product-core.md) for the complete configuration. + Provider credentials belong in the independent Core service. An unconfigured + product can start; Core operations return an explicit unavailable error. +- Root Compose deploys the product and its database. It mounts + `PARSAR_CORE_CONFIG_DIR` read-only for workspace bindings and caller key files. + The installer creates an empty binding list without overwriting existing + configuration. The product does not start a runtime container. +- The installer pulls the server image, prepares the data directory for the + image's actual UID/GID, verifies writability, then starts the product. Only the + preparation container runs as root. It generates stable database/master keys. +- The installer is a thin Compose wrapper. Its options cover the installation + directory, web bind/port, server image and validation. Native runtime history + migration and sandbox image options have been removed; no legacy deployment + compatibility or data backfill is provided by this cutover. - Services exposed through a deployment platform may gain an ingress network, but they must remain explicitly attached to the Compose `default` network when they depend on internal service DNS names such as `postgres`. @@ -2566,7 +2543,7 @@ or filesystem isolation. Automatic installation remains separate. `PARSAR_IMAGE_PULL_POLICY=always` for Parsar-owned images. Local image testing must opt out explicitly with `PARSAR_IMAGE_PULL_POLICY=never`. - Local development images stay opt-in through installer overrides such as - `--image parsar:local` / `--sandbox-image parsar-sandbox:local`; do not make + `--image parsar:local`; do not make local tags the default path for end users. - The production server image must provide Node.js 22.20 or newer, `npx`, and `git` for server-side Skills.sh installs. Keep the runtime image compatible @@ -2601,113 +2578,98 @@ or filesystem isolation. Automatic installation remains separate. four platforms. Companion installation does not grant API authorization; task-scoped uploads keep the current run requester and workspace checks. -### Runtime and execution concepts - -- The daemon resolves loopback Postgres capability-download URLs through its - paired server address before calling an adapter. Preserve the signed query - and resource path; external storage URLs and browser upload URLs are unchanged. -- `connector_type` chooses the protocol Parsar uses to run an agent - (`agent_daemon`, `http_agent`, ...). It does not say where the process - runs. -- `runtime_id` chooses the concrete paired runtime/device/sandbox that will - receive a run. It is a routing handle, not agent configuration. -- `agent_kind` chooses the daemon-side engine (`claude_code`, `codex`, - `pi`, `opencode`, `mcode`). It is interpreted only by `parsar-daemon`. -- Placement labels such as local device, cloud sandbox, and external agent - are UI/product concepts. Do not branch business logic on display copy. - Derive placement from typed runtime/provider/config fields in one shared - helper per layer. - -### Server versus daemon ownership - -- Agent exposure through MCP lives in `server/internal/api/agentmcp` and uses - the standard conversation dispatcher. Personal MCP credentials are stored - only as hashes, scoped to one user and Agent, expire after 30 days, and are - checked against current membership and resource state on every request. - They never authenticate Web sessions or runtime/device APIs. Replacing or - revoking a credential affects only that user's connection to that Agent. -- MCP calls persist their `mcp` source and real requesting user. The MCP - endpoint can start a task for its bound Agent and read only that user's runs - for that Agent; it does not bypass existing approvals or expose runtime - configuration, raw events, or other users' results. Long runs are retrieved - with bounded polling; their durable state stays in Parsar, not the MCP session. - -- Agent capability reads retain the stored binding version and expose its - pinning mode. Displayed current versions match the daemon's resolver: - Skill, Plugin, Bundle, and Knowledge can follow latest metadata (including - deprecation cutoffs); MCP and System Prompt currently use the stored binding - version. Config offers automatic-follow choices only for supported types and - retains per-binding configuration when changing version policy. - -- Cross-workspace installed capabilities remain visible and removable after - unpublishing. Their installation metadata reports source visibility and only - bound versions while private; marketplace discovery and new installs still - require a published source. -- Conversation user-message limits count Unicode code points after trimming - surrounding whitespace, not UTF-8 bytes. Keep route and store validation aligned. -- Agent capability upgrades accept private capabilities from the Agent's own - workspace; cross-workspace upgrades still require a public, available source. -- The server owns auth, workspaces, agent records, runtime bindings, run - records, audit/usage persistence, and upstream engine session ids. -- Soft-deleting an Agent preserves workspace-authorized conversation and run - history, including its identity. History reads expose deletion state; they - must not allow new messages or retries to execute the deleted Agent. -- Successful explicit Agent capability enable, upgrade, removal, and built-in - toggle requests emit Agent-targeted audit events with the authenticated actor - and capability identifiers. Never include configuration or credential values. -- Explicit Agent enable/disable requests pass the requesting user to the store - for audit attribution. Keep the target Agent separate from the actor, and - preserve the previous and next status in the event payload. -- `parsar-daemon` owns CLI discovery, process spawning, CLI-specific env, - cwd selection inside its host/container, permission prompts, and translating - CLI streams into Parsar daemon protocol frames. -- `internal/agentdaemon/proto` is the only shared wire contract between the - server and daemon. The daemon must not import `server/internal/...`, and the - server must not import daemon-internal adapter packages. -- The conversation SSE first-event timer observes run state; it is not an - execution deadline. Keep waiting while the stored run is queued or running. - Dispatch retains ownership of execution timeouts and terminal state. -- Any state needed to recover a conversation after a server restart, daemon - reconnect, or child-process exit must be stored durably by the server. - In-memory maps may cache waiters or sockets only; they must not be the - source of truth for conversation/session continuity. -- Work directory validation is a cross-boundary security rule: user input is - accepted only as an absolute path or `~/...`; daemon-side fallbacks must stay - under `~/.parsar/`. - -### External HTTP Agents - -- `connector_type=http` runs use the standard conversation dispatcher and its - 30-minute execution deadline. The HTTP connector performs one JSON POST and - emits one final reply; the dispatcher alone persists completion and usage. - Default development startup uses this same dispatcher. The legacy standalone - HTTP worker is not supported alongside the server; it bypasses credential - resolution, serial dispatch, and request cancellation ownership. -- Store `config.http.endpoint` and optional `config.http.secret_id`. Accept the - historical flat keys on input, but never forward endpoint or credential - configuration in the request body. Only `agent_config.system_prompt` is sent. -- Bearer secrets use `kind=provider=http_agent`, `auth_type=bearer`, and a - `{"token": "..."}` encrypted payload. Check active status and management - workspace on both configuration and every invocation. Global model secrets - are not HTTP Agent credentials. Reject URL userinfo and all redirects. -- The service owns models, tools, permissions, and conversation history, keyed - by `conversation_id`. Parsar capability/runtime bindings are not injected. - Text requests carry the existing `httprunner.AgentRequest` identity fields; - responses contain nonempty `content` and optional `store.UsageInput` `usage`. - Responses are limited to 4 MiB. Do not infer unreported usage or prices. -- Stop cancels the outbound request on the executing server instance; this - initial connector targets single-instance deployments. Cross-instance HTTP - request cancellation is not supported. Stop does not guarantee termination of work - inside a remote service; services should honor HTTP request cancellation and - deduplicate work by `run_id`. Retrying creates a new run. - -### Agent editing - -- The Agent edit form updates profile, model, and execution settings only. - Manage existing capability bindings, versions, and capability credentials - through the Config capability controls. Creation can choose initial bindings. - Profile edits must omit capability reconciliation and capability credential - snapshots. +### Product Core execution + +- Product Agent configuration uses the complete pinned inline Agent contract: + model, instructions (mapped once from the product `system_prompt` field), tools, + service_tier, reasoning, text and multi_agent. Do not narrow this schema to the + current Core MVP. Reject old engine/device/provider settings; Core validates + its current execution support. Business display/visibility stay in Parsar. + A supplied product `config` replaces the execution configuration; omitted config + preserves it. The separately supplied SP remains independent of that replacement. + Inline Session configuration is the current integration, not a claim that product + Agents are Core saved-Agent resources. See the [object mapping](docs/deploy/product-core.md#object-and-operation-mapping). +- Use workspace-specific Core clients with distinct project credentials. Never + infer isolation from metadata, or fall back to a global key for an unknown + workspace. Core verifies the configured project header. Keep the workspace's + project stable across restarts; rotate only its credential. Configuration and + deployment details live in [the product integration guide](docs/deploy/product-core.md). +- Core owns environment templates. Product routes authorize the workspace then + forward official SDK operations to that workspace's Core project. Do not cache + confidential template inputs in product tables or logs. The API forwards the + full upstream schema; the UI identifies unimplemented initialization fields and + does not present them as currently usable. Product Session metadata contains only safe environment + selectors; initialization belongs to templates. Preserve Parsar’s sidebar and + page-owned actions; do not duplicate navigation with upstream dashboard tabs. + Docker/E2B are Core hosted providers, while official + self_hosted requires its own executor connection. Missing key-management or + connection workflows must show unavailable, not call private dashboard APIs. +- Persist one Core session binding per product conversation/Agent and one Core + turn binding per product run. Freeze session requests, input and the preceding + turn cursor before submission. Use stable product binding/run IDs as Core + idempotency keys. A user retry creates a new product run; restarting an observer + reuses the existing run and never creates another input event. + Distinguish definitive admission rejection from uncertain delivery: Core's + terminal environment-unavailable/expired/cancelled 409 codes fail and settle the + product input. Other uncertain receipts retain recovery and the lane fence. +- Observe durable Turns and Items through the public SDK. Product SSE is a + projection of these reads, not Core event-stream replay. Persist projected + text/thinking/tool events before acknowledging them; restore their projection + from product events after an interruption. Join function-call output items by + `call_id`; a completed call alone is not its result. Preserve incomplete tools + as unsuccessful in both live and persisted trace presentation. Recovered terminal outcomes retain + their failure state even when the terminal event was already recorded. Persist + measured usage idempotently before settling completed, failed or cancelled Core + work; assistant-message creation is not a prerequisite for accounting. Neither service queries the other's database. +- Serialize each conversation/Agent lane with a PostgreSQL advisory transaction + lock on a dedicated connection, independent of the product query pool. Check + the connection while observing and cancel observation when the lease is lost. + Each product process observes at most eight runs concurrently. Account for + these additional connections when sizing PostgreSQL. +- Cancellation records durable product intent. Only the lane's current observer + sends the session-wide Core cancel event and waits for the old turn to settle + before admitting its successor. A delayed HTTP cancel handler must never + directly cancel whichever Core turn happens to be current. +- Recovery scans queued/running runs and unsettled terminal runs at startup and + periodically. Service shutdown stops observation without converting a running + Core turn into a failed product run. Product database interruptions at invocation, Session/input binding, + admission receipt, projection, usage or settlement boundaries are observation + failures, not Agent failures. Leave them recoverable; never cancel accepted Core + work because local persistence was temporarily unavailable. A deterministic + invalid-member result before a Core binding exists fails product work instead + of retrying forever. Frozen executions remain observable and cancellable + after Agent + retirement; retirement prevents new admission, not settlement of existing work. + Deleting a conversation atomically cancels queued/running work. Internal Core + cleanup reads retain access to submitted execution and its projection, including + failed/completed product runs still awaiting Core settlement, while + public conversation/run/event reads keep excluding deleted history. + Auth, workspace membership, conversation ownership, IM, MCP + access to Agents, scheduling, auditing and billing remain product concerns. +- The product currently supports text input, assistant text, reasoning summaries, + tool observations, raw token usage and cancellation. Capability bindings, + attachments, interactive approvals, application function results, local-device and sandbox + administration are unavailable in this product client. Show this limitation in + the Agent configuration page; reject unsupported input rather than silently + ignoring it. A Core turn waiting for an unsupported interaction is cancelled + and reported as unsupported. +- The product no longer exposes HTTP Agent invocation/worker/configuration, + native streaming, model-provider administration, runtime pairing/credentials, + sandbox lifecycle or in-place run requeue APIs. Reject retained legacy Agent + connector types at web/IM/retry/scheduling admission instead of leaving queued work. + Parsar retains its independent database, capability assets and versions, Skills + import/upload, MCP configuration/OAuth, permissions and business orchestration. + These are product asset operations, not runtime installation. Browser plugin + extensions also remain product UI behavior. Runtime capability activation and + loading must use Core; unsupported execution binding mutations are not exposed. + Asset import or OAuth success must never imply readiness for Agent execution. + Landed migrations and retained business history are not rewritten or deleted. +- Soft-deleting an Agent preserves authorized conversation/run history and its + identity. Deleted Agents must not accept new messages or retries. Explicit + enable/disable and visibility changes retain actor-attributed audit records. +- `parsar-daemon` and `internal/agentdaemon` are execution-service infrastructure; + their independent build and Core contracts remain in scope for their own tests. + Do not reintroduce direct product-to-daemon execution. ### Agent CLI adapter contract @@ -3040,64 +3002,12 @@ or filesystem isolation. Automatic installation remains separate. `request_id` through the canonical interaction's `device_id`; IM slots are only a legacy fallback. -### Sandbox and local runtime lifecycle - -- The default local install path provides one ready-to-use sandbox runtime. - Do not require the Parsar server container to create sibling Docker - containers through `/var/run/docker.sock` for normal first-run operation. -- Local Docker lifecycle, cloud sandbox lifecycle, and user-paired devices are - different providers behind the same daemon protocol. Keep provider-specific - create/renew/kill logic in `server/internal/sandbox/...` or a narrowly named - runtime provider; do not spread Docker/E2B calls through handlers, - connectors, or frontend components. -- Dynamic local sandbox scaling is not a product guarantee. If it is added, - it must be owned by an explicit local supervisor/runtime provider with a - reviewed Docker socket boundary, not by ad hoc server-side `docker run` - calls. -- Eager acquisition must be best-effort. Failure to prewarm a sandbox should - surface as runtime health/provisioning state, not crash unrelated startup - paths. -- Cloud sandbox maintenance runs once at server startup and every five minutes. - Automatic renewal requires a TTL longer than that interval; interrupted - renewals remain retryable, while a provider rejection disables the policy. -- A `spawning` sandbox binding holds that agent's only reservation slot - (`uk_sandboxes_active_per_agent` is partial on `killed_at is null`), and the - loser path waits on `spawning` indefinitely. Any code that reserves a slot - must therefore guarantee a terminal transition, and an acquire that finds a - reservation older than the cold-start bound must be able to reclaim it — - otherwise one crashed cold start wedges the agent permanently. - -### Sandbox images - -- `infra/sandbox/Dockerfile` (local Docker + generic) and - `infra/sandbox/e2b.Dockerfile` (e2b.app) must keep their shared runtime - payload, CLI versions, and hooks aligned; provider-specific bootstrap and - build mechanics may differ. - Agent CLI installs live only in `infra/sandbox/scripts/install-agents.sh`, - which both images run; do not inline per-CLI `npm install -g` / download - steps in either Dockerfile. That script owns the version pins and the Node - force-relink that keeps a base image's bundled Node from shadowing ours. -- The image must ship the hook scripts at the absolute paths - `server/internal/connector/agentdaemon/sandbox_seed.go` seeds into - `settings.json` (`/opt/parsar/hooks/claude/...`). The hooks fail open, so a - missing script degrades spec/memory injection silently instead of erroring — - changing one side means changing the other. -- e2b's template builder is not BuildKit. It rejects multi-stage builds (hence - the prebuilt binaries in `infra/sandbox/.build/`, staged by - `make e2b-template`), it does not persist `/tmp` between layers, and it - lowers `ARG FOO="bar"` keeping the quotes as literal characters — so version - ARGs in `e2b.Dockerfile` must stay unquoted. -- Build templates with `make e2b-template`. It writes only into - `infra/sandbox/.build/` (gitignored), never the repo root. - -### Testing cloud isolation locally - -- The daemon runs inside the cloud sandbox and dials back to - `PARSAR_PUBLIC_URL`, so a loopback URL cannot work: the sandbox resolves - `127.0.0.1` to itself and pairing times out. Expose the dev server through a - tunnel and set `PARSAR_PUBLIC_URL` to that hostname. -- `AGENT_DAEMON_SANDBOX_TEMPLATE` + `PARSAR_E2B_API_KEY` are the two required - values; see `.env.example` for the full set and their defaults. +### Execution environments + +Execution environment acquisition, lifecycle, native session state and sandbox +images are owned by Core. See [Environment ownership and placement](#environment-ownership-and-placement) +and the independent execution artifact contracts above. Product deployment must +not mount the Docker socket or provision runtimes on an Agent's behalf. ### API, DB, and generated surfaces @@ -3340,19 +3250,12 @@ width exceeds the available panel width. Its minimum height remains 64px; wrapped rows grow naturally. Keep this behavior in the shared header, with `actionClassName` reserved for page-specific action arrangements. -The Agent form checks workspace runtime status before creating or switching to -cloud execution. Unknown or unavailable status blocks advancing and submitting; -ordinary edits to an existing cloud Agent and local execution stay independent. -Cloud setup opens Runtime's Instances tab separately so form input is retained. -Missing-model setup follows the same pattern: open Models separately and refresh -the catalog from the still-open Agent form. Keep its draft in the mounted form; -do not serialize it into prerequisite URLs or add a second draft lifecycle. - -New and cloned Agent forms default invocation scope to workspace, matching the -server default. Scope choices explain the existing Feishu gate without implying -anonymous Web/API access. Public creation clears personal model credential choices -and requires a shared binding for credential-reference models; existing Agents -and their edit forms keep their stored scope. +Agent forms edit the model, instructions and pinned upstream Agent configuration. +Environment templates belong to Core; Session creation chooses a template or +an explicit environment type. Preserve drafts when an upstream operation fails +and show unavailable features without inventing a successful local substitute. +New and cloned Agents default invocation scope to workspace. Scope choices +explain the Feishu gate without implying anonymous Web/API access. Use `EmptyState` with `size="compact"` for detail tabs, subsections, and compact result panels. Keep their alignment and spacing in that shared diff --git a/Dockerfile b/Dockerfile index dcca568d..d68d14ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # # The image carries NO real secrets and NO internal addresses. # Operators inject DATABASE_URL / PARSAR_MASTER_KEY / Feishu OIDC / -# E2B credentials / etc. at runtime via environment variables or a +# Core workspace credential paths / etc. at runtime via environment variables or a # YAML config file mounted at $PARSAR_CONFIG_FILE. # # Build: make docker-build @@ -80,6 +80,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ COPY internal ./internal COPY catalog ./catalog +COPY packages/agents-client ./packages/agents-client COPY server ./server # Build all three binaries in one RUN so the layer represents one @@ -129,8 +130,6 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # - ca-certificates + tini ship pre-built. # - Server-side Skills.sh installs require npx and git. # - Operators can `docker exec -it ... bash` to debug. -# - The opencode local runner may shell out (rg, basic core utils); -# keeping a real userland avoids surprises. ############################################################################### FROM --platform=$TARGETPLATFORM ${RUNTIME_BASE} AS runtime @@ -209,10 +208,6 @@ EXPOSE 8080 HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ CMD wget -qO- --tries=1 --timeout=3 http://127.0.0.1:8080/healthz >/dev/null || exit 1 -# tini reaps zombie subprocesses spawned by the opencode local runner -# (or any future fork-exec path). Without it the server PID-1 would -# leak zombies on every prompt that shells out and an SRE poking the -# image weeks later would find a process table full of `` -# entries with no obvious culprit. +# tini reaps subprocesses used by product asset import tools. ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["/usr/local/bin/parsar-server"] diff --git a/Makefile b/Makefile index 8966861f..ba5b7e0f 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ endif PARSAR_IMAGE ?= parsar PARSAR_IMAGE_TAG ?= dev -.PHONY: help setup node-deps dev dev-db check check-setup check-sqlc check-go check-store check-web check-cli check-hygiene test test-fast test-go test-web typecheck-web lint-web-design lint-web test-cli typecheck reset-dev clean-dev paths migrate-dev sqlc-generate server web cli devgateway http-runner-once http-runner-loop dev-all smoke e2e-http-agent e2e-feishu-gateway dev-server-up dev-server-down dev-server-log bootstrap docker-build docker-build-no-cache openapi e2b-template e2b-template-binaries +.PHONY: help setup node-deps dev dev-db check check-setup check-sqlc check-go check-store check-web check-cli check-hygiene test test-fast test-go test-web typecheck-web lint-web-design lint-web test-cli typecheck reset-dev clean-dev paths migrate-dev sqlc-generate server web cli devgateway dev-all smoke e2e-feishu-gateway dev-server-up dev-server-down dev-server-log bootstrap docker-build docker-build-no-cache openapi e2b-template e2b-template-binaries help: @printf '%s\n' \ @@ -211,12 +211,6 @@ cli: devgateway: cd server && go run ./cmd/devgateway --help -http-runner-once: - cd server && go run ./cmd/httprunner --once - -http-runner-loop: - cd server && go run ./cmd/httprunner --interval $${PARSAR_HTTP_RUNNER_INTERVAL:-2s} --max-runs $${PARSAR_HTTP_RUNNER_MAX_RUNS:-100} - dev-all: ./scripts/dev-all.sh @@ -226,9 +220,6 @@ dev-all: smoke: ./scripts/smoke.sh -e2e-http-agent: - ./scripts/e2e-http-agent.sh - e2e-feishu-gateway: ./scripts/e2e-feishu-gateway.sh diff --git a/README.md b/README.md index 7e1013d3..f58df205 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,14 @@ Parsar is a team-first platform for dispatching, managing, and auditing AI coding agents. Send tasks from the tools your team already uses — chat, web UI, API — and get results back where they started: a thread, a PR, a webhook. -Supported agent runtimes: - -- **Claude Code** -- **Piagent** -- **Codex** -- More to come — the runtime layer is pluggable. +Agent execution goes through the independently deployed Agents API Core, whose +qualified profiles include **Claude Code**, **Codex**, and **MiniMax Code**. Parsar +keeps its own database for members, business assets, permissions and collaboration. ### Why Parsar - **Team-first.** Shared queues, run history, and permissions — not single-player agent loops. -- **Pluggable runtimes.** Claude Code today, Codex tomorrow, your in-house agent next week. +- **Independent execution.** Core owns models, runtime environments and execution; Parsar uses its public Agents API. - **Pluggable surfaces.** Feishu / Lark ships today; Slack, Discord, and webhooks on the roadmap. - **Auditable.** Every run is persisted: prompt, diff, logs, exit code. - **Self-hosted.** Your code, your secrets, your machine. No telemetry. @@ -54,8 +51,10 @@ make docker-build ./install.sh --image parsar:dev ``` -The default Compose stack starts PostgreSQL, the Parsar web control plane, -and a shared agent runtime together. +The default Compose stack starts PostgreSQL and the Parsar web control plane. +Deploy Core separately and configure a distinct Core project credential for each +workspace using the [product integration guide](docs/deploy/product-core.md). +Asset management works independently; Agent execution requires this connection. ## Contributing diff --git a/apps/web/src/components/admin/DevicePicker.tsx b/apps/web/src/components/admin/DevicePicker.tsx deleted file mode 100644 index 50dcc112..00000000 --- a/apps/web/src/components/admin/DevicePicker.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useEffect, useMemo } from "react" -import { useTranslation } from "react-i18next" -import { Plus } from "lucide-react" - -import { Button } from "../ui/button" -import { Select, SelectOption } from "../ui/select" -import { Skeleton } from "../ui/skeleton" -import { InlineError } from "../runtime/InlineError" -import { - isLocalDeviceRuntime, - isRuntimeSelectableForDispatch, - runtimeSupportsAgentKind, - useWorkspaceRuntimes, - type Runtime, -} from "../../lib/api-runtimes" - -interface DevicePickerProps { - workspaceID: string - value: string - onChange: (deviceID: string) => void - /** Selected daemon agent_kind. Empty means do not filter by engine. */ - agentKind?: string - /** Keep the currently-bound device visible even if it is not freshly selectable. */ - preserveSelected?: boolean - disabled?: boolean - /** When set, an inline "Add new device" entry is shown that opens this callback. */ - onAddDevice?: () => void -} - -export function DevicePicker({ workspaceID, value, onChange, agentKind, preserveSelected = false, disabled, onAddDevice }: DevicePickerProps) { - const { t } = useTranslation("admin") - // No polling: a ticking "Ns ago" / online→offline shuffle while the - // user is mid-form is just noise. Edit mode can still surface the - // already-bound device via preserveSelected. - const q = useWorkspaceRuntimes(workspaceID, "agent_daemon", { - placement: "local_device", - liveness: "online", - refetchInterval: false, - refetchOnMount: "always", - staleTime: 0, - }) - - const localDevices = useMemo( - () => (q.data ?? []).filter(isLocalDeviceRuntime), - [q.data], - ) - const onlineDevices = useMemo( - () => localDevices.filter(isRuntimeSelectableForDispatch), - [localDevices], - ) - const compatibleDevices = useMemo( - () => onlineDevices.filter((r) => runtimeSupportsAgentKind(r, agentKind)), - [agentKind, onlineDevices], - ) - const selectableDevices = useMemo(() => { - const selected = value ? localDevices.find((r) => r.id === value) : undefined - if (preserveSelected && selected && !compatibleDevices.some((r) => r.id === selected.id)) { - return [selected, ...compatibleDevices] - } - return compatibleDevices - }, [compatibleDevices, localDevices, preserveSelected, value]) - - useEffect(() => { - if (value || disabled || q.isLoading || q.isFetching || q.error) return - if (selectableDevices.length !== 1) return - onChange(selectableDevices[0].id) - }, [disabled, onChange, q.error, q.isFetching, q.isLoading, selectableDevices, value]) - - useEffect(() => { - // Skip while fetching, not just isLoading: a sibling PairDaemonDialog - // invalidates this list as the new daemon comes online, and during - // that refetch the freshly-set value would get wiped before the new - // row appears. - if (!value || q.isLoading || q.isFetching || q.error) return - if (selectableDevices.some((r) => r.id === value)) return - onChange("") - }, [onChange, q.error, q.isFetching, q.isLoading, selectableDevices, value]) - - if (q.isLoading) { - return - } - if (q.error) { - return {(q.error as Error).message} - } - - const addLabel = t("agents.form.devicePicker.addDevice", { defaultValue: "Pair a new device" }) - - if (selectableDevices.length === 0) { - const hasOnlineDevices = onlineDevices.length > 0 - return ( -
- - {t( - hasOnlineDevices - ? "agents.form.devicePicker.noCompatibleTitle" - : "agents.form.devicePicker.emptyTitle", - { - defaultValue: hasOnlineDevices - ? "No online devices compatible with the current Agent engine" - : "No agent daemons connected yet", - }, - )} - - {onAddDevice ? ( - - ) : ( - - {t( - hasOnlineDevices - ? "agents.form.devicePicker.noCompatibleDescription" - : "agents.form.devicePicker.emptyDescription", - { - defaultValue: hasOnlineDevices - ? "Open Runtime → Local devices to confirm the device has reported a heartbeat for this Agent engine, or switch to a device that supports it." - : "Open Runtime → Local devices to generate a pairing token, then run `parsar-daemon connect --url ... --token ...` on the target machine before returning here.", - }, - )} - - )} -
- ) - } - - return ( -
- - {onAddDevice && ( - - )} -
- ) -} - -function formatDeviceLabel(r: Runtime): string { - const parts = [r.name] - if (r.hostname && r.hostname !== r.name) parts.push(r.hostname) - // No "Ns ago" suffix — list does not poll, so relative timestamps would - // be misleading; staleness lives on the admin Runtime page. - return parts.join(" · ") -} diff --git a/apps/web/src/components/admin/PairDaemonDialog.tsx b/apps/web/src/components/admin/PairDaemonDialog.tsx deleted file mode 100644 index a5e167c6..00000000 --- a/apps/web/src/components/admin/PairDaemonDialog.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useEffect, useState } from "react" -import { useTranslation } from "react-i18next" -import { Check, Copy, Loader2, X } from "lucide-react" - -import { Button } from "../ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "../ui/dialog" -import { Field } from "../ui/label" -import { Input } from "../ui/input" -import { StatusIcon } from "../ui/status-icon" -import { VerbatimBlock } from "../ui/verbatim" -import { InlineError } from "../runtime/InlineError" -import { useCreateRuntimePairing, useWorkspaceRuntimes } from "../../lib/api-runtimes" -import { useBootstrapStatus } from "../../lib/api-bootstrap" -import { copyText } from "../../lib/clipboard" - -interface PairDaemonDialogProps { - open: boolean - onClose: () => void - workspaceID: string - /** - * Fires once when the freshly-minted runtime transitions out of - * pending_pairing (i.e. daemon connected). Use this to auto-select - * the device in a host form. - */ - onPaired?: (runtimeID: string) => void -} - -export function PairDaemonDialog({ open, onClose, workspaceID, onPaired }: PairDaemonDialogProps) { - const { t } = useTranslation("admin") - const { t: tc } = useTranslation("common") - const create = useCreateRuntimePairing(workspaceID) - // Prefer the server's configured public URL (PARSAR_PUBLIC_URL) over the - // browser origin so the minted command is correct even when the admin - // reaches the UI on a different host than daemons must dial back on. - const statusQ = useBootstrapStatus() - const serverPublicURL = statusQ.data?.public_url?.trim() ?? "" - // Poll runtime list here (5s) so the dialog can react when the daemon - // flips online, even when opened from a form with its own non-polling list. - const listQ = useWorkspaceRuntimes(workspaceID, "agent_daemon") - const [name, setName] = useState("") - const [result, setResult] = useState<{ - token: string - runtimeName: string - runtimeID: string - } | null>(null) - const [paired, setPaired] = useState(false) - - const allRuntimes = listQ.data ?? [] - const connected = result - ? allRuntimes.some((r) => r.id === result.runtimeID && r.liveness !== "pending_pairing") - : false - - // Fire onPaired once when the daemon flips online; guard against - // re-firing on every 5s list refetch. - useEffect(() => { - if (!connected || !result || paired) return - const timer = window.setTimeout(() => { - setPaired(true) - onPaired?.(result.runtimeID) - }, 0) - return () => window.clearTimeout(timer) - }, [connected, paired, result, onPaired]) - - function reset() { - setName("") - setResult(null) - setPaired(false) - create.reset() - } - function close() { - reset() - onClose() - } - - async function submit() { - const trimmed = name.trim() - if (!trimmed) return - const res = await create.mutateAsync({ name: trimmed, type: "agent_daemon" }) - setResult({ - token: res.pairing_token, - runtimeName: res.runtime.name, - runtimeID: res.runtime.id, - }) - } - - return ( - { - if (!o) close() - }} - > - - - - {t("runtime.agentDaemon.pair.title", { defaultValue: "Pair a new device" })} - - - {t("runtime.agentDaemon.pair.description", { - defaultValue: - "Generate a one-time token for this device, then run parsar-daemon connect on the target machine. The daemon will dial back to Parsar over WebSocket.", - })} - - - - {!result ? ( - <> -
{ - e.preventDefault() - void submit() - }} - > - - setName(e.target.value)} - placeholder="my-laptop" - autoFocus - data-testid="agent-daemon-pair-name" - /> - -
    -
  • - {t("runtime.agentDaemon.pair.safetyOutbound", { - defaultValue: "This host opens an outbound connection — no inbound ports required.", - })} -
  • -
  • - {t("runtime.agentDaemon.pair.safetyClaude", { - defaultValue: "Agent CLI, files, and secrets stay on this machine.", - })} -
  • -
  • - {t("runtime.agentDaemon.pair.safetyState", { - defaultValue: "Config, logs, state and cache are written under ~/.parsar/ — never into the repository the agent is working in.", - })} -
  • -
  • - {t("runtime.agentDaemon.pair.safetyOnce", { - defaultValue: "The token is shown once — it cannot be recovered after this dialog closes.", - })} -
  • -
- {create.error && {(create.error as Error).message}} -
- - - - - - ) : ( - <> -
-

- {t("runtime.agentDaemon.pair.successOneLine", { - defaultValue: - "Run this one command on {{name}} to connect (it downloads and connects automatically — no binary to install manually):", - name: result.runtimeName, - })} -

- -

- - {connected - ? t("runtime.agentDaemon.pair.connected", { defaultValue: "Device connected" }) - : t("runtime.agentDaemon.pair.waitingConnection", { - defaultValue: "Waiting for the device to connect…", - })} -

-
- - - - - )} -
-
- ) -} - -function DaemonCommandBlock({ - command, - description, - label, - testId, -}: { - command: string - description: string - label: string - testId: string -}) { - const { t } = useTranslation("admin") - const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle") - - async function copyCommand() { - const copied = await copyText(command) - setCopyStatus(copied ? "copied" : "failed") - window.setTimeout(() => setCopyStatus("idle"), 2000) - } - - return ( -
-
- {label} - -
- - {command} - -

{description}

-
- ) -} - -// Single command the operator pastes on the target machine: download via -// the server's install endpoint, then connect — all in one pipe. Pairing -// inputs ride as env vars (NOT a URL query string and NOT connect flags) -// so the one-shot token never lands in server/proxy access logs or `ps` -// output: `connect` hydrates these same vars and scrubs them from child -// argv (see apps/parsar-daemon/internal/cli/connect.go). The piped -// install script chmods the binary and execs `connect -b`, so the -// operator never sees the binary, its path, or the token. -function buildOneLineCommand(token: string, deviceName: string, publicURL?: string): string { - const origin = serverOrigin(publicURL) - return [ - `curl -fsSL ${origin}/api/v1/parsar-daemon/install.sh |`, - `PARSAR_DAEMON_CONNECT_URL=${origin}`, - `PARSAR_DAEMON_CONNECT_TOKEN=${token}`, - `PARSAR_DAEMON_CONNECT_DEVICE_NAME=${shellEscape(deviceName)}`, - `bash`, - ].join(" ") -} - -function serverOrigin(publicURL?: string): string { - const configured = publicURL?.trim() - if (configured) return configured.replace(/\/+$/, "") - return typeof window !== "undefined" && window.location?.origin - ? window.location.origin.replace(/\/+$/, "") - : "https://" -} - -function shellEscape(s: string): string { - if (/^[A-Za-z0-9._-]+$/.test(s)) return s - return `'${s.replace(/'/g, "'\\''")}'` -} diff --git a/apps/web/src/components/admin/SandboxPanel.tsx b/apps/web/src/components/admin/SandboxPanel.tsx deleted file mode 100644 index 9862466a..00000000 --- a/apps/web/src/components/admin/SandboxPanel.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { useEffect, useState } from "react" -import { useTranslation } from "react-i18next" -import { Box, CalendarClock, Loader2, RotateCcw } from "lucide-react" - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../ui/alert-dialog" -import { Button } from "../ui/button" -import { EmptyState } from "../ui/empty-state" -import { ErrorState } from "../ui/error-state" -import { PropertyList, Property } from "../ui/property-list" -import { Skeleton } from "../ui/skeleton" -import { StatusIcon, type StatusKind } from "../ui/status-icon" -import { - useSandboxBinding, - useRebuildSandbox, - useAcquireSandbox, - useRenewSandbox, - type SandboxStatusKind, -} from "../../lib/api-sandbox" -import { useWorkspaceRuntimes } from "../../lib/api-runtimes" -import { findSandboxRuntimeForAgent, isSandboxPairingExpired } from "../../lib/sandbox-runtime" -import { useNow } from "../../lib/use-now" -import { useRelativeTime } from "../../lib/relative-time" -import { SandboxPreparingNotice, SandboxStartupTimedOutNotice } from "./SandboxProvisioningNotice" -import { RailSection } from "../ui/detail-rail" - -const STATUS_FOR_KIND: Record = { - live: "completed", - transient: "running", - terminal: "cancelled", -} - -function Timestamp({ iso }: { iso: string }) { - // Subscribe to the ticking clock so "Xm ago" advances; the value itself is unused. - useNow() - const fmtAgo = useRelativeTime() - return {fmtAgo(iso)} -} - -function describeRemaining(iso: string, now: number): string | null { - const target = new Date(iso).getTime() - if (Number.isNaN(target)) return null - const ms = target - now - if (ms <= 0) return "" - const totalMinutes = Math.floor(ms / 60_000) - const days = Math.floor(totalMinutes / (60 * 24)) - const hours = Math.floor((totalMinutes - days * 60 * 24) / 60) - const minutes = totalMinutes - days * 60 * 24 - hours * 60 - if (days >= 1) return hours > 0 ? `${days}d ${hours}h` : `${days}d` - if (hours >= 1) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h` - return `${Math.max(minutes, 1)}m` -} - -function ExpiresValue({ iso }: { iso?: string }) { - const { t } = useTranslation("admin") - const now = useNow() - if (!iso) return <>{t("agents.detail.sandbox.fields.expiresAtUnknown")} - const remaining = describeRemaining(iso, now) - const label = - remaining === "" - ? t("agents.detail.sandbox.expires.expired") - : remaining - ? t("agents.detail.sandbox.expires.remaining", { value: remaining }) - : null - return ( - - {new Date(iso).toLocaleString()} - {label && · {label}} - - ) -} - -export function SandboxPanel({ - workspaceID, - agentID, -}: { - workspaceID: string | null - agentID: string -}) { - const { t } = useTranslation("admin") - const { t: tc } = useTranslation("common") - const query = useSandboxBinding(workspaceID, agentID) - const runtimeQuery = useWorkspaceRuntimes(workspaceID ?? "", "agent_daemon") - const rebuildMut = useRebuildSandbox(workspaceID, agentID) - const acquireMut = useAcquireSandbox(workspaceID, agentID) - const renewMut = useRenewSandbox(workspaceID, agentID) - const now = useNow() - const refetchSandbox = query.refetch - const refetchRuntimes = runtimeQuery.refetch - - const [confirmingRebuild, setConfirmingRebuild] = useState(false) - const [provisioningSince, setProvisioningSince] = useState(null) - - function handleConfirm() { - setProvisioningSince(Date.now()) - rebuildMut.mutate(undefined, { onSettled: () => setConfirmingRebuild(false) }) - } - - function triggerAcquire() { - setProvisioningSince(Date.now()) - acquireMut.mutate() - } - - const binding = query.data - const sandboxRuntime = findSandboxRuntimeForAgent(runtimeQuery.data ?? [], agentID) - const runtimeTimedOut = sandboxRuntime ? isSandboxPairingExpired(sandboxRuntime, now) : false - const manualProvisioningActive = - provisioningSince !== null && - now - provisioningSince < 5 * 60_000 && - (!binding || binding.status_kind !== "live") - const preparing = - acquireMut.isPending || - rebuildMut.isPending || - manualProvisioningActive || - Boolean(sandboxRuntime?.liveness === "pending_pairing" && !runtimeTimedOut) - - useEffect(() => { - if (!preparing) return - const tick = window.setInterval(() => { - void refetchSandbox() - void refetchRuntimes() - }, 2500) - return () => window.clearInterval(tick) - }, [preparing, refetchSandbox, refetchRuntimes]) - - if (query.isLoading) { - return ( -
- - -
- ) - } - - if (query.error) { - return ( - void query.refetch()} - /> - ) - } - if (!binding) { - return ( - - {preparing ? ( - - ) : sandboxRuntime && runtimeTimedOut ? ( - - ) : ( - - {acquireMut.isPending && } - {t("agents.detail.sandbox.actions.provision")} - - } - /> - )} - {acquireMut.error && ( - - )} - - ) - } - - const busy = renewMut.isPending || rebuildMut.isPending || binding.status_kind !== "live" - - return ( -
- - - -
- } - > - - - - {binding.status} - - {binding.sandbox_id} - {binding.template_id} - - - - - - - - - - {binding.killed_at && ( - - - - )} - {binding.binding_id} - {binding.cache_key} - - {binding.status_kind !== "live" && ( -

{t("agents.detail.sandbox.notLiveHint")}

- )} - {preparing && ( -
- -
- )} - - - {rebuildMut.error && ( - - )} - {renewMut.error && ( - - )} - {renewMut.isSuccess && renewMut.data?.expires_at && ( -

- - {t("agents.detail.sandbox.renewedToast", { - expiresAt: new Date(renewMut.data.expires_at).toLocaleString(), - })} -

- )} - - { - if (!next && !rebuildMut.isPending) setConfirmingRebuild(false) - }} - > - - - {t("agents.detail.sandbox.confirm.rebuild.title")} - {t("agents.detail.sandbox.confirm.rebuild.description")} - - - - - - - - - - - - - ) -} diff --git a/apps/web/src/components/admin/SandboxProvisioningNotice.tsx b/apps/web/src/components/admin/SandboxProvisioningNotice.tsx deleted file mode 100644 index a5476c1e..00000000 --- a/apps/web/src/components/admin/SandboxProvisioningNotice.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useTranslation } from "react-i18next" -import { AlertTriangle, Loader2 } from "lucide-react" - -import { Button } from "../ui/button" -import { PropertyList, Property } from "../ui/property-list" -import { StatusIcon } from "../ui/status-icon" -import type { Runtime } from "../../lib/api-runtimes" -import { useNow } from "../../lib/use-now" - -type StepState = "active" | "pending" - -function elapsedSeconds(startedAt?: string): number { - if (!startedAt) return 0 - const started = new Date(startedAt).getTime() - if (Number.isNaN(started)) return 0 - return Math.max(0, Math.floor((Date.now() - started) / 1000)) -} - -/** One 32px hairline step row: status icon, title in ink, detail muted after " · ". */ -function Step({ state, label, detail }: { state: StepState; label: string; detail?: string }) { - return ( -
  • - - - {label} - {detail && · {detail}} - -
  • - ) -} - -export function SandboxPreparingNotice({ - runtime, - startedAt, -}: { - runtime?: Runtime | null - startedAt?: string -}) { - const { t } = useTranslation("admin") - useNow() - const elapsed = elapsedSeconds(startedAt) - const imagePullActive = elapsed >= 10 - const slowImagePull = elapsed >= 30 - return ( -
    -

    -

    -
      - - - - -
    - {(runtime || startedAt) && ( - - {runtime && ( - {runtime.id} - )} - {startedAt && ( - - {new Date(startedAt).toLocaleString()} - - )} - - )} -
    - ) -} - -export function SandboxStartupTimedOutNotice({ - runtime, - retrying, - onRetry, -}: { - runtime: Runtime - retrying: boolean - onRetry: () => void -}) { - const { t } = useTranslation("admin") - return ( -
    -
    -
    - - {runtime.id} - - -
    - ) -} diff --git a/apps/web/src/components/conversation/ConversationThread.tsx b/apps/web/src/components/conversation/ConversationThread.tsx index 07e6e200..c13b9fa1 100644 --- a/apps/web/src/components/conversation/ConversationThread.tsx +++ b/apps/web/src/components/conversation/ConversationThread.tsx @@ -60,7 +60,6 @@ const THREAD_STYLE = { ["--thread-max-width" as string]: "48rem" } /** title · conversation id · age (actions replace the age on hover) */ -import type { SandboxSendGuard } from "../../lib/sandbox-send-guard" /* ============================================================== */ /* The conversation thread, mounted by the console and by /c/ */ @@ -86,7 +85,6 @@ interface MainProps { onSendFromEmpty: (content: string) => Promise onRenameAfterFirstMessage: (cid: string, title: string) => Promise focusComposer?: boolean - sandboxGuard?: SandboxSendGuard /** * "console" draws the admin topbar above the thread; "bare" leaves it out * for a shell that already names what you are looking at. @@ -198,7 +196,6 @@ function ConversationMainInner(p: MainProps & { err: unknown; isUnreachable: boo onExpand={p.onExpand} onSendFromEmpty={p.onSendFromEmpty} focusComposer={p.focusComposer} - sandboxGuard={p.sandboxGuard} /> ) } @@ -217,7 +214,6 @@ function ConversationMainInner(p: MainProps & { err: unknown; isUnreachable: boo workspaceID={p.conv.workspace_id} onRenameAfterFirstMessage={p.onRenameAfterFirstMessage} focusComposer={p.focusComposer} - sandboxGuard={p.sandboxGuard} /> ) } @@ -230,7 +226,6 @@ function ConversationMainInner(p: MainProps & { err: unknown; isUnreachable: boo folded={p.folded} chrome={p.chrome} onExpand={p.onExpand} - sandboxGuard={p.sandboxGuard} /> ) } @@ -258,7 +253,6 @@ function EmptyChat({ onSendFromEmpty, onRenameAfterFirstMessage, focusComposer, - sandboxGuard, canWrite, }: { agent: Agent | undefined @@ -272,7 +266,6 @@ function EmptyChat({ onSendFromEmpty?: (content: string) => Promise onRenameAfterFirstMessage?: (cid: string, title: string) => Promise focusComposer?: boolean - sandboxGuard?: SandboxSendGuard canWrite: boolean }) { const { t } = useTranslation("admin") @@ -307,7 +300,7 @@ function EmptyChat({ onRenameAfterFirstMessage(conversationId, title) : undefined } - blockReason={sandboxGuard?.blocked ? sandboxGuard.message : undefined} /> @@ -338,7 +330,6 @@ function ChatStream({ folded, onExpand, chrome, - sandboxGuard, }: { conversationId: string canWrite: boolean @@ -346,7 +337,6 @@ function ChatStream({ folded: boolean onExpand: () => void chrome?: "console" | "bare" - sandboxGuard?: SandboxSendGuard }) { const { t } = useTranslation("admin") const fmtAgo = useRelativeTime() @@ -718,7 +708,7 @@ function ChatStream({ conversationId={conversationId} agentName={agentName} placeholder={agentDeleted ? t("agents.deletedLabel") : t("conversations.composer.placeholder", { agent: agentName })} - disabled={!canWrite || !agent || agentDeleted || sandboxGuard?.blocked} + disabled={!canWrite || !agent || agentDeleted} onAfterSend={async () => scrollToLatest()} onRunStarted={startRun} onStartError={(message: string) => setChatToast({ text: message })} @@ -739,7 +729,7 @@ function ChatStream({ : undefined } cancelling={cancelRunMut.isPending} - blockReason={agentDeleted ? t("conversations.composer.agentDeleted") : !canWrite ? t("conversations.composer.readOnly") : sandboxGuard?.blocked ? sandboxGuard.message : undefined} + blockReason={agentDeleted ? t("conversations.composer.agentDeleted") : !canWrite ? t("conversations.composer.readOnly") : undefined} /> )} diff --git a/apps/web/src/components/layout/AdminLayout.tsx b/apps/web/src/components/layout/AdminLayout.tsx index 22c87700..4a930902 100644 --- a/apps/web/src/components/layout/AdminLayout.tsx +++ b/apps/web/src/components/layout/AdminLayout.tsx @@ -1,3 +1,4 @@ +import { SessionLauncher } from "./SessionLauncher" import type { ReactNode } from "react" import { useTranslation } from "react-i18next" import { cn } from "../../lib/utils" @@ -10,7 +11,6 @@ import { Bot, Wrench, Database, - Cpu, Plug, Users, Settings, @@ -128,9 +128,8 @@ const menuGroups: MenuGroup[] = [ // and the platforms it can be let out on. groupKey: "buildGroup", items: [ - { id: "models", itemKey: "models", icon: Database }, + { id: "environments", itemKey: "environments", icon: Database }, { id: "capabilities", itemKey: "capabilities", icon: Wrench }, - { id: "runtime", itemKey: "runtime", icon: Cpu }, { id: "connections", itemKey: "connections", icon: Plug }, { id: "secrets", itemKey: "secrets", icon: KeyRound }, ], @@ -215,6 +214,7 @@ export function AdminLayout({ className="relative flex min-w-0 flex-1 flex-col overflow-hidden" tabIndex={-1} > + {["agents", "environments", "conversations"].includes(activeMenu) && } {fullBleed ? ( children diff --git a/apps/web/src/components/layout/SessionLauncher.tsx b/apps/web/src/components/layout/SessionLauncher.tsx new file mode 100644 index 00000000..8f69d6e8 --- /dev/null +++ b/apps/web/src/components/layout/SessionLauncher.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react" +import { useWorkspaceId } from "../../lib/workspace" +import { useMyWorkspaces } from "../../lib/api-workspaces" +import { StartSessionDialog } from "../../pages/admin/core/StartSessionDialog" + +export function SessionLauncher() { + const workspaceID = useWorkspaceId() + const workspaces = useMyWorkspaces() + const role = workspaces.data?.workspaces.find(workspace => workspace.id === workspaceID)?.role + const [session, setSession] = useState<{ agentID?: string } | null>(null) + useEffect(() => { + const start = (event: Event) => setSession({ agentID: (event as CustomEvent).detail }) + window.addEventListener("core:start-session", start) + return () => window.removeEventListener("core:start-session", start) + }, []) + return session && workspaceID && role && role !== "viewer" + ? setSession(null)} /> + : null +} diff --git a/apps/web/src/components/runtime/ConnectivityResultPanel.tsx b/apps/web/src/components/runtime/ConnectivityResultPanel.tsx deleted file mode 100644 index 3a13ad41..00000000 --- a/apps/web/src/components/runtime/ConnectivityResultPanel.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from "react" -import { useTranslation } from "react-i18next" -import { ChevronDown, X } from "lucide-react" - -import { Button } from "../ui/button" -import { StatusIcon, type StatusKind } from "../ui/status-icon" -import { VerbatimBlock } from "../ui/verbatim" -import type { - ConnectivityCheck, - ConnectivityCheckCategory, - ConnectivityResult, -} from "../../lib/api-runtime" -import { cn } from "../../lib/utils" - -interface ConnectivityResultPanelProps { - result: ConnectivityResult - checkLabelFor: (name: string) => string - onDismiss: () => void -} - -type SummaryKey = `runtime.connectivity.summary.${ConnectivityResult["overall"]}` -type ErrorCategoryKey = `runtime.connectivity.errorCategories.${ConnectivityCheckCategory}` -type NextStepsKey = `runtime.connectivity.nextSteps.${ConnectivityCheckCategory}` - -function summaryKey(overall: ConnectivityResult["overall"]): SummaryKey { - return `runtime.connectivity.summary.${overall}` as const -} - -function errorCategoryKey(cat: ConnectivityCheckCategory): ErrorCategoryKey { - return `runtime.connectivity.errorCategories.${cat}` as const -} - -function nextStepsKey(cat: ConnectivityCheckCategory): NextStepsKey { - return `runtime.connectivity.nextSteps.${cat}` as const -} - -const STATUS_FOR_OVERALL: Record = { - pass: "completed", - partial: "interrupted", - fail: "failed", -} - -/** The summary copy carries a leading glyph; the status icon already says it. */ -function stripLeadingGlyph(s: string): string { - return s.replace(/^[^\p{L}\p{N}]+/u, "") -} - -/** - * Result of a connectivity test: a 32px summary row (status icon, ink - * sentence, collapse toggle, dismiss), then one hairline row per check and - * the raw error output in a mono `pre` on the muted tone. - */ -export function ConnectivityResultPanel({ result, checkLabelFor, onDismiss }: ConnectivityResultPanelProps) { - const { t } = useTranslation("admin") - // A new result (new started_at) resets the disclosure: open unless it passed. - const resultKey = `${result.started_at}:${result.overall}` - const [expandedFor, setExpandedFor] = useState<{ key: string; open: boolean } | null>(null) - const expanded = expandedFor?.key === resultKey ? expandedFor.open : result.overall !== "pass" - const setExpanded = (update: (prev: boolean) => boolean) => - setExpandedFor({ key: resultKey, open: update(expanded) }) - - const seconds = (result.duration_ms / 1000).toFixed(1) - const firstFail = result.checks.find((c) => !c.pass && c.error) - const failIdx = firstFail ? result.checks.indexOf(firstFail) : -1 - const hasSkipped = failIdx >= 0 && result.checks.slice(failIdx + 1).some((c) => !c.pass && !c.error) - const rawDetails = result.checks.filter((c) => c.error?.detail).map((c) => `${c.name}: ${c.error?.detail}`) - - return ( -
    -
    - - - -
    - - {expanded && ( -
    -
      - {result.checks.map((c) => ( - - ))} -
    - {firstFail?.error && ( -

    - {t("runtime.connectivity.suggestionLabel")}: - {t(nextStepsKey(normalizeCheckCategory(firstFail.error.category)))} - {hasSkipped && {t("runtime.connectivity.notRunAfter")}} -

    - )} - {rawDetails.length > 0 && ( - - {rawDetails.join("\n")} - - )} -
    - )} -
    - ) -} - -function CheckRow({ check, label }: { check: ConnectivityCheck; label: string }) { - const { t } = useTranslation("admin") - const seconds = (check.duration_ms / 1000).toFixed(1) - const isSkipped = !check.pass && !check.error - const status: StatusKind = check.pass ? "completed" : isSkipped ? "cancelled" : "failed" - return ( -
  • - - - {label} - {check.error && ( - - {" · "} - {t(errorCategoryKey(normalizeCheckCategory(check.error.category)))} - - )} - {isSkipped && · {t("runtime.connectivity.checks.notRun")}} - - {seconds}s -
  • - ) -} - -const KNOWN_ERROR_CATEGORIES = new Set([ - "credInvalid", - "quotaExceeded", - "unreachable", - "runtimeDown", - "promptTimeout", - "unknown", -]) - -function normalizeCheckCategory(category: unknown): ConnectivityCheckCategory { - return KNOWN_ERROR_CATEGORIES.has(category as ConnectivityCheckCategory) - ? (category as ConnectivityCheckCategory) - : "unknown" -} diff --git a/apps/web/src/components/runtime/RuntimeCapabilityHeader.tsx b/apps/web/src/components/runtime/RuntimeCapabilityHeader.tsx deleted file mode 100644 index dc2df981..00000000 --- a/apps/web/src/components/runtime/RuntimeCapabilityHeader.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useTranslation } from "react-i18next" - -import { Skeleton } from "../ui/skeleton" -import { StatusIcon, type StatusKind } from "../ui/status-icon" -import { useRuntimeStatus } from "../../lib/api-runtime" - -interface RuntimeCapabilityHeaderProps { - workspaceID: string | null -} - -type SandboxBucket = "not-configured" | "healthy" | "misconfigured" | "unreachable" - -/** - * Two 32px hairline rows summarising local and cloud capability: a status - * icon, the capability name in ink, the state sentence muted. - */ -export function RuntimeCapabilityHeader({ workspaceID }: RuntimeCapabilityHeaderProps) { - const { t } = useTranslation("admin") - const statusQ = useRuntimeStatus(workspaceID) - - if (statusQ.isLoading) { - return ( -
    -
    -
    -
    - ) - } - - const sandbox = classifySandbox(statusQ.data, !!statusQ.error) - const sandboxCopy = SANDBOX_COPY[sandbox] - - return ( -
      - - -
    - ) -} - -function classifySandbox( - status: ReturnType["data"], - unreachable: boolean, -): SandboxBucket { - if (unreachable || !status) return "unreachable" - if (status.profile === "managed") return status.available ? "healthy" : "misconfigured" - if (!status.has_credential) return "not-configured" - if (status.available) return "healthy" - return "misconfigured" -} - -type SandboxBodyKey = - | "runtime.capability.sandbox.healthy" - | "runtime.capability.sandbox.notConfigured" - | "runtime.capability.sandbox.misconfigured" - | "runtime.capability.sandbox.unreachable" - -const SANDBOX_COPY: Record = { - healthy: { status: "completed", bodyKey: "runtime.capability.sandbox.healthy" }, - "not-configured": { status: "queued", bodyKey: "runtime.capability.sandbox.notConfigured" }, - misconfigured: { status: "interrupted", bodyKey: "runtime.capability.sandbox.misconfigured" }, - unreachable: { status: "failed", bodyKey: "runtime.capability.sandbox.unreachable" }, -} - -function CapabilityRow({ - status, - title, - body, - testId, -}: { - status: StatusKind - title: string - body: string - testId: string -}) { - return ( -
  • - - {title} - {body} -
  • - ) -} diff --git a/apps/web/src/components/runtime/RuntimeCredentialCard.tsx b/apps/web/src/components/runtime/RuntimeCredentialCard.tsx deleted file mode 100644 index c634a521..00000000 --- a/apps/web/src/components/runtime/RuntimeCredentialCard.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { useState } from "react" -import { useTranslation } from "react-i18next" -import { Loader2 } from "lucide-react" - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../ui/alert-dialog" -import { Button } from "../ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "../ui/dialog" -import { Field } from "../ui/label" -import { Input } from "../ui/input" -import { InlineError } from "./InlineError" -import { Skeleton } from "../ui/skeleton" -import { ApiError } from "../../lib/api-client" -import { - useClearRuntimeCredential, - useRuntimeStatus, - useSaveRuntimeCredential, -} from "../../lib/api-runtime" - -interface RuntimeCredentialCardProps { - workspaceID: string | null - /** When false, hide mutation buttons but keep the state row visible. */ - isAdmin: boolean - className?: string -} - -export function RuntimeCredentialCard({ workspaceID, isAdmin, className }: RuntimeCredentialCardProps) { - const { t } = useTranslation("admin") - const statusQ = useRuntimeStatus(workspaceID) - const saveMut = useSaveRuntimeCredential(workspaceID) - const clearMut = useClearRuntimeCredential(workspaceID) - const [saveOpen, setSaveOpen] = useState(false) - const [confirmClear, setConfirmClear] = useState(false) - - if (statusQ.isLoading) { - return ( -
    - - -
    - ) - } - - // RuntimeStatusBanner already surfaces "unreachable"; do not repeat it. - if (statusQ.error || !statusQ.data) return null - - const hasCredential = statusQ.data.has_credential - const masked = statusQ.data.credential_masked - - return ( -
    -
    -
    -

    {t("runtime.credential.title")}

    -

    - {hasCredential ? t("runtime.credential.state.hasCredential") : t("runtime.credential.state.noCredential")} - {hasCredential && {masked ?? "•••"}} -

    -
    - {isAdmin && ( -
    - {hasCredential ? ( - <> - - - - ) : ( - - )} -
    - )} -
    - - { - setSaveOpen(open) - if (!open) saveMut.reset() - }} - pending={saveMut.isPending} - error={saveMut.error} - existing={hasCredential} - onSubmit={(payload) => saveMut.mutate(payload, { onSuccess: () => setSaveOpen(false) })} - /> - - - - - {t("runtime.credential.delete.title")} - {t("runtime.credential.delete.description")} - - {clearMut.error && ( - - {clearMut.error instanceof ApiError ? clearMut.error.envelope.message : t("runtime.credential.error.generic")} - - )} - - - - - - - - - - -
    - ) -} - -interface SaveDialogProps { - open: boolean - pending: boolean - error: unknown - existing: boolean - onOpenChange: (open: boolean) => void - onSubmit: (payload: { apiKey: string }) => void -} - -function SaveDialog({ open, pending, error, existing, onOpenChange, onSubmit }: SaveDialogProps) { - const { t } = useTranslation("admin") - const [apiKey, setApiKey] = useState("") - const canSubmit = apiKey.trim() !== "" && !pending - const errMsg = error instanceof ApiError ? error.envelope.message : error instanceof Error ? error.message : null - const hint = t("runtime.credential.save.field.apiKeyHint") - - return ( - - - - - {existing ? t("runtime.credential.save.titleReset") : t("runtime.credential.save.titleNew")} - - {t("runtime.credential.save.description")} - -
    { - e.preventDefault() - if (canSubmit) onSubmit({ apiKey: apiKey.trim() }) - }} - > - - setApiKey(e.target.value)} - placeholder="e2b_..." - className="font-mono" - data-testid="runtime-credential-api-key-input" - /> - - {errMsg && {errMsg}} -
    - - - - -
    -
    - ) -} diff --git a/apps/web/src/components/runtime/RuntimeStatusBanner.tsx b/apps/web/src/components/runtime/RuntimeStatusBanner.tsx deleted file mode 100644 index d190a448..00000000 --- a/apps/web/src/components/runtime/RuntimeStatusBanner.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { ReactNode } from "react" -import { useTranslation } from "react-i18next" -import { RefreshCw } from "lucide-react" - -import { Skeleton } from "../ui/skeleton" -import { Button } from "../ui/button" -import { StatusIcon, type StatusKind } from "../ui/status-icon" -import { useRuntimeStatus, type RuntimeStatus } from "../../lib/api-runtime" - -interface RuntimeStatusBannerProps { - workspaceID: string | null - /** One action for the row's right edge (e.g. the credential button). */ - action?: ReactNode - className?: string -} - -/** - * Cloud runtime status and its existing recovery action. - */ -export function RuntimeStatusBanner({ workspaceID, action, className }: RuntimeStatusBannerProps) { - const { t } = useTranslation("admin") - const query = useRuntimeStatus(workspaceID) - - if (query.isLoading) { - return ( -
    -
    - -
    -
    - ) - } - - if (query.error || !query.data) { - return ( - void query.refetch()} data-testid="runtime-status-retry"> -