Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 48 additions & 45 deletions .impeccable/design.json

Large diffs are not rendered by default.

54 changes: 28 additions & 26 deletions DESIGN.md

Large diffs are not rendered by default.

144 changes: 100 additions & 44 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,114 @@
# Product

## Register
<!-- impeccable:product-schema 1 -->

product
## Platform

web

## Users

A single technical operator running Orva on hardware they own: homelabbers, indie developers, small self-hosting teams. Most often working alone, after hours, on a single monitor in a dim room. They are comfortable with shell, JSON, and curl, and they expect a control plane that respects that competence rather than abstracting it away.
The primary user is a technical operator running Orva on hardware they own: a
homelabber, indie developer, or small on-premises team. They are comfortable
with shells, JSON, and HTTP APIs, and usually operate one Orva instance from a
single screen.

The job to be done is the same loop they would otherwise pay a cloud provider for: write a function, deploy it, invoke it from outside, debug when it breaks, schedule it, and persist a little bit of state. Orva replaces the cloud account, not the function. The user already knows what serverless is; they want it on their own box, with the warm-pool latency and dashboards intact.
Their core loop is to write a function, deploy it, invoke it, inspect failures,
schedule work, and persist small amounts of state without depending on a cloud
control plane.

## Product Purpose

Orva is a self-hosted Function-as-a-Service for homelab and on-premises use. It deploys JavaScript (Node.js 24), Python (3.14), and TypeScript functions into nsjail sandboxes and exposes them over HTTP, with a built-in dashboard, CLI, MCP server, and an in-product AI assistant.

Success looks like this: an operator brings up the container, writes a function in the editor, hits Deploy, and the first invocation lands in single-digit milliseconds. They never have to leave the dashboard for the day-2 surface, jobs, cron, secrets, KV, webhooks, firewall, traces. The control plane feels like a serious piece of infrastructure they actually want to keep running, not a hobby project they tolerate.

## Brand Personality

Three words: **operator-grade, calm, technical.**

The voice is the voice of someone who has run a function platform at scale and is now building one to keep at home. Dry, pragmatic, slightly opinionated. Empty states explain what something is and what it will look like once it has data, not a sales pitch for the feature. Section captions are short, declarative, and do not introduce themselves. Mono font carries data; sans carries prose; nothing carries marketing.

Confidence without polish. Linear, Railway, Fly, Tailscale admin: that family. Looks like a tool that an engineer would build for themselves, then realised would also work for the next operator over.

## Anti-references

What Orva must not look or feel like:

- **AWS Console.** Every-feature-on-screen, region selectors, low information density per pixel despite the noise. Orva sits on one host; the UI should feel like one host.
- **Generic SaaS dashboard with hero-metric tiles.** Big number, small label, supporting stats, gradient accent under the value. Orva surfaces metrics, but they belong on bars and sparklines, not in template-shaped tiles.
- **Vercel / Railway / landing-page onboarding panels** with diagonal gradient backgrounds, decorative blurred circles, and three identical glassmorphic feature chips (icon + heading + short description, repeated). The onboarding view today still leans on this template; PRODUCT.md keeps the rule even after it is fixed.
- **Cloud-vendor branding.** Clouds, sky gradients, "scale instantly" copy, planet-scale anything. Orva runs on one box you can touch.
- **AI-generated control planes.** Purple-on-near-black with violet accents, glowing borders, gradient text headings, animated mesh backgrounds. Orva is dark and uses violet, but the discipline that keeps it from reading as that template comes from the rest of these anti-references.

## Design Principles

1. **Operator over operator-of-operators.** Built for the person who runs Orva, not for the team that manages a fleet of operators. No multi-tenant shapes, no role-based abstraction layers in the UI surface. One operator, one console.

2. **Density without noise.** Pack signal into every row. A long table beats a paginated card grid. Hidden scrollbars and tight line-height are intentional, not accidents to be fixed.

3. **Practice what you preach.** The dashboard runs on the same self-hosted, single-binary ethos that Orva sells. The UI should not require a service the platform itself does not run.

4. **Show, don't tell.** Empty states show what the data will look like once it exists (a sample row, a real curl example, a trace shape) rather than marketing copy or icon illustrations.

5. **Confidence without polish.** Skip the demo flourishes (gradient backgrounds, decorative blurs, animated heroes). The platform earns trust by looking like the kind of tool the user could have written themselves, not by impressing them.
Orva is a self-hosted Function-as-a-Service for homelab and on-premises use. It
deploys JavaScript, TypeScript, and Python functions into nsjail sandboxes and
exposes them over HTTP through a built-in dashboard, CLI, MCP server, and AI
assistant.

Success means an operator can bring up one instance and complete both the first
deploy and routine operations without assembling separate control-plane
services or leaving Orva for common day-two tasks.

## Positioning

Orva provides a complete single-host function platform rather than a remote
cloud account or a collection of loosely connected self-hosted tools. The same
instance owns sandboxed execution, warm pools, deployment history, schedules,
jobs, KV state, webhooks, egress policy, observability, CLI access, MCP tools,
and an AI operator.

## Operating Context

Orva runs in homelabs and on-premises environments where operators value local
ownership, predictable behavior, and direct access to their infrastructure.
They move between the web dashboard, terminal, CI, external HTTP clients, and
AI/MCP clients. The dashboard is an operating console, not a marketing surface.

Most sessions are short and task-oriented: deploy or edit one function, inspect
an execution, respond to a failure, or adjust one integration. The interface
must make the next operational action obvious without requiring the operator to
re-read platform explanations on every visit.

## Capabilities and Constraints

- The supported runtimes are Node.js 24, Python 3.14, and TypeScript compiled
into the Node runtime. Orva deliberately supports latest-stable runtimes only.
- Linux function execution is isolated with nsjail. Egress policy is per
sandbox and fail-closed.
- The server is distributed as a single binary with the dashboard embedded.
- Server configuration is environment-variable based. CLI configuration is
stored separately in the operator's home directory.
- Orva is a single-instance operator product. Multi-tenant administration and
fleet-management abstractions are not product goals.
- Dark mode is the supported visual environment. A light theme is not a current
commitment.
- Existing functionality must remain accessible while the interface uses
progressive disclosure to keep routine tasks focused.

## Brand Commitments

Orva is operator-grade, calm, and technical. Its voice is dry, pragmatic, and
direct. It assumes competence, uses plain operational language, and avoids
marketing copy. Inter carries prose; JetBrains Mono is reserved for code, paths,
identifiers, and comparable measurements.

The interface must not resemble a cloud-vendor console, a generic SaaS metric
dashboard, or a decorative AI-generated control plane. Violet is the existing
identity accent, but restraint, hierarchy, and product-specific content keep it
from becoming ornamental.

## Evidence on Hand

- The repository contains the complete runnable product, including the Vue
dashboard, Go server, CLI, MCP server, runtime adapters, and automated tests.
- `CONTRACT.md` is the canonical operational and release contract.
- `docs/reference.md` is the canonical user-facing product reference.
- `DESIGN.md` records the incumbent visual system and its constraints.
- No testimonials, customer logos, usage claims, or external validation assets
are supplied. Future work must not fabricate them.

## Product Principles

1. **Operational clarity.** The current task and next action should be obvious.
Explain a concept once, at the point where the information changes a
decision.
2. **Secure defaults.** Isolation, authentication, secrets, and egress controls
should fail safely without turning every screen into a security lecture.
3. **Low cognitive load.** Preserve complete capability while hiding advanced
detail until it is relevant. Use headings and spacing before adding boxes.
4. **Local ownership.** Core workflows must remain self-hosted and usable
without a hosted control plane or third-party design dependency.
5. **Operator trust.** Show real state, precise errors, and recoverable actions.
Avoid decorative data, invented proof, and interface theatrics.

## Accessibility & Inclusion

Target: WCAG 2.1 AA across the dashboard. Specific commitments:
Target WCAG 2.1 AA across the dashboard:

- `prefers-reduced-motion` respected; transitions are short (≤150ms) and limited to color and opacity, never layout properties.
- Every interactive control reachable by keyboard. Focus rings visible against the dark surface. Modal and drawer dialogs implement focus trap and restore.
- Icon-only buttons carry `aria-label`. Status badges use color plus glyph or text, never colour alone.
- Heading hierarchy is real (`<h1>` → `<h2>` → `<h3>`), not styled `<div>`s, so screen-reader navigation works.
- Touch targets meet 44×44 on mobile breakpoints, including filter chips and icon controls.
- Dark theme is the default and the only theme. A light theme is not a roadmap commitment; the audience and use case both lean dark, and a half-supported toggle is worse than none. Revisit if real user demand emerges.
- Every interactive control is keyboard reachable with a visible focus state.
- Labels, hints, errors, and icon-only actions expose programmatic names and
relationships.
- Status never relies on color alone.
- Heading hierarchy uses semantic `h1`, `h2`, and `h3` elements.
- Touch targets meet a 44 by 44 pixel effective area on mobile.
- Reduced-motion preferences preserve understandable state changes without
layout animation.
24 changes: 21 additions & 3 deletions backend/internal/ai/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ type Manager struct {
// delete path maps it to 409 Conflict.
var ErrConversationBusy = errors.New("a turn is already in progress for this conversation")

// maxToolIterationsPerTurn is an internal runaway-work guard, not an operator
// preference. Twenty-five model iterations leaves ample room for multi-step
// instance work while bounding provider cost and automatically invoked tools.
const maxToolIterationsPerTurn = 25

// tryLockConv marks a conversation busy, returning false if it already is.
func (m *Manager) tryLockConv(id string) bool {
m.convMu.Lock()
Expand Down Expand Up @@ -689,9 +694,9 @@ func (m *Manager) resolvedSettings() database.AISettings {
if s.ApprovalPolicy == "" {
s.ApprovalPolicy = "all_writes"
}
if s.MaxToolIterations <= 0 {
s.MaxToolIterations = 25
}
// This is deliberately fixed rather than user-configurable. Normalize old
// rows written when Settings exposed the implementation guardrail.
s.MaxToolIterations = maxToolIterationsPerTurn
if strings.TrimSpace(s.SystemPrompt) == "" {
s.SystemPrompt = defaultSystemPrompt
}
Expand Down Expand Up @@ -727,6 +732,7 @@ func (m *Manager) SaveSettings(in database.AISettings) (database.AISettings, err
if !validApproval(in.ApprovalPolicy) {
return database.AISettings{}, fmt.Errorf("invalid approval_policy %q", in.ApprovalPolicy)
}
in.MaxToolIterations = maxToolIterationsPerTurn
// Preserve the operator's provider/model selection (managed separately via
// SaveSelection) so a general-settings save from the settings form — which
// doesn't carry these fields — never wipes the cross-device choice.
Expand Down Expand Up @@ -1013,6 +1019,18 @@ func (m *Manager) UpdateConversation(id string, title *string, archived *bool) (

func (m *Manager) DeleteConversation(id string) error { return m.db.DeleteConversation(id) }

// DeleteAllConversations atomically clears chat history when no conversation
// is mid-turn. Holding convMu across the delete closes the gap where a new turn
// could start after the busy check but before the database statement.
func (m *Manager) DeleteAllConversations() (int64, error) {
m.convMu.Lock()
defer m.convMu.Unlock()
if len(m.convBusy) > 0 {
return 0, ErrConversationBusy
}
return m.db.DeleteAllConversations()
}

func (m *Manager) ListMessages(convID string, sinceSeq int) ([]*database.AIMessage, error) {
return m.db.ListMessages(convID, sinceSeq)
}
Expand Down
49 changes: 49 additions & 0 deletions backend/internal/ai/manager_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package ai

import (
"errors"
"path/filepath"
"strings"
"testing"

"github.com/Harsh-2002/Orva/backend/internal/database"
)

// TestTryLockConv covers the per-conversation turn guard: one turn per
Expand Down Expand Up @@ -34,6 +38,18 @@ func TestTryLockConv(t *testing.T) {
}
}

func TestDeleteAllConversationsRejectsActiveTurn(t *testing.T) {
m := &Manager{}
if !m.tryLockConv("c1") {
t.Fatal("lock c1")
}
defer m.unlockConv("c1")

if _, err := m.DeleteAllConversations(); !errors.Is(err, ErrConversationBusy) {
t.Fatalf("DeleteAllConversations error = %v, want ErrConversationBusy", err)
}
}

func TestSaveProviderRequiresOllamaBaseURL(t *testing.T) {
m := &Manager{}
for _, baseURL := range []string{"", " ", "\t\n"} {
Expand All @@ -43,3 +59,36 @@ func TestSaveProviderRequiresOllamaBaseURL(t *testing.T) {
}
}
}

func TestToolIterationBudgetIsFixedInternally(t *testing.T) {
db, err := database.New(filepath.Join(t.TempDir(), "orva.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })

m := &Manager{db: db}
in := database.AISettings{
ThinkingLevel: "standard",
ApprovalPolicy: "all_writes",
MaxToolIterations: 999,
}
got, err := m.SaveSettings(in)
if err != nil {
t.Fatal(err)
}
if got.MaxToolIterations != maxToolIterationsPerTurn {
t.Fatalf("MaxToolIterations = %d, want fixed internal budget %d", got.MaxToolIterations, maxToolIterationsPerTurn)
}

stored, _, err := db.GetSettings("default")
if err != nil {
t.Fatal(err)
}
if stored.MaxToolIterations != maxToolIterationsPerTurn {
t.Fatalf("stored MaxToolIterations = %d, want %d", stored.MaxToolIterations, maxToolIterationsPerTurn)
}
}
10 changes: 10 additions & 0 deletions backend/internal/database/ai_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,16 @@ func (db *Database) DeleteConversation(id string) error {
return err
}

// DeleteAllConversations clears chat history in one statement. Foreign-key
// cascades remove every message and tool call with the parent conversations.
func (db *Database) DeleteAllConversations() (int64, error) {
res, err := db.write.Exec(`DELETE FROM ai_conversations`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}

func scanConversation(scan func(...any) error) (*AIConversation, error) {
var (
c AIConversation
Expand Down
49 changes: 49 additions & 0 deletions backend/internal/database/ai_chat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package database

import "testing"

func TestDeleteAllConversationsCascadesHistory(t *testing.T) {
db := newTestDB(t)

first := &AIConversation{Title: "First"}
second := &AIConversation{Title: "Second"}
for _, conversation := range []*AIConversation{first, second} {
if err := db.CreateConversation(conversation); err != nil {
t.Fatalf("CreateConversation: %v", err)
}
}
message := &AIMessage{ConversationID: first.ID, Role: "assistant", Content: "done"}
if err := db.InsertMessage(message); err != nil {
t.Fatalf("InsertMessage: %v", err)
}
if err := db.InsertToolCall(&AIToolCall{
ConversationID: first.ID,
MessageID: message.ID,
ToolName: "list_functions",
Status: "succeeded",
}); err != nil {
t.Fatalf("InsertToolCall: %v", err)
}

deleted, err := db.DeleteAllConversations()
if err != nil {
t.Fatalf("DeleteAllConversations: %v", err)
}
if deleted != 2 {
t.Fatalf("deleted = %d, want 2", deleted)
}
for _, table := range []string{"ai_conversations", "ai_messages", "ai_tool_calls"} {
var count int
if err := db.read.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil {
t.Fatalf("count %s: %v", table, err)
}
if count != 0 {
t.Errorf("%s has %d rows after clear, want 0", table, count)
}
}

deleted, err = db.DeleteAllConversations()
if err != nil || deleted != 0 {
t.Errorf("second clear: deleted=%d err=%v, want 0, nil", deleted, err)
}
}
14 changes: 7 additions & 7 deletions backend/internal/oauth/consent.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
publishes via Tailwind, so the consent screen reads as a first-class
Orva surface, not a bolt-on. */
:root {
--bg: #12111C;
--surface: #1A1929;
--surface-hover: #252438;
--border: #2D2B42;
--foreground: #FFFFFF;
--foreground-muted: #A3A3B3;
--bg: #0B0D10;
--surface: #15181D;
--surface-hover: #23272E;
--border: #343A44;
--foreground: #F4F6F8;
--foreground-muted: #ADB4BE;
--primary: #553F83;
--primary-hover: #684D9E;
--secondary-hover: #3E3B5A;
--secondary-hover: #353D47;
--danger: #ef4444;
--warning: #eab308;
--success: #22c55e;
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/server/ai_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,19 @@ func (h *AIHandler) DeleteConversation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}

func (h *AIHandler) DeleteAllConversations(w http.ResponseWriter, r *http.Request) {
deleted, err := h.Manager.DeleteAllConversations()
if errors.Is(err, ai.ErrConversationBusy) {
respond.Error(w, http.StatusConflict, "CONVERSATION_BUSY", err.Error(), RequestID(r.Context()))
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "DB_ERROR", err.Error(), RequestID(r.Context()))
return
}
respond.JSON(w, http.StatusOK, map[string]any{"deleted": deleted})
}

func (h *AIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.Atoi(r.URL.Query().Get("since_seq"))
msgs, err := h.Manager.ListMessages(r.PathValue("id"), since)
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/server/oauth_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ func TestConsentScreenIsNativeOrva(t *testing.T) {
// Native Orva chrome: same brand mark, font, and colour
// tokens as the dashboard's Login.vue.
"Inter:wght",
`#12111C`, // dashboard --color-background hex, baked into the inline CSS
`#0B0D10`, // dashboard --color-background hex, baked into the inline CSS
`#553F83`, // dashboard --color-primary (the Orva purple)
`f(x)`, // logo glyph
">Orva<",
Expand Down
Loading