diff --git a/.github/workflows/build-neko-rk3588-image.yml b/.github/workflows/build-neko-rk3588-image.yml new file mode 100644 index 000000000..8dd9ab8ee --- /dev/null +++ b/.github/workflows/build-neko-rk3588-image.yml @@ -0,0 +1,48 @@ +name: Build Neko RK3588 image + +# Builds the RK3588 VPU-encode neko image (MPP + gstreamer-rockchip compiled +# from source against trixie GStreamer 1.26) on a GitHub arm64 runner, so the +# Pi is never used for the build. The Pi only does the final validation run. +# On a PR the image is built but NOT pushed (compile validation only); on +# dev/master it is built and pushed to GHCR. +on: + push: + branches: [master, dev] + paths: + - 'app-catalog/streaming/neko-browser/Dockerfile.rk3588' + - '.github/workflows/build-neko-rk3588-image.yml' + pull_request: + paths: + - 'app-catalog/streaming/neko-browser/Dockerfile.rk3588' + - '.github/workflows/build-neko-rk3588-image.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build (push only on dev/master) + uses: docker/build-push-action@v6 + with: + context: app-catalog/streaming/neko-browser + file: app-catalog/streaming/neko-browser/Dockerfile.rk3588 + platforms: linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: | + ghcr.io/jaylfc/taos-neko-rk3588:latest + ghcr.io/jaylfc/taos-neko-rk3588:${{ github.sha }} + cache-from: type=gha,scope=neko-rk3588 + cache-to: type=gha,mode=max,scope=neko-rk3588 diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 14243dd2e..55bd280de 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Fetch Dependabot metadata id: meta - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@v3 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 840819983..7b4efcb70 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,6 @@ docs/audit/ .understand-anything/ docs/agent-jobs/ .design/ + +# Update rollback target (written by the updater, never tracked) +.taos-rollback diff --git a/CHANGELOG.md b/CHANGELOG.md index 2020c3614..095217b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] +## [1.0.0-beta.6] - 2026-06-21 + +### Added +- Coding Studio gains a model-agnostic tool-calling loop: agents read, edit, and verify files inside a workspace-jailed sandbox using filesystem tool primitives, driven by a LiteLLM-backed model step. +- Cluster capability map: worker registration and heartbeats populate a per-node capability and hardware map with admin endpoints, plus a non-destructive stale-node offline sweep. +- Append-only board audit log: every task transition is recorded, with a project-scoped activity feed and a task audit endpoint, indexed for unbounded growth. +- `taos rollback`: a CLI recovery path that restores the previous branch and version, so a broken update can be recovered even when the dashboard is unreachable. + +### Changed +- One Browser app: the separate streamed-browser app is gone. The Browser app attaches a Neko streamed session through a toggle, and a RAM-capable Pi host can serve the session itself instead of reporting that it is not capable. +- The default store no longer seeds the X, Reddit, YouTube, and GitHub apps; they are optional installs. + +### Fixed +- Browser sessions resolve the target worker before creating the session row, so a failed placement no longer leaves an orphaned session. +- Auto-expiring notification toasts no longer archive themselves into the History view. +- Dependabot majors updated: actions/checkout v7, dependabot/fetch-metadata v3, and the dev Python dependency group. + ## [1.0.0-beta.5] - 2026-06-20 ### Added @@ -24,6 +41,25 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio - Security: dompurify updated to 3.4.11; cryptography and pydantic-settings advisories cleared. - Install: the core install no longer aborts when optional components fail, and drops to the service user without assuming sudo (WSL robustness). +## [1.0.0-beta.4.1] - 2026-06-20 + +### Changed +- Installs and in-app updates verify the prebuilt bundle's SHA256 before extracting; a corrupted or tampered bundle is rejected and falls back to a local build. +- Re-installs update the existing install in place instead of forking a second copy. + +### Fixed +- Symlink-safe staging (no fixed /tmp paths as root), atomic-rename swap, and a fix so the bundle is no longer treated as perpetually stale. +- README corrected (installs download a prebuilt bundle, no local build) and links rebranded to jaylfc/taOS. + +## [1.0.0-beta.4] - 2026-06-20 + +### Added +- "Reduce effects" toggle (Settings, Accessibility) for low-end devices: disables background blur, heavy shadows, and continuous animations for a smoother UI on older hardware. + +### Changed +- The installer and in-app update download a prebuilt UI bundle instead of building it locally, so installs and upgrades are faster and no longer fail or silently stay on the old version on low-memory machines including WSL. A local build, when still needed, now fails with a clear message instead of half-updating. +- CI runs on self-hosted runners and gates the desktop test suite. + ## [1.0.0-beta.3] - 2026-06-16 ### Added diff --git a/app-catalog/streaming/neko-browser/Dockerfile.rk3588 b/app-catalog/streaming/neko-browser/Dockerfile.rk3588 index 3f5cbd342..ef05e382b 100644 --- a/app-catalog/streaming/neko-browser/Dockerfile.rk3588 +++ b/app-catalog/streaming/neko-browser/Dockerfile.rk3588 @@ -1,22 +1,47 @@ -# taOS Neko Chromium for RK3588 (Orange Pi 5 Plus) — HW decode + WebRTC encode via VPU. -# Device nodes (/dev/mpp_service, /dev/dri, /dev/rga) are passed at `docker run` -# by the host-tier resolver, not baked in. Software encode is the fallback when -# this image/devices aren't available (see resolve_neko_image). -# Multi-arch base (has a linux/arm64 variant — verified on the Pi). The old -# `arm-chromium` tag does not exist. -FROM ghcr.io/m1k1o/neko/chromium:latest +# taOS Neko Chromium for RK3588 — VPU H.264 encode via MPP + gstreamer-rockchip. +# Multi-stage: stage 1 compiles Rockchip MPP, RGA and the gstreamer-rockchip +# plugin against GStreamer 1.26 (trixie, matching the neko-cdp runtime, so the +# plugin ABI lines up). Stage 2 layers the built libs onto the CDP image. +# +# Built for the Rockchip VENDOR kernel (6.1.x-vendor-rk35xx) which exposes the +# VPU via /dev/mpp_service (no V4L2). Shared publicly for the community (#624). -# Rockchip MPP + RGA + Mali userspace for GStreamer rkmpp HW encode/decode. +# ---- stage 1: build the rockchip multimedia userspace -------------------- +FROM debian:trixie AS rkbuild +ENV GIT_TERMINAL_PROMPT=0 LIBDIR=lib/aarch64-linux-gnu RUN apt-get update && apt-get install -y --no-install-recommends \ - gstreamer1.0-rockchip \ - librockchip-mpp1 \ - librga2 \ - && rm -rf /var/lib/apt/lists/* || true + git ca-certificates build-essential cmake meson ninja-build pkg-config \ + libdrm-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +# MPP (Media Process Platform) — nyanmisaka's maintained fork builds cleanly. +RUN git clone --depth=1 -b jellyfin-mpp https://github.com/nyanmisaka/mpp.git mpp \ + && cmake -S mpp -B mpp/build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=$LIBDIR \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DBUILD_TEST=OFF \ + && cmake --build mpp/build -j"$(nproc)" && cmake --install mpp/build +# RGA (2D raster graphics accel). +RUN git clone --depth=1 -b jellyfin-rga https://github.com/nyanmisaka/rk-mirrors.git rga \ + && meson setup rga rga/build --prefix=/usr --libdir=$LIBDIR -Dlibdrm=false -Dlibrga_demo=false --buildtype=release \ + && meson install -C rga/build +# gstreamer-rockchip plugin (mpph264enc) — BoxCloud streaming fork, against 1.26. +RUN git clone --depth=1 https://github.com/BoxCloudIRL/gstreamer-rockchip.git gst \ + && meson setup gst gst/build --prefix=/usr --libdir=$LIBDIR --buildtype=release \ + && meson install -C gst/build +# Collect outputs into a staging dir (tolerant of exact naming); logged for debug. +RUN mkdir -p /stage/gst \ + && cp -a /usr/lib/aarch64-linux-gnu/librockchip_mpp.so* /stage/ 2>/dev/null || true \ + && cp -a /usr/lib/aarch64-linux-gnu/librockchip_vpu.so* /stage/ 2>/dev/null || true \ + && cp -a /usr/lib/aarch64-linux-gnu/librga.so* /stage/ 2>/dev/null || true \ + && cp -a /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstrockchip*.so* /stage/gst/ 2>/dev/null || true \ + && echo "=== staged libs ===" && ls -la /stage /stage/gst -LABEL taos.app.id="neko-browser" \ - taos.streaming.encode="rkmpp" \ - taos.hardware.soc="rk3588" - -# Prefer the VPU encoder; Neko reads its pipeline from env / its own config. -ENV NEKO_VIDEO_CODEC=h264 \ - GST_PLUGIN_FEATURE_RANK="mpph264enc:512" +# ---- stage 2: layer onto the CDP image ----------------------------------- +FROM ghcr.io/jaylfc/taos-neko-cdp:latest +COPY --from=rkbuild /stage/ /tmp/rkstage/ +RUN cp -a /tmp/rkstage/*.so* /usr/lib/aarch64-linux-gnu/ 2>/dev/null || true \ + && cp -a /tmp/rkstage/gst/*.so* /usr/lib/aarch64-linux-gnu/gstreamer-1.0/ 2>/dev/null || true \ + && rm -rf /tmp/rkstage && ldconfig +LABEL taos.app.id="neko-browser-rk3588" taos.streaming.encode="rkmpp" \ + taos.hardware.soc="rk3588" taos.base="taos-neko-cdp" +ENV NEKO_VIDEO_CODEC=h264 GST_PLUGIN_FEATURE_RANK="mpph264enc:512" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index f1e7190ba..69c24f00f 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2", diff --git a/desktop/package.json b/desktop/package.json index ed09feed8..c22bc53a9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "tinyagentos-desktop", "private": true, - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src/apps/CalendarApp.test.tsx b/desktop/src/apps/CalendarApp.test.tsx new file mode 100644 index 000000000..2dca9906d --- /dev/null +++ b/desktop/src/apps/CalendarApp.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CalendarApp } from "./CalendarApp"; + +const FIXED_DATE = new Date(2025, 5, 15); // June 15, 2025 + +function mockToday() { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_DATE); +} + +describe("CalendarApp", () => { + beforeEach(() => { + mockToday(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders the current month and year in the header", () => { + render(); + expect(screen.getByText("June 2025")).toBeTruthy(); + }); + + it("renders all seven day-of-week column headers", () => { + render(); + expect(screen.getByText("Mon")).toBeTruthy(); + expect(screen.getByText("Tue")).toBeTruthy(); + expect(screen.getByText("Wed")).toBeTruthy(); + expect(screen.getByText("Thu")).toBeTruthy(); + expect(screen.getByText("Fri")).toBeTruthy(); + expect(screen.getByText("Sat")).toBeTruthy(); + expect(screen.getByText("Sun")).toBeTruthy(); + }); + + it("renders the correct number of days for the current month", () => { + render(); + // June 2025 has 30 days; day "1" appears as both a trailing prev-month day + // and the first day of June, so use getAllByText for that one. + const days1 = screen.getAllByText("1"); + expect(days1.length).toBeGreaterThanOrEqual(1); + const day30 = screen.getAllByText("30"); + expect(day30.length).toBeGreaterThanOrEqual(1); + // There are 30 current-month cells plus leading/trailing fill cells + const currentMonthCells = document.querySelectorAll( + ".text-shell-text.hover\\:bg-shell-surface", + ); + expect(currentMonthCells.length).toBe(30); + }); + + it("navigates to the next month when the right arrow is clicked", () => { + render(); + const nextBtn = screen.getByRole("button", { name: /next month/i }); + fireEvent.click(nextBtn); + expect(screen.getByText("July 2025")).toBeTruthy(); + }); + + it("navigates to the previous month when the left arrow is clicked", () => { + render(); + const prevBtn = screen.getByRole("button", { name: /previous month/i }); + fireEvent.click(prevBtn); + expect(screen.getByText("May 2025")).toBeTruthy(); + }); + + it("wraps from January to December of the previous year when going prev", () => { + // Set "today" to January 2025 + vi.setSystemTime(new Date(2025, 0, 10)); + render(); + expect(screen.getByText("January 2025")).toBeTruthy(); + const prevBtn = screen.getByRole("button", { name: /previous month/i }); + fireEvent.click(prevBtn); + expect(screen.getByText("December 2024")).toBeTruthy(); + }); + + it("wraps from December to January of the next year when going next", () => { + // Set "today" to December 2025 + vi.setSystemTime(new Date(2025, 11, 10)); + render(); + expect(screen.getByText("December 2025")).toBeTruthy(); + const nextBtn = screen.getByRole("button", { name: /next month/i }); + fireEvent.click(nextBtn); + expect(screen.getByText("January 2026")).toBeTruthy(); + }); + + it("highlights today's date with the accent class", () => { + render(); + // June 15 is "today" per our fixed clock; the cell should have the accent bg + const todayCell = screen.getByText("15")?.closest("span"); + expect(todayCell?.className).toContain("bg-accent"); + }); + + it("returns to the current month when Today is clicked after navigating", () => { + render(); + const nextBtn = screen.getByRole("button", { name: /next month/i }); + fireEvent.click(nextBtn); + fireEvent.click(nextBtn); + expect(screen.getByText("August 2025")).toBeTruthy(); + const todayBtn = screen.getByRole("button", { name: /today/i }); + fireEvent.click(todayBtn); + expect(screen.getByText("June 2025")).toBeTruthy(); + }); +}); diff --git a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx b/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx deleted file mode 100644 index b0e70b044..000000000 --- a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; -import { StreamedBrowserApp } from "./StreamedBrowserApp"; - -// LiveBrowserView renders an iframe — stub it so we can assert on its props -// without needing a real DOM iframe environment. -vi.mock("@/apps/BrowserApp/LiveBrowserView", () => ({ - LiveBrowserView: ({ nekoUrl, streamToken }: { nekoUrl: string; streamToken: string }) => ( -
- ), -})); - -// Radix Tooltip uses pointerEvents; jsdom doesn't fire them by default. -// Mock Tooltip so it renders children + content inline. -vi.mock("@radix-ui/react-tooltip", () => ({ - Provider: ({ children }: { children: React.ReactNode }) => <>{children}, - Root: ({ children }: { children: React.ReactNode }) => <>{children}, - Trigger: ({ children }: { children: React.ReactNode; asChild?: boolean }) => <>{children}, - Portal: ({ children }: { children: React.ReactNode }) => <>{children}, - Content: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Arrow: () => null, -})); - -const WINDOW_ID = "win-sb-test"; - -const originalFetch = global.fetch; - -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: false }); -}); - -afterEach(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function mockFetch(status: number, body: unknown): ReturnType { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - }); -} - -/** Build a fetch mock that routes requests by URL fragment. */ -function routedFetch(routes: Record) { - return vi.fn().mockImplementation((url: string) => { - for (const [pattern, resp] of Object.entries(routes)) { - if (url.includes(pattern)) { - return Promise.resolve({ - ok: resp.status >= 200 && resp.status < 300, - status: resp.status, - json: () => Promise.resolve(resp.body), - }); - } - } - return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); - }); -} - -const runningMineSession = { - id: "mine-1", - owner_type: "user", - owner_id: "user-1", - status: "running", - neko_url: "https://neko.local", - stream_token: "tok-mine", -}; - -const agentSession = { - id: "agent-sess-1", - owner_type: "agent", - owner_id: "researcher", - status: "running", - neko_url: "https://neko-agent.local", - url: "https://example.com", - stream_token: "tok-agent", -}; - -const sessionListWithAgent = { - sessions: [ - { - id: "mine-1", - owner_type: "user", - owner_id: "user-1", - status: "running", - neko_url: "https://neko.local", - url: null, - }, - { - id: "agent-sess-1", - owner_type: "agent", - owner_id: "researcher", - status: "running", - neko_url: "https://neko-agent.local", - url: "https://example.com", - }, - ], -}; - -// ── C1 regression: My browser running session ───────────────────────────────── - -describe("StreamedBrowserApp — My browser running session (C1 regression)", () => { - it("renders LiveBrowserView with nekoUrl and streamToken when session is running", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - }); - - await act(async () => { - render(); - }); - - const view = screen.getByTestId("live-browser-view"); - expect(view).toBeTruthy(); - expect(view.getAttribute("data-neko-url")).toBe("https://neko.local"); - expect(view.getAttribute("data-stream-token")).toBe("tok-mine"); - }); - - it("calls /api/browser/sessions/mine with credentials: include", async () => { - const fetchMock = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - }); - global.fetch = fetchMock; - - await act(async () => { - render(); - }); - - expect(fetchMock).toHaveBeenCalledWith( - "/api/browser/sessions/mine", - expect.objectContaining({ credentials: "include" }), - ); - }); -}); - -// ── Session switcher ────────────────────────────────────────────────────────── - -describe("StreamedBrowserApp — session switcher", () => { - it("renders 'My browser' entry", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - }); - - await act(async () => { - render(); - }); - - expect(screen.getByRole("button", { name: /my browser/i })).toBeTruthy(); - }); - - it("renders agent sessions from GET /api/browser/sessions", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions": { status: 200, body: sessionListWithAgent }, - }); - - await act(async () => { - render(); - }); - - // The agent name "researcher" should appear as a button in the rail - const btn = screen.getAllByRole("button").find((b) => b.textContent?.includes("researcher")); - expect(btn).toBeTruthy(); - }); - - it("selecting an agent session fetches /{id} and renders LiveBrowserView", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions/agent-sess-1": { status: 200, body: agentSession }, - "/api/browser/sessions": { status: 200, body: sessionListWithAgent }, - }); - - await act(async () => { - render(); - }); - - // Click the agent session button - const agentBtn = screen.getAllByRole("button").find((b) => - b.textContent?.includes("researcher"), - ); - expect(agentBtn).toBeTruthy(); - - await act(async () => { - fireEvent.click(agentBtn!); - }); - - // Should now show the agent's live stream - const view = screen.getByTestId("live-browser-view"); - expect(view.getAttribute("data-neko-url")).toBe("https://neko-agent.local"); - expect(view.getAttribute("data-stream-token")).toBe("tok-agent"); - }); - - it("shows 'Watching ' label when viewing an agent session", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions/agent-sess-1": { status: 200, body: agentSession }, - "/api/browser/sessions": { status: 200, body: sessionListWithAgent }, - }); - - await act(async () => { - render(); - }); - - const agentBtn = screen.getAllByRole("button").find((b) => - b.textContent?.includes("researcher"), - ); - await act(async () => { - fireEvent.click(agentBtn!); - }); - - expect(screen.getByText(/watching researcher/i)).toBeTruthy(); - }); - - it("request-control button is disabled on agent sessions", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions/agent-sess-1": { status: 200, body: agentSession }, - "/api/browser/sessions": { status: 200, body: sessionListWithAgent }, - }); - - await act(async () => { - render(); - }); - - const agentBtn = screen.getAllByRole("button").find((b) => - b.textContent?.includes("researcher"), - ); - await act(async () => { - fireEvent.click(agentBtn!); - }); - - const reqCtrl = screen.getByRole("button", { name: /request control/i }); - expect(reqCtrl).toBeTruthy(); - expect(reqCtrl).toBeDisabled(); - }); - - it("no request-control button on My browser view", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - }); - - await act(async () => { - render(); - }); - - expect(screen.queryByRole("button", { name: /request control/i })).toBeNull(); - }); -}); - -// ── Migrating state ─────────────────────────────────────────────────────────── - -describe("StreamedBrowserApp — migrating state", () => { - it("renders migrating message when mine session status is migrating", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { - status: 200, - body: { id: "mine-1", status: "migrating", neko_url: null, stream_token: null }, - }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - // Poll call returns the same migrating state (no progression needed for this test) - "/api/browser/sessions/mine-1": { - status: 200, - body: { id: "mine-1", status: "migrating", neko_url: null, stream_token: null }, - }, - }); - - await act(async () => { - render(); - }); - - const status = screen.getByRole("status"); - expect(status.textContent).toMatch(/moving.*another device/i); - expect(screen.queryByTestId("live-browser-view")).toBeNull(); - }); - - it("renders migrating message when agent session status is migrating", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 200, body: runningMineSession }, - "/api/browser/sessions/agent-sess-1": { - status: 200, - body: { id: "agent-sess-1", owner_type: "agent", owner_id: "researcher", status: "migrating", neko_url: null }, - }, - "/api/browser/sessions": { status: 200, body: sessionListWithAgent }, - }); - - await act(async () => { - render(); - }); - - const agentBtn = screen.getAllByRole("button").find((b) => - b.textContent?.includes("researcher"), - ); - await act(async () => { - fireEvent.click(agentBtn!); - }); - - const status = screen.getByRole("status"); - expect(status.textContent).toMatch(/moving.*another device/i); - }); -}); - -// ── 409 no_capable_node ─────────────────────────────────────────────────────── - -describe("StreamedBrowserApp — 409 no_capable_node", () => { - it("shows gate-and-guide message, not a blank screen", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 409, body: { error: "no_capable_node" } }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - }); - - await act(async () => { - render(); - }); - - const alert = screen.getByRole("alert"); - expect(alert).toBeTruthy(); - expect(alert.textContent).toMatch(/capable device/i); - expect(screen.queryByTestId("live-browser-view")).toBeNull(); - }); -}); - -// ── Error states ────────────────────────────────────────────────────────────── - -describe("StreamedBrowserApp — error states", () => { - it("shows error message and Retry button on server error", async () => { - global.fetch = routedFetch({ - "/api/browser/sessions/mine": { status: 500, body: {} }, - "/api/browser/sessions": { status: 200, body: { sessions: [] } }, - }); - - await act(async () => { - render(); - }); - - const alert = screen.getByRole("alert"); - expect(alert).toBeTruthy(); - const retryBtn = screen.getByRole("button", { name: /retry/i }); - expect(retryBtn).toBeTruthy(); - }); - - it("shows error message and Retry button on network failure", async () => { - global.fetch = vi.fn().mockImplementation((url: string) => { - if ((url as string).includes("/mine")) return Promise.reject(new TypeError("Network error")); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ sessions: [] }) }); - }); - - await act(async () => { - render(); - }); - - expect(screen.getByRole("alert")).toBeTruthy(); - expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy(); - }); - - it("re-fetches when Retry is clicked", async () => { - let mineCallCount = 0; - global.fetch = vi.fn().mockImplementation((url: string) => { - if ((url as string).includes("/mine")) { - mineCallCount += 1; - if (mineCallCount === 1) { - // First /mine call — fail - return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) }); - } - // Retry /mine call — succeed - return Promise.resolve({ - ok: true, status: 200, - json: () => Promise.resolve({ id: "s2", status: "running", neko_url: "https://neko.local", stream_token: "tok-xyz" }), - }); - } - // /sessions list — always OK - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ sessions: [] }) }); - }); - - await act(async () => { - render(); - }); - - expect(screen.getByRole("alert")).toBeTruthy(); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /retry/i })); - }); - - expect(screen.getByTestId("live-browser-view")).toBeTruthy(); - }); -}); - -// ── Connecting/polling state ────────────────────────────────────────────────── - -describe("StreamedBrowserApp — connecting/polling state", () => { - it("shows connecting message when session is pending, then goes live after poll", async () => { - vi.useRealTimers(); - - const fetchMock = vi.fn() - // /sessions list - .mockResolvedValueOnce({ - ok: true, status: 200, - json: () => Promise.resolve({ sessions: [] }), - }) - // /mine returns pending - .mockResolvedValueOnce({ - ok: true, status: 200, - json: () => Promise.resolve({ id: "sess-pending", status: "pending", neko_url: null }), - }) - // Poll call: session now running - .mockResolvedValueOnce({ - ok: true, status: 200, - json: () => Promise.resolve({ id: "sess-pending", status: "running", neko_url: "https://neko.local", stream_token: "tok-poll" }), - }); - global.fetch = fetchMock; - - render(); - - await waitFor(() => { - expect(screen.getByRole("status")).toBeTruthy(); - }, { timeout: 3000 }); - expect(screen.getByRole("status").textContent).toMatch(/waiting|starting/i); - - await waitFor(() => { - expect(screen.getByTestId("live-browser-view")).toBeTruthy(); - }, { timeout: 4000 }); - - expect(screen.getByTestId("live-browser-view").getAttribute("data-stream-token")).toBe("tok-poll"); - }, 10000); -}); diff --git a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.tsx b/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.tsx deleted file mode 100644 index 427e55202..000000000 --- a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.tsx +++ /dev/null @@ -1,541 +0,0 @@ -/** - * StreamedBrowserApp — always-on streamed Chromium session with session switcher (C2). - * - * Session switcher (slim left rail) lists: - * • "My browser" — the user's own always-on session via GET /api/browser/sessions/mine - * • Agent sessions — from GET /api/browser/sessions, polled every ~10 s - * - * Selecting "My browser" uses the existing /mine flow (C1 behaviour). - * Selecting an agent session fetches GET /api/browser/sessions/{id} for its stream_token, - * then renders LiveBrowserView in watch-only mode with a "watching 's browser" label. - * - * Request-control button is rendered disabled — Neko member-control handoff (sub-plan G) - * is not yet built. The button is a placeholder so the UX slot is reserved. - * - * Migrating state (status === "migrating", emitted by sub-plan F) is rendered like the - * connecting state with the message "Moving your browser to another device…". - * - * No browser chrome is built here — Chromium's omnibox/tabs live inside the stream. - * - * State machine (per selected session): - * loading — initial fetch in-flight - * connecting — session not yet running; polling - * migrating — session is migrating to another node - * live — running + neko_url + stream_token present - * no_node — 409 no_capable_node (user's own session only) - * error — any other failure (shows Retry) - */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { LiveBrowserView } from "@/apps/BrowserApp/LiveBrowserView"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import { Loader2, MonitorPlay, AlertCircle, Monitor, Bot } from "lucide-react"; -import * as Tooltip from "@radix-ui/react-tooltip"; - -const POLL_INTERVAL_MS = 1500; -const POLL_MAX_TRIES = 20; -const SESSION_LIST_POLL_MS = 10_000; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface SessionSummary { - id: string; - owner_type: "user" | "agent"; - owner_id: string; - status: string; - neko_url: string | null; - url: string | null; -} - -interface BrowserSession extends SessionSummary { - stream_token?: string | null; -} - -type Selection = - | { kind: "mine" } - | { kind: "agent"; sessionId: string; agentName: string }; - -type ViewState = - | { phase: "loading" } - | { phase: "connecting" } - | { phase: "migrating" } - | { phase: "live"; nekoUrl: string; streamToken: string; watchLabel?: string } - | { phase: "no_node" } - | { phase: "error"; message: string }; - -interface StreamedBrowserAppProps { - windowId: string; -} - -// ─── Session Switcher ───────────────────────────────────────────────────────── - -interface SwitcherProps { - sessions: SessionSummary[]; - selected: Selection; - onSelect: (sel: Selection) => void; - isMobile: boolean; -} - -function SessionSwitcher({ sessions, selected, onSelect, isMobile }: SwitcherProps) { - const agentSessions = sessions.filter((s) => s.owner_type === "agent"); - const isMineSel = selected.kind === "mine"; - - // Mobile: collapse the sidebar to a thin horizontal selector, and hide it - // entirely when there's only the user's own browser so the stream is full-bleed. - if (isMobile) { - if (agentSessions.length === 0 && selected.kind === "mine") return null; - return ( - - ); - } - - return ( - - ); -} - -function truncateUrl(url: string): string { - try { - const u = new URL(url); - return u.hostname + (u.pathname !== "/" ? u.pathname.slice(0, 20) : ""); - } catch { - return url.slice(0, 24); - } -} - -// ─── Main App ───────────────────────────────────────────────────────────────── - -export function StreamedBrowserApp({ windowId: _windowId }: StreamedBrowserAppProps) { - const [sessions, setSessions] = useState([]); - const [selected, setSelected] = useState({ kind: "mine" }); - const [viewState, setViewState] = useState({ phase: "loading" }); - - const pollRef = useRef | null>(null); - const triesRef = useRef(0); - const cancelledRef = useRef(false); - const listTimerRef = useRef | null>(null); - - const stopPolling = useCallback(() => { - if (pollRef.current) { - clearTimeout(pollRef.current); - pollRef.current = null; - } - }, []); - - // Declared early so fetchMine can capture it in its closure. - const isMobile = useIsMobile(); - - // ── Session list poller ────────────────────────────────────────────────── - - const fetchSessionList = useCallback(async () => { - try { - const resp = await fetch("/api/browser/sessions", { credentials: "include" }); - if (!resp.ok) return; - const data: { sessions: SessionSummary[] } = await resp.json(); - setSessions(data.sessions ?? []); - } catch { - // Non-fatal: list is best-effort; current view keeps working - } - }, []); - - useEffect(() => { - void fetchSessionList(); - listTimerRef.current = setInterval(() => void fetchSessionList(), SESSION_LIST_POLL_MS); - return () => { - if (listTimerRef.current) clearInterval(listTimerRef.current); - }; - }, [fetchSessionList]); - - // ── My browser flow (C1) ───────────────────────────────────────────────── - - const fetchMine = useCallback(async (isRetry = false) => { - cancelledRef.current = false; - triesRef.current = 0; - stopPolling(); - - if (!isRetry) setViewState({ phase: "loading" }); - - let resp: Response; - try { - const deviceParam = isMobile ? "mobile" : "desktop"; - resp = await fetch(`/api/browser/sessions/mine?device=${deviceParam}`, { credentials: "include" }); - } catch { - if (!cancelledRef.current) { - setViewState({ phase: "error", message: "Could not reach the taOS server." }); - } - return; - } - - if (cancelledRef.current) return; - - if (resp.status === 409) { - let body: { error?: string } = {}; - try { body = await resp.json(); } catch { /* ignore */ } - if (body.error === "no_capable_node") { - setViewState({ phase: "no_node" }); - } else { - setViewState({ phase: "error", message: `Unexpected conflict (${body.error ?? resp.status}).` }); - } - return; - } - - if (!resp.ok) { - setViewState({ phase: "error", message: `Server error (${resp.status}).` }); - return; - } - - let session: BrowserSession; - try { - session = await resp.json(); - } catch { - setViewState({ phase: "error", message: "Could not parse server response." }); - return; - } - - if (session.status === "migrating") { - setViewState({ phase: "migrating" }); - schedulePoll(session.id); - return; - } - - if (session.status === "running" && session.neko_url && session.stream_token) { - setViewState({ phase: "live", nekoUrl: session.neko_url, streamToken: session.stream_token }); - return; - } - - setViewState({ phase: "connecting" }); - schedulePoll(session.id); - }, [stopPolling, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Agent session flow ─────────────────────────────────────────────────── - - const fetchAgentSession = useCallback(async (sessionId: string, agentName: string, isRetry = false) => { - cancelledRef.current = false; - triesRef.current = 0; - stopPolling(); - - if (!isRetry) setViewState({ phase: "loading" }); - - let resp: Response; - try { - resp = await fetch(`/api/browser/sessions/${encodeURIComponent(sessionId)}`, { - credentials: "include", - }); - } catch { - if (!cancelledRef.current) { - setViewState({ phase: "error", message: "Could not reach the taOS server." }); - } - return; - } - - if (cancelledRef.current) return; - - if (!resp.ok) { - setViewState({ phase: "error", message: `Could not load agent session (${resp.status}).` }); - return; - } - - let session: BrowserSession; - try { - session = await resp.json(); - } catch { - setViewState({ phase: "error", message: "Could not parse session response." }); - return; - } - - if (session.status === "migrating") { - setViewState({ phase: "migrating" }); - schedulePoll(sessionId, agentName); - return; - } - - if (session.status === "running" && session.neko_url && session.stream_token) { - setViewState({ - phase: "live", - nekoUrl: session.neko_url, - streamToken: session.stream_token, - watchLabel: agentName, - }); - return; - } - - // Session exists but not yet running - setViewState({ phase: "connecting" }); - schedulePoll(sessionId, agentName); - }, [stopPolling]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Poll loop (shared) ─────────────────────────────────────────────────── - - const schedulePoll = useCallback((sessionId: string, agentName?: string) => { - if (cancelledRef.current) return; - triesRef.current += 1; - if (triesRef.current > POLL_MAX_TRIES) { - setViewState({ phase: "error", message: "Browser session took too long to start." }); - return; - } - - pollRef.current = setTimeout(async () => { - if (cancelledRef.current) return; - - let resp: Response; - try { - resp = await fetch(`/api/browser/sessions/${encodeURIComponent(sessionId)}`, { - credentials: "include", - }); - } catch { - if (!cancelledRef.current) { - setViewState({ phase: "error", message: "Lost connection while waiting for browser to start." }); - } - return; - } - - if (cancelledRef.current) return; - - if (!resp.ok) { - setViewState({ phase: "error", message: `Session poll failed (${resp.status}).` }); - return; - } - - let session: BrowserSession; - try { - session = await resp.json(); - } catch { - setViewState({ phase: "error", message: "Could not parse session response." }); - return; - } - - if (session.status === "migrating") { - setViewState({ phase: "migrating" }); - schedulePoll(sessionId, agentName); - return; - } - - if (session.status === "running" && session.neko_url && session.stream_token) { - setViewState({ - phase: "live", - nekoUrl: session.neko_url, - streamToken: session.stream_token, - watchLabel: agentName, - }); - } else { - schedulePoll(sessionId, agentName); - } - }, POLL_INTERVAL_MS); - }, []); // stable - - // ── Effect: re-fetch when selection changes ────────────────────────────── - - useEffect(() => { - cancelledRef.current = false; - if (selected.kind === "mine") { - void fetchMine(); - } else { - void fetchAgentSession(selected.sessionId, selected.agentName); - } - return () => { - cancelledRef.current = true; - stopPolling(); - }; - }, [selected, fetchMine, fetchAgentSession, stopPolling]); - - // ── Retry handler ──────────────────────────────────────────────────────── - - const handleRetry = useCallback(() => { - if (selected.kind === "mine") { - void fetchMine(true); - } else { - void fetchAgentSession(selected.sessionId, selected.agentName, true); - } - }, [selected, fetchMine, fetchAgentSession]); - - // ── Render ─────────────────────────────────────────────────────────────── - - const renderContent = () => { - if (viewState.phase === "live") { - return ( -
- - {viewState.watchLabel && ( -
-
- )} -
- ); - } - - if (viewState.phase === "loading" || viewState.phase === "connecting" || viewState.phase === "migrating") { - const message = - viewState.phase === "loading" - ? "Starting your browser…" - : viewState.phase === "migrating" - ? "Moving your browser to another device…" - : "Waiting for browser to be ready…"; - - return ( -
-
- ); - } - - if (viewState.phase === "no_node") { - return ( -
-
- ); - } - - // error phase - return ( -
-
- ); - }; - - return ( -
- - {renderContent()} -
- ); -} diff --git a/desktop/src/apps/StreamedBrowserApp/index.ts b/desktop/src/apps/StreamedBrowserApp/index.ts deleted file mode 100644 index 6dcc5ffaa..000000000 --- a/desktop/src/apps/StreamedBrowserApp/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { StreamedBrowserApp } from "./StreamedBrowserApp"; diff --git a/desktop/src/components/AppErrorBoundary.test.tsx b/desktop/src/components/AppErrorBoundary.test.tsx new file mode 100644 index 000000000..952ebe1e4 --- /dev/null +++ b/desktop/src/components/AppErrorBoundary.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AppErrorBoundary } from "./AppErrorBoundary"; +import { BackendUnavailableError } from "@/lib/taos-fetch"; + +function ThrowOnRender({ error }: { error: Error }) { + throw error; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("AppErrorBoundary", () => { + it("renders children when no error has occurred", () => { + render( + +
app content
+
, + ); + expect(screen.getByText("app content")).toBeInTheDocument(); + }); + + it("shows the waiting skeleton when BackendUnavailableError is caught", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + expect(screen.getByText("Waiting for taOS to come back\u2026")).toBeInTheDocument(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("shows the chunk reload page when ChunkLoadError is caught", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const err = new Error("Loading chunk 42 failed"); + err.name = "ChunkLoadError"; + render( + + + , + ); + expect(screen.getByText("taOS was updated")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); + expect(screen.getByText(/load the new version/i)).toBeInTheDocument(); + }); + + it("shows the generic error message for unknown errors", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + expect(screen.getByText("Something went wrong.")).toBeInTheDocument(); + }); + + it("does not render children after an error is caught", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + +
visible child
+
, + ); + expect(screen.queryByText("visible child")).not.toBeInTheDocument(); + expect(screen.getByText("Something went wrong.")).toBeInTheDocument(); + }); + + it("classifies dynamically import failures as chunk errors", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const err = new Error("Failed to fetch dynamically imported module"); + render( + + + , + ); + expect(screen.getByText("taOS was updated")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/components/ContextMenu.test.tsx b/desktop/src/components/ContextMenu.test.tsx new file mode 100644 index 000000000..6ea1e61f9 --- /dev/null +++ b/desktop/src/components/ContextMenu.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ContextMenu } from "./ContextMenu"; + +vi.mock("@/hooks/use-is-mobile", () => ({ + useIsMobile: () => false, +})); + +describe("ContextMenu", () => { + it("renders all menu item labels", () => { + const items = [ + { label: "Copy", action: vi.fn() }, + { label: "Paste", action: vi.fn() }, + { label: "Delete", action: vi.fn() }, + ]; + render( + + ); + + expect(screen.getByText("Copy")).toBeInTheDocument(); + expect(screen.getByText("Paste")).toBeInTheDocument(); + expect(screen.getByText("Delete")).toBeInTheDocument(); + }); + + it("renders a separator between items", () => { + const items = [ + { label: "Copy", action: vi.fn() }, + { separator: true, label: "" }, + { label: "Paste", action: vi.fn() }, + ]; + render( + + ); + + expect(screen.getByText("Copy")).toBeInTheDocument(); + expect(screen.getByText("Paste")).toBeInTheDocument(); + // The separator renders as a
with a top border + const menu = screen.getByRole("menu"); + const separators = menu.querySelectorAll("div.border-t"); + expect(separators.length).toBeGreaterThanOrEqual(1); + }); + + it("renders disabled items and does not fire action on click", () => { + const action = vi.fn(); + const items = [ + { label: "Disabled Item", action, disabled: true }, + { label: "Enabled Item", action: vi.fn() }, + ]; + render( + + ); + + const disabledBtn = screen.getByRole("menuitem", { name: /disabled item/i }); + expect(disabledBtn).toBeDisabled(); + + fireEvent.click(disabledBtn); + expect(action).not.toHaveBeenCalled(); + }); + + it("fires action and onClose when an enabled item is clicked", () => { + const action = vi.fn(); + const onClose = vi.fn(); + const items = [{ label: "Save", action }]; + render(); + + fireEvent.click(screen.getByRole("menuitem", { name: /save/i })); + + expect(action).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when Escape is pressed", () => { + const onClose = vi.fn(); + const items = [{ label: "One", action: vi.fn() }]; + render(); + + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("navigates to next item with ArrowDown", () => { + const items = [ + { label: "First", action: vi.fn() }, + { label: "Second", action: vi.fn() }, + ]; + render( + + ); + + const menu = screen.getByRole("menu"); + // First item gets auto-focused by the useEffect + const firstItem = screen.getByRole("menuitem", { name: /first/i }); + expect(document.activeElement).toBe(firstItem); + + fireEvent.keyDown(menu, { key: "ArrowDown" }); + + const secondItem = screen.getByRole("menuitem", { name: /second/i }); + expect(document.activeElement).toBe(secondItem); + }); + + it("wraps around with ArrowDown at the last item", () => { + const items = [ + { label: "First", action: vi.fn() }, + { label: "Last", action: vi.fn() }, + ]; + render( + + ); + + const menu = screen.getByRole("menu"); + const firstItem = screen.getByRole("menuitem", { name: /first/i }); + expect(document.activeElement).toBe(firstItem); + + // Move to last + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: /last/i })); + + // Wrap to first + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(firstItem); + }); + + it("renders items with icons", () => { + const items = [ + { label: "Copy", icon: C, action: vi.fn() }, + ]; + render( + + ); + + expect(screen.getByTestId("copy-icon")).toBeInTheDocument(); + expect(screen.getByText("Copy")).toBeInTheDocument(); + }); + + it("renders an empty menu without crashing", () => { + const onClose = vi.fn(); + render(); + + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + + // Pressing Escape with no items still calls onClose + fireEvent.keyDown(menu, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("has the correct aria-label on the menu", () => { + render( + + ); + + expect(screen.getByRole("menu")).toHaveAttribute("aria-label", "Context menu"); + }); +}); diff --git a/desktop/src/components/Dock.test.tsx b/desktop/src/components/Dock.test.tsx new file mode 100644 index 000000000..f6ccd199a --- /dev/null +++ b/desktop/src/components/Dock.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +let mockPinned = ["messages", "agents", "files", "store", "settings"]; +let mockWindows: Array<{ + id: string; + appId: string; + minimized: boolean; + focused: boolean; + zIndex: number; + position: { x: number; y: number }; + size: { w: number; h: number }; + maximized: boolean; + snapped: null; + launchNonce: number; +}> = []; +let mockStructure: Record< + string, + { variant?: string } & Record +> = {}; + +const mockOpenWindow = vi.fn(); +const mockFocusWindow = vi.fn(); +const mockRestoreWindow = vi.fn(); + +vi.mock("@/stores/dock-store", () => ({ + useDockStore: (sel: (s: Record) => unknown) => + sel({ + pinned: mockPinned, + pin: vi.fn(), + unpin: vi.fn(), + }), +})); + +vi.mock("@/stores/process-store", () => ({ + useProcessStore: ( + selOrUndefined?: ((s: Record) => unknown) | Record + ) => { + const state = { + windows: mockWindows, + openWindow: mockOpenWindow, + focusWindow: mockFocusWindow, + restoreWindow: mockRestoreWindow, + minimizeWindow: vi.fn(), + maximizeWindow: vi.fn(), + recenterWindow: vi.fn(), + closeWindow: vi.fn(), + }; + if (typeof selOrUndefined === "function") { + return selOrUndefined(state); + } + return state; + }, +})); + +vi.mock("@/stores/theme-store", () => ({ + useThemeStore: (sel: (s: Record) => unknown) => + sel({ + structure: mockStructure, + }), +})); + +vi.mock("@/registry/app-registry", () => ({ + getApp: (id: string) => ({ + id, + name: + id === "messages" + ? "Messages" + : id === "agents" + ? "Agents" + : id === "files" + ? "Files" + : id === "store" + ? "Store" + : id === "settings" + ? "Settings" + : id === "browser" + ? "Browser" + : "Test App", + icon: "message-circle", + category: "platform", + defaultSize: { w: 900, h: 600 }, + minSize: { w: 400, h: 300 }, + singleton: true, + pinned: true, + launchpadOrder: 1, + }), +})); + +vi.mock("@/hooks/use-is-mobile", () => ({ + useIsMobile: () => false, +})); + +import { Dock } from "./Dock"; + +beforeEach(() => { + mockPinned = ["messages", "agents", "files", "store", "settings"]; + mockWindows = []; + mockStructure = {}; + vi.clearAllMocks(); +}); + +describe("Dock", () => { + it("renders pinned apps and the Launchpad button", () => { + const onLaunchpadOpen = vi.fn(); + render(); + + expect( + screen.getByRole("button", { name: /launchpad/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /open messages/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /open agents/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /open files/i }) + ).toBeInTheDocument(); + }); + + it("renders with empty pinned list shows only Launchpad", () => { + mockPinned = []; + const onLaunchpadOpen = vi.fn(); + render(); + + expect( + screen.getByRole("button", { name: /launchpad/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /open messages/i }) + ).toBeNull(); + }); + + it("calls onLaunchpadOpen when Launchpad button is clicked", () => { + const onLaunchpadOpen = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /launchpad/i })); + expect(onLaunchpadOpen).toHaveBeenCalledOnce(); + }); + + it("renders running apps not in pinned after a separator", () => { + mockWindows = [ + { + id: "win-1", + appId: "browser", + minimized: false, + focused: true, + zIndex: 1, + position: { x: 0, y: 0 }, + size: { w: 1024, h: 700 }, + maximized: false, + snapped: null, + launchNonce: 0, + }, + ]; + const onLaunchpadOpen = vi.fn(); + render(); + + expect( + screen.getByRole("button", { name: /open browser/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /open agents/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/components/EmojiPicker.test.tsx b/desktop/src/components/EmojiPicker.test.tsx new file mode 100644 index 000000000..7c4bf146f --- /dev/null +++ b/desktop/src/components/EmojiPicker.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { EmojiPickerField } from "./EmojiPicker"; + +describe("EmojiPickerField", () => { + it("shows + when value is empty", () => { + render(); + const btn = screen.getByRole("button", { name: "Open emoji picker" }); + expect(btn).toHaveTextContent("+"); + }); + + it("shows the current emoji value when provided", () => { + render(); + const btn = screen.getByRole("button", { name: "Open emoji picker" }); + expect(btn).toHaveTextContent("🦉"); + }); + + it("is collapsed by default (aria-expanded false, no dialog)", () => { + render(); + const btn = screen.getByRole("button", { name: "Open emoji picker" }); + expect(btn).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("dialog", { name: "Emoji picker" })).toBeNull(); + }); + + it("opens the picker dialog on click and sets aria-expanded", () => { + render(); + const btn = screen.getByRole("button", { name: "Open emoji picker" }); + fireEvent.click(btn); + expect(btn).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("dialog", { name: "Emoji picker" })).toBeInTheDocument(); + }); + + it("toggles the picker closed on a second click", () => { + render(); + const btn = screen.getByRole("button", { name: "Open emoji picker" }); + fireEvent.click(btn); + expect(screen.getByRole("dialog", { name: "Emoji picker" })).toBeInTheDocument(); + fireEvent.click(btn); + expect(btn).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("dialog", { name: "Emoji picker" })).toBeNull(); + }); + + it("calls onChange with the picked emoji and closes the picker", () => { + const onChange = vi.fn(); + render(); + const btn = screen.getByRole("button", { name: "Open emoji picker" }); + fireEvent.click(btn); + expect(screen.getByRole("dialog", { name: "Emoji picker" })).toBeInTheDocument(); + + const picker = screen.getByRole("dialog", { name: "Emoji picker" }); + const emojiButtons = picker.querySelectorAll("button"); + if (emojiButtons.length > 0) { + fireEvent.click(emojiButtons[0]); + expect(onChange).toHaveBeenCalledTimes(1); + expect(btn).toHaveAttribute("aria-expanded", "false"); + } + }); +}); diff --git a/desktop/src/components/Launchpad.test.tsx b/desktop/src/components/Launchpad.test.tsx new file mode 100644 index 000000000..54fcf2cdd --- /dev/null +++ b/desktop/src/components/Launchpad.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +vi.mock("@/stores/process-store", () => { + const store = { openWindow: vi.fn(() => "win-1") }; + return { + useProcessStore: (sel?: (s: typeof store) => unknown) => + sel ? sel(store) : store, + }; +}); + +vi.mock("@/hooks/use-installed-services", () => ({ + useInstalledServices: () => [], +})); + +vi.mock("@/hooks/use-installed-optional-apps", () => ({ + useInstalledOptionalApps: () => new Set(), +})); + +vi.mock("@/hooks/use-installed-userspace-apps", () => ({ + useInstalledUserspaceApps: () => [], +})); + +vi.mock("@/hooks/use-shortcut-registry", () => ({ + useShortcut: vi.fn(), +})); + +vi.mock("@/registry/app-registry", () => ({ + getLaunchableApps: (installedOptional: Set) => [ + { id: "messages", name: "Messages", icon: "message-circle", category: "platform", defaultSize: { w: 900, h: 600 } }, + { id: "mail", name: "Mail", icon: "mail", category: "platform", defaultSize: { w: 1200, h: 800 } }, + { id: "weather", name: "Weather", icon: "cloud", category: "os", defaultSize: { w: 800, h: 600 } }, + { id: "chess", name: "Chess", icon: "crown", category: "game", defaultSize: { w: 700, h: 700 } }, + ].filter((a) => !a.id.startsWith("service:") && !a.id.startsWith("userspace:") && (a.id !== "reddit" || installedOptional.has("reddit"))), + getApp: (id: string) => { + const apps: Record = { + messages: { id: "messages", name: "Messages", defaultSize: { w: 900, h: 600 } }, + mail: { id: "mail", name: "Mail", defaultSize: { w: 1200, h: 800 } }, + weather: { id: "weather", name: "Weather", defaultSize: { w: 800, h: 600 } }, + chess: { id: "chess", name: "Chess", defaultSize: { w: 700, h: 700 } }, + }; + return apps[id]; + }, + getOrRegisterServiceApp: (appId: string, displayName: string) => ({ + id: `service:${appId}`, + name: displayName, + defaultSize: { w: 1100, h: 750 }, + }), +})); + +import { Launchpad } from "./Launchpad"; + +describe("Launchpad", () => { + const onClose = vi.fn(); + const onOpenApp = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when open is false", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders the dialog with aria-label when open is true", () => { + render(); + expect(screen.getByRole("dialog", { name: /launchpad/i })).toBeInTheDocument(); + }); + + it("renders the search input", () => { + render(); + expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument(); + }); + + it("renders category headings for the built-in apps", () => { + render(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getByText("Utilities")).toBeInTheDocument(); + expect(screen.getByText("Games")).toBeInTheDocument(); + }); + + it("renders app names for built-in apps", () => { + render(); + expect(screen.getByText("Messages")).toBeInTheDocument(); + expect(screen.getByText("Mail")).toBeInTheDocument(); + expect(screen.getByText("Weather")).toBeInTheDocument(); + expect(screen.getByText("Chess")).toBeInTheDocument(); + }); + + it("filters apps by search query", () => { + render(); + const input = screen.getByPlaceholderText("Search apps..."); + fireEvent.change(input, { target: { value: "chess" } }); + expect(screen.getByText("Chess")).toBeInTheDocument(); + expect(screen.queryByText("Messages")).not.toBeInTheDocument(); + expect(screen.queryByText("Mail")).not.toBeInTheDocument(); + }); + + it("shows clear button when query is non-empty and clears on click", () => { + render(); + const input = screen.getByPlaceholderText("Search apps..."); + fireEvent.change(input, { target: { value: "chess" } }); + const clearBtn = screen.getByRole("button", { name: /clear search/i }); + expect(clearBtn).toBeInTheDocument(); + fireEvent.click(clearBtn); + expect(input).toHaveValue(""); + }); + + it("does not show clear button when query is empty", () => { + render(); + expect(screen.queryByRole("button", { name: /clear search/i })).not.toBeInTheDocument(); + }); + + it("calls onClose when an app icon is clicked", () => { + render(); + const btn = screen.getByRole("button", { name: /open messages/i }); + fireEvent.click(btn); + // handleLaunch calls onClose, and the click bubbles to the overlay onClick + // which also calls onClose. Both fire as expected by the component design. + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("calls onOpenApp with the window id returned by openWindow when launching an app", () => { + render(); + const btn = screen.getByRole("button", { name: /open messages/i }); + fireEvent.click(btn); + expect(onOpenApp).toHaveBeenCalledWith("win-1"); + }); + + it("does not render Services section when no services are installed", () => { + render(); + expect(screen.queryByText("Services")).not.toBeInTheDocument(); + }); + + it("does not render My Apps section when no userspace apps are installed", () => { + render(); + expect(screen.queryByText("My Apps")).not.toBeInTheDocument(); + }); +}); diff --git a/desktop/src/components/LoginScreen.test.tsx b/desktop/src/components/LoginScreen.test.tsx new file mode 100644 index 000000000..250bda290 --- /dev/null +++ b/desktop/src/components/LoginScreen.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { LoginScreen } from "./LoginScreen"; + +let originalUserAgent: string; +let originalRequestFullscreen: typeof document.documentElement.requestFullscreen; + +describe("LoginScreen", () => { + beforeEach(() => { + originalUserAgent = navigator.userAgent; + originalRequestFullscreen = document.documentElement.requestFullscreen; + document.documentElement.requestFullscreen = vi.fn().mockResolvedValue(undefined); + }); + + afterEach(() => { + Object.defineProperty(navigator, "userAgent", { + value: originalUserAgent, + configurable: true, + }); + document.documentElement.requestFullscreen = originalRequestFullscreen; + vi.restoreAllMocks(); + }); + + function setUserAgent(ua: string) { + Object.defineProperty(navigator, "userAgent", { + value: ua, + configurable: true, + }); + } + + it("renders the Launch taOS button when not launching", () => { + setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0"); + const onLaunch = vi.fn(); + render(); + const btn = screen.getByRole("button", { name: /launch taos/i }); + expect(btn).toBeInTheDocument(); + expect(btn).not.toBeDisabled(); + }); + + it("shows Launching... text and disables the button while launching", async () => { + setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0"); + const onLaunch = vi.fn(); + render(); + const btn = screen.getByRole("button", { name: /launch taos/i }); + fireEvent.click(btn); + expect(btn).toBeDisabled(); + expect(screen.getByText("Launching...")).toBeInTheDocument(); + }); + + it("calls onLaunch after the launch delay", async () => { + vi.useFakeTimers(); + setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0"); + const onLaunch = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /launch taos/i })); + expect(onLaunch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(600); + expect(onLaunch).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("shows 'Full experience available' for Chrome user agent", () => { + setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0"); + const onLaunch = vi.fn(); + render(); + expect(screen.getByText("Full experience available")).toBeInTheDocument(); + expect(screen.queryByText(/install taos as an app/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/full keyboard support/i)).not.toBeInTheDocument(); + }); + + it("shows 'Install taOS as an app' for Safari user agent", () => { + setUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/17.0 Safari/604.1.34", + ); + const onLaunch = vi.fn(); + render(); + expect(screen.getByText("Install taOS as an app for the best experience")).toBeInTheDocument(); + expect(screen.queryByText(/full experience available/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/full keyboard support/i)).not.toBeInTheDocument(); + }); + + it("shows 'use Chrome or Edge' for unknown browsers", () => { + setUserAgent("Mozilla/5.0 (compatible; SomeBot/1.0)"); + const onLaunch = vi.fn(); + render(); + expect( + screen.getByText("For full keyboard support, use Chrome or Edge"), + ).toBeInTheDocument(); + expect(screen.queryByText(/full experience available/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/install taos as an app/i)).not.toBeInTheDocument(); + }); + + it("renders the taOS logo image", () => { + setUserAgent("Mozilla/5.0 Chrome/120.0.0.0"); + const onLaunch = vi.fn(); + render(); + const img = screen.getByRole("img", { name: /taos/i }); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "/static/taos-logo.png"); + }); + + it("calls requestFullscreen when the launch button is clicked", () => { + setUserAgent("Mozilla/5.0 Chrome/120.0.0.0"); + const onLaunch = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /launch taos/i })); + expect(document.documentElement.requestFullscreen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/desktop/src/components/NotificationCentre.test.tsx b/desktop/src/components/NotificationCentre.test.tsx index f2b04387c..7e797d38f 100644 --- a/desktop/src/components/NotificationCentre.test.tsx +++ b/desktop/src/components/NotificationCentre.test.tsx @@ -9,6 +9,8 @@ const closeCentre = vi.fn(); const markAllRead = vi.fn(); const clearAll = vi.fn(); const dismiss = vi.fn(); +const archivedNotifications = vi.fn(() => []); +const clearArchived = vi.fn(); let notifications: Notification[] = []; @@ -21,6 +23,8 @@ vi.mock("@/stores/notification-store", () => ({ markAllRead, clearAll, dismiss, + archivedNotifications, + clearArchived, }), })); diff --git a/desktop/src/components/NotificationCentre.tsx b/desktop/src/components/NotificationCentre.tsx index 65e316e71..a0cec2bb4 100644 --- a/desktop/src/components/NotificationCentre.tsx +++ b/desktop/src/components/NotificationCentre.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { X, Bell, CheckCheck, Trash2 } from "lucide-react"; +import { X, Bell, CheckCheck, Trash2, History } from "lucide-react"; import { useNotificationStore, type Notification } from "@/stores/notification-store"; import { useProcessStore } from "@/stores/process-store"; import { getApp } from "@/registry/app-registry"; @@ -16,10 +16,66 @@ function formatTime(ts: number): string { return `${Math.floor(delta / 86400_000)}d ago`; } +function NotificationItem({ + n, + onDismiss, + onItemClick, +}: { + n: Notification; + onDismiss: (id: string) => void; + onItemClick: (n: Notification) => void; +}) { + return ( + +
+ + ); +} + export function NotificationCentre() { - const { notifications, centreOpen, closeCentre, markRead, markAllRead, clearAll, dismiss } = useNotificationStore(); + const { + notifications, + centreOpen, + closeCentre, + markRead, + markAllRead, + clearAll, + dismiss, + archivedNotifications, + clearArchived, + } = useNotificationStore(); const openWindow = useProcessStore((s) => s.openWindow); const [checklistDismissed, setChecklistDismissed] = useState(false); + const [tab, setTab] = useState<"inbox" | "history">("inbox"); + + const active = notifications.filter((n) => !n.archived); + const archived = archivedNotifications(); // Optimistic local mark-read, plus a best-effort backend write for server // items so the read state persists across reloads. Network never blocks the UI. @@ -87,7 +143,7 @@ export function NotificationCentre() { Notifications
- {notifications.length > 0 && ( + {tab === "inbox" && active.length > 0 && ( <> )} + {tab === "history" && archived.length > 0 && ( + + )}
+ {/* Tabs */} +
+ + +
+ {/* List */}
- {!checklistDismissed && ( - setChecklistDismissed(true)} /> + {tab === "inbox" && ( + <> + {!checklistDismissed && ( + setChecklistDismissed(true)} /> + )} + {active.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( + active.map((n) => ( + + )) + )} + )} - {notifications.length === 0 ? ( -
- -

No notifications

-
- ) : ( - notifications.map((n) => ( - -
- - )) + )) + )} + )} diff --git a/desktop/src/components/NotificationToast.test.tsx b/desktop/src/components/NotificationToast.test.tsx index ddf919c83..4618d7741 100644 --- a/desktop/src/components/NotificationToast.test.tsx +++ b/desktop/src/components/NotificationToast.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { NotificationToasts } from "./NotificationToast"; const mockDismiss = vi.fn(); @@ -64,4 +64,30 @@ describe("NotificationToasts", () => { fireEvent.click(screen.getByRole("button", { name: /dismiss notification/i })); await waitFor(() => expect(mockDismiss).toHaveBeenCalledWith("test-2")); }); + + it("auto-expires the toast after 5s without archiving it", () => { + vi.useFakeTimers(); + try { + mockNotifications.length = 0; + mockNotifications.push({ + id: "test-3", + source: "system", + title: "Synced", + body: "Your files are up to date.", + level: "success", + read: false, + timestamp: Date.now(), + }); + mockDismiss.mockClear(); + render(); + expect(screen.getByText("Synced")).toBeInTheDocument(); + // Toast vanishes from view once the 5s timer fires... + act(() => vi.advanceTimersByTime(5000)); + expect(screen.queryByText("Synced")).not.toBeInTheDocument(); + // ...but auto-expiry must never archive (dismiss is the explicit action). + expect(mockDismiss).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/desktop/src/components/NotificationToast.tsx b/desktop/src/components/NotificationToast.tsx index f46cfe763..33d95b429 100644 --- a/desktop/src/components/NotificationToast.tsx +++ b/desktop/src/components/NotificationToast.tsx @@ -264,7 +264,7 @@ function extractTagged(text: string, tag: string): string | undefined { /* ToastItem */ /* ------------------------------------------------------------------ */ -function ToastItem({ notif }: { notif: Notification }) { +function ToastItem({ notif, onExpire }: { notif: Notification; onExpire: () => void }) { const dismiss = useNotificationStore((s) => s.dismiss); const Icon = LEVEL_ICONS[notif.level]; const isAgentPaused = notif.source === "agent.paused"; @@ -272,9 +272,12 @@ function ToastItem({ notif }: { notif: Notification }) { useEffect(() => { // Agent-paused toasts stay until the user explicitly acts on them. if (isAgentPaused) return; - const timer = setTimeout(() => dismiss(notif.id), 5000); + // Auto-expiry only hides the toast; it must NOT archive. Archiving is an + // explicit user action (the X button / "Keep paused"). Otherwise every + // toast that simply times out would silently fill the History view. + const timer = setTimeout(onExpire, 5000); return () => clearTimeout(timer); - }, [notif.id, dismiss, isAgentPaused]); + }, [notif.id, onExpire, isAgentPaused]); return (
[n.id, n] as const)); const active = toastIds .map((id) => byId.get(id)) - .filter((n): n is Notification => !!n && !n.read) + .filter((n): n is Notification => !!n && !n.read && !n.archived) .slice(0, 3); return ( @@ -342,7 +345,11 @@ export function NotificationToasts() { role="region" > {active.map((n) => ( - + setToastIds((prev) => prev.filter((id) => id !== n.id))} + /> ))}
); diff --git a/desktop/src/components/ServiceIcon.test.tsx b/desktop/src/components/ServiceIcon.test.tsx new file mode 100644 index 000000000..c7e43901b --- /dev/null +++ b/desktop/src/components/ServiceIcon.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +const mockOnClick = vi.fn(); + +const baseService = { + app_id: "test-app", + display_name: "Test App", + icon: "https://example.com/icon.png", + url: "https://example.com", + category: "productivity", + backend: "docker", + status: "running" as const, +}; + +import { ServiceIcon } from "./ServiceIcon"; + +describe("ServiceIcon", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders the display name as visible text", () => { + render(); + expect(screen.getByText("Test App")).toBeInTheDocument(); + }); + + it("renders a button with an accessible label containing the display name", () => { + render(); + const button = screen.getByRole("button", { name: /open test app/i }); + expect(button).toBeInTheDocument(); + }); + + it("calls onClick when the button is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open test app/i })); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it("renders the service icon image when icon is provided", () => { + render(); + const img = screen.getByRole("img", { name: /test app/i }); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "https://example.com/icon.png"); + }); + + it("falls back to the generic grid icon when icon is null", () => { + const noIconService = { ...baseService, icon: null }; + const { container } = render( + + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("falls back to the generic grid icon when the image fails to load", () => { + const { container } = render( + + ); + const img = screen.getByRole("img", { name: /test app/i }); + fireEvent.error(img); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("truncates long display names with ellipsis", () => { + const longNameService = { + ...baseService, + display_name: "A Very Long Service Name That Exceeds The Max Width", + }; + render(); + const span = screen.getByText( + "A Very Long Service Name That Exceeds The Max Width" + ); + expect(span).toHaveClass("truncate"); + }); +}); diff --git a/desktop/src/components/SetupChecklist.test.tsx b/desktop/src/components/SetupChecklist.test.tsx new file mode 100644 index 000000000..7ff42f629 --- /dev/null +++ b/desktop/src/components/SetupChecklist.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { SetupChecklist } from "./SetupChecklist"; + +vi.mock("@/stores/process-store", () => ({ + useProcessStore: (sel: (s: Record) => unknown) => + sel({ + openWindow: vi.fn(), + }), +})); + +vi.mock("@/registry/app-registry", () => ({ + getApp: (id: string) => ({ + id, + name: id, + icon: "app", + category: "platform", + defaultSize: { w: 800, h: 600 }, + minSize: { w: 400, h: 300 }, + singleton: true, + pinned: false, + launchpadOrder: 1, + }), +})); + +const baseStatus = { + account: false, + has_provider: false, + taos_model_set: false, + has_agent: false, + memory_enabled: false, + dismissed: false, + complete: false, +}; + +function mockFetchStatus(status: Record) { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url === "/api/setup/status") { + return Promise.resolve( + new Response(JSON.stringify(status), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (url === "/api/setup/dismiss") { + return Promise.resolve(new Response("{}", { status: 200 })); + } + return Promise.reject(new Error("unexpected fetch: " + url)); + }) as unknown as typeof fetch, + ); +} + +describe("SetupChecklist", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders nothing when status is still loading (null)", () => { + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ) as unknown as typeof fetch, + ); + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing when status.dismissed is true", () => { + mockFetchStatus({ ...baseStatus, dismissed: true }); + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing when status.complete is true", () => { + mockFetchStatus({ ...baseStatus, complete: true }); + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders the header with Get started and step count", async () => { + mockFetchStatus(baseStatus); + render(); + expect(await screen.findByText("Get started")).toBeInTheDocument(); + expect(screen.getByText("0/5")).toBeInTheDocument(); + }); + + it("renders all five step labels", async () => { + mockFetchStatus(baseStatus); + render(); + expect(await screen.findByText("Create your account")).toBeInTheDocument(); + expect(screen.getByText("Add a provider")).toBeInTheDocument(); + expect(screen.getByText("Choose a model for the taOS agent")).toBeInTheDocument(); + expect(screen.getByText("Deploy your first agent")).toBeInTheDocument(); + expect(screen.getByText("Set up memory")).toBeInTheDocument(); + }); + + it("renders detail text for incomplete steps", async () => { + mockFetchStatus(baseStatus); + render(); + expect(await screen.findByText("Connect a cloud API key or local model server")).toBeInTheDocument(); + expect(screen.getByText("Pick the model your taOS agent will use")).toBeInTheDocument(); + expect(screen.getByText("Deploy an AI agent (Hermes recommended)")).toBeInTheDocument(); + expect(screen.getByText("taOSmd memory is recommended and on by default")).toBeInTheDocument(); + }); + + it("shows the correct done count when some steps are complete", async () => { + mockFetchStatus({ + ...baseStatus, + account: true, + has_provider: true, + }); + render(); + expect(await screen.findByText("2/5")).toBeInTheDocument(); + }); + + it("does not show detail text for completed steps", async () => { + mockFetchStatus({ + ...baseStatus, + account: true, + }); + render(); + expect(await screen.findByText("Create your account")).toBeInTheDocument(); + // "Done at sign-up" is the detail for account, which should not render when done + expect(screen.queryByText("Done at sign-up")).toBeNull(); + }); + + it("renders a dismiss button with accessible label", async () => { + mockFetchStatus(baseStatus); + render(); + const dismissBtn = await screen.findByRole("button", { + name: "Dismiss setup checklist", + }); + expect(dismissBtn).toBeInTheDocument(); + }); + + it("calls onDismissed after clicking dismiss", async () => { + mockFetchStatus(baseStatus); + const onDismissed = vi.fn(); + render(); + const dismissBtn = await screen.findByRole("button", { + name: "Dismiss setup checklist", + }); + fireEvent.click(dismissBtn); + await waitFor(() => { + expect(onDismissed).toHaveBeenCalledTimes(1); + }); + }); + + it("renders accessible aria-labels for completed and pending steps", async () => { + mockFetchStatus({ + ...baseStatus, + account: true, + has_provider: false, + }); + render(); + const doneStep = await screen.findByRole("button", { + name: "Create your account — complete", + }); + expect(doneStep).toBeInTheDocument(); + const pendingStep = screen.getByRole("button", { + name: "Add a provider", + }); + expect(pendingStep).toBeInTheDocument(); + }); + + it("renders a list with role=list for the steps", async () => { + mockFetchStatus(baseStatus); + render(); + expect(await screen.findByRole("list")).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/components/StatusIndicators.test.tsx b/desktop/src/components/StatusIndicators.test.tsx new file mode 100644 index 000000000..687ec0623 --- /dev/null +++ b/desktop/src/components/StatusIndicators.test.tsx @@ -0,0 +1,189 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const openWindowMock = vi.fn(); + +vi.mock("@/stores/process-store", () => ({ + useProcessStore: (sel: (s: { openWindow: () => void }) => unknown) => + sel({ openWindow: openWindowMock }), +})); + +vi.mock("@/registry/app-registry", () => ({ + getApp: (id: string) => + id === "dashboard" + ? { id: "dashboard", name: "Activity", defaultSize: { w: 1100, h: 720 } } + : undefined, +})); + +vi.mock("lucide-react", () => { + const mk = (name: string) => + function MockIcon({ size }: { size?: number }) { + return ; + }; + return { + Cpu: mk("cpu"), + MemoryStick: mk("memory-stick"), + Zap: mk("zap"), + CircuitBoard: mk("circuit-board"), + }; +}); + +import { StatusIndicators } from "./StatusIndicators"; + +let originalFetch: typeof globalThis.fetch; + +function mockFetch(json: Record) { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Map([["content-type", "application/json"]]) as unknown as Headers, + json: () => Promise.resolve(json), + }) as unknown as typeof fetch; +} + +describe("StatusIndicators", () => { + beforeEach(() => { + originalFetch = globalThis.fetch; + openWindowMock.mockClear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("renders nothing while data is loading", () => { + globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as unknown as typeof fetch; + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders CPU and RAM indicators after fetch resolves", async () => { + mockFetch({ + resources: { cpu_percent: 42, ram_percent: 65 }, + hardware: { gpu: { type: "none" }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /open dashboard/i })).toBeInTheDocument(); + }); + + expect(screen.getByTitle("CPU: 42%")).toBeInTheDocument(); + expect(screen.getByTitle("RAM: 65%")).toBeInTheDocument(); + }); + + it("shows the correct accessible labels for CPU and RAM with values", async () => { + mockFetch({ + resources: { cpu_percent: 42, ram_percent: 65 }, + hardware: { gpu: { type: "none" }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + const cpuEl = screen.getByTitle("CPU: 42%"); + const ramEl = screen.getByTitle("RAM: 65%"); + + expect(cpuEl).toHaveAttribute("aria-label", "CPU usage 42 percent"); + expect(ramEl).toHaveAttribute("aria-label", "RAM usage 65 percent"); + }); + + it("shows VRAM indicator when GPU is present", async () => { + mockFetch({ + resources: { cpu_percent: 30, ram_percent: 50, vram_percent: 75 }, + hardware: { gpu: { type: "nvidia", vram_mb: 8192 }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + expect(screen.getByTitle("CPU: 30%")).toBeInTheDocument(); + expect(screen.getByTitle("RAM: 50%")).toBeInTheDocument(); + expect(screen.getByTitle("VRAM: 75%")).toBeInTheDocument(); + }); + + it("shows NPU indicator when NPU is present", async () => { + mockFetch({ + resources: { cpu_percent: 20, ram_percent: 40, npu_pct: 15 }, + hardware: { gpu: { type: "none" }, npu: { type: "apple" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + expect(screen.getByTitle("CPU: 20%")).toBeInTheDocument(); + expect(screen.getByTitle("RAM: 40%")).toBeInTheDocument(); + expect(screen.getByTitle("NPU: 15%")).toBeInTheDocument(); + expect(screen.queryByTitle(/VRAM/)).toBeNull(); + }); + + it("shows both VRAM and NPU when GPU and NPU are present", async () => { + mockFetch({ + resources: { cpu_percent: 55, ram_pct: 70, vram_pct: 80, npu_pct: 25 }, + hardware: { gpu: { type: "nvidia", vram_mb: 16384 }, npu: { type: "qualcomm" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + expect(screen.getByTitle("CPU: 55%")).toBeInTheDocument(); + expect(screen.getByTitle("RAM: 70%")).toBeInTheDocument(); + expect(screen.getByTitle("VRAM: 80%")).toBeInTheDocument(); + expect(screen.getByTitle("NPU: 25%")).toBeInTheDocument(); + }); + + it("shows unknown usage when values are null", async () => { + mockFetch({ + resources: {}, + hardware: { gpu: { type: "none" }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + const cpuEl = screen.getByTitle("CPU: \u2014"); + const ramEl = screen.getByTitle("RAM: \u2014"); + + expect(cpuEl).toHaveAttribute("aria-label", "CPU usage unknown"); + expect(ramEl).toHaveAttribute("aria-label", "RAM usage unknown"); + }); + + it("calls openWindow when the dashboard button is clicked", async () => { + mockFetch({ + resources: { cpu_percent: 10, ram_percent: 20 }, + hardware: { gpu: { type: "none" }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + fireEvent.click(screen.getByRole("button", { name: /open dashboard/i })); + expect(openWindowMock).toHaveBeenCalledWith("dashboard", { w: 1100, h: 720 }); + }); + + it("applies compact class when compact prop is true", async () => { + mockFetch({ + resources: { cpu_percent: 10, ram_percent: 20 }, + hardware: { gpu: { type: "none" }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + const btn = screen.getByRole("button", { name: /open dashboard/i }); + expect(btn.className).not.toContain("px-1"); + }); + + it("applies non-compact padding class when compact is false", async () => { + mockFetch({ + resources: { cpu_percent: 10, ram_percent: 20 }, + hardware: { gpu: { type: "none" }, npu: { type: "none" } }, + }); + + render(); + await waitFor(() => screen.getByRole("button", { name: /open dashboard/i })); + + const btn = screen.getByRole("button", { name: /open dashboard/i }); + expect(btn.className).toContain("px-1"); + }); +}); diff --git a/desktop/src/components/TaosAssistantSettings.test.tsx b/desktop/src/components/TaosAssistantSettings.test.tsx new file mode 100644 index 000000000..7e66ec413 --- /dev/null +++ b/desktop/src/components/TaosAssistantSettings.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { TaosAssistantSettings } from "./TaosAssistantSettings"; +import { useTaosAgentStore } from "@/stores/taos-agent-store"; + +vi.mock("@/lib/models", () => ({ + fetchClusterWorkers: vi.fn(), + fetchCloudProviders: vi.fn(), + workersToAggregated: vi.fn(), + cloudProvidersToAggregated: vi.fn(), + localProvidersToAggregated: vi.fn(), +})); + +import { + fetchClusterWorkers, + fetchCloudProviders, + workersToAggregated, + cloudProvidersToAggregated, + localProvidersToAggregated, +} from "@/lib/models"; + +const mockFetch = vi.fn(); + +function setupFetch() { + mockFetch.mockImplementation((url: string) => { + if (url === "/api/models") { + return Promise.resolve({ + ok: true, + json: async () => ({ models: [] }), + }); + } + if (url === "/api/taos-agent/settings") { + return Promise.resolve({ ok: true }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + vi.stubGlobal("fetch", mockFetch); +} + +function resetStore() { + useTaosAgentStore.setState({ + isOpen: false, + messages: [], + model: null, + streaming: false, + settingsOpen: false, + }); +} + +describe("TaosAssistantSettings", () => { + beforeEach(() => { + resetStore(); + setupFetch(); + vi.mocked(fetchClusterWorkers).mockResolvedValue([]); + vi.mocked(fetchCloudProviders).mockResolvedValue([]); + vi.mocked(workersToAggregated).mockReturnValue([]); + vi.mocked(cloudProvidersToAggregated).mockReturnValue([]); + vi.mocked(localProvidersToAggregated).mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("renders nothing when open is false", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders the dialog with title when open is true", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("taOS agent — Settings")).toBeInTheDocument(); + }); + + it("shows the current model name in the header when set", () => { + useTaosAgentStore.setState({ model: "gpt-4o" }); + render(); + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + }); + + it("does not show a model name span when no model is set", () => { + useTaosAgentStore.setState({ model: null }); + render(); + expect(screen.getByText("taOS agent — Settings")).toBeInTheDocument(); + expect(screen.queryByText("gpt-4o")).not.toBeInTheDocument(); + }); + + it("calls onClose when the close button is clicked", () => { + const onClose = vi.fn(); + render(); + const closeBtn = screen.getByRole("button", { name: /close settings/i }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when the backdrop is clicked", () => { + const onClose = vi.fn(); + render(); + const backdrop = screen.getByRole("dialog"); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("fetches models on open and passes loaded state to ModelPickerFlow", async () => { + vi.mocked(fetchClusterWorkers).mockResolvedValue([]); + vi.mocked(fetchCloudProviders).mockResolvedValue([]); + render(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/models"); + }); + expect(fetchClusterWorkers).toHaveBeenCalledTimes(1); + expect(fetchCloudProviders).toHaveBeenCalledTimes(1); + }); + + it("sends PATCH to /api/taos-agent/settings with selected model and closes", async () => { + const onClose = vi.fn(); + useTaosAgentStore.setState({ model: "old-model" }); + + vi.mocked(localProvidersToAggregated).mockReturnValue([ + { id: "llama-3", name: "Llama 3", host: "controller", hostKind: "controller" }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("Llama 3")).toBeInTheDocument(); + }); + + const modelBtn = screen.getByText("Llama 3"); + fireEvent.click(modelBtn); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/api/taos-agent/settings", + expect.objectContaining({ + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "llama-3" }), + }) + ); + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(useTaosAgentStore.getState().model).toBe("llama-3"); + }); +}); diff --git a/desktop/src/components/TopBar.tsx b/desktop/src/components/TopBar.tsx index 998879faf..7de32c892 100644 --- a/desktop/src/components/TopBar.tsx +++ b/desktop/src/components/TopBar.tsx @@ -99,7 +99,7 @@ function PowerMenu() { export function TopBar({ onSearchOpen, onAssistantOpen }: Props) { const clock = useClock(); const { showWidgets, toggleWidgets } = useWidgetStore(); - const unreadCount = useNotificationStore((s) => s.notifications.filter((n) => !n.read).length); + const unreadCount = useNotificationStore((s) => s.notifications.filter((n) => !n.read && !n.archived).length); const toggleCentre = useNotificationStore((s) => s.toggleCentre); return ( diff --git a/desktop/src/components/UpdateAvailableToast.test.tsx b/desktop/src/components/UpdateAvailableToast.test.tsx new file mode 100644 index 000000000..9bb8e7529 --- /dev/null +++ b/desktop/src/components/UpdateAvailableToast.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { UpdateAvailableToast } from "./UpdateAvailableToast"; + +const mockAddNotification = vi.fn(); +const mockCurrentVersion: { value: string | null } = { value: null }; + +vi.mock("@/contexts/BackendStatusContext", () => ({ + useBackendStatus: () => ({ + currentVersion: mockCurrentVersion.value, + }), +})); + +vi.mock("@/stores/notification-store", () => ({ + useNotificationStore: (selector: (state: { addNotification: typeof mockAddNotification }) => unknown) => + selector({ addNotification: mockAddNotification }), +})); + +describe("UpdateAvailableToast", () => { + beforeEach(() => { + mockAddNotification.mockClear(); + mockCurrentVersion.value = null; + }); + + it("renders nothing", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("does not fire notification when currentVersion is null", () => { + mockCurrentVersion.value = null; + render(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it("does not fire notification when versions match", () => { + mockCurrentVersion.value = "1.0.0"; + render(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it("does not fire notification for dev build version", () => { + mockCurrentVersion.value = "1.0.1"; + render(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it("does not fire notification for 0.0.0 build version", () => { + mockCurrentVersion.value = "1.0.1"; + render(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it("fires notification when backend version differs from build version", () => { + mockCurrentVersion.value = "1.0.1"; + render(); + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification).toHaveBeenCalledWith({ + source: "system", + level: "info", + title: "New taOS version available", + body: "Reload to upgrade from 1.0.0 to 1.0.1.", + }); + }); + + it("strips build metadata from versions before comparing", () => { + mockCurrentVersion.value = "1.0.0+a3bd632"; + render(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it("does not re-fire notification for the same version on re-render", () => { + mockCurrentVersion.value = "1.0.1"; + const { rerender } = render(); + expect(mockAddNotification).toHaveBeenCalledTimes(1); + rerender(); + expect(mockAddNotification).toHaveBeenCalledTimes(1); + }); +}); diff --git a/desktop/src/components/WallpaperPicker.test.tsx b/desktop/src/components/WallpaperPicker.test.tsx new file mode 100644 index 000000000..6a7294896 --- /dev/null +++ b/desktop/src/components/WallpaperPicker.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { WallpaperPicker } from "./WallpaperPicker"; +import { useThemeStore } from "@/stores/theme-store"; + +describe("WallpaperPicker", () => { + beforeEach(() => { + useThemeStore.setState({ + wallpaperId: "graphite", + wallpaperKind: "image", + wallpaperOverlayText: null, + showOverlayText: true, + wallpaperParams: { density: 200, speed: 0.5, glow: 6 }, + }); + }); + + it("renders nothing when open is false", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders the dialog with title when open is true", () => { + render(); + expect(screen.getByRole("dialog", { name: /change wallpaper/i })).toBeInTheDocument(); + expect(screen.getByText("Change Wallpaper")).toBeInTheDocument(); + }); + + it("renders all wallpaper buttons with labels", () => { + render(); + expect(screen.getByRole("button", { name: /graphite/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /neural \(live\)/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /classic/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /aurora/i })).toBeInTheDocument(); + }); + + it("marks the active wallpaper with aria-pressed and a check indicator", () => { + useThemeStore.setState({ wallpaperId: "aurora" }); + render(); + const activeBtn = screen.getByRole("button", { name: /aurora/i }); + expect(activeBtn).toHaveAttribute("aria-pressed", "true"); + const inactiveBtn = screen.getByRole("button", { name: /graphite/i }); + expect(inactiveBtn).toHaveAttribute("aria-pressed", "false"); + }); + + it("updates wallpaperId when a wallpaper button is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /midnight blue/i })); + expect(useThemeStore.getState().wallpaperId).toBe("midnight"); + }); + + it("calls onClose when the close button is clicked", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows animated wallpaper sliders when wallpaperKind is animated", () => { + useThemeStore.setState({ wallpaperKind: "animated" }); + render(); + expect(screen.getByLabelText("Density")).toBeInTheDocument(); + expect(screen.getByLabelText("Speed")).toBeInTheDocument(); + expect(screen.getByLabelText("Glow")).toBeInTheDocument(); + }); + + it("does not show animated wallpaper sliders when wallpaperKind is image", () => { + useThemeStore.setState({ wallpaperKind: "image" }); + render(); + expect(screen.queryByLabelText("Density")).toBeNull(); + expect(screen.queryByLabelText("Speed")).toBeNull(); + expect(screen.queryByLabelText("Glow")).toBeNull(); + }); + + it("shows the slogan toggle when wallpaperOverlayText is set", () => { + useThemeStore.setState({ wallpaperOverlayText: "taOS" }); + render(); + const toggle = screen.getByRole("switch", { name: /show slogan/i }); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-checked", "true"); + }); + + it("does not show the slogan toggle when wallpaperOverlayText is null", () => { + useThemeStore.setState({ wallpaperOverlayText: null }); + render(); + expect(screen.queryByRole("switch", { name: /show slogan/i })).toBeNull(); + }); + + it("reflects showOverlayText=false on the slogan toggle", () => { + useThemeStore.setState({ wallpaperOverlayText: "taOS", showOverlayText: false }); + render(); + const toggle = screen.getByRole("switch", { name: /show slogan/i }); + expect(toggle).toHaveAttribute("aria-checked", "false"); + }); + + it("calls toggleOverlayText when the slogan switch is clicked", () => { + useThemeStore.setState({ wallpaperOverlayText: "taOS", showOverlayText: true }); + render(); + fireEvent.click(screen.getByRole("switch", { name: /show slogan/i })); + expect(useThemeStore.getState().showOverlayText).toBe(false); + }); + + it("displays current wallpaperParams values on the sliders", () => { + useThemeStore.setState({ + wallpaperKind: "animated", + wallpaperParams: { density: 150, speed: 1.2, glow: 8 }, + }); + render(); + expect(screen.getByText("150")).toBeInTheDocument(); + expect(screen.getByText("1.2")).toBeInTheDocument(); + expect(screen.getByText("8")).toBeInTheDocument(); + }); + + it("calls setWallpaperParam when a slider is changed", () => { + useThemeStore.setState({ wallpaperKind: "animated" }); + render(); + const densitySlider = screen.getByLabelText("Density"); + fireEvent.change(densitySlider, { target: { value: "300" } }); + expect(useThemeStore.getState().wallpaperParams.density).toBe(300); + }); +}); diff --git a/desktop/src/hooks/use-installed-optional-apps.test.ts b/desktop/src/hooks/use-installed-optional-apps.test.ts new file mode 100644 index 000000000..0f70162bb --- /dev/null +++ b/desktop/src/hooks/use-installed-optional-apps.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useInstalledOptionalApps } from "./use-installed-optional-apps"; +import { onAppEvent, emitAppEvent, APP_OPTIONAL_CHANGED } from "@/lib/app-event-bus"; + +describe("useInstalledOptionalApps", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns an empty set before the fetch resolves", () => { + let resolveJson: (value: { installed: string[] }) => void; + (fetch as ReturnType).mockReturnValueOnce( + new Promise(() => { /* pending */ }), + ); + + const { result } = renderHook(() => useInstalledOptionalApps()); + expect(result.current).toBeInstanceOf(Set); + expect(result.current.size).toBe(0); + }); + + it("fetches installed optional apps and returns them as a Set", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ installed: ["reddit", "youtube"] }), + }); + + const { result } = renderHook(() => useInstalledOptionalApps()); + + await waitFor(() => expect(result.current.size).toBe(2)); + expect(result.current.has("reddit")).toBe(true); + expect(result.current.has("youtube")).toBe(true); + expect(fetch).toHaveBeenCalledWith( + "/api/apps/optional/installed", + expect.objectContaining({ headers: { Accept: "application/json" } }), + ); + }); + + it("returns an empty set when the response is not ok", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => useInstalledOptionalApps()); + + await waitFor(() => { + expect(fetch).toHaveBeenCalled(); + }); + expect(result.current).toBeInstanceOf(Set); + expect(result.current.size).toBe(0); + }); + + it("returns an empty set when fetch rejects", async () => { + (fetch as ReturnType).mockRejectedValueOnce( + new Error("network failure"), + ); + + const { result } = renderHook(() => useInstalledOptionalApps()); + + await waitFor(() => { + expect(fetch).toHaveBeenCalled(); + }); + expect(result.current).toBeInstanceOf(Set); + expect(result.current.size).toBe(0); + }); + + it("re-fetches when APP_OPTIONAL_CHANGED event fires", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ installed: ["reddit"] }), + }); + + const { result } = renderHook(() => useInstalledOptionalApps()); + + await waitFor(() => expect(result.current.has("reddit")).toBe(true)); + + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ installed: ["reddit", "github", "x"] }), + }); + + emitAppEvent(APP_OPTIONAL_CHANGED, "github"); + + await waitFor(() => expect(result.current.size).toBe(3)); + expect(result.current.has("github")).toBe(true); + expect(result.current.has("x")).toBe(true); + }); + + it("returns an empty set when the response has no installed field", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const { result } = renderHook(() => useInstalledOptionalApps()); + + await waitFor(() => { + expect(fetch).toHaveBeenCalled(); + }); + expect(result.current).toBeInstanceOf(Set); + expect(result.current.size).toBe(0); + }); +}); diff --git a/desktop/src/hooks/use-installed-services.test.ts b/desktop/src/hooks/use-installed-services.test.ts new file mode 100644 index 000000000..53dd88749 --- /dev/null +++ b/desktop/src/hooks/use-installed-services.test.ts @@ -0,0 +1,137 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useInstalledServices } from "./use-installed-services"; +import { onAppEvent } from "@/lib/app-event-bus"; + +vi.mock("@/lib/app-event-bus", () => ({ + onAppEvent: vi.fn().mockReturnValue(() => {}), + APP_INSTALLED: "app.installed", +})); + +describe("useInstalledServices", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns an empty array before the fetch resolves", () => { + (fetch as ReturnType).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useInstalledServices()); + expect(result.current).toEqual([]); + }); + + it("fetches /api/apps/installed on mount and populates services", async () => { + const mockServices = [ + { + app_id: "firefox", + display_name: "Firefox", + icon: null, + url: "http://localhost:3000", + category: "browser", + backend: "docker", + status: "running" as const, + }, + { + app_id: "code-server", + display_name: "Code Server", + icon: null, + url: "http://localhost:8080", + category: "development", + backend: "docker", + status: "stopped" as const, + }, + ]; + + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockServices, + }); + + const { result } = renderHook(() => useInstalledServices()); + + await waitFor(() => expect(result.current).toHaveLength(2)); + expect(fetch).toHaveBeenCalledWith("/api/apps/installed"); + expect(result.current).toEqual(mockServices); + }); + + it("returns an empty array when the response is not ok", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => useInstalledServices()); + + await waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(result.current).toEqual([]); + }); + + it("returns an empty array when fetch rejects", async () => { + (fetch as ReturnType).mockRejectedValueOnce( + new Error("Network error"), + ); + + const { result } = renderHook(() => useInstalledServices()); + + await waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(result.current).toEqual([]); + }); + + it("re-fetches when an app.installed event fires", async () => { + const firstBatch = [ + { + app_id: "firefox", + display_name: "Firefox", + icon: null, + url: "http://localhost:3000", + category: "browser", + backend: "docker", + status: "running" as const, + }, + ]; + + const secondBatch = [ + ...firstBatch, + { + app_id: "code-server", + display_name: "Code Server", + icon: null, + url: "http://localhost:8080", + category: "development", + backend: "docker", + status: "running" as const, + }, + ]; + + (fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: async () => firstBatch, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => secondBatch, + }); + + let appInstalledCallback: (() => void) | undefined; + (onAppEvent as ReturnType).mockImplementation( + (_name: string, cb: () => void) => { + appInstalledCallback = cb; + return () => {}; + }, + ); + + const { result } = renderHook(() => useInstalledServices()); + + await waitFor(() => expect(result.current).toHaveLength(1)); + expect(fetch).toHaveBeenCalledTimes(1); + + appInstalledCallback!(); + + await waitFor(() => expect(result.current).toHaveLength(2)); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/desktop/src/hooks/use-installed-userspace-apps.test.ts b/desktop/src/hooks/use-installed-userspace-apps.test.ts new file mode 100644 index 000000000..45189d976 --- /dev/null +++ b/desktop/src/hooks/use-installed-userspace-apps.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useInstalledUserspaceApps } from "./use-installed-userspace-apps"; + +vi.mock("@/lib/userspace-apps", () => ({ + fetchUserspaceApps: vi.fn(), + USERSPACE_APPS_CHANGED: "taos:userspace-apps-changed", +})); + +vi.mock("@/registry/app-registry", () => ({ + syncUserspaceApps: vi.fn(), +})); + +vi.mock("@/lib/app-event-bus", () => ({ + onAppEvent: vi.fn().mockReturnValue(() => {}), +})); + +import { fetchUserspaceApps } from "@/lib/userspace-apps"; +import { syncUserspaceApps } from "@/registry/app-registry"; + +const mockedFetchUserspaceApps = vi.mocked(fetchUserspaceApps); +const mockedSyncUserspaceApps = vi.mocked(syncUserspaceApps); + +describe("useInstalledUserspaceApps", () => { + beforeEach(() => { + mockedFetchUserspaceApps.mockClear(); + mockedSyncUserspaceApps.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns an empty array on initial render", () => { + mockedFetchUserspaceApps.mockResolvedValueOnce([]); + const { result } = renderHook(() => useInstalledUserspaceApps()); + expect(result.current).toEqual([]); + }); + + it("populates apps after a successful fetch", async () => { + const manifests = [ + { + id: "userspace:app-1", + name: "App One", + icon: "layout-grid", + category: "userspace" as const, + component: () => Promise.resolve({ default: () => null }), + defaultSize: { w: 900, h: 600 }, + minSize: { w: 360, h: 280 }, + singleton: true, + pinned: false, + launchpadOrder: 100, + }, + { + id: "userspace:app-2", + name: "App Two", + icon: "layout-grid", + category: "userspace" as const, + component: () => Promise.resolve({ default: () => null }), + defaultSize: { w: 900, h: 600 }, + minSize: { w: 360, h: 280 }, + singleton: true, + pinned: false, + launchpadOrder: 100, + }, + ]; + mockedFetchUserspaceApps.mockResolvedValueOnce(manifests); + + const { result } = renderHook(() => useInstalledUserspaceApps()); + + await waitFor(() => { + expect(result.current).toHaveLength(2); + }); + + expect(mockedSyncUserspaceApps).toHaveBeenCalledWith(manifests); + expect(result.current.map((m) => m.id)).toEqual([ + "userspace:app-1", + "userspace:app-2", + ]); + }); + + it("returns an empty array when fetch rejects", async () => { + mockedFetchUserspaceApps.mockRejectedValueOnce(new Error("network")); + + const { result } = renderHook(() => useInstalledUserspaceApps()); + + await waitFor(() => { + expect(mockedFetchUserspaceApps).toHaveBeenCalled(); + }); + + expect(result.current).toEqual([]); + }); + + it("re-fetches when USERSPACE_APPS_CHANGED event fires", async () => { + const firstBatch = [ + { + id: "userspace:app-1", + name: "App One", + icon: "layout-grid", + category: "userspace" as const, + component: () => Promise.resolve({ default: () => null }), + defaultSize: { w: 900, h: 600 }, + minSize: { w: 360, h: 280 }, + singleton: true, + pinned: false, + launchpadOrder: 100, + }, + ]; + const secondBatch = [ + { + id: "userspace:app-1", + name: "App One", + icon: "layout-grid", + category: "userspace" as const, + component: () => Promise.resolve({ default: () => null }), + defaultSize: { w: 900, h: 600 }, + minSize: { w: 360, h: 280 }, + singleton: true, + pinned: false, + launchpadOrder: 100, + }, + { + id: "userspace:app-3", + name: "App Three", + icon: "layout-grid", + category: "userspace" as const, + component: () => Promise.resolve({ default: () => null }), + defaultSize: { w: 900, h: 600 }, + minSize: { w: 360, h: 280 }, + singleton: true, + pinned: false, + launchpadOrder: 100, + }, + ]; + + mockedFetchUserspaceApps.mockResolvedValueOnce(firstBatch); + + let eventHandler: (() => void) | undefined; + const { onAppEvent } = await import("@/lib/app-event-bus"); + (onAppEvent as ReturnType).mockImplementation( + (_name: string, handler: () => void) => { + eventHandler = handler; + return () => {}; + }, + ); + + const { result } = renderHook(() => useInstalledUserspaceApps()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + + mockedFetchUserspaceApps.mockResolvedValueOnce(secondBatch); + + eventHandler!(); + + await waitFor(() => { + expect(result.current).toHaveLength(2); + }); + + expect(mockedSyncUserspaceApps).toHaveBeenCalledTimes(2); + expect(result.current.map((m) => m.id)).toEqual([ + "userspace:app-1", + "userspace:app-3", + ]); + }); +}); diff --git a/desktop/src/hooks/use-shortcut-registry.test.tsx b/desktop/src/hooks/use-shortcut-registry.test.tsx new file mode 100644 index 000000000..bf3ab88db --- /dev/null +++ b/desktop/src/hooks/use-shortcut-registry.test.tsx @@ -0,0 +1,206 @@ +import { render, screen } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + ShortcutProvider, + useShortcuts, + useShortcut, + parseCombo, + matchesEvent, +} from "./use-shortcut-registry"; +import type { ReactNode } from "react"; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function makeFakeEvent(key: string, opts: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {}) { + return { + key, + ctrlKey: opts.ctrl ?? false, + shiftKey: opts.shift ?? false, + altKey: opts.alt ?? false, + metaKey: opts.meta ?? false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; +} + +function ShortcutRegistrar({ combo, action, label, scope }: { combo: string; action: () => void; label: string; scope?: "system" | "app" | "overlay" }) { + useShortcut(combo, action, label, scope); + return null; +} + +describe("parseCombo", () => { + it("parses a simple key", () => { + expect(parseCombo("k")).toEqual({ ctrl: false, shift: false, alt: false, key: "k" }); + }); + + it("parses ctrl+key combo", () => { + expect(parseCombo("Ctrl+K")).toEqual({ ctrl: true, shift: false, alt: false, key: "k" }); + }); + + it("parses ctrl+shift+key combo", () => { + expect(parseCombo("Ctrl+Shift+S")).toEqual({ ctrl: true, shift: true, alt: false, key: "s" }); + }); + + it("parses ctrl+alt+key combo", () => { + const result = parseCombo("ctrl+alt+delete"); + expect(result.ctrl).toBe(true); + expect(result.alt).toBe(true); + expect(result.key).toBe("delete"); + }); +}); + +describe("matchesEvent", () => { + it("matches a plain key press", () => { + expect(matchesEvent(parseCombo("k"), makeFakeEvent("k"))).toBe(true); + }); + + it("does not match a different key", () => { + expect(matchesEvent(parseCombo("k"), makeFakeEvent("j"))).toBe(false); + }); + + it("matches ctrl+key combo", () => { + expect(matchesEvent(parseCombo("Ctrl+S"), makeFakeEvent("s", { ctrl: true }))).toBe(true); + }); + + it("does not match when ctrl is missing", () => { + expect(matchesEvent(parseCombo("Ctrl+S"), makeFakeEvent("s"))).toBe(false); + }); + + it("uses metaKey as ctrl", () => { + expect(matchesEvent(parseCombo("Ctrl+K"), makeFakeEvent("k", { meta: true }))).toBe(true); + }); + + it("requires shift when specified", () => { + expect(matchesEvent(parseCombo("Ctrl+Shift+A"), makeFakeEvent("a", { ctrl: true }))).toBe(false); + }); + + it("does not match when extra modifier is pressed", () => { + expect(matchesEvent(parseCombo("Ctrl+A"), makeFakeEvent("a", { ctrl: true, shift: true }))).toBe(false); + }); +}); + +describe("useShortcuts outside provider", () => { + it("returns a no-op getAll and keyboardLockActive=false", () => { + const { result } = renderHook(() => useShortcuts()); + expect(result.current.getAll()).toEqual([]); + expect(result.current.keyboardLockActive).toBe(false); + }); +}); + +describe("useShortcuts inside provider", () => { + it("returns empty list initially", () => { + const { result } = renderHook(() => useShortcuts(), { wrapper }); + expect(result.current.getAll()).toEqual([]); + expect(result.current.keyboardLockActive).toBe(false); + }); + + it("keyboardLockActive starts as false", () => { + const { result } = renderHook(() => useShortcuts(), { wrapper }); + expect(result.current.keyboardLockActive).toBe(false); + }); +}); + +describe("shortcut registration", () => { + it("registers a shortcut and getAll reflects it", () => { + const action = vi.fn(); + let shortcutsRef: ReturnType | null = null; + + function CaptureShortcuts() { + shortcutsRef = useShortcuts(); + return null; + } + + act(() => { + render( + + + + + ); + }); + + expect(shortcutsRef).not.toBeNull(); + const all = shortcutsRef!.getAll(); + expect(all).toHaveLength(1); + expect(all[0].combo).toBe("Ctrl+K"); + expect(all[0].scope).toBe("app"); + }); + + it("unregisters a shortcut on unmount", () => { + const action = vi.fn(); + let shortcutsRef: ReturnType | null = null; + + function CaptureShortcuts() { + shortcutsRef = useShortcuts(); + return null; + } + + const { rerender } = render( + + + + + ); + + expect(shortcutsRef!.getAll()).toHaveLength(1); + + act(() => { + rerender( + + + + ); + }); + + expect(shortcutsRef!.getAll()).toHaveLength(0); + }); + + it("registers multiple shortcuts", () => { + let shortcutsRef: ReturnType | null = null; + + function CaptureShortcuts() { + shortcutsRef = useShortcuts(); + return null; + } + + act(() => { + render( + + + + + + ); + }); + + const all = shortcutsRef!.getAll(); + expect(all).toHaveLength(2); + const scopes = all.map((s: { scope: string }) => s.scope).sort(); + expect(scopes).toEqual(["app", "system"]); + }); + + it("defaults scope to system when not specified", () => { + let shortcutsRef: ReturnType | null = null; + + function CaptureShortcuts() { + shortcutsRef = useShortcuts(); + return null; + } + + act(() => { + render( + + + + + ); + }); + + const all = shortcutsRef!.getAll(); + expect(all).toHaveLength(1); + expect(all[0].scope).toBe("system"); + }); +}); diff --git a/desktop/src/hooks/use-snap-zones.test.ts b/desktop/src/hooks/use-snap-zones.test.ts new file mode 100644 index 000000000..6b141b5ef --- /dev/null +++ b/desktop/src/hooks/use-snap-zones.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { + useSnapZones, + detectSnapZone, + getSnapBounds, +} from "./use-snap-zones"; +import type { SnapPosition } from "@/stores/process-store"; + +const VP = { width: 1200, height: 800, topBarH: 40, dockH: 60 }; + +describe("detectSnapZone", () => { + it("returns null when cursor is in the middle of the viewport", () => { + expect(detectSnapZone(600, 400, VP)).toBeNull(); + }); + + it("returns 'left' when cursor is near the left edge", () => { + expect(detectSnapZone(5, 400, VP)).toBe("left"); + }); + + it("returns 'right' when cursor is near the right edge", () => { + expect(detectSnapZone(1195, 400, VP)).toBe("right"); + }); + + it("returns 'top-left' when cursor is near the top-left corner", () => { + expect(detectSnapZone(5, 50, VP)).toBe("top-left"); + }); + + it("returns 'top-right' when cursor is near the top-right corner", () => { + expect(detectSnapZone(1195, 50, VP)).toBe("top-right"); + }); + + it("returns 'bottom-left' when cursor is near the bottom-left corner", () => { + expect(detectSnapZone(5, 730, VP)).toBe("bottom-left"); + }); + + it("returns 'bottom-right' when cursor is near the bottom-right corner", () => { + expect(detectSnapZone(1195, 730, VP)).toBe("bottom-right"); + }); +}); + +describe("getSnapBounds", () => { + it("returns null for a null snap position", () => { + expect(getSnapBounds(null, VP)).toBeNull(); + }); + + it("returns half-width left bounds for 'left'", () => { + expect(getSnapBounds("left", VP)).toEqual({ + x: 0, + y: 0, + w: 600, + h: 700, + }); + }); + + it("returns half-width right bounds for 'right'", () => { + expect(getSnapBounds("right", VP)).toEqual({ + x: 600, + y: 0, + w: 600, + h: 700, + }); + }); + + it("returns quarter bounds for 'top-left'", () => { + expect(getSnapBounds("top-left", VP)).toEqual({ + x: 0, + y: 0, + w: 600, + h: 350, + }); + }); + + it("returns quarter bounds for 'top-right'", () => { + expect(getSnapBounds("top-right", VP)).toEqual({ + x: 600, + y: 0, + w: 600, + h: 350, + }); + }); + + it("returns quarter bounds for 'bottom-left'", () => { + expect(getSnapBounds("bottom-left", VP)).toEqual({ + x: 0, + y: 350, + w: 600, + h: 350, + }); + }); + + it("returns quarter bounds for 'bottom-right'", () => { + expect(getSnapBounds("bottom-right", VP)).toEqual({ + x: 600, + y: 350, + w: 600, + h: 350, + }); + }); +}); + +describe("useSnapZones", () => { + it("starts with null preview and null previewBounds", () => { + const { result } = renderHook(() => useSnapZones(VP)); + + expect(result.current.preview).toBeNull(); + expect(result.current.previewBounds).toBeNull(); + }); + + it("updates preview and previewBounds when onDrag detects a zone", () => { + const { result } = renderHook(() => useSnapZones(VP)); + + act(() => { + result.current.onDrag(5, 400); + }); + + expect(result.current.preview).toBe("left"); + expect(result.current.previewBounds).toEqual({ + x: 0, + y: 0, + w: 600, + h: 700, + }); + }); + + it("updates preview to a different zone when onDrag moves to a new zone", () => { + const { result } = renderHook(() => useSnapZones(VP)); + + act(() => { + result.current.onDrag(5, 400); + }); + expect(result.current.preview).toBe("left"); + + act(() => { + result.current.onDrag(1195, 400); + }); + expect(result.current.preview).toBe("right"); + expect(result.current.previewBounds).toEqual({ + x: 600, + y: 0, + w: 600, + h: 700, + }); + }); + + it("sets preview back to null when onDrag moves out of any zone", () => { + const { result } = renderHook(() => useSnapZones(VP)); + + act(() => { + result.current.onDrag(5, 400); + }); + expect(result.current.preview).toBe("left"); + + act(() => { + result.current.onDrag(600, 400); + }); + expect(result.current.preview).toBeNull(); + expect(result.current.previewBounds).toBeNull(); + }); + + it("onDragStop returns the current zone and resets preview to null", () => { + const { result } = renderHook(() => useSnapZones(VP)); + + act(() => { + result.current.onDrag(5, 400); + }); + expect(result.current.preview).toBe("left"); + + let stoppedZone: SnapPosition; + act(() => { + stoppedZone = result.current.onDragStop(); + }); + + expect(stoppedZone!).toBe("left"); + expect(result.current.preview).toBeNull(); + expect(result.current.previewBounds).toBeNull(); + }); + + it("onDragStop returns null when no zone is active", () => { + const { result } = renderHook(() => useSnapZones(VP)); + + let stoppedZone: SnapPosition; + act(() => { + stoppedZone = result.current.onDragStop(); + }); + + expect(stoppedZone!).toBeNull(); + expect(result.current.preview).toBeNull(); + }); +}); diff --git a/desktop/src/registry/app-registry.ts b/desktop/src/registry/app-registry.ts index 79aa05a35..6f3c2820f 100644 --- a/desktop/src/registry/app-registry.ts +++ b/desktop/src/registry/app-registry.ts @@ -66,8 +66,9 @@ const apps: AppManifest[] = [ { id: "calculator", name: "Calculator", icon: "calculator", category: "os", component: () => import("@/apps/CalculatorApp").then((m) => ({ default: m.CalculatorApp })), defaultSize: { w: 320, h: 480 }, minSize: { w: 280, h: 400 }, singleton: true, pinned: false, launchpadOrder: 20 }, { id: "calendar", name: "Calendar", icon: "calendar", category: "os", component: () => import("@/apps/CalendarApp").then((m) => ({ default: m.CalendarApp })), defaultSize: { w: 900, h: 600 }, minSize: { w: 600, h: 400 }, singleton: true, pinned: false, launchpadOrder: 21 }, { id: "contacts", name: "Contacts", icon: "contact", category: "os", component: () => import("@/apps/ContactsApp").then((m) => ({ default: m.ContactsApp })), defaultSize: { w: 700, h: 500 }, minSize: { w: 400, h: 300 }, singleton: true, pinned: false, launchpadOrder: 22 }, + // Single Browser app. The proxy vs real-Neko-streamed engine is a per-tab + // toggle inside it (BrowserModeToggle / LiveBrowserView), not a separate app. { id: "browser", name: "Browser", icon: "globe", category: "os", component: () => import("@/apps/BrowserApp").then((m) => ({ default: m.BrowserApp })), defaultSize: { w: 1024, h: 700 }, minSize: { w: 600, h: 400 }, singleton: false, pinned: false, launchpadOrder: 23 }, - { id: "streamed-browser", name: "Browser (Streamed)", icon: "monitor-play", category: "os", component: () => import("@/apps/StreamedBrowserApp").then((m) => ({ default: m.StreamedBrowserApp })), defaultSize: { w: 1280, h: 800 }, minSize: { w: 800, h: 500 }, singleton: false, pinned: false, launchpadOrder: 23.5 }, { id: "media-player", name: "Media Player", icon: "play-circle", category: "os", component: () => import("@/apps/MediaPlayerApp").then((m) => ({ default: m.MediaPlayerApp })), defaultSize: { w: 800, h: 500 }, minSize: { w: 400, h: 300 }, singleton: false, pinned: false, launchpadOrder: 24 }, { id: "text-editor", name: "Text Editor", icon: "file-text", category: "os", component: () => import("@/apps/TextEditorApp").then((m) => ({ default: m.TextEditorApp })), defaultSize: { w: 800, h: 550 }, minSize: { w: 400, h: 300 }, singleton: false, pinned: false, launchpadOrder: 25 }, { id: "image-viewer", name: "Image Viewer", icon: "eye", category: "os", component: () => import("@/apps/ImageViewerApp").then((m) => ({ default: m.ImageViewerApp })), defaultSize: { w: 800, h: 600 }, minSize: { w: 400, h: 300 }, singleton: false, pinned: false, launchpadOrder: 26 }, diff --git a/desktop/src/registry/optional-apps.ts b/desktop/src/registry/optional-apps.ts index a17b565f9..a96edc5a2 100644 --- a/desktop/src/registry/optional-apps.ts +++ b/desktop/src/registry/optional-apps.ts @@ -1,8 +1,13 @@ /** - * Presentation metadata for the optional, Store-installable frontend apps - * (Reddit / YouTube / GitHub / X). The registry (app-registry.ts) owns the - * runtime manifest (component, sizing); this owns how they look in the Store's - * "taOS Apps" section. ids must match the registry entries marked `optional`. + * Presentation metadata for optional, Store-installable frontend apps. The + * registry (app-registry.ts) owns the runtime manifest (component, sizing); + * this owns how they look in the Store's "taOS Apps" section. ids must match + * the registry entries marked `optional`. + * + * The platform social apps (Reddit / YouTube / GitHub / X) were DE-SEEDED from + * the default Store: they are unfinished and now live as the operator's private + * App Studio drafts, to be finished + published on stream rather than offered to + * every user. Their registry manifests + components remain for that reseed. */ export interface OptionalAppMeta { @@ -18,33 +23,5 @@ export interface OptionalAppMeta { cover: string; } -export const OPTIONAL_APPS: OptionalAppMeta[] = [ - { - id: "reddit", - name: "Reddit", - icon: "scroll-text", - tagline: "Browse subreddits, posts, and comments inside taOS.", - cover: "linear-gradient(135deg, #ff4500 0%, #cc3700 100%)", - }, - { - id: "youtube-library", - name: "YouTube", - icon: "play-circle", - tagline: "A focused video library and watch surface.", - cover: "linear-gradient(135deg, #ff0033 0%, #b3001f 100%)", - }, - { - id: "github-browser", - name: "GitHub", - icon: "github", - tagline: "Track repos, issues, and pull requests at a glance.", - cover: "linear-gradient(135deg, #2b3137 0%, #14161a 100%)", - }, - { - id: "x-monitor", - name: "X", - icon: "at-sign", - tagline: "Monitor timelines and posts from X.", - cover: "linear-gradient(135deg, #1a1a1a 0%, #000000 100%)", - }, -]; +// Empty after the social-app de-seed. New optional Store apps get added here. +export const OPTIONAL_APPS: OptionalAppMeta[] = []; diff --git a/desktop/src/stores/dock-store.test.ts b/desktop/src/stores/dock-store.test.ts new file mode 100644 index 000000000..f1d318180 --- /dev/null +++ b/desktop/src/stores/dock-store.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useDockStore } from "./dock-store"; + +const DEFAULT_PINNED = ["messages", "agents", "files", "store", "settings"]; + +beforeEach(() => { + useDockStore.setState({ pinned: [...DEFAULT_PINNED] }); +}); + +describe("dock-store — defaults", () => { + it("starts with the default pinned list", () => { + expect(useDockStore.getState().pinned).toEqual(DEFAULT_PINNED); + }); +}); + +describe("dock-store — pin", () => { + it("appends a new app id to the pinned list", () => { + useDockStore.getState().pin("terminal"); + const pinned = useDockStore.getState().pinned; + expect(pinned).toContain("terminal"); + expect(pinned).toEqual([...DEFAULT_PINNED, "terminal"]); + }); + + it("does not duplicate an already-pinned app id", () => { + useDockStore.getState().pin("messages"); + const pinned = useDockStore.getState().pinned; + expect(pinned).toEqual(DEFAULT_PINNED); + expect(pinned.filter((id) => id === "messages")).toHaveLength(1); + }); +}); + +describe("dock-store — unpin", () => { + it("removes an app id from the pinned list", () => { + useDockStore.getState().unpin("agents"); + const pinned = useDockStore.getState().pinned; + expect(pinned).not.toContain("agents"); + expect(pinned).toEqual(["messages", "files", "store", "settings"]); + }); + + it("does nothing when unpinning an id that is not pinned", () => { + useDockStore.getState().unpin("nonexistent"); + expect(useDockStore.getState().pinned).toEqual(DEFAULT_PINNED); + }); +}); + +describe("dock-store — reorder", () => { + it("replaces the pinned list with the provided order", () => { + const reordered = ["settings", "store", "files", "agents", "messages"]; + useDockStore.getState().reorder(reordered); + expect(useDockStore.getState().pinned).toEqual(reordered); + }); + + it("accepts an empty list", () => { + useDockStore.getState().reorder([]); + expect(useDockStore.getState().pinned).toEqual([]); + }); +}); + +describe("dock-store — reset to defaults", () => { + it("restores the default pinned list after mutations", () => { + const store = useDockStore.getState(); + store.pin("terminal"); + store.unpin("messages"); + store.reorder(["agents", "files"]); + + useDockStore.setState({ pinned: [...DEFAULT_PINNED] }); + expect(useDockStore.getState().pinned).toEqual(DEFAULT_PINNED); + }); +}); diff --git a/desktop/src/stores/notification-store.test.ts b/desktop/src/stores/notification-store.test.ts index b8631fa3f..060e9645a 100644 --- a/desktop/src/stores/notification-store.test.ts +++ b/desktop/src/stores/notification-store.test.ts @@ -73,11 +73,17 @@ describe("mergeServerNotifications", () => { expect(useNotificationStore.getState().notifications.map((n) => n.id)).toContain("srv-901"); store.dismiss("srv-901"); - expect(useNotificationStore.getState().notifications.map((n) => n.id)).not.toContain("srv-901"); + // Dismissed item is archived, not removed from the store. + const all = useNotificationStore.getState().notifications; + const dismissed = all.find((n) => n.id === "srv-901"); + expect(dismissed).toBeDefined(); + expect(dismissed?.archived).toBe(true); - // Backend still reports it (no server-side dismiss); it must stay hidden. + // Backend still reports it (no server-side dismiss); it must stay hidden from active. store.mergeServerNotifications([srv(901, 100)]); - expect(useNotificationStore.getState().notifications.map((n) => n.id)).not.toContain("srv-901"); + const afterPoll = useNotificationStore.getState().notifications; + const stillArchived = afterPoll.find((n) => n.id === "srv-901"); + expect(stillArchived?.archived).toBe(true); }); it("caps the merged list at 100 items", () => { @@ -87,3 +93,75 @@ describe("mergeServerNotifications", () => { expect(useNotificationStore.getState().notifications).toHaveLength(100); }); }); + +describe("dismiss archives instead of removing", () => { + it("sets archived flag on dismiss", () => { + const store = useNotificationStore.getState(); + const id = store.addNotification({ source: "system", title: "test", body: "body", level: "info" }); + + store.dismiss(id); + + const all = useNotificationStore.getState().notifications; + expect(all).toHaveLength(1); + expect(all[0].archived).toBe(true); + expect(all[0].id).toBe(id); + }); + + it("excludes archived items from active notifications", () => { + const store = useNotificationStore.getState(); + const id1 = store.addNotification({ source: "system", title: "keep", body: "active", level: "info" }); + const id2 = store.addNotification({ source: "system", title: "archive", body: "gone", level: "info" }); + + store.dismiss(id2); + + const active = useNotificationStore.getState().notifications.filter((n) => !n.archived); + expect(active).toHaveLength(1); + expect(active[0].id).toBe(id1); + }); + + it("archivedNotifications returns archived items newest-first", () => { + const store = useNotificationStore.getState(); + store.addNotification({ source: "system", title: "old", body: "b", level: "info" }); + const id2 = store.addNotification({ source: "system", title: "new", body: "b", level: "info" }); + + store.dismiss(id2); + + const archived = store.archivedNotifications(); + expect(archived).toHaveLength(1); + expect(archived[0].id).toBe(id2); + expect(archived[0].archived).toBe(true); + }); + + it("clearAll archives all notifications", () => { + const store = useNotificationStore.getState(); + store.addNotification({ source: "system", title: "a", body: "b", level: "info" }); + store.addNotification({ source: "system", title: "c", body: "d", level: "info" }); + + store.clearAll(); + + const all = useNotificationStore.getState().notifications; + expect(all).toHaveLength(2); + expect(all.every((n) => n.archived)).toBe(true); + }); + + it("clearArchived removes archived items permanently", () => { + const store = useNotificationStore.getState(); + const id = store.addNotification({ source: "system", title: "test", body: "b", level: "info" }); + store.dismiss(id); + + store.clearArchived(); + + expect(useNotificationStore.getState().notifications).toHaveLength(0); + }); + + it("unreadCount excludes archived items", () => { + const store = useNotificationStore.getState(); + const id1 = store.addNotification({ source: "system", title: "unread", body: "b", level: "info" }); + store.addNotification({ source: "system", title: "to-archive", body: "b", level: "info" }); + + expect(store.unreadCount()).toBe(2); + + store.dismiss(id1); + expect(store.unreadCount()).toBe(1); + }); +}); diff --git a/desktop/src/stores/notification-store.ts b/desktop/src/stores/notification-store.ts index b67fdcc94..206bc34f1 100644 --- a/desktop/src/stores/notification-store.ts +++ b/desktop/src/stores/notification-store.ts @@ -12,6 +12,8 @@ export interface Notification { timestamp: number; /** Extra typed payload for structured notifications like agent.paused. */ meta?: Record; + /** When true the notification has been dismissed/archived. */ + archived?: boolean; } interface NotificationStore { @@ -27,6 +29,8 @@ interface NotificationStore { toggleCentre: () => void; closeCentre: () => void; unreadCount: () => number; + archivedNotifications: () => Notification[]; + clearArchived: () => void; } let counter = 0; @@ -64,10 +68,13 @@ export const useNotificationStore = create((set, get) => ({ const merged = items .filter((n) => !dismissedServerIds.has(n.id)) .map((n) => (priorRead.get(n.id) ? { ...n, read: true } : n)); - // Keep every client-origin item ("notif-N") untouched, drop the old - // server items (replaced by the fresh list), de-dupe, sort newest-first. - const client = s.notifications.filter((n) => !n.id.startsWith("srv-")); - const combined = [...merged, ...client]; + // Keep every client-origin item ("notif-N") untouched, plus any archived + // server items (they must survive polls). Drop the old unarchived server + // items (replaced by the fresh list), de-dupe, sort newest-first. + const kept = s.notifications.filter( + (n) => !n.id.startsWith("srv-") || n.archived, + ); + const combined = [...merged, ...kept]; combined.sort((a, b) => b.timestamp - a.timestamp); return { notifications: combined.slice(0, 100) }; }); @@ -85,7 +92,11 @@ export const useNotificationStore = create((set, get) => ({ dismiss(id) { if (id.startsWith("srv-")) dismissedServerIds.add(id); - set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })); + set((s) => ({ + notifications: s.notifications.map((n) => + n.id === id ? { ...n, archived: true } : n, + ), + })); }, clearAll() { @@ -93,7 +104,9 @@ export const useNotificationStore = create((set, get) => ({ for (const n of s.notifications) { if (n.id.startsWith("srv-")) dismissedServerIds.add(n.id); } - return { notifications: [] }; + return { + notifications: s.notifications.map((n) => ({ ...n, archived: true })), + }; }); }, @@ -106,6 +119,18 @@ export const useNotificationStore = create((set, get) => ({ }, unreadCount() { - return get().notifications.filter((n) => !n.read).length; + return get().notifications.filter((n) => !n.read && !n.archived).length; + }, + + archivedNotifications() { + return get().notifications + .filter((n) => n.archived) + .sort((a, b) => b.timestamp - a.timestamp); + }, + + clearArchived() { + set((s) => ({ + notifications: s.notifications.filter((n) => !n.archived), + })); }, })); diff --git a/desktop/src/stores/theme-store.test.ts b/desktop/src/stores/theme-store.test.ts new file mode 100644 index 000000000..b45905a15 --- /dev/null +++ b/desktop/src/stores/theme-store.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useThemeStore } from "./theme-store"; + +const reset = () => { + useThemeStore.setState({ + wallpaperId: "graphite", + wallpaperImage: "url('/static/wallpaper-graphite.png')", + wallpaperMobileImage: "url('/static/wallpaper-graphite-mobile.png')", + wallpaperFallback: "#141415", + wallpaperLightImage: "url('/static/wallpaper-graphite-light.png')", + wallpaperLightMobileImage: "url('/static/wallpaper-graphite-light-mobile.png')", + wallpaperLightFallback: "#eef0f3", + wallpaperKind: "image", + wallpaperComponent: null, + wallpaperOverlayText: null, + showOverlayText: true, + wallpaperParams: { density: 200, speed: 0.5, glow: 6 }, + showDesktopIcons: true, + reduceEffects: false, + structure: {}, + effects: [], + activeThemeId: "default", + wallpaperByTheme: {}, + themeDefaultWallpaper: {}, + wallpaperIdByTheme: {}, + }); +}; + +describe("theme-store", () => { + beforeEach(() => { + reset(); + localStorage.clear(); + }); + + it("setWallpaper updates wallpaper fields for a known wallpaper id", () => { + useThemeStore.getState().setWallpaper("neural-live"); + const s = useThemeStore.getState(); + expect(s.wallpaperId).toBe("neural-live"); + expect(s.wallpaperImage).toBe(""); + expect(s.wallpaperKind).toBe("animated"); + expect(s.wallpaperComponent).toBe("particles"); + expect(s.wallpaperOverlayText).toBe("taOS"); + expect(s.wallpaperFallback).toBe("#141415"); + }); + + it("setWallpaper records the choice under the active theme for later restore", () => { + useThemeStore.setState({ activeThemeId: "custom-a" }); + useThemeStore.getState().setWallpaper("aurora"); + expect(useThemeStore.getState().wallpaperIdByTheme["custom-a"]).toBe("aurora"); + }); + + it("setWallpaper ignores an unknown wallpaper id and leaves state untouched", () => { + const before = useThemeStore.getState(); + useThemeStore.getState().setWallpaper("does-not-exist"); + const after = useThemeStore.getState(); + expect(after.wallpaperId).toBe(before.wallpaperId); + expect(after.wallpaperImage).toBe(before.wallpaperImage); + expect(after.wallpaperFallback).toBe(before.wallpaperFallback); + }); + + it("toggleOverlayText flips the flag and persists the pref", () => { + const initial = useThemeStore.getState().showOverlayText; + useThemeStore.getState().toggleOverlayText(); + expect(useThemeStore.getState().showOverlayText).toBe(!initial); + expect(localStorage.getItem("taos-wallpaper-slogan")).toBe(initial ? "off" : "on"); + + useThemeStore.getState().toggleOverlayText(); + expect(useThemeStore.getState().showOverlayText).toBe(initial); + expect(localStorage.getItem("taos-wallpaper-slogan")).toBe(initial ? "on" : "off"); + }); + + it("setWallpaperParam updates only the requested key and persists all params", () => { + useThemeStore.getState().setWallpaperParam("density", 50); + const params = useThemeStore.getState().wallpaperParams; + expect(params.density).toBe(50); + expect(params.speed).toBe(0.5); + expect(params.glow).toBe(6); + expect(JSON.parse(localStorage.getItem("taos-wallpaper-params")!)).toEqual({ + density: 50, + speed: 0.5, + glow: 6, + }); + }); + + it("toggleDesktopIcons flips the boolean", () => { + const before = useThemeStore.getState().showDesktopIcons; + useThemeStore.getState().toggleDesktopIcons(); + expect(useThemeStore.getState().showDesktopIcons).toBe(!before); + useThemeStore.getState().toggleDesktopIcons(); + expect(useThemeStore.getState().showDesktopIcons).toBe(before); + }); + + it("setReduceEffects sets the flag and persists the pref", () => { + useThemeStore.getState().setReduceEffects(true); + expect(useThemeStore.getState().reduceEffects).toBe(true); + expect(localStorage.getItem("taos-reduce-effects")).toBe("on"); + + useThemeStore.getState().setReduceEffects(false); + expect(useThemeStore.getState().reduceEffects).toBe(false); + expect(localStorage.getItem("taos-reduce-effects")).toBe("off"); + }); + + it("getWallpapers returns the full wallpaper catalogue with stable ids", () => { + const list = useThemeStore.getState().getWallpapers(); + expect(list.length).toBeGreaterThan(0); + const ids = list.map((w) => w.id); + expect(ids).toContain("graphite"); + expect(ids).toContain("neural-live"); + expect(ids).toContain("default"); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("reset: showDesktopIcons defaults to true after explicit false then reset", () => { + useThemeStore.getState().toggleDesktopIcons(); + expect(useThemeStore.getState().showDesktopIcons).toBe(false); + reset(); + expect(useThemeStore.getState().showDesktopIcons).toBe(true); + }); +}); diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 3bd97ad8f..dec85d8ab 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -27,7 +27,6 @@ export default defineConfig({ "src/apps/BrowserApp/AddressBar.test.tsx", "src/apps/BrowserApp/keyboard.test.ts", "src/apps/BrowserApp/ProfileSwitcher.test.tsx", - "src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx", // Order-dependent: passes in isolation, fails under the full suite (#114): "src/components/__tests__/EmojiPicker.test.tsx", ], diff --git a/docs/STATUS.md b/docs/STATUS.md index 8113b1798..ce717105e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ SINGLE SOURCE OF TRUTH for cross-agent handoff. -Last updated: 2026-06-20 ~20:35 UTC, @taOS-dev (POST-RESET BURST. Jay's two priorities BOTH merged to dev: #1227 Browser app redesign (collapsible sidebar via browser-ui-store zustand, design-bar tokens, empty/connecting placeholders, LiveBrowserView untouched) [#66], and #1228 the STREAMED-BROWSER WHITE-SCREEN FIX (#73): neko_url is now host-aware (rewritten from X-Forwarded-Host/Host on return so over Tailscale the iframe loads the Tailscale IP not the unreachable LAN IP) + NEKO_WEBRTC_NAT1TO1 includes BOTH LAN + Tailscale IPs (tailscale ip -4 / tailscale0 detection, no-op without tailscale). NEEDS LIVE PI VERIFY: pull Pi to dev, open Browser over Tailscale, iframe host should = tailscale IP + video should paint. Root cause was confirmed: neko_url hardcoded the LAN IP (Jay reaches taOS as a PWA over Tailscale) -> pure white. Also merged: Code Studio slice2 #1225 (apply-blocks fallback -- taos-agent has NO tool-calling loop so real live edits need an agent execution engine, a later slice), store submission state machine #1226. THREE background-security-review fixes merged (08faf70b): IDOR guard on get_submission (403 unless owner/admin/published), apply-blocks symlink-TOCTOU (re-resolve parent in-root + O_NOFOLLOW per write), install_registry admin gate on record/set-version/delete (global AuthMiddleware covers authn; this adds authz). #1226 500->400 on invalid kind/mode fixed. IN FLIGHT: #124 fix-forward agent (async tailscale detect via to_thread + wire the connecting empty-state + sidebar keyboard a11y). neko-cdp image is BUILT+PUBLIC+multi-arch (Pi pulls it); #1223 sandbox hardening merged+replied. BOARD: store-pipeline foundations all merged (submission store, install registry, sharing grants tsk-jrwild, TUI manifest, verification runner tsk-qgn5en if it landed); next store-pipeline = gitaos repo/bundle publish + App Studio publish UI + verification wiring (App Studio app-project model per #81). Crons + resume pair re-armed (next pair b665fe4b 02:13 / 7fd78b20 02:32 BST). five_hour 10% / seven_day 27%. PRIOR ENTRY BELOW.) +Last updated: 2026-06-21 ~midday, @taOS-dev (EPIC SLICES ROUND 2+3 ALL MERGED to dev (Jay: "keep going"). Eight epic PRs landed today across the three locked epics, every one green + bot-reviewed: ROUND 1 (integration on the foundations): #1276 cluster capability-map endpoints, #1277 board audit-log wiring + per-task history, #1278 coding tool-calling dispatch. ROUND 2/3 (next slices): #1279 cluster capability map auto-populates from worker REGISTRATION (worker hardware dict already carries cpu/ram/gpu/npu; best-effort, preserves draining + carries forward omitted hardware on re-register), #1280 coding model-agnostic tool-calling LOOP engine (run_tool_loop drives any model_step; iteration guard + soft-error feed-back + safe stop on garbage), #1281 audit PROJECT-SCOPED activity feed (added project_id + JSON detail columns via a guarded _post_init ALTER -- additive, no destructive migration; GET /api/projects/{id}/audit, scoped so no cross-project leak), #1282 cluster STALE-NODE offline sweep (mark_stale_offline = non-destructive prune; POST .../capability/sweep), #1283 coding litellm-backed MODEL_STEP (to_openai_tools + transcript<->messages + parse_completion; completion_fn injectable, lazy litellm import). NET: CODING STUDIO BACKEND IS A COMPLETE VERTICAL on dev -- fs-tools -> jailed dispatch -> loop engine -> model driver; a real model can now drive workspace edits end to end (only the route-to-UI wiring + a concrete agent-model binding remain). CLUSTER: map + endpoints + self-population + soft stale-offline (next = frontend Cluster view). AUDIT: foundation + per-transition recording + per-task history + project feed + detail column (next = richer event coverage + UI). BOT-REVIEW LOOP WORKED: the gated auto-merge watchers HELD every feature PR that picked up a gitar/coderabbit Bug/Edge-Case; I fixed all SIX this session before merge -- toast-archive (#1258), capability heartbeat-clobbers-draining (#1276), audit close TOCTOU (#1277), dispatch UnicodeDecodeError+size (#1278), reg-clobbers-hardware (#1279), loop non-dict step + malformed call (#1280), parse_completion empty-choices (#1283). One test-file merge conflict (#1279 vs #1282 both appended capability route tests) resolved keep-both. Guardrails respected throughout: no new auth, no destructive migrations, no installer/boot. five_hour ~49% / seven_day 6% -- ample budget. NEXT (Jay's call): coding route+UI wiring (run the loop against an agent's configured model from a workspace), cluster frontend, richer audit events. PRIOR ENTRY BELOW.) + +================================================================== +STATE 2026-06-21 ~morning, @taOS-dev (MORNING EPIC-BUILD PASS, Jay authorised the Claude-budget spend ("yes you do it please, do everything listed"). CLEARED THE OVERNIGHT REVIEW QUEUE: merged the 3 held epic FOUNDATIONS to dev (#1239 capability map, #1274 board audit log, #1275 coding fs-tools) + the 3 manual dependabot majors (#1253 fetch-metadata 2->3, #1254 checkout 4->7 [the lone lagging workflow; rest of the repo was already v7], #1255 litellm patch). FIXED #1258 TOAST-ARCHIVE BUG: the 5s auto-expire timer called dismiss() which set archived=true, so every toast that timed out was silently filed into the notification History; auto-expiry now only hides the toast (removes it from the visible set), archiving stays an explicit user action (X / Keep paused); added a fake-timer regression test (pushed to exec/tsk-dafnla, CI running). GHCR: Jay set taos-neko-rk3588 PUBLIC (manual TODO cleared). THEN BUILT THE 3 EPIC-INTEGRATION SLICES on the foundations, each its own green PR HELD for Jay: #1276 cluster capability-map endpoints (heartbeat[worker-HMAC]/list/set-status/prune, 16 tests), #1277 board audit-log wiring (ProjectTaskStore records every create/claim/release/close/reopen transition best-effort + GET .../tasks/{id}/audit history endpoint; reopen already did undo-of-close; 9 tests), #1278 coding tool-calling dispatch (agent_tools/coding_tools.py TOOL_SCHEMAS + dispatch() over the jailed fs-tools, GET /api/coding/tools + POST .../workspaces/{id}/tool execute step; 11 tests). All three reuse existing auth/jail (no new auth), no DB migrations, no installer/boot. Branch hygiene note: the audit work was briefly committed onto the capability branch then rebase --onto origin/dev split cleanly; PR #1276 was never polluted. five_hour was ~14% at build time, seven_day 2% -- ample budget. NEXT: merge #1276/#1277/#1278 + #1258 once CI greens (fold any gitar findings first). PRIOR ENTRY BELOW.) + +================================================================== +STATE 2026-06-21 ~00:45 UTC, @taOS-dev (OVERNIGHT QUEUE-FILL + #125 DONE. RK3588 HW ENCODE (#125/#624) COMPLETE: from-source MPP + gstreamer-rockchip (BoxCloud fork) builds on a GitHub arm64 runner (build-neko-rk3588-image.yml, never on the Pi), publishes ghcr.io/jaylfc/taos-neko-rk3588; live-validated on the Pi -- mpph264enc encodes 720p on the VPU in 0.18s/60frames with /dev/mpp_service+/dev/dri+/dev/rga. Resolver flipped (#1236, merged); CDP url now also exposed for the rk3588 image (it is built FROM the CDP image). MANUAL TODO FOR JAY: toggle the taos-neko-rk3588 GHCR package to PUBLIC (REST API cannot set container visibility). CHANGELOG backfilled beta.4 + beta.4.1. OWL LANE: re-fed after a 4h starvation -- ~34 conflict-free cards posted tonight (route/module/hook test waves, 2 bugs #841+#888, agent-coordination doc #96, 4 epic FOUNDATIONS [cluster capability map, append-only board audit log #105, relationships+permissions tests, coding fs-tool primitives], 4 features [searx JSON #969, notif history #62, settings-notif #65, chat select+edit #835/#834]). DISPATCHER POLICY CHANGE (Jay): dispatch_loop.sh now auto-merges ONLY test/doc PRs; every feature/bug/foundation PR is HELD for @taOS-dev review (it greps the PR file list). GROK DISABLED: ~/.taos-team/GROK_DISABLED marker + a guard in dispatcher.sh; remove to re-enable. GUARDRAILS on feature cards: no auth/security, no DB migrations, no installer/systemd/boot, no public copy. EPIC DIRECTIONS LOCKED (Jay, all four): cluster=capability-map first; append-only=board audit log first; social=relationships+permissions foundation; coding-studio=minimal tool-calling loop in taos-agent (fs-tools card is its first slice). OWL QUALITY WATCH: gitar flagged #1237 tests assert-nothing-unless-200 (vacuous) -- tighten future test-card specs to require meaningful assertions regardless of status. PRIOR ENTRY BELOW.) + +================================================================== +STATE 2026-06-20 ~22:20 UTC, @taOS-dev (BETA.5 SHIPPED: promoted dev->master (#1234, merge 5c988638), tagged v1.0.0-beta.5, GitHub Release cut (release trigger publishes the desktop bundle). This promotion = 47 commits since beta.4.1. KEY LANDINGS: #1230 the real STREAMED-BROWSER FIX -- single connecting-host NAT1TO1 (the comma-separated multi-IP mapping from #1228 was breaking pion WebRTC with 'invalid 1:1 NAT IP mapping'; validated LIVE, video paints over Tailscale; this is the actual white-screen root cause, not the host-rewrite). #1229 the #124 nits + gitar must-fixes: connecting-overlay now has a 15s timeout fallback so it cannot hang over a live session; sidebar close affordance is keyboard-focusable; the async-detect nit reconciled with #1230 -- build_neko_run_args reverted to a direct call (it is pure post-#1230) and the real blocking gethostbyname is now offloaded via asyncio.to_thread at the route. DEPENDABOT: #1213/#1214/#1215 version groups merged (lucide icons verified all-present in 0.577), #1232 dompurify 3.4.11 (the one genuinely-outstanding advisory); the 6 critical/high alerts were STALE (dev already had litellm 1.89.2 / fastapi-sso 0.21.0) and clear now master is promoted. NEW: #1231 dependabot auto-merge workflow (npm+actions patch/minor + security patch/minor on green; majors+python stay manual) + dev BRANCH PROTECTION (required checks test 3.12/3.13 + spa-build + lint; strict off; admins NOT enforced so admin-merge still works). VERSION DECISION (Jay): beta.4.2 is NOT PEP 440-valid so pyproject could not hold it (that is why beta.4/4.1 left pyproject at beta.3); moved to beta.5 -- valid in semver AND PEP 440 -- across all 3 files + both lockfiles. DOC GAP: CHANGELOG jumps beta.3 -> beta.5 (beta.4/4.1 never got changelog entries, only GitHub Releases); offered Jay a backfill, not done. EXTERNAL: @simonlpaige (external) left an expert SSRF/DNS-rebinding hardening suggestion on #971 (cached IP->hostname map + httpx client-factory middleware, or AsyncHTTPTransport with SNI hint via server_hostname); replied with Jay's go-ahead, plan tracked in #971 (prototype client-factory middleware first). NEXT: #125 RK3588 hardware video encode for neko (#624). five_hour 30% / seven_day 29%. PRIOR ENTRY BELOW.) + +================================================================== +STATE 2026-06-20 ~20:35 UTC, @taOS-dev (POST-RESET BURST. Jay's two priorities BOTH merged to dev: #1227 Browser app redesign (collapsible sidebar via browser-ui-store zustand, design-bar tokens, empty/connecting placeholders, LiveBrowserView untouched) [#66], and #1228 the STREAMED-BROWSER WHITE-SCREEN FIX (#73): neko_url is now host-aware (rewritten from X-Forwarded-Host/Host on return so over Tailscale the iframe loads the Tailscale IP not the unreachable LAN IP) + NEKO_WEBRTC_NAT1TO1 includes BOTH LAN + Tailscale IPs (tailscale ip -4 / tailscale0 detection, no-op without tailscale). NEEDS LIVE PI VERIFY: pull Pi to dev, open Browser over Tailscale, iframe host should = tailscale IP + video should paint. Root cause was confirmed: neko_url hardcoded the LAN IP (Jay reaches taOS as a PWA over Tailscale) -> pure white. Also merged: Code Studio slice2 #1225 (apply-blocks fallback -- taos-agent has NO tool-calling loop so real live edits need an agent execution engine, a later slice), store submission state machine #1226. THREE background-security-review fixes merged (08faf70b): IDOR guard on get_submission (403 unless owner/admin/published), apply-blocks symlink-TOCTOU (re-resolve parent in-root + O_NOFOLLOW per write), install_registry admin gate on record/set-version/delete (global AuthMiddleware covers authn; this adds authz). #1226 500->400 on invalid kind/mode fixed. IN FLIGHT: #124 fix-forward agent (async tailscale detect via to_thread + wire the connecting empty-state + sidebar keyboard a11y). neko-cdp image is BUILT+PUBLIC+multi-arch (Pi pulls it); #1223 sandbox hardening merged+replied. BOARD: store-pipeline foundations all merged (submission store, install registry, sharing grants tsk-jrwild, TUI manifest, verification runner tsk-qgn5en if it landed); next store-pipeline = gitaos repo/bundle publish + App Studio publish UI + verification wiring (App Studio app-project model per #81). Crons + resume pair re-armed (next pair b665fe4b 02:13 / 7fd78b20 02:32 BST). five_hour 10% / seven_day 27%. PRIOR ENTRY BELOW.) ================================================================== STATE 2026-06-20 ~19:18 UTC, @taOS-dev (WIND-DOWN at five_hour 95% (Jay: push to ~97). NEKO #1223 DONE: built+fixed the taos-neko-cdp image (Dockerfile.cdp had a multi-line RUN parse error that failed all 4 prior builds -> single-line; now built, PUBLIC, multi-arch arm64+amd64, Pi pulls it). Live-tested on the Pi: neko v3 auth is URL query params (?pwd=/?usr=) into the WebSocket handshake, NO cookie/hash/localStorage on the connect path, so removing allow-same-origin is SAFE. Merged #1223 to master (01d4f043) + back-merged to dev; reply posted to @tomaioo. CODE STUDIO: slice1 #1220 merged; slice2 = apply-blocks FALLBACK (#1225, branch feat/coding-studio-slice2) because /api/taos-agent/chat delegates to OpenCodeAdapter with NO tool-calling loop -> real live agent file-edits need an agent execution engine (next slice). #1225 has the gitar .git-write-guard fix pushed; GATE+MERGE #1225 pending. WHITE-SCREEN (Jay's streamed Browser app): NOT #1223 (his served bundle built 14:43 predates it, still has allow-same-origin). Session genuinely runs (container taos-neko-... up port 8801, neko_url set, node=host, NAT1TO1=Pi LAN ip correct, EPR 59010-59019 published) but the stream does not paint -> pre-existing render bug. NEED FROM JAY: does the white area show any neko UI vs pure white, and is taOS loaded over http or https (pure white + https => mixed-content block of the http neko_url). NEXT WORK (Jay asked, post-reset): (1) redesign Browser app to the design bar with COLLAPSIBLE SIDEBAR etc (#66, in_progress), (2) fix the streamed-browser render / Pi browser-node (#71). BOARD wave-2 (store pipeline): tsk-kknsro submission state machine (PR #1226), tsk-qgn5en verification check runner, tsk-jrwild sharing-grants -- owl-dispatch gates+merges green lane PRs; catch gitar must-fix via :23 repo-watch. Resume pair 986713ac (21:13 BST) / 9821573c (21:32 BST) for the 20:10 UTC reset. five_hour 95% / seven_day 26%. PRIOR ENTRY BELOW.) diff --git a/neko-lan-test.png b/neko-lan-test.png new file mode 100644 index 000000000..56345f2b5 Binary files /dev/null and b/neko-lan-test.png differ diff --git a/neko-single-test.png b/neko-single-test.png new file mode 100644 index 000000000..9064ca82f Binary files /dev/null and b/neko-single-test.png differ diff --git a/neko-tcpmux-test.png b/neko-tcpmux-test.png new file mode 100644 index 000000000..56345f2b5 Binary files /dev/null and b/neko-tcpmux-test.png differ diff --git a/pyproject.toml b/pyproject.toml index dc41b8272..bef941c64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tinyagentos" -version = "1.0.0-beta.5" +version = "1.0.0-beta.6" description = "Self-hosted AI agent memory system for low-power hardware" license = { file = "LICENSE" } requires-python = ">=3.11" @@ -46,7 +46,7 @@ dependencies = [ [project.optional-dependencies] worker = ["pystray>=0.19.0", "Pillow>=10.0"] -proxy = ["litellm[proxy]>=1.89.2", "prisma>=0.11.0"] +proxy = ["litellm[proxy]>=1.89.3", "prisma>=0.11.0"] dev = [ "pytest>=9.1.1", "pytest-asyncio>=0.23.0", diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 000000000..eef38f3d9 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# taOS rollback -- restore the branch + commit the last update left, then +# restart. Pure git + service restart on purpose: it must work when an update +# has broken the Python app or the dashboard is unreachable. +# +# bash scripts/rollback.sh # undo the last update (branch + version) +# bash scripts/rollback.sh # roll back to a specific tag/branch/sha +# +# The updater records the pre-update state in /.taos-rollback before it +# touches anything, so even a clean fast-forward has a restore point. +set -euo pipefail + +# --- locate the install (this script lives in /scripts) --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="${TAOS_INSTALL_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" +cd "$INSTALL_DIR" + +if [[ ! -d .git ]]; then + echo "taos rollback: $INSTALL_DIR is not a git checkout; cannot roll back" >&2 + exit 1 +fi + +log(){ echo "[rollback] $*"; } + +target_ref="${1:-}" + +if [[ -n "$target_ref" ]]; then + # Explicit target: a tag, branch, or sha the user named. + prev_branch="" + prev_sha="$target_ref" + log "explicit target: $target_ref" +elif [[ -f .taos-rollback ]]; then + # shellcheck disable=SC1091 + source .taos-rollback + prev_branch="${prev_branch:-}" + prev_sha="${prev_sha:-}" + log "recorded target: branch='${prev_branch}' commit='${prev_sha:0:12}'" +else + # Fallback for installs predating the recorded file: newest recovery tag. + prev_sha="$(git tag --list 'taos-pre-update-*' --sort=-creatordate | head -1)" + prev_branch="" + if [[ -z "$prev_sha" ]]; then + echo "taos rollback: no recorded rollback target and no taos-pre-update-* tag found" >&2 + exit 1 + fi + log "no record file; using newest recovery tag: $prev_sha" +fi + +# Best-effort fetch so an explicit branch/tag that only exists on the remote +# can still be resolved; never fatal (offline recovery must still work). +git fetch origin --tags --quiet 2>/dev/null || log "fetch skipped (offline?)" + +# An explicit target may name a branch that only exists on the remote +# (origin/). Resolve it to the remote ref and recreate it locally so both +# branch and version are restored, not just a detached commit. +if [[ -n "$target_ref" ]] && ! git rev-parse --verify --quiet "${prev_sha}^{commit}" >/dev/null; then + if git rev-parse --verify --quiet "origin/${target_ref}^{commit}" >/dev/null; then + prev_branch="$target_ref" + prev_sha="origin/${target_ref}" + log "explicit target resolved to remote branch origin/${target_ref}" + fi +fi + +if ! git rev-parse --verify --quiet "${prev_sha}^{commit}" >/dev/null; then + echo "taos rollback: cannot resolve '$prev_sha' to a commit" >&2 + exit 1 +fi + +# A broken update often leaves a dirty tree, and a plain checkout would fail +# under set -e and defeat the recovery. Stash local changes (including +# untracked) first so the checkout always lands; getting back to a working +# version beats preserving in-place edits, and the stash keeps them retrievable. +if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + if git stash push --include-untracked -m "taos-rollback-$(git rev-parse --short HEAD 2>/dev/null)" >/dev/null 2>&1; then + log "stashed local changes before rollback (recover with: git stash list)" + else + log "could not stash local changes; will force the checkout" + fi +fi + +# Restore BOTH branch and version: move/create the recorded branch onto the +# recorded commit and check it out. With no recorded branch (explicit ref or +# tag fallback) we land in detached HEAD at that commit, which is still a valid +# running state. Fall back to --force so a stubborn tree can never block recovery. +if [[ -n "$prev_branch" && "$prev_branch" != "HEAD" ]]; then + log "restoring branch '$prev_branch' at ${prev_sha:0:12}" + git checkout -B "$prev_branch" "$prev_sha" 2>/dev/null || git checkout --force -B "$prev_branch" "$prev_sha" +else + log "checking out $prev_sha (detached)" + git checkout --quiet --detach "$prev_sha" 2>/dev/null || git checkout --quiet --force --detach "$prev_sha" +fi + +# --- restart the service (first method that applies wins) --- +restarted="" +if command -v systemctl >/dev/null 2>&1; then + if systemctl is-enabled tinyagentos >/dev/null 2>&1 || systemctl status tinyagentos >/dev/null 2>&1; then + sudo systemctl restart tinyagentos 2>/dev/null && restarted="systemd" || true + fi + if [[ -z "$restarted" ]] && systemctl --user status tinyagentos >/dev/null 2>&1; then + systemctl --user restart tinyagentos 2>/dev/null && restarted="systemd --user" || true + fi +fi +if [[ -z "$restarted" ]] && command -v launchctl >/dev/null 2>&1; then + plist="$HOME/Library/LaunchAgents/com.tinyagentos.controller.plist" + if [[ -f "$plist" ]]; then + launchctl unload "$plist" 2>/dev/null || true + launchctl load "$plist" 2>/dev/null && restarted="launchd" || true + fi +fi +if [[ -z "$restarted" && -x "$INSTALL_DIR/scripts/taos-run.sh" ]]; then + # Stop the running controller, but never signal THIS rollback process or its + # ancestors: the invoking `taos` CLI also has "tinyagentos" in its command + # line, so a bare `pkill -f tinyagentos` would kill the process doing the + # rollback before the new controller starts. + skip=" $$ " + _p=$$ + while _p="$(ps -o ppid= -p "$_p" 2>/dev/null | tr -d ' ')"; do + [[ -z "$_p" || "$_p" == "0" || "$_p" == "1" ]] && break + skip="$skip$_p " + done + for _pid in $(pgrep -f "taos-run.sh|tinyagentos" 2>/dev/null || true); do + case "$skip" in *" $_pid "*) continue ;; esac + kill "$_pid" 2>/dev/null || true + done + nohup "$INSTALL_DIR/scripts/taos-run.sh" >/dev/null 2>&1 & + restarted="nohup" +fi + +now_sha="$(git rev-parse --short HEAD)" +now_ref="$(git rev-parse --abbrev-ref HEAD)" +log "rolled back to ${now_ref} @ ${now_sha}" +if [[ -n "$restarted" ]]; then + log "service restarted via ${restarted}. Give it a few seconds, then reload taOS." +else + log "could not auto-restart the service; restart taOS manually to finish." +fi +log "note: if the web UI looks stale, the frontend bundle may need a rebuild (re-run the installer)." diff --git a/tests/conftest.py b/tests/conftest.py index 4c2d90881..282525420 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -301,6 +301,10 @@ async def client(app, tmp_data_dir): if project_store._db is not None: await project_store.close() await project_store.init() + board_audit = app.state.board_audit + if board_audit._db is not None: + await board_audit.close() + await board_audit.init() project_task_store = app.state.project_task_store if project_task_store._db is not None: await project_task_store.close() @@ -357,6 +361,7 @@ async def client(app, tmp_data_dir): yield c await canvas_store.close() await project_task_store.close() + await board_audit.close() await project_store.close() await chat_channels.close() await chat_messages.close() @@ -501,6 +506,10 @@ async def client_with_qmd(app_with_qmd): if project_store._db is not None: await project_store.close() await project_store.init() + board_audit = app_with_qmd.state.board_audit + if board_audit._db is not None: + await board_audit.close() + await board_audit.init() project_task_store = app_with_qmd.state.project_task_store if project_task_store._db is not None: await project_task_store.close() @@ -524,6 +533,7 @@ async def client_with_qmd(app_with_qmd): yield c await canvas_store.close() await project_task_store.close() + await board_audit.close() await project_store.close() await chat_channels.close() await chat_messages.close() diff --git a/tests/test_agent_registry_store.py b/tests/test_agent_registry_store.py new file mode 100644 index 000000000..7b8ec79aa --- /dev/null +++ b/tests/test_agent_registry_store.py @@ -0,0 +1,813 @@ +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest +import pytest_asyncio + +from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + _assert_valid_transition, + _b64url_decode, + _b64url_encode, + _row_to_dict, + _slugify, + load_or_create_signing_keypair, + mint_canonical_id, + mint_registry_token, + verify_registry_token, + VALID_STATUSES, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def store(tmp_path): + """Fresh AgentRegistryStore backed by a temp sqlite file.""" + s = AgentRegistryStore(tmp_path / "agent_registry.db") + await s.init() + yield s + await s.close() + + +@pytest.fixture +def signing_keypair(tmp_path): + """Generate an Ed25519 keypair via the store helper.""" + priv, pub = load_or_create_signing_keypair(tmp_path / "keys") + return priv, pub + + +# --------------------------------------------------------------------------- +# Module-level pure-function tests +# --------------------------------------------------------------------------- + + +class TestSlugify: + def test_basic(self): + assert _slugify("My Agent") == "my-agent" + + def test_mixed_case(self): + assert _slugify("TaOS Agent") == "taos-agent" + + def test_special_chars(self): + assert _slugify("agent@v2.0!") == "agent-v2-0" + + def test_empty_string(self): + assert _slugify("") == "agent" + + def test_only_special_chars(self): + assert _slugify("!@#$%") == "agent" + + def test_leading_trailing_dashes(self): + assert _slugify(" hello ") == "hello" + + def test_multiple_spaces(self): + assert _slugify("a b c") == "a-b-c" + + +class TestMintCanonicalId: + def test_format(self): + ts = datetime(2026, 3, 15, 14, 30, 45, tzinfo=timezone.utc) + result = mint_canonical_id("my-agent", ts) + assert result == "my-agent-20260315-143045" + + def test_different_slug(self): + ts = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + assert mint_canonical_id("bot", ts) == "bot-20260101-000000" + + +class TestB64url: + def test_encode_roundtrip(self): + raw = b'{"alg":"EdDSA","typ":"JWT"}' + encoded = _b64url_encode(raw) + assert _b64url_decode(encoded) == raw + + def test_no_padding(self): + encoded = _b64url_encode(b"test") + assert "=" not in encoded + + def test_empty(self): + assert _b64url_encode(b"") == "" + assert _b64url_decode("") == b"" + + +class TestAssertValidTransition: + def test_pending_to_active(self): + _assert_valid_transition("pending", "active") + + def test_pending_to_rejected(self): + _assert_valid_transition("pending", "rejected") + + def test_active_to_suspended(self): + _assert_valid_transition("active", "suspended") + + def test_suspended_to_active(self): + _assert_valid_transition("suspended", "active") + + def test_active_to_revoked(self): + _assert_valid_transition("active", "revoked") + + def test_suspended_to_revoked(self): + _assert_valid_transition("suspended", "revoked") + + def test_pending_to_revoked(self): + _assert_valid_transition("pending", "revoked") + + def test_rejected_to_revoked(self): + _assert_valid_transition("rejected", "revoked") + + def test_rejected_to_pending(self): + _assert_valid_transition("rejected", "pending") + + def test_rejected_to_active(self): + _assert_valid_transition("rejected", "active") + + def test_invalid_status_raises(self): + with pytest.raises(ValueError, match="unknown status"): + _assert_valid_transition("active", "nonexistent") + + def test_invalid_transition_raises(self): + with pytest.raises(ValueError, match="invalid lifecycle transition"): + _assert_valid_transition("active", "pending") + + def test_revoked_is_terminal(self): + with pytest.raises(ValueError, match="invalid lifecycle transition"): + _assert_valid_transition("revoked", "active") + + def test_valid_statuses_frozen(self): + assert "active" in VALID_STATUSES + assert "pending" in VALID_STATUSES + assert "suspended" in VALID_STATUSES + assert "revoked" in VALID_STATUSES + assert "rejected" in VALID_STATUSES + + +class TestRowToDict: + def _make_row(self, data): + """Create a minimal row-like object with dict-access and .keys().""" + return _FakeRow(data) + + def test_basic_conversion(self): + row = self._make_row({"id": 1, "canonical_id": "test-1", "capabilities": '["a","b"]'}) + result = _row_to_dict(row) + assert result["capabilities"] == ["a", "b"] + + def test_empty_capabilities(self): + row = self._make_row({"id": 1, "capabilities": "[]"}) + result = _row_to_dict(row) + assert result["capabilities"] == [] + + def test_null_capabilities(self): + row = self._make_row({"id": 1, "capabilities": None}) + result = _row_to_dict(row) + assert result["capabilities"] == [] + + def test_invalid_json_capabilities(self): + row = self._make_row({"id": 1, "capabilities": "not-json"}) + result = _row_to_dict(row) + assert result["capabilities"] == [] + + def test_preserves_other_fields(self): + row = self._make_row({"id": 1, "display_name": "My Agent", "capabilities": "[]"}) + result = _row_to_dict(row) + assert result["display_name"] == "My Agent" + + +class _FakeRow: + """Minimal stand-in for aiosqlite.Row.""" + def __init__(self, data): + self._data = data + + def keys(self): + return self._data.keys() + + def __getitem__(self, key): + return self._data[key] + + +# --------------------------------------------------------------------------- +# Token minting / verification +# --------------------------------------------------------------------------- + + +class TestTokenMinting: + def test_mint_returns_three_parts(self, signing_keypair): + priv, pub = signing_keypair + token = mint_registry_token("agent-001", priv) + parts = token.split(".") + assert len(parts) == 3 + + def test_mint_and_verify_roundtrip(self, signing_keypair): + priv, pub = signing_keypair + token = mint_registry_token("agent-002", priv, user_id="user-1", framework="openclaw") + payload = verify_registry_token(token, pub) + assert payload["sub"] == "agent-002" + assert payload["iss"] == "taos-registry" + assert payload["user_id"] == "user-1" + assert payload["framework"] == "openclaw" + + def test_mint_with_project_id(self, signing_keypair): + priv, pub = signing_keypair + token = mint_registry_token("agent-003", priv, project_id="proj-99") + payload = verify_registry_token(token, pub) + assert payload["project_id"] == "proj-99" + + def test_mint_without_project_id_omits_claim(self, signing_keypair): + priv, pub = signing_keypair + token = mint_registry_token("agent-004", priv) + payload = verify_registry_token(token, pub) + assert "project_id" not in payload + + def test_verify_bad_signature_raises(self, signing_keypair, tmp_path): + priv, _ = signing_keypair + # Generate a different keypair for verification + _, wrong_pub = load_or_create_signing_keypair(tmp_path / "other_keys") + token = mint_registry_token("agent-005", priv) + with pytest.raises(ValueError, match="signature verification failed"): + verify_registry_token(token, wrong_pub) + + def test_verify_malformed_token_raises(self, signing_keypair): + _, pub = signing_keypair + with pytest.raises(ValueError, match="three dot-separated parts"): + verify_registry_token("only.two", pub) + + def test_verify_truncated_token_raises(self, signing_keypair): + _, pub = signing_keypair + with pytest.raises(ValueError): + verify_registry_token("one", pub) + + def test_token_has_jti(self, signing_keypair): + priv, pub = signing_keypair + token = mint_registry_token("agent-006", priv) + payload = verify_registry_token(token, pub) + assert "jti" in payload + assert len(payload["jti"]) == 32 # uuid4 hex + + def test_token_has_iat(self, signing_keypair): + priv, pub = signing_keypair + token = mint_registry_token("agent-007", priv) + payload = verify_registry_token(token, pub) + assert "iat" in payload + assert isinstance(payload["iat"], int) + + +# --------------------------------------------------------------------------- +# Signing keypair persistence +# --------------------------------------------------------------------------- + + +class TestSigningKeypair: + def test_creates_keypair(self, tmp_path): + d = tmp_path / "keys" + priv, pub = load_or_create_signing_keypair(d) + assert b"PRIVATE" in priv + assert b"PUBLIC" in pub + + def test_idempotent(self, tmp_path): + d = tmp_path / "keys" + priv1, pub1 = load_or_create_signing_keypair(d) + priv2, pub2 = load_or_create_signing_keypair(d) + assert priv1 == priv2 + assert pub1 == pub2 + + def test_pem_file_created(self, tmp_path): + d = tmp_path / "keys" + load_or_create_signing_keypair(d) + pem_file = d / "agent_registry_signing.pem" + assert pem_file.exists() + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: registration +# --------------------------------------------------------------------------- + + +class TestRegister: + @pytest.mark.asyncio + async def test_basic_registration(self, store): + row = await store.register( + framework="openclaw", + display_name="My Agent", + user_id="user-1", + ) + assert row["framework"] == "openclaw" + assert row["display_name"] == "My Agent" + assert row["user_id"] == "user-1" + assert row["status"] == "active" + assert row["canonical_id"].startswith("my-agent-") + assert row["capabilities"] == [] + + @pytest.mark.asyncio + async def test_registration_with_capabilities(self, store): + row = await store.register( + framework="openclaw", + display_name="Cap Agent", + capabilities=["read", "write"], + ) + assert row["capabilities"] == ["read", "write"] + + @pytest.mark.asyncio + async def test_registration_with_handle_and_role(self, store): + row = await store.register( + framework="openclaw", + display_name="Handled", + handle="@handler", + role="worker", + ) + assert row["handle"] == "@handler" + assert row["role"] == "worker" + + @pytest.mark.asyncio + async def test_external_selfjoin_is_pending(self, store): + row = await store.register( + framework="openclaw", + display_name="Ext", + origin="external-selfjoin", + ) + assert row["status"] == "pending" + + @pytest.mark.asyncio + async def test_default_origin_is_active(self, store): + row = await store.register( + framework="openclaw", + display_name="Default", + ) + assert row["status"] == "active" + + @pytest.mark.asyncio + async def test_empty_display_name_uses_framework_slug(self, store): + row = await store.register( + framework="openclaw", + display_name="", + ) + assert row["canonical_id"].startswith("openclaw-") + + @pytest.mark.asyncio + async def test_canonical_id_is_unique(self, store): + r1 = await store.register(framework="openclaw", display_name="Same") + r2 = await store.register(framework="openclaw", display_name="Same") + assert r1["canonical_id"] != r2["canonical_id"] + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.register(framework="openclaw") + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: get +# --------------------------------------------------------------------------- + + +class TestGet: + @pytest.mark.asyncio + async def test_get_existing(self, store): + registered = await store.register(framework="openclaw", display_name="Get Me") + fetched = await store.get(registered["canonical_id"]) + assert fetched is not None + assert fetched["canonical_id"] == registered["canonical_id"] + assert fetched["display_name"] == "Get Me" + + @pytest.mark.asyncio + async def test_get_nonexistent(self, store): + result = await store.get("does-not-exist-20260101-000000") + assert result is None + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.get("anything") + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: list_all +# --------------------------------------------------------------------------- + + +class TestListAll: + @pytest.mark.asyncio + async def test_empty(self, store): + assert await store.list_all() == [] + + @pytest.mark.asyncio + async def test_lists_all(self, store): + await store.register(framework="openclaw", display_name="A") + await store.register(framework="openclaw", display_name="B") + rows = await store.list_all() + assert len(rows) == 2 + + @pytest.mark.asyncio + async def test_filter_by_status(self, store): + await store.register(framework="openclaw", display_name="Active One") + r2 = await store.register( + framework="openclaw", + display_name="Pending One", + origin="external-selfjoin", + ) + active_rows = await store.list_all(status="active") + pending_rows = await store.list_all(status="pending") + assert len(active_rows) == 1 + assert len(pending_rows) == 1 + assert pending_rows[0]["canonical_id"] == r2["canonical_id"] + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.list_all() + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: list_for_user +# --------------------------------------------------------------------------- + + +class TestListForUser: + @pytest.mark.asyncio + async def test_filters_by_user(self, store): + await store.register(framework="openclaw", display_name="U1", user_id="user-1") + await store.register(framework="openclaw", display_name="U2", user_id="user-2") + await store.register(framework="openclaw", display_name="U3", user_id="user-1") + rows = await store.list_for_user("user-1") + assert len(rows) == 2 + assert all(r["user_id"] == "user-1" for r in rows) + + @pytest.mark.asyncio + async def test_user_no_agents(self, store): + await store.register(framework="openclaw", display_name="Other", user_id="user-1") + rows = await store.list_for_user("user-empty") + assert rows == [] + + @pytest.mark.asyncio + async def test_filter_by_user_and_status(self, store): + await store.register(framework="openclaw", display_name="UA", user_id="user-a") + r2 = await store.register( + framework="openclaw", + display_name="UP", + user_id="user-a", + origin="external-selfjoin", + ) + pending = await store.list_for_user("user-a", status="pending") + assert len(pending) == 1 + assert pending[0]["canonical_id"] == r2["canonical_id"] + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.list_for_user("anyone") + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: list_revoked +# --------------------------------------------------------------------------- + + +class TestListRevoked: + @pytest.mark.asyncio + async def test_empty_when_none_revoked(self, store): + await store.register(framework="openclaw", display_name="Active") + assert await store.list_revoked() == [] + + @pytest.mark.asyncio + async def test_lists_revoked(self, store): + r1 = await store.register(framework="openclaw", display_name="To Revoke") + await store.revoke(r1["canonical_id"]) + revoked = await store.list_revoked() + assert len(revoked) == 1 + assert revoked[0]["canonical_id"] == r1["canonical_id"] + assert revoked[0]["revoked_at"] is not None + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.list_revoked() + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: list_inactive +# --------------------------------------------------------------------------- + + +class TestListInactive: + @pytest.mark.asyncio + async def test_empty_when_all_active(self, store): + await store.register(framework="openclaw", display_name="Active") + assert await store.list_inactive() == [] + + @pytest.mark.asyncio + async def test_lists_non_active(self, store): + r1 = await store.register( + framework="openclaw", + display_name="Pending", + origin="external-selfjoin", + ) + inactive = await store.list_inactive() + assert len(inactive) == 1 + assert inactive[0]["canonical_id"] == r1["canonical_id"] + assert inactive[0]["status"] == "pending" + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.list_inactive() + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: set_status (lifecycle transitions) +# --------------------------------------------------------------------------- + + +class TestSetStatus: + @pytest.mark.asyncio + async def test_pending_to_active(self, store): + row = await store.register( + framework="openclaw", + display_name="Promote", + origin="external-selfjoin", + ) + assert row["status"] == "pending" + updated = await store.set_status(row["canonical_id"], "active") + assert updated["status"] == "active" + + @pytest.mark.asyncio + async def test_pending_to_rejected(self, store): + row = await store.register( + framework="openclaw", + display_name="Reject Me", + origin="external-selfjoin", + ) + updated = await store.set_status(row["canonical_id"], "rejected") + assert updated["status"] == "rejected" + + @pytest.mark.asyncio + async def test_active_to_suspended(self, store): + row = await store.register(framework="openclaw", display_name="Suspend Me") + updated = await store.set_status(row["canonical_id"], "suspended") + assert updated["status"] == "suspended" + + @pytest.mark.asyncio + async def test_suspended_to_active(self, store): + row = await store.register(framework="openclaw", display_name="Reactivate") + await store.set_status(row["canonical_id"], "suspended") + updated = await store.set_status(row["canonical_id"], "active") + assert updated["status"] == "active" + + @pytest.mark.asyncio + async def test_active_to_revoked(self, store): + row = await store.register(framework="openclaw", display_name="Revoke Me") + updated = await store.set_status(row["canonical_id"], "revoked") + assert updated["status"] == "revoked" + assert updated["revoked_at"] is not None + + @pytest.mark.asyncio + async def test_rejected_to_pending(self, store): + row = await store.register( + framework="openclaw", + display_name="Reopen", + origin="external-selfjoin", + ) + await store.set_status(row["canonical_id"], "rejected") + updated = await store.set_status(row["canonical_id"], "pending") + assert updated["status"] == "pending" + + @pytest.mark.asyncio + async def test_rejected_to_active(self, store): + row = await store.register( + framework="openclaw", + display_name="Direct Approve", + origin="external-selfjoin", + ) + await store.set_status(row["canonical_id"], "rejected") + updated = await store.set_status(row["canonical_id"], "active") + assert updated["status"] == "active" + + @pytest.mark.asyncio + async def test_nonexistent_raises_key_error(self, store): + with pytest.raises(KeyError): + await store.set_status("no-such-id-20260101-000000", "active") + + @pytest.mark.asyncio + async def test_invalid_status_raises_value_error(self, store): + row = await store.register(framework="openclaw", display_name="Bad Status") + with pytest.raises(ValueError, match="unknown status"): + await store.set_status(row["canonical_id"], "garbage") + + @pytest.mark.asyncio + async def test_invalid_transition_raises_value_error(self, store): + row = await store.register(framework="openclaw", display_name="Bad Trans") + with pytest.raises(ValueError, match="invalid lifecycle transition"): + await store.set_status(row["canonical_id"], "pending") + + @pytest.mark.asyncio + async def test_revoked_is_terminal(self, store): + row = await store.register(framework="openclaw", display_name="Terminal") + await store.set_status(row["canonical_id"], "revoked") + with pytest.raises(ValueError, match="invalid lifecycle transition"): + await store.set_status(row["canonical_id"], "active") + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.set_status("anything", "active") + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: update +# --------------------------------------------------------------------------- + + +class TestUpdate: + @pytest.mark.asyncio + async def test_update_display_name(self, store): + row = await store.register(framework="openclaw", display_name="Old Name") + updated = await store.update(row["canonical_id"], display_name="New Name") + assert updated["display_name"] == "New Name" + + @pytest.mark.asyncio + async def test_update_handle(self, store): + row = await store.register(framework="openclaw", display_name="Handled") + updated = await store.update(row["canonical_id"], handle="@newhandle") + assert updated["handle"] == "@newhandle" + + @pytest.mark.asyncio + async def test_update_role(self, store): + row = await store.register(framework="openclaw", display_name="Roled") + updated = await store.update(row["canonical_id"], role="manager") + assert updated["role"] == "manager" + + @pytest.mark.asyncio + async def test_update_capabilities(self, store): + row = await store.register( + framework="openclaw", + display_name="Capped", + capabilities=["read"], + ) + updated = await store.update(row["canonical_id"], capabilities=["read", "write"]) + assert updated["capabilities"] == ["read", "write"] + + @pytest.mark.asyncio + async def test_update_multiple_fields(self, store): + row = await store.register(framework="openclaw", display_name="Multi") + updated = await store.update( + row["canonical_id"], + display_name="Multi Updated", + handle="@multi", + capabilities=["admin"], + ) + assert updated["display_name"] == "Multi Updated" + assert updated["handle"] == "@multi" + assert updated["capabilities"] == ["admin"] + + @pytest.mark.asyncio + async def test_update_nonexistent_returns_none(self, store): + result = await store.update("no-such-20260101-000000", display_name="X") + assert result is None + + @pytest.mark.asyncio + async def test_update_no_fields_returns_unchanged(self, store): + row = await store.register(framework="openclaw", display_name="Noop") + updated = await store.update(row["canonical_id"]) + assert updated["display_name"] == "Noop" + + @pytest.mark.asyncio + async def test_immutable_fields_not_changed(self, store): + row = await store.register( + framework="openclaw", + display_name="Immutable", + user_id="user-1", + ) + updated = await store.update(row["canonical_id"], display_name="Changed") + assert updated["user_id"] == "user-1" + assert updated["framework"] == "openclaw" + assert updated["canonical_id"] == row["canonical_id"] + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.update("anything", display_name="X") + + +# --------------------------------------------------------------------------- +# AgentRegistryStore: revoke +# --------------------------------------------------------------------------- + + +class TestRevoke: + @pytest.mark.asyncio + async def test_revoke_sets_revoked_at(self, store): + row = await store.register(framework="openclaw", display_name="Revoke Me") + assert row["revoked_at"] is None + updated = await store.revoke(row["canonical_id"]) + assert updated["revoked_at"] is not None + assert updated["status"] == "revoked" + + @pytest.mark.asyncio + async def test_revoke_nonexistent_returns_none(self, store): + result = await store.revoke("no-such-20260101-000000") + assert result is None + + @pytest.mark.asyncio + async def test_revoke_idempotent(self, store): + row = await store.register(framework="openclaw", display_name="Idem") + first = await store.revoke(row["canonical_id"]) + second = await store.revoke(row["canonical_id"]) + assert first["revoked_at"] == second["revoked_at"] + assert first["status"] == "revoked" + assert second["status"] == "revoked" + + @pytest.mark.asyncio + async def test_not_initialized_raises(self, tmp_path): + s = AgentRegistryStore(tmp_path / "not_init.db") + with pytest.raises(RuntimeError, match="not initialised"): + await s.revoke("anything") + + +# --------------------------------------------------------------------------- +# Full lifecycle round-trip +# --------------------------------------------------------------------------- + + +class TestFullLifecycle: + @pytest.mark.asyncio + async def test_register_get_update_revoke(self, store): + registered = await store.register( + framework="openclaw", + display_name="Lifecycle Agent", + user_id="user-lc", + capabilities=["read"], + ) + cid = registered["canonical_id"] + assert registered["status"] == "active" + + fetched = await store.get(cid) + assert fetched["display_name"] == "Lifecycle Agent" + + updated = await store.update(cid, capabilities=["read", "write"]) + assert updated["capabilities"] == ["read", "write"] + + revoked = await store.revoke(cid) + assert revoked["status"] == "revoked" + assert revoked["revoked_at"] is not None + + @pytest.mark.asyncio + async def test_external_selfjoin_full_flow(self, store): + registered = await store.register( + framework="openclaw", + display_name="Ext Flow", + origin="external-selfjoin", + ) + cid = registered["canonical_id"] + assert registered["status"] == "pending" + + approved = await store.set_status(cid, "active") + assert approved["status"] == "active" + + suspended = await store.set_status(cid, "suspended") + assert suspended["status"] == "suspended" + + reactivated = await store.set_status(cid, "active") + assert reactivated["status"] == "active" + + revoked = await store.set_status(cid, "revoked") + assert revoked["status"] == "revoked" + assert revoked["revoked_at"] is not None + + @pytest.mark.asyncio + async def test_list_filters_after_transitions(self, store): + r1 = await store.register(framework="openclaw", display_name="A1") + r2 = await store.register( + framework="openclaw", + display_name="P1", + origin="external-selfjoin", + ) + r3 = await store.register(framework="openclaw", display_name="S1") + + # Suspend r3 + await store.set_status(r3["canonical_id"], "suspended") + + # r2 stays pending + active = await store.list_all(status="active") + pending = await store.list_all(status="pending") + suspended = await store.list_all(status="suspended") + + active_ids = {r["canonical_id"] for r in active} + assert r1["canonical_id"] in active_ids + assert r2["canonical_id"] not in active_ids + assert r3["canonical_id"] not in active_ids + + assert len(pending) == 1 + assert pending[0]["canonical_id"] == r2["canonical_id"] + + assert len(suspended) == 1 + assert suspended[0]["canonical_id"] == r3["canonical_id"] diff --git a/tests/test_apps_installed.py b/tests/test_apps_installed.py index 6770666e5..4f084f966 100644 --- a/tests/test_apps_installed.py +++ b/tests/test_apps_installed.py @@ -175,24 +175,24 @@ async def test_install_then_listed(self, apps_client): assert resp.json() == {"installed": []} # Install an allowlisted optional app. - resp = await client.post("/api/apps/optional/reddit/install") + resp = await client.post("/api/apps/optional/coding-studio/install") assert resp.status_code == 200 - assert resp.json()["app_id"] == "reddit" + assert resp.json()["app_id"] == "coding-studio" resp = await client.get("/api/apps/optional/installed") - assert resp.json()["installed"] == ["reddit"] + assert resp.json()["installed"] == ["coding-studio"] @pytest.mark.asyncio async def test_uninstall_removes(self, apps_client): client, _ = apps_client - await client.post("/api/apps/optional/x-monitor/install") + await client.post("/api/apps/optional/coding-studio/install") resp = await client.get("/api/apps/optional/installed") - assert "x-monitor" in resp.json()["installed"] + assert "coding-studio" in resp.json()["installed"] - resp = await client.post("/api/apps/optional/x-monitor/uninstall") + resp = await client.post("/api/apps/optional/coding-studio/uninstall") assert resp.status_code == 200 resp = await client.get("/api/apps/optional/installed") - assert "x-monitor" not in resp.json()["installed"] + assert "coding-studio" not in resp.json()["installed"] @pytest.mark.asyncio async def test_unknown_app_rejected(self, apps_client): @@ -207,10 +207,10 @@ async def test_optional_apps_excluded_from_services_list(self, apps_client): """Installed optional frontend apps must NOT show as proxy services (they have no runtime location).""" client, _ = apps_client - await client.post("/api/apps/optional/github-browser/install") + await client.post("/api/apps/optional/coding-studio/install") resp = await client.get("/api/apps/installed") ids = {i["app_id"] for i in resp.json()} - assert "github-browser" not in ids + assert "coding-studio" not in ids class TestOptionalAppCatalog: @@ -237,20 +237,20 @@ async def test_install_records_app_versions_version(self, apps_client): """Installing an app should record the APP_VERSIONS version in the DB.""" from tinyagentos.routes.apps import APP_VERSIONS client, store = apps_client - resp = await client.post("/api/apps/optional/reddit/install") + resp = await client.post("/api/apps/optional/coding-studio/install") assert resp.status_code == 200 rows = await store.list_installed() - row = next((r for r in rows if r["app_id"] == "reddit"), None) + row = next((r for r in rows if r["app_id"] == "coding-studio"), None) assert row is not None - assert row["version"] == APP_VERSIONS["reddit"] + assert row["version"] == APP_VERSIONS["coding-studio"] @pytest.mark.asyncio async def test_update_available_false_for_fresh_install(self, apps_client): """A freshly installed app records the current version, so update_available=false.""" client, _ = apps_client - await client.post("/api/apps/optional/youtube-library/install") + await client.post("/api/apps/optional/coding-studio/install") resp = await client.get("/api/apps/optional/catalog") - app = next(a for a in resp.json()["apps"] if a["id"] == "youtube-library") + app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio") assert app["installed"] is True assert app["update_available"] is False @@ -263,15 +263,15 @@ async def test_update_available_true_when_recorded_version_is_older(self, apps_c # Seed the DB with an older version directly. await store._db.execute( "INSERT OR REPLACE INTO installed_apps (app_id, installed_at, version, metadata) VALUES (?, ?, ?, ?)", - ("x-monitor", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})), + ("coding-studio", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})), ) await store._db.commit() resp = await client.get("/api/apps/optional/catalog") - app = next(a for a in resp.json()["apps"] if a["id"] == "x-monitor") + app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio") assert app["installed"] is True - # APP_VERSIONS["x-monitor"] is "1.0.0" which is > "0.9.0" + # APP_VERSIONS["coding-studio"] is "1.0.0" which is > "0.9.0" assert app["update_available"] is True - assert app["version"] == APP_VERSIONS["x-monitor"] + assert app["version"] == APP_VERSIONS["coding-studio"] @pytest.mark.asyncio async def test_catalog_does_not_leak_unknown_ids(self, apps_client): diff --git a/tests/test_board_audit.py b/tests/test_board_audit.py new file mode 100644 index 000000000..df4ca54f0 --- /dev/null +++ b/tests/test_board_audit.py @@ -0,0 +1,131 @@ +import pytest + +from tinyagentos.board_audit import BoardAuditLog + + +async def _log(tmp_path): + s = BoardAuditLog(tmp_path / "audit.db") + await s.init() + return s + + +@pytest.mark.asyncio +async def test_record_and_history_ordered(tmp_path): + s = await _log(tmp_path) + e1 = await s.record("tsk-1", "claimed", actor="@kilo", from_status="open", to_status="claimed", ts="2026-01-01T00:00:00+00:00") + e2 = await s.record("tsk-1", "merged", actor="@taOS-dev", from_status="claimed", to_status="done", ts="2026-01-01T00:00:00+00:00") + hist = await s.history("tsk-1") + assert [h["id"] for h in hist] == [e1, e2] # insertion order, stable even on equal ts + assert hist[0]["event"] == "claimed" and hist[0]["actor"] == "@kilo" + assert hist[1]["to_status"] == "done" + await s.close() + + +@pytest.mark.asyncio +async def test_append_only_no_mutate_or_delete(tmp_path): + s = await _log(tmp_path) + await s.record("tsk-1", "open") + # The store exposes no update/delete API: recording the same task again keeps both. + await s.record("tsk-1", "open") + assert len(await s.history("tsk-1")) == 2 + assert not hasattr(s, "delete") + assert not hasattr(s, "update") + await s.close() + + +@pytest.mark.asyncio +async def test_history_scoped_per_task(tmp_path): + s = await _log(tmp_path) + await s.record("tsk-1", "open") + await s.record("tsk-2", "open") + assert len(await s.history("tsk-1")) == 1 + assert await s.history("tsk-missing") == [] + await s.close() + + +@pytest.mark.asyncio +async def test_all_since_filters_by_ts(tmp_path): + s = await _log(tmp_path) + await s.record("tsk-1", "old", ts="2026-01-01T00:00:00+00:00") + await s.record("tsk-1", "new", ts="2026-06-01T00:00:00+00:00") + recent = await s.all_since("2026-03-01T00:00:00+00:00") + assert [r["event"] for r in recent] == ["new"] + await s.close() + + +@pytest.mark.asyncio +async def test_record_requires_task_and_event(tmp_path): + s = await _log(tmp_path) + with pytest.raises(ValueError): + await s.record("", "open") + with pytest.raises(ValueError): + await s.record("tsk-1", "") + await s.close() + + +@pytest.mark.asyncio +async def test_get_returns_event_or_none(tmp_path): + s = await _log(tmp_path) + eid = await s.record("tsk-1", "open") + assert (await s.get(eid))["event"] == "open" + assert await s.get("ba-missing") is None + await s.close() + + +@pytest.mark.asyncio +async def test_recent_for_project_scoped_and_newest_first(tmp_path): + s = await _log(tmp_path) + await s.record("tsk-a", "task.created", project_id="prj-1", to_status="open") + await s.record("tsk-b", "task.created", project_id="prj-2", to_status="open") + await s.record("tsk-a", "task.closed", project_id="prj-1", to_status="closed") + feed = await s.recent_for_project("prj-1") + # Only prj-1 events, newest first. + assert [e["event"] for e in feed] == ["task.closed", "task.created"] + assert all(e["project_id"] == "prj-1" for e in feed) + await s.close() + + +@pytest.mark.asyncio +async def test_recent_for_project_respects_limit(tmp_path): + s = await _log(tmp_path) + for i in range(5): + await s.record(f"tsk-{i}", "task.created", project_id="prj-1", to_status="open") + assert len(await s.recent_for_project("prj-1", limit=2)) == 2 + await s.close() + + +@pytest.mark.asyncio +async def test_record_stores_detail_json(tmp_path): + s = await _log(tmp_path) + eid = await s.record("tsk-1", "task.created", project_id="prj-1", detail={"title": "Ship it"}) + ev = await s.get(eid) + assert ev["detail"] == {"title": "Ship it"} + await s.close() + + +@pytest.mark.asyncio +async def test_post_init_migrates_old_schema(tmp_path): + """An existing board_audit table without project_id/detail is upgraded in place.""" + import aiosqlite + + db = tmp_path / "old.db" + async with aiosqlite.connect(str(db)) as conn: + await conn.execute( + "CREATE TABLE board_audit (id TEXT PRIMARY KEY, task_id TEXT NOT NULL, " + "event TEXT NOT NULL, actor TEXT NOT NULL DEFAULT '', from_status TEXT, " + "to_status TEXT, ts TEXT NOT NULL)" + ) + await conn.execute( + "INSERT INTO board_audit (id, task_id, event, ts) VALUES ('ba-old', 'tsk-z', 'task.created', '2026-01-01T00:00:00+00:00')" + ) + await conn.commit() + + s = BoardAuditLog(db) + await s.init() # _post_init should ALTER in the new columns + ev = await s.get("ba-old") + assert ev["project_id"] == "" + assert ev["detail"] == {} + # New writes carry project_id and are queryable by the feed. + await s.record("tsk-new", "task.created", project_id="prj-9", to_status="open") + assert len(await s.recent_for_project("prj-9")) == 1 + await s.close() diff --git a/tests/test_board_audit_wiring.py b/tests/test_board_audit_wiring.py new file mode 100644 index 000000000..f62e159d0 --- /dev/null +++ b/tests/test_board_audit_wiring.py @@ -0,0 +1,97 @@ +"""Tests for board audit-log wiring into the project task lifecycle (#105). + +Every status transition (create -> claim -> release -> close -> reopen) must +append an append-only audit event, readable via the task audit endpoint, in +insertion order with accurate from/to status. +""" +import pytest + + +async def _make_task(client): + pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"] + tid = (await client.post(f"/api/projects/{pid}/tasks", json={"title": "T1"})).json()["id"] + return pid, tid + + +@pytest.mark.asyncio +async def test_create_records_open_event(client): + pid, tid = await _make_task(client) + resp = await client.get(f"/api/projects/{pid}/tasks/{tid}/audit") + assert resp.status_code == 200 + events = resp.json()["events"] + assert len(events) == 1 + assert events[0]["event"] == "task.created" + assert events[0]["from_status"] is None + assert events[0]["to_status"] == "open" + + +@pytest.mark.asyncio +async def test_full_lifecycle_is_audited_in_order(client): + pid, tid = await _make_task(client) + await client.post(f"/api/projects/{pid}/tasks/{tid}/claim", json={"claimer_id": "agent-1"}) + await client.post(f"/api/projects/{pid}/tasks/{tid}/release", json={"releaser_id": "agent-1"}) + await client.post(f"/api/projects/{pid}/tasks/{tid}/claim", json={"claimer_id": "agent-2"}) + await client.post( + f"/api/projects/{pid}/tasks/{tid}/close", + json={"closed_by": "agent-2", "reason": "done"}, + ) + await client.post(f"/api/projects/{pid}/tasks/{tid}/reopen", json={"reopened_by": "user"}) + + events = (await client.get(f"/api/projects/{pid}/tasks/{tid}/audit")).json()["events"] + transitions = [(e["event"], e["from_status"], e["to_status"]) for e in events] + assert transitions == [ + ("task.created", None, "open"), + ("task.claimed", "open", "claimed"), + ("task.released", "claimed", "open"), + ("task.claimed", "open", "claimed"), + ("task.closed", "claimed", "closed"), + ("task.reopened", "closed", "open"), + ] + # The closing actor is captured. + closed = next(e for e in events if e["event"] == "task.closed") + assert closed["actor"] == "agent-2" + + +@pytest.mark.asyncio +async def test_audit_endpoint_404s_unknown_task(client): + pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"] + resp = await client.get(f"/api/projects/{pid}/tasks/tsk-nope/audit") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_failed_transition_is_not_audited(client): + """A no-op transition (reopen on a non-closed task) records nothing.""" + pid, tid = await _make_task(client) + resp = await client.post( + f"/api/projects/{pid}/tasks/{tid}/reopen", json={"reopened_by": "user"} + ) + assert resp.status_code == 409 + events = (await client.get(f"/api/projects/{pid}/tasks/{tid}/audit")).json()["events"] + # Only the creation event; the failed reopen left no trace. + assert [e["event"] for e in events] == ["task.created"] + + +@pytest.mark.asyncio +async def test_project_audit_feed_is_scoped(client): + """The project-wide feed returns only that project's events, newest first.""" + p1 = (await client.post("/api/projects", json={"name": "P1", "slug": "p1"})).json()["id"] + p2 = (await client.post("/api/projects", json={"name": "P2", "slug": "p2"})).json()["id"] + t1 = (await client.post(f"/api/projects/{p1}/tasks", json={"title": "A"})).json()["id"] + await client.post(f"/api/projects/{p2}/tasks", json={"title": "B"}) + await client.post(f"/api/projects/{p1}/tasks/{t1}/claim", json={"claimer_id": "agent-1"}) + + feed = (await client.get(f"/api/projects/{p1}/audit")).json()["events"] + events = [e["event"] for e in feed] + # Newest first: claim then create; nothing from p2. + assert events == ["task.claimed", "task.created"] + assert all(e["project_id"] == p1 for e in feed) + + +@pytest.mark.asyncio +async def test_project_audit_feed_limit_clamped(client): + p1 = (await client.post("/api/projects", json={"name": "P1", "slug": "p1"})).json()["id"] + await client.post(f"/api/projects/{p1}/tasks", json={"title": "A"}) + await client.post(f"/api/projects/{p1}/tasks", json={"title": "B"}) + feed = (await client.get(f"/api/projects/{p1}/audit", params={"limit": 1})).json()["events"] + assert len(feed) == 1 diff --git a/tests/test_browser_container.py b/tests/test_browser_container.py index cef92ebfa..681ffd4f7 100644 --- a/tests/test_browser_container.py +++ b/tests/test_browser_container.py @@ -173,9 +173,10 @@ async def test_runner_start_desktop_mock_returns_details(): # CDP image (option C foundation) # --------------------------------------------------------------------------- -def test_rk3588_image_is_cdp_image(): - """RK3588 must resolve to the CDP-enabled custom image.""" - assert DEFAULT_NEKO_RK3588_IMAGE == DEFAULT_NEKO_CDP_IMAGE +def test_rk3588_uses_dedicated_hwencode_image(): + """RK3588 resolves to the dedicated rkmpp HW-encode image, not the plain CDP image.""" + assert DEFAULT_NEKO_RK3588_IMAGE == "ghcr.io/jaylfc/taos-neko-rk3588:latest" + assert DEFAULT_NEKO_RK3588_IMAGE != DEFAULT_NEKO_CDP_IMAGE @pytest.mark.asyncio diff --git a/tests/test_browser_proxy_origin.py b/tests/test_browser_proxy_origin.py new file mode 100644 index 000000000..635dd1383 --- /dev/null +++ b/tests/test_browser_proxy_origin.py @@ -0,0 +1,266 @@ +"""Tests for tinyagentos.browser_proxy_origin pure functions.""" +from __future__ import annotations + +import os +import sys +import time + +import pytest + +# Ensure the project root is on sys.path so the editable-installed +# tinyagentos package is importable when pytest bypasses the .pth hook. +_PROJECT_ROOT = os.path.join(os.path.dirname(__file__), "..") +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + + +class _FakeState: + """Minimal stand-in for app.state with configurable attributes.""" + + def __init__(self, attrs: dict | None = None): + if attrs: + for k, v in attrs.items(): + setattr(self, k, v) + + +class _FakeAuthMgr: + """Minimal auth manager that returns users by id.""" + + def __init__(self, users: dict | None = None): + self._users = users or {} + + def get_user_by_id(self, user_id: str): + return self._users.get(user_id) + + +# --------------------------------------------------------------------------- +# _is_safe_next +# --------------------------------------------------------------------------- + + +class TestIsSafeNext: + """_is_safe_next validates the redirect target in the redeem flow.""" + + def test_valid_proxy_path_is_safe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("/api/desktop/browser/proxy") is True + + def test_valid_proxy_path_with_query_is_safe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("/api/desktop/browser/proxy?url=https%3A%2F%2Fexample.com") is True + + def test_absolute_url_is_unsafe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("https://evil.com/steal") is False + + def test_scheme_relative_is_unsafe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("//evil.com/steal") is False + + def test_root_path_is_unsafe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("/") is False + + def test_other_path_is_unsafe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("/__taos/redeem") is False + + def test_empty_string_is_unsafe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("") is False + + def test_double_slash_is_unsafe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + assert _is_safe_next("/api/desktop/browser/proxy/extra") is False + + def test_path_with_fragment_is_safe(self): + from tinyagentos.browser_proxy_origin import _is_safe_next + + # Fragment is stripped by urlsplit; path still matches proxy endpoint. + # The function guards against off-origin redirects, not fragments. + assert _is_safe_next("/api/desktop/browser/proxy#fragment") is True + + +# --------------------------------------------------------------------------- +# _session_store +# --------------------------------------------------------------------------- + + +class TestSessionStore: + """_session_store lazily initializes and returns the session dict.""" + + def test_creates_store_when_missing(self): + from tinyagentos.browser_proxy_origin import _session_store + + state = _FakeState() + store = _session_store(state) + + assert store is not None + assert isinstance(store, dict) + assert store is state.browser_proxy_sessions + + def test_returns_existing_store(self): + from tinyagentos.browser_proxy_origin import _session_store + + existing: dict = {} + state = _FakeState({"browser_proxy_sessions": existing}) + store = _session_store(state) + + assert store is existing + + +# --------------------------------------------------------------------------- +# _new_browser_session +# --------------------------------------------------------------------------- + + +class TestNewBrowserSession: + """_new_browser_session creates a session and returns its id.""" + + def test_creates_session_with_user_and_expiry(self): + from tinyagentos.browser_proxy_origin import _new_browser_session + + state = _FakeState() + session_id = _new_browser_session(state, "user-42") + + assert isinstance(session_id, str) + assert len(session_id) > 0 + + store = state.browser_proxy_sessions + assert session_id in store + assert store[session_id]["user_id"] == "user-42" + assert store[session_id]["expires_at"] > time.monotonic() + + def test_each_call_returns_unique_id(self): + from tinyagentos.browser_proxy_origin import _new_browser_session + + state = _FakeState() + id1 = _new_browser_session(state, "user-1") + id2 = _new_browser_session(state, "user-1") + + assert id1 != id2 + assert len(state.browser_proxy_sessions) == 2 + + +# --------------------------------------------------------------------------- +# _resolve_browser_session +# --------------------------------------------------------------------------- + + +class TestResolveBrowserSession: + """_resolve_browser_session validates and resolves a session id.""" + + def test_valid_session_returns_user_id(self): + from tinyagentos.browser_proxy_origin import ( + _new_browser_session, + _resolve_browser_session, + ) + + state = _FakeState() + session_id = _new_browser_session(state, "user-99") + + result = _resolve_browser_session(state, session_id) + + assert result == "user-99" + + def test_unknown_session_returns_none(self): + from tinyagentos.browser_proxy_origin import _resolve_browser_session + + state = _FakeState() + + assert _resolve_browser_session(state, "nonexistent-id") is None + + def test_expired_session_returns_none_and_removes_entry(self): + from tinyagentos.browser_proxy_origin import ( + _new_browser_session, + _resolve_browser_session, + ) + + state = _FakeState() + session_id = _new_browser_session(state, "user-ttl") + entry = state.browser_proxy_sessions[session_id] + entry["expires_at"] = time.monotonic() - 1 + + result = _resolve_browser_session(state, session_id) + + assert result is None + assert session_id not in state.browser_proxy_sessions + + def test_valid_session_refreshes_expiry(self): + from tinyagentos.browser_proxy_origin import ( + _new_browser_session, + _resolve_browser_session, + ) + + state = _FakeState() + session_id = _new_browser_session(state, "user-refresh") + original_expiry = state.browser_proxy_sessions[session_id]["expires_at"] + + time.sleep(0.01) + _resolve_browser_session(state, session_id) + + assert state.browser_proxy_sessions[session_id]["expires_at"] > original_expiry + + +# --------------------------------------------------------------------------- +# _SharedState +# --------------------------------------------------------------------------- + + +class TestSharedState: + """_SharedState delegates allowlisted attrs and isolates local ones.""" + + def test_allowlisted_attribute_delegates_to_shared(self): + from tinyagentos.browser_proxy_origin import _SharedState + + shared = _FakeState({"auth": _FakeAuthMgr(), "main_port": 6969}) + proxy = _SharedState(shared) + + assert proxy.auth is shared.auth + assert proxy.main_port == 6969 + + def test_non_allowlisted_attribute_raises(self): + from tinyagentos.browser_proxy_origin import _SharedState + + shared = _FakeState({"auth": _FakeAuthMgr()}) + proxy = _SharedState(shared) + + with pytest.raises(AttributeError, match="not in the proxy-origin allowlist"): + proxy.secrets + + def test_local_setattr_does_not_leak_to_shared(self): + from tinyagentos.browser_proxy_origin import _SharedState + + shared = _FakeState({"auth": _FakeAuthMgr()}) + proxy = _SharedState(shared) + + proxy.browser_proxy_sessions = {"abc": 123} + + assert proxy.browser_proxy_sessions == {"abc": 123} + assert not hasattr(shared, "browser_proxy_sessions") + + def test_local_getattr_takes_priority_over_shared(self): + from tinyagentos.browser_proxy_origin import _SharedState + + shared = _FakeState({"auth": _FakeAuthMgr()}) + proxy = _SharedState(shared) + + proxy.auth = "local-override" + + assert proxy.auth == "local-override" + + def test_init_rejects_extra_kwargs(self): + from tinyagentos.browser_proxy_origin import _SharedState + + shared = _FakeState() + + with pytest.raises(TypeError): + _SharedState(shared, bad_kwarg=1) diff --git a/tests/test_capability_map.py b/tests/test_capability_map.py new file mode 100644 index 000000000..4d3909dd5 --- /dev/null +++ b/tests/test_capability_map.py @@ -0,0 +1,138 @@ +import time + +import pytest + +from tinyagentos.cluster.capability_map import CapabilityMap + + +async def _store(tmp_path): + s = CapabilityMap(tmp_path / "cap.db") + await s.init() + return s + + +def _node(node_id="n1", status="online", last_seen=1000): + return { + "node_id": node_id, + "hostname": f"host-{node_id}", + "cpu": {"arch": "aarch64", "cores": 8, "soc": "rk3588"}, + "ram_mb": 16384, + "gpu": {"type": "mali", "vram_mb": 0, "cuda": False, "vulkan": True}, + "npu": {"type": "rknpu", "tops": 6}, + "status": status, + "last_seen": last_seen, + } + + +@pytest.mark.asyncio +async def test_upsert_get_roundtrip(tmp_path): + s = await _store(tmp_path) + out = await s.upsert(_node()) + assert out["node_id"] == "n1" + assert out["cpu"]["soc"] == "rk3588" + got = await s.get("n1") + assert got["gpu"]["vulkan"] is True + assert got["npu"]["tops"] == 6 + assert got["ram_mb"] == 16384 + await s.close() + + +@pytest.mark.asyncio +async def test_upsert_updates_not_duplicates(tmp_path): + s = await _store(tmp_path) + await s.upsert(_node(status="online")) + await s.upsert(_node(status="draining")) + assert (await s.get("n1"))["status"] == "draining" + assert len(await s.list()) == 1 + await s.close() + + +@pytest.mark.asyncio +async def test_list_status_filter(tmp_path): + s = await _store(tmp_path) + await s.upsert(_node("n1", "online")) + await s.upsert(_node("n2", "offline")) + assert {n["node_id"] for n in await s.list(status="online")} == {"n1"} + assert len(await s.list()) == 2 + await s.close() + + +@pytest.mark.asyncio +async def test_set_status(tmp_path): + s = await _store(tmp_path) + await s.upsert(_node()) + out = await s.set_status("n1", "draining") + assert out["status"] == "draining" + assert await s.set_status("missing", "online") is None + await s.close() + + +@pytest.mark.asyncio +async def test_set_status_rejects_invalid(tmp_path): + s = await _store(tmp_path) + await s.upsert(_node()) + with pytest.raises(ValueError): + await s.set_status("n1", "bogus") + await s.close() + + +@pytest.mark.asyncio +async def test_upsert_rejects_invalid_status(tmp_path): + s = await _store(tmp_path) + with pytest.raises(ValueError): + await s.upsert(_node(status="bogus")) + await s.close() + + +@pytest.mark.asyncio +async def test_get_unknown_returns_none(tmp_path): + s = await _store(tmp_path) + assert await s.get("nope") is None + await s.close() + + +@pytest.mark.asyncio +async def test_prune_stale(tmp_path): + s = await _store(tmp_path) + await s.upsert(_node("old", "online", last_seen=1000)) + await s.upsert(_node("fresh", "online", last_seen=int(time.time()))) + pruned = await s.prune_stale(older_than_s=3600) + assert pruned == 1 + assert await s.get("old") is None + assert await s.get("fresh") is not None + await s.close() + + +@pytest.mark.asyncio +async def test_upsert_preserves_explicit_zero_last_seen(tmp_path): + s = await _store(tmp_path) + out = await s.upsert(_node(last_seen=0)) + assert out["last_seen"] == 0 + await s.close() + + +@pytest.mark.asyncio +async def test_upsert_requires_node_id(tmp_path): + s = await _store(tmp_path) + with pytest.raises(ValueError): + await s.upsert({"hostname": "x"}) + await s.close() + + +@pytest.mark.asyncio +async def test_mark_stale_offline_preserves_row(tmp_path): + s = CapabilityMap(tmp_path / "cap.db") + await s.init() + # Fresh online node (recent last_seen) + a stale online one + a draining one. + await s.upsert({"node_id": "fresh", "status": "online"}) + await s.upsert({"node_id": "stale", "status": "online", "last_seen": 1}) + await s.upsert({"node_id": "drain", "status": "draining", "last_seen": 1}) + flipped = await s.mark_stale_offline(older_than_s=60) + assert flipped == 1 + assert (await s.get("stale"))["status"] == "offline" + assert (await s.get("fresh"))["status"] == "online" + # draining is an admin intent; a stale sweep must not touch it. + assert (await s.get("drain"))["status"] == "draining" + # The row is preserved, not deleted. + assert await s.get("stale") is not None + await s.close() diff --git a/tests/test_coding_loop.py b/tests/test_coding_loop.py new file mode 100644 index 000000000..abdffabf7 --- /dev/null +++ b/tests/test_coding_loop.py @@ -0,0 +1,142 @@ +"""Tests for the Coding Studio tool-calling loop engine (#86). + +The loop is model-agnostic: a scripted ``model_step`` stands in for the real +model glue, so we can exercise the drive/dispatch/guard logic deterministically. +""" +import pytest + +from tinyagentos.agent_tools import coding_loop + + +def _scripted(steps): + """Return a model_step that yields the given steps in order, then finals.""" + seq = list(steps) + last_transcript = {} + + async def model_step(transcript): + last_transcript["value"] = transcript + if seq: + return seq.pop(0) + return {"type": "final", "text": "done"} + + model_step.last = last_transcript + return model_step + + +@pytest.mark.asyncio +async def test_loop_writes_then_reads_then_finishes(tmp_path): + """A two-tool plan runs against the workspace and the loop returns final.""" + steps = [ + { + "type": "tool_calls", + "calls": [ + {"id": "c1", "name": "write_file", "arguments": {"path": "a.txt", "content": "hi"}}, + ], + }, + { + "type": "tool_calls", + "calls": [{"id": "c2", "name": "read_file", "arguments": {"path": "a.txt"}}], + }, + {"type": "final", "text": "the file says hi"}, + ] + out = await coding_loop.run_tool_loop(tmp_path, _scripted(steps)) + assert out["stopped"] == "final" + assert out["final"] == "the file says hi" + assert out["iterations"] == 3 + assert (tmp_path / "a.txt").read_text() == "hi" + # The read result is in the transcript for the model to have consumed. + tool_msgs = [m for m in out["transcript"] if m["role"] == "tool"] + assert tool_msgs[-1]["result"] == {"ok": True, "result": "hi"} + + +@pytest.mark.asyncio +async def test_loop_immediate_final(tmp_path): + out = await coding_loop.run_tool_loop(tmp_path, _scripted([{"type": "final", "text": "nothing to do"}])) + assert out["iterations"] == 1 + assert out["final"] == "nothing to do" + + +@pytest.mark.asyncio +async def test_loop_tool_error_is_fed_back_not_raised(tmp_path): + """A failing tool call surfaces as a soft error in the transcript; loop continues.""" + steps = [ + { + "type": "tool_calls", + "calls": [{"id": "c1", "name": "read_file", "arguments": {"path": "../escape"}}], + }, + {"type": "final", "text": "recovered"}, + ] + out = await coding_loop.run_tool_loop(tmp_path, _scripted(steps)) + assert out["final"] == "recovered" + tool_msg = next(m for m in out["transcript"] if m["role"] == "tool") + assert tool_msg["result"]["ok"] is False + + +@pytest.mark.asyncio +async def test_loop_respects_max_iterations(tmp_path): + """A model that never finishes is stopped by the iteration guard.""" + # Always asks for a (harmless) list_dir, never finals. + async def never_done(transcript): + return {"type": "tool_calls", "calls": [{"id": "x", "name": "list_dir", "arguments": {}}]} + + out = await coding_loop.run_tool_loop(tmp_path, never_done, max_iterations=3) + assert out["stopped"] == "max_iterations" + assert out["final"] is None + assert out["iterations"] == 3 + + +@pytest.mark.asyncio +async def test_loop_unknown_step_shape_stops_safely(tmp_path): + async def garbage(transcript): + return {"type": "???"} + + out = await coding_loop.run_tool_loop(tmp_path, garbage) + assert out["stopped"] == "final" + assert out["final"] is None + assert out["iterations"] == 1 + + +@pytest.mark.asyncio +async def test_loop_passes_growing_transcript_to_model(tmp_path): + steps = [ + {"type": "tool_calls", "calls": [{"id": "c1", "name": "list_dir", "arguments": {}}]}, + {"type": "final", "text": "ok"}, + ] + ms = _scripted(steps) + out = await coding_loop.run_tool_loop(tmp_path, ms, initial_transcript=[{"role": "user", "content": "go"}]) + # By the final step the model saw the user msg, the assistant tool_calls, and the tool result. + seen_roles = [m["role"] for m in ms.last["value"]] + assert seen_roles[0] == "user" + assert "tool" in seen_roles + assert out["final"] == "ok" + + +@pytest.mark.asyncio +async def test_loop_non_dict_step_stops_safely(tmp_path): + """model_step returning None / a list must not crash the loop.""" + async def returns_none(transcript): + return None + + out = await coding_loop.run_tool_loop(tmp_path, returns_none) + assert out["stopped"] == "final" + assert out["final"] is None + assert out["iterations"] == 1 + + +@pytest.mark.asyncio +async def test_loop_malformed_call_entry_is_soft_error(tmp_path): + """A non-dict entry in calls surfaces as a soft error, loop continues.""" + steps = [ + {"type": "tool_calls", "calls": ["not-a-dict", None]}, + {"type": "final", "text": "kept going"}, + ] + seq = list(steps) + + async def model_step(transcript): + return seq.pop(0) if seq else {"type": "final", "text": "done"} + + out = await coding_loop.run_tool_loop(tmp_path, model_step) + assert out["final"] == "kept going" + tool_msgs = [m for m in out["transcript"] if m["role"] == "tool"] + assert all(m["result"]["ok"] is False for m in tool_msgs) + assert tool_msgs[0]["result"]["error"] == "malformed tool call" diff --git a/tests/test_coding_model.py b/tests/test_coding_model.py new file mode 100644 index 000000000..b97537292 --- /dev/null +++ b/tests/test_coding_model.py @@ -0,0 +1,126 @@ +"""Tests for the litellm-backed model_step adapter (#86). + +A fake completion_fn stands in for litellm so the conversion + parsing logic is +exercised without a live model or network. +""" +import json + +import pytest + +from tinyagentos.agent_tools import coding_loop, coding_model + + +def test_to_openai_tools_shape(): + tools = coding_model.to_openai_tools() + names = {t["function"]["name"] for t in tools} + assert names == {"read_file", "write_file", "file_exists", "list_dir"} + assert all(t["type"] == "function" for t in tools) + # parameters carry the input_schema verbatim. + read = next(t for t in tools if t["function"]["name"] == "read_file") + assert read["function"]["parameters"]["required"] == ["path"] + + +def test_transcript_to_messages_maps_shapes(): + transcript = [ + {"role": "user", "content": "go"}, + {"role": "assistant", "tool_calls": [{"id": "c1", "name": "list_dir", "arguments": {"path": "."}}]}, + {"role": "tool", "tool_call_id": "c1", "name": "list_dir", "result": {"ok": True, "result": ["a"]}}, + ] + msgs = coding_model.transcript_to_messages(transcript, system="be helpful") + assert msgs[0] == {"role": "system", "content": "be helpful"} + assert msgs[1]["content"] == "go" + # assistant tool_calls become OpenAI function tool_calls with stringified args. + tc = msgs[2]["tool_calls"][0] + assert tc["function"]["name"] == "list_dir" + assert json.loads(tc["function"]["arguments"]) == {"path": "."} + # tool result is JSON-encoded into content. + assert json.loads(msgs[3]["content"]) == {"ok": True, "result": ["a"]} + + +class _Obj: + """Minimal object-style stand-in for a litellm response.""" + + def __init__(self, d): + self.__dict__.update(d) + + +def test_parse_completion_object_style_tool_call(): + resp = _Obj( + { + "choices": [ + _Obj( + { + "message": _Obj( + { + "content": None, + "tool_calls": [ + _Obj( + { + "id": "c1", + "function": _Obj( + {"name": "write_file", "arguments": '{"path":"a.txt","content":"hi"}'} + ), + } + ) + ], + } + ) + } + ) + ] + } + ) + step = coding_model.parse_completion(resp) + assert step["type"] == "tool_calls" + assert step["calls"][0] == {"id": "c1", "name": "write_file", "arguments": {"path": "a.txt", "content": "hi"}} + + +def test_parse_completion_dict_style_final(): + resp = {"choices": [{"message": {"content": "all done", "tool_calls": None}}]} + assert coding_model.parse_completion(resp) == {"type": "final", "text": "all done"} + + +def test_parse_completion_tolerates_bad_arguments(): + resp = {"choices": [{"message": {"tool_calls": [{"id": "c1", "function": {"name": "list_dir", "arguments": "not-json"}}]}}]} + step = coding_model.parse_completion(resp) + assert step["calls"][0]["arguments"] == {} + + +@pytest.mark.asyncio +async def test_model_step_drives_loop_end_to_end(tmp_path): + """A scripted completion_fn + the real loop writes then reads a file.""" + # Two model turns: first a write tool call, then a final answer. + scripted = [ + {"choices": [{"message": {"tool_calls": [ + {"id": "c1", "function": {"name": "write_file", "arguments": '{"path":"hello.py","content":"print(1)"}'}} + ]}}]}, + {"choices": [{"message": {"content": "wrote hello.py", "tool_calls": None}}]}, + ] + seen = {"models": [], "tools": None} + + async def fake_completion(model, messages, tools): + seen["models"].append(model) + seen["tools"] = tools + return scripted.pop(0) + + step = coding_model.make_litellm_model_step("openai/gpt-x", completion_fn=fake_completion) + out = await coding_loop.run_tool_loop(tmp_path, step) + + assert out["stopped"] == "final" + assert out["final"] == "wrote hello.py" + assert (tmp_path / "hello.py").read_text() == "print(1)" + # The model was actually called with the workspace tools. + assert seen["models"] == ["openai/gpt-x", "openai/gpt-x"] + assert {t["function"]["name"] for t in seen["tools"]} == {"read_file", "write_file", "file_exists", "list_dir"} + + +def test_parse_completion_empty_choices_is_safe_final(): + assert coding_model.parse_completion({"choices": []}) == {"type": "final", "text": ""} + + +def test_parse_completion_missing_choices_is_safe_final(): + assert coding_model.parse_completion({}) == {"type": "final", "text": ""} + + +def test_parse_completion_null_message_is_safe_final(): + assert coding_model.parse_completion({"choices": [{"message": None}]}) == {"type": "final", "text": ""} diff --git a/tests/test_coding_tool_loop.py b/tests/test_coding_tool_loop.py new file mode 100644 index 000000000..162689643 --- /dev/null +++ b/tests/test_coding_tool_loop.py @@ -0,0 +1,135 @@ +"""Tests for the Coding Studio agent tool-calling loop (#86). + +Covers the dispatch substrate (unit) and the two HTTP endpoints that drive it. +""" +import pytest +import pytest_asyncio + +from tinyagentos.agent_tools import coding_tools + + +# --------------------------------------------------------------------------- +# dispatch() unit tests (no HTTP) +# --------------------------------------------------------------------------- + +def test_dispatch_write_then_read_roundtrip(tmp_path): + r = coding_tools.dispatch(tmp_path, "write_file", {"path": "a/b.txt", "content": "hi"}) + assert r["ok"] is True + r = coding_tools.dispatch(tmp_path, "read_file", {"path": "a/b.txt"}) + assert r == {"ok": True, "result": "hi"} + + +def test_dispatch_unknown_tool(tmp_path): + r = coding_tools.dispatch(tmp_path, "delete_everything", {}) + assert r["ok"] is False + assert "unknown tool" in r["error"] + + +def test_dispatch_missing_argument(tmp_path): + r = coding_tools.dispatch(tmp_path, "write_file", {"path": "x.txt"}) + assert r["ok"] is False + assert "missing argument" in r["error"] + + +def test_dispatch_jail_violation_is_caught(tmp_path): + r = coding_tools.dispatch(tmp_path, "read_file", {"path": "../escape.txt"}) + assert r["ok"] is False + assert "refused" in r["error"] + + +def test_dispatch_read_missing_file(tmp_path): + r = coding_tools.dispatch(tmp_path, "read_file", {"path": "nope.txt"}) + assert r["ok"] is False + assert r["error"] == "file not found" + + +def test_dispatch_read_binary_is_soft_error(tmp_path): + (tmp_path / "blob.bin").write_bytes(b"\xff\xfe\x00\x01\x80") + r = coding_tools.dispatch(tmp_path, "read_file", {"path": "blob.bin"}) + assert r["ok"] is False + assert "UTF-8" in r["error"] + + +def test_dispatch_read_too_large_is_soft_error(tmp_path): + (tmp_path / "big.txt").write_text("a" * (coding_tools.MAX_READ_BYTES + 1)) + r = coding_tools.dispatch(tmp_path, "read_file", {"path": "big.txt"}) + assert r["ok"] is False + assert "limit" in r["error"] + + +def test_dispatch_list_dir_defaults_to_root(tmp_path): + coding_tools.dispatch(tmp_path, "write_file", {"path": "one.txt", "content": "1"}) + r = coding_tools.dispatch(tmp_path, "list_dir", {}) + assert r["ok"] is True + assert "one.txt" in r["result"] + + +def test_dispatch_file_exists(tmp_path): + coding_tools.dispatch(tmp_path, "write_file", {"path": "here.txt", "content": "x"}) + assert coding_tools.dispatch(tmp_path, "file_exists", {"path": "here.txt"})["result"] is True + assert coding_tools.dispatch(tmp_path, "file_exists", {"path": "gone.txt"})["result"] is False + + +# --------------------------------------------------------------------------- +# HTTP endpoints +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def coding_client(app, client): + store = app.state.coding_workspaces + if store._db is not None: + await store.close() + await store.init() + yield client, app + + +@pytest.mark.asyncio +async def test_tools_endpoint_lists_schemas(coding_client): + client, _app = coding_client + r = await client.get("/api/coding/tools") + assert r.status_code == 200 + names = {t["name"] for t in r.json()["tools"]} + assert names == {"read_file", "write_file", "file_exists", "list_dir"} + + +@pytest.mark.asyncio +async def test_tool_endpoint_executes_against_workspace(coding_client): + client, _app = coding_client + ws = (await client.post("/api/coding/workspaces", json={"name": "loop"})).json() + wid = ws["id"] + + r = await client.post( + f"/api/coding/workspaces/{wid}/tool", + json={"name": "write_file", "arguments": {"path": "main.py", "content": "print(1)"}}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + + r = await client.post( + f"/api/coding/workspaces/{wid}/tool", + json={"name": "read_file", "arguments": {"path": "main.py"}}, + ) + assert r.json() == {"ok": True, "result": "print(1)"} + + +@pytest.mark.asyncio +async def test_tool_endpoint_jail_violation_is_soft_error(coding_client): + client, _app = coding_client + ws = (await client.post("/api/coding/workspaces", json={"name": "jail"})).json() + r = await client.post( + f"/api/coding/workspaces/{ws['id']}/tool", + json={"name": "read_file", "arguments": {"path": "../../etc/passwd"}}, + ) + # Soft error (HTTP 200, ok=false) so the loop can recover. + assert r.status_code == 200 + assert r.json()["ok"] is False + + +@pytest.mark.asyncio +async def test_tool_endpoint_unknown_workspace_404(coding_client): + client, _app = coding_client + r = await client.post( + "/api/coding/workspaces/ws-nope/tool", + json={"name": "list_dir", "arguments": {}}, + ) + assert r.status_code == 404 diff --git a/tests/test_download_manager.py b/tests/test_download_manager.py new file mode 100644 index 000000000..07c04dacc --- /dev/null +++ b/tests/test_download_manager.py @@ -0,0 +1,478 @@ +import asyncio +import hashlib +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import pytest_asyncio + +from tinyagentos.download_manager import DownloadManager, DownloadTask + + +# --------------------------------------------------------------------------- +# DownloadTask dataclass +# --------------------------------------------------------------------------- + +class TestDownloadTask: + def test_defaults(self): + task = DownloadTask(id="t1", url="http://example.com/model.bin", dest=Path("/tmp/model.bin")) + assert task.id == "t1" + assert task.url == "http://example.com/model.bin" + assert task.dest == Path("/tmp/model.bin") + assert task.total_bytes == 0 + assert task.downloaded_bytes == 0 + assert task.status == "pending" + assert task.error == "" + assert task.started_at == 0 + assert task.completed_at == 0 + + def test_custom_fields(self): + task = DownloadTask( + id="t2", + url="http://example.com/x", + dest=Path("/tmp/x"), + total_bytes=100, + status="downloading", + ) + assert task.total_bytes == 100 + assert task.status == "downloading" + + +# --------------------------------------------------------------------------- +# DownloadManager: construction and torrent-settings wiring +# --------------------------------------------------------------------------- + +class TestDownloadManagerInit: + def test_no_arg_constructor(self): + dm = DownloadManager() + assert dm._tasks == {} + assert dm._running == {} + assert dm._torrent_settings_store is None + assert dm._torrent is None + + def test_with_torrent_settings_store(self): + store = MagicMock() + dm = DownloadManager(torrent_settings_store=store) + assert dm._torrent_settings_store is store + + +class TestApplyTorrentSettings: + def test_no_op_when_torrent_not_instantiated(self): + dm = DownloadManager() + dm.apply_torrent_settings(MagicMock()) + + def test_delegates_to_torrent(self): + dm = DownloadManager() + fake_torrent = MagicMock() + dm._torrent = fake_torrent + settings = MagicMock() + dm.apply_torrent_settings(settings) + fake_torrent.apply_settings.assert_called_once_with(settings) + + +class TestGetTorrentDownloader: + def test_returns_cached_instance(self): + dm = DownloadManager() + cached = MagicMock() + dm._torrent = cached + assert dm._get_torrent_downloader() is cached + + def test_returns_none_when_import_raises(self): + dm = DownloadManager() + with patch("tinyagentos.torrent_downloader.TorrentDownloader", side_effect=ImportError("no libtorrent")): + result = dm._get_torrent_downloader() + assert result is None + + def test_returns_none_when_torrent_raises(self): + dm = DownloadManager() + with patch("tinyagentos.torrent_downloader.TorrentDownloader", side_effect=RuntimeError("nope")): + result = dm._get_torrent_downloader() + assert result is None + + def test_creates_with_settings_from_store(self): + dm = DownloadManager() + fake_settings = MagicMock() + fake_store = MagicMock() + fake_store.load.return_value = fake_settings + dm._torrent_settings_store = fake_store + + mock_torrent_cls = MagicMock() + with patch("tinyagentos.torrent_downloader.TorrentDownloader", mock_torrent_cls): + result = dm._get_torrent_downloader() + mock_torrent_cls.assert_called_once_with(settings=fake_settings) + assert result is mock_torrent_cls.return_value + + def test_creates_without_settings_when_no_store(self): + dm = DownloadManager() + mock_torrent_cls = MagicMock() + with patch("tinyagentos.torrent_downloader.TorrentDownloader", mock_torrent_cls): + result = dm._get_torrent_downloader() + mock_torrent_cls.assert_called_once_with(settings=None) + + +# --------------------------------------------------------------------------- +# start_download +# --------------------------------------------------------------------------- + +class TestStartDownload: + @pytest.mark.asyncio + async def test_creates_task_and_stores_it(self, tmp_path): + dm = DownloadManager() + dest = tmp_path / "model.bin" + task = dm.start_download("dl-1", "http://example.com/model.bin", dest) + assert task.id == "dl-1" + assert task.url == "http://example.com/model.bin" + assert task.dest == dest + assert task.status == "pending" + assert "dl-1" in dm._tasks + assert "dl-1" in dm._running + dm._running["dl-1"].cancel() + try: + await dm._running["dl-1"] + except (asyncio.CancelledError, Exception): + pass + + @pytest.mark.asyncio + async def test_returns_same_task_as_stored(self, tmp_path): + dm = DownloadManager() + dest = tmp_path / "model.bin" + task = dm.start_download("dl-2", "http://example.com/m.bin", dest) + assert dm.get_progress("dl-2") is task + dm._running["dl-2"].cancel() + try: + await dm._running["dl-2"] + except (asyncio.CancelledError, Exception): + pass + + +# --------------------------------------------------------------------------- +# get_progress / list_active / list_all +# --------------------------------------------------------------------------- + +class TestProgressAndListing: + def test_get_progress_returns_none_for_unknown(self): + dm = DownloadManager() + assert dm.get_progress("nonexistent") is None + + def test_get_progress_returns_task(self): + dm = DownloadManager() + task = DownloadTask(id="x", url="http://x", dest=Path("/tmp/x")) + dm._tasks["x"] = task + assert dm.get_progress("x") is task + + def test_list_active_filters_completed_and_error(self): + dm = DownloadManager() + dm._tasks["a"] = DownloadTask(id="a", url="http://a", dest=Path("/tmp/a"), status="pending") + dm._tasks["b"] = DownloadTask(id="b", url="http://b", dest=Path("/tmp/b"), status="downloading") + dm._tasks["c"] = DownloadTask(id="c", url="http://c", dest=Path("/tmp/c"), status="complete") + dm._tasks["d"] = DownloadTask(id="d", url="http://d", dest=Path("/tmp/d"), status="error") + active = dm.list_active() + ids = {t.id for t in active} + assert ids == {"a", "b"} + + def test_list_active_empty_when_all_complete(self): + dm = DownloadManager() + dm._tasks["a"] = DownloadTask(id="a", url="http://a", dest=Path("/tmp/a"), status="complete") + assert dm.list_active() == [] + + def test_list_all_returns_everything(self): + dm = DownloadManager() + dm._tasks["a"] = DownloadTask(id="a", url="http://a", dest=Path("/tmp/a"), status="pending") + dm._tasks["b"] = DownloadTask(id="b", url="http://b", dest=Path("/tmp/b"), status="complete") + all_tasks = dm.list_all() + assert len(all_tasks) == 2 + + def test_list_all_empty(self): + dm = DownloadManager() + assert dm.list_all() == [] + + +# --------------------------------------------------------------------------- +# _download (HTTP path) -- mocked httpx +# --------------------------------------------------------------------------- + +class TestDownloadHttp: + @pytest_asyncio.fixture + def dm(self): + return DownloadManager() + + def _make_async_context_manager_mock(self, content: bytes, content_length: int | None = None): + """Build a mock that works as both `async with client.stream(...)` and + `async with client` (the outer AsyncClient context manager). + + The code does:: + async with httpx.AsyncClient(...) as client: + async with client.stream("GET", url) as resp: + ... + """ + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + if content_length is not None: + mock_resp.headers = {"content-length": str(content_length)} + else: + mock_resp.headers = {} + + async def _aiter_bytes(chunk_size=65536): + for i in range(0, len(content), chunk_size): + yield content[i:i + chunk_size] + + mock_resp.aiter_bytes = _aiter_bytes + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + return mock_resp + + def _make_mock_client(self, mock_resp): + """Build a mock httpx.AsyncClient that works as an async context manager + whose `.stream()` method also returns an async context manager.""" + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.stream = MagicMock(return_value=mock_resp) + return mock_client + + @pytest.mark.asyncio + async def test_successful_download(self, dm, tmp_path): + data = b"hello world" * 100 + dest = tmp_path / "out.bin" + task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest) + mock_resp = self._make_async_context_manager_mock(data, len(data)) + mock_client = self._make_mock_client(mock_resp) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download(task, expected_sha256=None) + + assert task.status == "complete" + assert task.downloaded_bytes == len(data) + assert task.total_bytes == len(data) + assert task.completed_at > 0 + assert task.error == "" + assert dest.read_bytes() == data + + @pytest.mark.asyncio + async def test_download_with_correct_sha256(self, dm, tmp_path): + data = b"test data" + expected_hash = hashlib.sha256(data).hexdigest() + dest = tmp_path / "out.bin" + task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest) + mock_resp = self._make_async_context_manager_mock(data, len(data)) + mock_client = self._make_mock_client(mock_resp) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download(task, expected_sha256=expected_hash) + + assert task.status == "complete" + assert dest.exists() + + @pytest.mark.asyncio + async def test_download_sha256_mismatch(self, dm, tmp_path): + data = b"test data" + dest = tmp_path / "out.bin" + task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest) + mock_resp = self._make_async_context_manager_mock(data, len(data)) + mock_client = self._make_mock_client(mock_resp) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download(task, expected_sha256="wronghash") + + assert task.status == "error" + assert task.error == "SHA256 mismatch" + assert not dest.exists() + + @pytest.mark.asyncio + async def test_download_http_error(self, dm, tmp_path): + dest = tmp_path / "out.bin" + task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest) + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock(side_effect=Exception("404 Not Found")) + mock_resp.headers = {} + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.stream = MagicMock(return_value=mock_resp) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download(task, expected_sha256=None) + + assert task.status == "error" + assert task.error == "404 Not Found" + + @pytest.mark.asyncio + async def test_download_creates_parent_dirs(self, dm, tmp_path): + data = b"x" + dest = tmp_path / "sub" / "dir" / "out.bin" + task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest) + mock_resp = self._make_async_context_manager_mock(data, 1) + mock_client = self._make_mock_client(mock_resp) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download(task, expected_sha256=None) + + assert dest.exists() + assert dest.read_bytes() == data + + @pytest.mark.asyncio + async def test_download_no_content_length(self, dm, tmp_path): + data = b"no content length" + dest = tmp_path / "out.bin" + task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest) + mock_resp = self._make_async_context_manager_mock(data, None) + mock_client = self._make_mock_client(mock_resp) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download(task, expected_sha256=None) + + assert task.status == "complete" + assert task.total_bytes == 0 + assert task.downloaded_bytes == len(data) + + +# --------------------------------------------------------------------------- +# _download_with_fallback: torrent-first and fallback logic +# --------------------------------------------------------------------------- + +class TestDownloadWithFallback: + @pytest_asyncio.fixture + def dm(self): + return DownloadManager() + + def _make_http_mock(self, data: bytes): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.headers = {"content-length": str(len(data))} + + async def _aiter_bytes(chunk_size=65536): + yield data + + mock_resp.aiter_bytes = _aiter_bytes + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.stream = MagicMock(return_value=mock_resp) + return mock_client + + @pytest.mark.asyncio + async def test_falls_through_to_http_when_no_magnet(self, dm, tmp_path): + data = b"fallback data" + dest = tmp_path / "model.bin" + task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest) + mock_client = self._make_http_mock(data) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download_with_fallback(task, expected_sha256=None, magnet=None) + + assert task.status == "complete" + assert dest.read_bytes() == data + + @pytest.mark.asyncio + async def test_falls_through_when_license_disallows(self, dm, tmp_path): + data = b"http only" + dest = tmp_path / "model.bin" + task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest) + mock_client = self._make_http_mock(data) + + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download_with_fallback( + task, + expected_sha256=None, + magnet="magnet:?xt=urn:btih:abc", + license_allows_redistribution=False, + ) + + assert task.status == "complete" + + @pytest.mark.asyncio + async def test_torrent_success_path(self, dm, tmp_path): + dest = tmp_path / "model.bin" + task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest) + + fake_torrent = AsyncMock() + fake_progress_task = MagicMock() + fake_progress_task.total_bytes = 999 + fake_progress_task.downloaded_bytes = 999 + + async def mock_download(task_id, magnet_or_torrent, dest, expected_sha256, progress_cb): + progress_cb(fake_progress_task) + + fake_torrent.download = mock_download + + with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent): + await dm._download_with_fallback( + task, + expected_sha256=None, + magnet="magnet:?xt=urn:btih:abc", + license_allows_redistribution=True, + ) + + assert task.status == "complete" + assert task.total_bytes == 999 + assert task.downloaded_bytes == 999 + assert task.completed_at > 0 + + @pytest.mark.asyncio + async def test_torrent_failure_falls_back_to_http(self, dm, tmp_path): + data = b"http fallback after torrent error" + dest = tmp_path / "model.bin" + task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest) + + fake_torrent = AsyncMock() + fake_torrent.download = AsyncMock(side_effect=Exception("no peers")) + mock_client = self._make_http_mock(data) + + with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent): + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download_with_fallback( + task, + expected_sha256=None, + magnet="magnet:?xt=urn:btih:abc", + license_allows_redistribution=True, + ) + + assert task.status == "complete" + assert dest.read_bytes() == data + + @pytest.mark.asyncio + async def test_torrent_unavailable_falls_back_to_http(self, dm, tmp_path): + data = b"http because no torrent" + dest = tmp_path / "model.bin" + task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest) + mock_client = self._make_http_mock(data) + + with patch.object(dm, "_get_torrent_downloader", return_value=None): + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download_with_fallback( + task, + expected_sha256=None, + magnet="magnet:?xt=urn:btih:abc", + license_allows_redistribution=True, + ) + + assert task.status == "complete" + + @pytest.mark.asyncio + async def test_torrent_resets_state_before_http_fallback(self, dm, tmp_path): + data = b"clean slate" + dest = tmp_path / "model.bin" + task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest) + + fake_torrent = AsyncMock() + fake_torrent.download = AsyncMock(side_effect=Exception("torrent broke")) + mock_client = self._make_http_mock(data) + + with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent): + with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client): + await dm._download_with_fallback( + task, + expected_sha256=None, + magnet="magnet:?xt=urn:btih:abc", + license_allows_redistribution=True, + ) + + assert task.status == "complete" + assert task.error == "" diff --git a/tests/test_feedback_store.py b/tests/test_feedback_store.py new file mode 100644 index 000000000..5fff495f5 --- /dev/null +++ b/tests/test_feedback_store.py @@ -0,0 +1,173 @@ +import pytest +import pytest_asyncio + +from tinyagentos.feedback_store import MAX_BODY_LEN, MAX_SCREENSHOT_LEN, FeedbackStore + + +@pytest_asyncio.fixture +async def store(tmp_path): + store = FeedbackStore(tmp_path / "feedback.db") + await store.init() + yield store + await store.close() + + +@pytest.mark.asyncio +async def test_create_returns_all_fields(store): + row = await store.create( + user_id="user-1", + type="bug", + title="Something broke", + body="Details here", + app="myapp", + ) + assert row["user_id"] == "user-1" + assert row["type"] == "bug" + assert row["title"] == "Something broke" + assert row["body"] == "Details here" + assert row["app"] == "myapp" + assert row["screenshot"] == "" + assert "id" in row + assert "created_at" in row + + +@pytest.mark.asyncio +async def test_create_with_screenshot(store): + row = await store.create( + user_id="user-1", + type="bug", + title="Screenshot test", + body="body", + screenshot="data:image/png;base64,AAAA", + ) + assert row["screenshot"] == "data:image/png;base64,AAAA" + + +@pytest.mark.asyncio +async def test_create_generates_unique_ids(store): + row1 = await store.create(user_id="u", type="bug", title="t1", body="b1") + row2 = await store.create(user_id="u", type="bug", title="t2", body="b2") + assert row1["id"] != row2["id"] + + +@pytest.mark.asyncio +async def test_get_by_id_returns_full_row(store): + created = await store.create( + user_id="user-1", + type="feature", + title="Add dark mode", + body="please", + screenshot="data:image/png;base64,BBBB", + app="settings", + ) + fetched = await store.get_by_id(created["id"], "user-1") + assert fetched is not None + assert fetched["id"] == created["id"] + assert fetched["user_id"] == "user-1" + assert fetched["type"] == "feature" + assert fetched["title"] == "Add dark mode" + assert fetched["body"] == "please" + assert fetched["screenshot"] == "data:image/png;base64,BBBB" + assert fetched["app"] == "settings" + assert fetched["has_screenshot"] is True + + +@pytest.mark.asyncio +async def test_get_by_id_wrong_user_returns_none(store): + created = await store.create(user_id="user-1", type="bug", title="t", body="b") + result = await store.get_by_id(created["id"], "user-2") + assert result is None + + +@pytest.mark.asyncio +async def test_get_by_id_nonexistent_returns_none(store): + result = await store.get_by_id("nonexistent-id", "user-1") + assert result is None + + +@pytest.mark.asyncio +async def test_get_by_id_has_screenshot_false_when_empty(store): + created = await store.create(user_id="u", type="bug", title="t", body="b", screenshot="") + fetched = await store.get_by_id(created["id"], "u") + assert fetched["has_screenshot"] is False + + +@pytest.mark.asyncio +async def test_list_for_user_returns_items_most_recent_first(store): + r1 = await store.create(user_id="user-1", type="bug", title="old", body="b1") + r2 = await store.create(user_id="user-1", type="bug", title="new", body="b2") + rows = await store.list_for_user("user-1") + assert len(rows) == 2 + assert rows[0]["id"] == r2["id"] + assert rows[0]["title"] == "new" + assert rows[1]["id"] == r1["id"] + assert rows[1]["title"] == "old" + + +@pytest.mark.asyncio +async def test_list_for_user_excludes_screenshot_blob(store): + await store.create( + user_id="user-1", + type="bug", + title="t", + body="b", + screenshot="data:image/png;base64,SECRET", + ) + rows = await store.list_for_user("user-1") + assert len(rows) == 1 + assert "screenshot" not in rows[0] + assert rows[0]["has_screenshot"] is True + + +@pytest.mark.asyncio +async def test_list_for_user_scoped_to_user(store): + await store.create(user_id="user-1", type="bug", title="u1-item", body="b") + await store.create(user_id="user-2", type="bug", title="u2-item", body="b") + rows = await store.list_for_user("user-1") + assert len(rows) == 1 + assert rows[0]["user_id"] == "user-1" + + +@pytest.mark.asyncio +async def test_list_for_user_empty_when_no_feedback(store): + rows = await store.list_for_user("nobody") + assert rows == [] + + +@pytest.mark.asyncio +async def test_list_for_user_has_screenshot_false(store): + await store.create(user_id="u", type="bug", title="t", body="b", screenshot="") + rows = await store.list_for_user("u") + assert rows[0]["has_screenshot"] is False + + +@pytest.mark.asyncio +async def test_list_for_user_returns_expected_keys(store): + await store.create(user_id="u", type="bug", title="t", body="b") + rows = await store.list_for_user("u") + assert len(rows) == 1 + expected_keys = {"id", "user_id", "type", "title", "body", "app", "created_at", "has_screenshot"} + assert set(rows[0].keys()) == expected_keys + + +@pytest.mark.asyncio +async def test_create_default_app(store): + row = await store.create(user_id="u", type="bug", title="t", body="") + assert row["app"] == "" + + +@pytest.mark.asyncio +async def test_multiple_types(store): + await store.create(user_id="u", type="bug", title="bug report", body="b") + await store.create(user_id="u", type="feature", title="feature req", body="f") + await store.create(user_id="u", type="question", title="question", body="q") + rows = await store.list_for_user("u") + assert len(rows) == 3 + types = {r["type"] for r in rows} + assert types == {"bug", "feature", "question"} + + +@pytest.mark.asyncio +async def test_constants_are_sane(): + assert MAX_SCREENSHOT_LEN == 4_000_000 + assert MAX_BODY_LEN == 20_000 diff --git a/tests/test_fs_tools.py b/tests/test_fs_tools.py new file mode 100644 index 000000000..9385c428d --- /dev/null +++ b/tests/test_fs_tools.py @@ -0,0 +1,59 @@ +import os + +import pytest + +from tinyagentos.agent_tools.fs_tools import ( + read_file, write_file, file_exists, list_dir, JailViolation, +) + + +def _ws(tmp_path): + root = tmp_path / "workspace" + root.mkdir() + return root + + +def test_write_then_read_roundtrip(tmp_path): + root = _ws(tmp_path) + n = write_file(root, "sub/dir/file.txt", "hello") + assert n == 5 + assert read_file(root, "sub/dir/file.txt") == "hello" + assert file_exists(root, "sub/dir/file.txt") is True + assert file_exists(root, "nope.txt") is False + + +def test_escape_via_dotdot_is_refused(tmp_path): + root = _ws(tmp_path) + (tmp_path / "secret.txt").write_text("top secret") + with pytest.raises(JailViolation): + read_file(root, "../secret.txt") + with pytest.raises(JailViolation): + write_file(root, "../escaped.txt", "x") + assert file_exists(root, "../secret.txt") is False + + +def test_git_path_is_refused(tmp_path): + root = _ws(tmp_path) + with pytest.raises(JailViolation): + write_file(root, ".git/hooks/pre-commit", "#!/bin/sh\necho pwned") + with pytest.raises(JailViolation): + read_file(root, ".git/config") + + +def test_symlink_escape_is_refused(tmp_path): + root = _ws(tmp_path) + (tmp_path / "outside.txt").write_text("secret") + os.symlink(tmp_path / "outside.txt", root / "link.txt") + with pytest.raises(JailViolation): + read_file(root, "link.txt") + + +def test_list_dir(tmp_path): + root = _ws(tmp_path) + write_file(root, "a.txt", "1") + write_file(root, "b.txt", "2") + write_file(root, "sub/c.txt", "3") + assert list_dir(root) == ["a.txt", "b.txt", "sub"] + assert list_dir(root, "sub") == ["c.txt"] + with pytest.raises(JailViolation): + list_dir(root, "../") diff --git a/tests/test_installed_apps.py b/tests/test_installed_apps.py new file mode 100644 index 000000000..68349bbc0 --- /dev/null +++ b/tests/test_installed_apps.py @@ -0,0 +1,162 @@ +import pytest +import pytest_asyncio + +from tinyagentos.installed_apps import InstalledAppsStore + + +@pytest_asyncio.fixture +async def store(tmp_path): + store = InstalledAppsStore(tmp_path / "installed_apps.db") + await store.init() + yield store + await store.close() + + +@pytest.mark.asyncio +async def test_install_and_is_installed(store): + assert not await store.is_installed("myapp") + await store.install("myapp", version="1.0.0") + assert await store.is_installed("myapp") + + +@pytest.mark.asyncio +async def test_install_default_version_and_metadata(store): + await store.install("myapp") + rows = await store.list_installed() + assert len(rows) == 1 + assert rows[0]["app_id"] == "myapp" + assert rows[0]["version"] == "" + assert rows[0]["metadata"] == {} + + +@pytest.mark.asyncio +async def test_install_with_metadata(store): + await store.install("myapp", version="2.0.0", metadata={"author": "test"}) + rows = await store.list_installed() + assert rows[0]["version"] == "2.0.0" + assert rows[0]["metadata"] == {"author": "test"} + + +@pytest.mark.asyncio +async def test_install_replace_existing(store): + await store.install("myapp", version="1.0.0", metadata={"old": "data"}) + await store.install("myapp", version="2.0.0", metadata={"new": "data"}) + rows = await store.list_installed() + assert len(rows) == 1 + assert rows[0]["version"] == "2.0.0" + assert rows[0]["metadata"] == {"new": "data"} + + +@pytest.mark.asyncio +async def test_list_installed_order(store): + await store.install("app-a", version="1.0") + await store.install("app-b", version="1.0") + await store.install("app-c", version="1.0") + rows = await store.list_installed() + assert [r["app_id"] for r in rows] == ["app-c", "app-b", "app-a"] + + +@pytest.mark.asyncio +async def test_list_installed_empty(store): + rows = await store.list_installed() + assert rows == [] + + +@pytest.mark.asyncio +async def test_uninstall_returns_true_when_exists(store): + await store.install("myapp") + assert await store.uninstall("myapp") is True + assert not await store.is_installed("myapp") + + +@pytest.mark.asyncio +async def test_uninstall_returns_false_when_missing(store): + assert await store.uninstall("nonexistent") is False + + +@pytest.mark.asyncio +async def test_update_and_get_runtime_location(store): + await store.update_runtime_location("myapp", "localhost", 8080, backend="rkllama", ui_path="/ui") + loc = await store.get_runtime_location("myapp") + assert loc is not None + assert loc["runtime_host"] == "localhost" + assert loc["runtime_port"] == 8080 + assert loc["backend"] == "rkllama" + assert loc["ui_path"] == "/ui" + + +@pytest.mark.asyncio +async def test_get_runtime_location_returns_none_when_missing(store): + assert await store.get_runtime_location("nonexistent") is None + + +@pytest.mark.asyncio +async def test_update_runtime_location_defaults(store): + await store.update_runtime_location("myapp", "host", 9000) + loc = await store.get_runtime_location("myapp") + assert loc["backend"] == "" + assert loc["ui_path"] == "/" + + +@pytest.mark.asyncio +async def test_update_runtime_location_overwrite(store): + await store.update_runtime_location("myapp", "host1", 1000) + await store.update_runtime_location("myapp", "host2", 2000, backend="b2", ui_path="/p") + loc = await store.get_runtime_location("myapp") + assert loc["runtime_host"] == "host2" + assert loc["runtime_port"] == 2000 + assert loc["backend"] == "b2" + assert loc["ui_path"] == "/p" + + +@pytest.mark.asyncio +async def test_remove_runtime_location(store): + await store.update_runtime_location("myapp", "host", 8080) + await store.remove_runtime_location("myapp") + assert await store.get_runtime_location("myapp") is None + + +@pytest.mark.asyncio +async def test_remove_runtime_location_when_missing(store): + await store.remove_runtime_location("nonexistent") + + +@pytest.mark.asyncio +async def test_full_round_trip(store): + await store.install("myapp", version="1.0.0", metadata={"key": "val"}) + assert await store.is_installed("myapp") + + rows = await store.list_installed() + assert len(rows) == 1 + assert rows[0]["app_id"] == "myapp" + + await store.update_runtime_location("myapp", "127.0.0.1", 3000) + loc = await store.get_runtime_location("myapp") + assert loc["runtime_host"] == "127.0.0.1" + assert loc["runtime_port"] == 3000 + + assert await store.uninstall("myapp") is True + assert not await store.is_installed("myapp") + assert await store.get_runtime_location("myapp") is not None + + +@pytest.mark.asyncio +async def test_multiple_apps(store): + await store.install("app-1", version="1.0") + await store.install("app-2", version="2.0") + await store.install("app-3", version="3.0") + + rows = await store.list_installed() + assert len(rows) == 3 + + assert await store.is_installed("app-1") + assert await store.is_installed("app-2") + assert await store.is_installed("app-3") + + await store.uninstall("app-2") + assert not await store.is_installed("app-2") + assert await store.is_installed("app-1") + assert await store.is_installed("app-3") + + rows = await store.list_installed() + assert len(rows) == 2 diff --git a/tests/test_office_docs.py b/tests/test_office_docs.py new file mode 100644 index 000000000..6e707fd97 --- /dev/null +++ b/tests/test_office_docs.py @@ -0,0 +1,173 @@ +import pytest +import pytest_asyncio + +from tinyagentos.office_docs import VALID_KINDS, _new_doc_id, OfficeDocStore + + +@pytest_asyncio.fixture +async def office_doc_store(tmp_path): + store = OfficeDocStore(tmp_path / "office_docs.db") + await store.init() + yield store + await store.close() + + +class TestNewDocId: + def test_format(self): + doc_id = _new_doc_id() + assert doc_id.startswith("doc-") + suffix = doc_id[4:] + assert len(suffix) == 8 + alphabet = "abcdefghijklmnopqrstuvwxyz234567" + for ch in suffix: + assert ch in alphabet + + def test_uniqueness(self): + ids = {_new_doc_id() for _ in range(100)} + assert len(ids) == 100 + + +class TestValidKinds: + def test_expected_kinds(self): + assert VALID_KINDS == {"write", "calc", "db", "slides"} + + +@pytest.mark.asyncio +async def test_create_happy_path(office_doc_store): + row = await office_doc_store.create(kind="write", title="My Doc", content="Hello") + assert row["kind"] == "write" + assert row["title"] == "My Doc" + assert row["content"] == "Hello" + assert row["id"].startswith("doc-") + assert row["created_at"] == row["updated_at"] + assert isinstance(row["created_at"], int) + + +@pytest.mark.asyncio +async def test_create_all_kinds(office_doc_store): + for kind in VALID_KINDS: + row = await office_doc_store.create(kind=kind, title=kind, content="x") + assert row["kind"] == kind + + +@pytest.mark.asyncio +async def test_create_invalid_kind(office_doc_store): + with pytest.raises(ValueError, match="kind must be one of"): + await office_doc_store.create(kind="invalid", title="T", content="C") + + +@pytest.mark.asyncio +async def test_get_existing(office_doc_store): + created = await office_doc_store.create(kind="write", title="T", content="C") + fetched = await office_doc_store.get(created["id"]) + assert fetched is not None + assert fetched["id"] == created["id"] + assert fetched["kind"] == "write" + assert fetched["title"] == "T" + assert fetched["content"] == "C" + assert "created_at" in fetched + assert "updated_at" in fetched + + +@pytest.mark.asyncio +async def test_get_nonexistent(office_doc_store): + result = await office_doc_store.get("doc-nonexistent") + assert result is None + + +@pytest.mark.asyncio +async def test_list_empty(office_doc_store): + rows = await office_doc_store.list() + assert rows == [] + + +@pytest.mark.asyncio +async def test_list_returns_all(office_doc_store): + await office_doc_store.create(kind="write", title="A", content="a") + await office_doc_store.create(kind="calc", title="B", content="b") + rows = await office_doc_store.list() + assert len(rows) == 2 + + +@pytest.mark.asyncio +async def test_list_order_desc_updated_at(office_doc_store): + import asyncio + r1 = await office_doc_store.create(kind="write", title="First", content="1") + await asyncio.sleep(1.1) + r2 = await office_doc_store.create(kind="write", title="Second", content="2") + rows = await office_doc_store.list() + assert rows[0]["id"] == r2["id"] + assert rows[1]["id"] == r1["id"] + + +@pytest.mark.asyncio +async def test_list_excludes_content(office_doc_store): + await office_doc_store.create(kind="write", title="T", content="secret") + rows = await office_doc_store.list() + assert len(rows) == 1 + assert "content" not in rows[0] + + +@pytest.mark.asyncio +async def test_update_title_and_content(office_doc_store): + created = await office_doc_store.create(kind="write", title="Old", content="old") + updated = await office_doc_store.update(created["id"], title="New", content="new") + assert updated is not None + assert updated["title"] == "New" + assert updated["content"] == "new" + assert updated["kind"] == "write" + assert updated["updated_at"] >= created["updated_at"] + + +@pytest.mark.asyncio +async def test_update_with_kind_change(office_doc_store): + created = await office_doc_store.create(kind="write", title="T", content="C") + updated = await office_doc_store.update(created["id"], title="T", content="C", kind="calc") + assert updated["kind"] == "calc" + + +@pytest.mark.asyncio +async def test_update_invalid_kind(office_doc_store): + created = await office_doc_store.create(kind="write", title="T", content="C") + with pytest.raises(ValueError, match="kind must be one of"): + await office_doc_store.update(created["id"], title="T", content="C", kind="bogus") + + +@pytest.mark.asyncio +async def test_update_nonexistent(office_doc_store): + result = await office_doc_store.update("doc-noexist", title="X", content="Y") + assert result is None + + +@pytest.mark.asyncio +async def test_delete_existing(office_doc_store): + created = await office_doc_store.create(kind="write", title="T", content="C") + deleted = await office_doc_store.delete(created["id"]) + assert deleted is True + assert await office_doc_store.get(created["id"]) is None + + +@pytest.mark.asyncio +async def test_delete_nonexistent(office_doc_store): + result = await office_doc_store.delete("doc-noexist") + assert result is False + + +@pytest.mark.asyncio +async def test_create_get_update_delete_roundtrip(office_doc_store): + created = await office_doc_store.create(kind="write", title="Start", content="orig") + doc_id = created["id"] + + fetched = await office_doc_store.get(doc_id) + assert fetched["title"] == "Start" + + updated = await office_doc_store.update(doc_id, title="Edited", content="changed", kind="calc") + assert updated["title"] == "Edited" + assert updated["kind"] == "calc" + + rows = await office_doc_store.list() + assert len(rows) == 1 + + assert await office_doc_store.delete(doc_id) is True + assert await office_doc_store.get(doc_id) is None + assert await office_doc_store.list() == [] diff --git a/tests/test_rollback.py b/tests/test_rollback.py new file mode 100644 index 000000000..424f7f260 --- /dev/null +++ b/tests/test_rollback.py @@ -0,0 +1,68 @@ +import subprocess + +import pytest + +from tinyagentos.rollback import ROLLBACK_FILE, read_rollback_target, record_pre_update + + +def test_record_then_read_roundtrip(tmp_path): + record_pre_update(tmp_path, branch="dev", sha="abc123def", ts=1700000000) + target = read_rollback_target(tmp_path) + assert target == {"branch": "dev", "sha": "abc123def", "ts": "1700000000"} + + +def test_record_overwrites(tmp_path): + record_pre_update(tmp_path, branch="dev", sha="aaa", ts=1) + record_pre_update(tmp_path, branch="feat/x", sha="bbb", ts=2) + assert read_rollback_target(tmp_path) == {"branch": "feat/x", "sha": "bbb", "ts": "2"} + + +def test_read_none_when_absent(tmp_path): + assert read_rollback_target(tmp_path) is None + + +def test_file_is_shell_sourceable(tmp_path): + """scripts/rollback.sh sources this file, so bash must read the same values.""" + record_pre_update(tmp_path, branch="feat/odd-name", sha="deadbeef", ts=42) + out = subprocess.check_output( + ["bash", "-c", f"source '{tmp_path / ROLLBACK_FILE}' && echo \"$prev_branch|$prev_sha|$prev_ts\""], + text=True, + ).strip() + assert out == "feat/odd-name|deadbeef|42" + + +def test_quote_injection_is_safe(tmp_path): + # A branch name with a quote must not break the sourceable file. + record_pre_update(tmp_path, branch="a'b", sha="c", ts=1) + assert read_rollback_target(tmp_path)["branch"] == "a'b" + out = subprocess.check_output( + ["bash", "-c", f"source '{tmp_path / ROLLBACK_FILE}' && printf '%s' \"$prev_branch\""], + text=True, + ) + assert out == "a'b" + + +@pytest.mark.asyncio +async def test_update_records_rollback_target(tmp_path, monkeypatch): + """update_to_master records the pre-update branch + sha before mutating.""" + import tinyagentos.update_runner as ur + + calls = {"n": 0} + + async def fake_run(args, cwd): + # Simulate: fetch ok, on branch 'dev', HEAD sha, clean tree, ff-merge ok. + joined = " ".join(args) + if "rev-parse --abbrev-ref" in joined: + return (0, "dev\n") + if "rev-parse HEAD" in joined: + return (0, "abc1234567\n") + if "status --porcelain" in joined: + return (0, "") # clean + return (0, "") + + monkeypatch.setattr(ur, "_run", fake_run) + await ur.update_to_master(tmp_path) + target = read_rollback_target(tmp_path) + assert target is not None + assert target["branch"] == "dev" + assert target["sha"] == "abc1234567" diff --git a/tests/test_routes_agent_deploy.py b/tests/test_routes_agent_deploy.py new file mode 100644 index 000000000..8d9dbd9e3 --- /dev/null +++ b/tests/test_routes_agent_deploy.py @@ -0,0 +1,403 @@ +"""Endpoint-level tests for the agent deploy route, exercising the helpers +in tinyagentos/routes/agent_deploy.py through the FastAPI test client. + +The full /api/agents/deploy endpoint requires live infrastructure +(container runtime, taosmd, LLM proxy, etc.) and is NOT tested end-to-end. +Instead we exercise the validation and routing helpers that the endpoint +calls, which are reachable through the endpoint with appropriate mocking. +""" + +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, Mock, patch + +from tinyagentos.cluster.model_resolver import ModelLocation + + +def _app(client): + return client._transport.app + + +# --------------------------------------------------------------------------- +# validate_framework_and_ram +# --------------------------------------------------------------------------- + + +class TestValidateFrameworkAndRam: + """Tests for agent_deploy.validate_framework_and_ram via the deploy endpoint.""" + + @pytest.mark.asyncio + async def test_unknown_framework_returns_400(self, client): + """A framework not in the registry catalog must return 400.""" + mock_manifest = Mock() + mock_manifest.id = "some-framework" + mock_manifest.type = "agent-framework" + + mock_registry = Mock() + mock_registry.list_available = Mock(return_value=[mock_manifest]) + + app = _app(client) + app.state.registry = mock_registry + app.state.hardware_profile = None + + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "framework": "nonexistent-fw"}, + ) + assert r.status_code == 400 + body = r.json() + assert "error" in body + assert "nonexistent-fw" in body["error"] + + @pytest.mark.asyncio + async def test_unknown_framework_lists_available(self, client): + """The 400 error for an unknown framework lists available frameworks.""" + mock_m1 = Mock() + mock_m1.id = "openclaw" + mock_m1.type = "agent-framework" + mock_m2 = Mock() + mock_m2.id = "smolagents" + mock_m2.type = "agent-framework" + + mock_registry = Mock() + mock_registry.list_available = Mock(return_value=[mock_m1, mock_m2]) + + app = _app(client) + app.state.registry = mock_registry + app.state.hardware_profile = None + + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "framework": "bogus"}, + ) + assert r.status_code == 400 + body = r.json() + assert "openclaw" in body["error"] + assert "smolagents" in body["error"] + + @pytest.mark.asyncio + async def test_low_ram_returns_400(self, client): + """A framework that needs more RAM than available must return 400.""" + mock_manifest = Mock() + mock_manifest.id = "openclaw" + mock_manifest.type = "agent-framework" + mock_manifest.requires = {"ram_mb": 2048} + + mock_registry = Mock() + mock_registry.list_available = Mock(return_value=[mock_manifest]) + mock_registry.get = Mock(return_value=mock_manifest) + + mock_hw = Mock() + mock_hw.ram_mb = 2048 # 2 GB -- not enough for 2048 + 500 + 2048 + + app = _app(client) + app.state.registry = mock_registry + app.state.hardware_profile = mock_hw + + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "framework": "openclaw"}, + ) + assert r.status_code == 400 + body = r.json() + assert "error" in body + assert "ram_mb" in body + assert "min_ram_mb" in body + assert body["framework"] == "openclaw" + + @pytest.mark.asyncio + async def test_sufficient_ram_passes_validation(self, client): + """With enough RAM, framework validation passes (endpoint proceeds past it).""" + mock_manifest = Mock() + mock_manifest.id = "openclaw" + mock_manifest.type = "agent-framework" + mock_manifest.requires = {"ram_mb": 512} + + mock_registry = Mock() + mock_registry.list_available = Mock(return_value=[mock_manifest]) + mock_registry.get = Mock(return_value=mock_manifest) + + mock_hw = Mock() + mock_hw.ram_mb = 16384 # 16 GB -- plenty + + app = _app(client) + app.state.registry = mock_registry + app.state.hardware_profile = mock_hw + + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "framework": "openclaw"}, + ) + # Should NOT get a 400 from framework validation. + assert r.status_code != 400 or "framework" not in r.json().get("error", "").lower() + + @pytest.mark.asyncio + async def test_framework_none_skips_validation(self, client): + """framework='none' skips both catalog lookup and RAM check.""" + mock_registry = Mock() + mock_registry.list_available = Mock(return_value=[]) + + app = _app(client) + app.state.registry = mock_registry + app.state.hardware_profile = None + + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "framework": "none"}, + ) + # Should not get a framework-related 400. + if r.status_code == 400: + assert "framework" not in r.json().get("error", "").lower() + + @pytest.mark.asyncio + async def test_no_hardware_profile_skips_ram_check(self, client): + """When hardware_profile is None, RAM check is skipped.""" + mock_manifest = Mock() + mock_manifest.id = "openclaw" + mock_manifest.type = "agent-framework" + mock_manifest.requires = {"ram_mb": 99999} + + mock_registry = Mock() + mock_registry.list_available = Mock(return_value=[mock_manifest]) + mock_registry.get = Mock(return_value=mock_manifest) + + app = _app(client) + app.state.registry = mock_registry + app.state.hardware_profile = None + + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "framework": "openclaw"}, + ) + # Should not get a RAM-related 400 since hw profile is None. + if r.status_code == 400: + assert "ram" not in r.json().get("error", "").lower() + + +# --------------------------------------------------------------------------- +# resolve_deploy_routing +# --------------------------------------------------------------------------- + + +class TestResolveDeployRouting: + """Tests for agent_deploy.resolve_deploy_routing via the deploy endpoint.""" + + @pytest.mark.asyncio + async def test_model_not_found_returns_404(self, client): + """A model that resolves to not_found must return 404.""" + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation(kind="not_found"), + ): + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent", "model": "nonexistent-model"}, + ) + assert r.status_code == 404 + body = r.json() + assert "error" in body + assert "nonexistent-model" in body["error"] + + @pytest.mark.asyncio + async def test_model_routed_to_worker_returns_202(self, client): + """A model on a worker (no pin) must return 202 with routing info.""" + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation( + kind="worker", + hosts=["worker-a", "worker-b"], + canonical_host="worker-a", + ), + ): + r = await client.post( + "/api/agents/deploy", + json={ + "name": "test-agent", + "model": "qwen2.5-7b", + }, + ) + assert r.status_code == 202 + body = r.json() + assert body["status"] == "routed" + assert body["worker"] == "worker-a" + assert "worker-a" in body["available_on"] + assert "worker-b" in body["available_on"] + + @pytest.mark.asyncio + async def test_model_routed_to_pinned_worker_returns_202(self, client): + """A model on a worker with a valid pin returns 202.""" + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation( + kind="worker", + hosts=["worker-a", "worker-b"], + canonical_host="worker-a", + ), + ): + r = await client.post( + "/api/agents/deploy", + json={ + "name": "test-agent", + "model": "qwen2.5-7b", + "target_worker": "worker-b", + }, + ) + assert r.status_code == 202 + body = r.json() + assert body["status"] == "routed" + assert body["worker"] == "worker-b" + + @pytest.mark.asyncio + async def test_pinned_worker_without_model_returns_409(self, client): + """A pinned worker that does NOT have the model must return 409.""" + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation( + kind="worker", + hosts=["worker-a"], + canonical_host="worker-a", + ), + ): + r = await client.post( + "/api/agents/deploy", + json={ + "name": "test-agent", + "model": "qwen2.5-7b", + "target_worker": "worker-b", + }, + ) + assert r.status_code == 409 + body = r.json() + assert "error" in body + assert "worker-b" in body["error"] + assert body["pinned_worker"] == "worker-b" + assert body["model"] == "qwen2.5-7b" + assert "worker-a" in body["available_on"] + + @pytest.mark.asyncio + async def test_no_model_skips_routing(self, client): + """When no model is specified, routing is skipped entirely.""" + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent"}, + ) + # Should not get a 404/409 from routing. + assert r.status_code not in (404, 409) + + @pytest.mark.asyncio + async def test_cloud_model_falls_through(self, client): + """A cloud model resolves to 'cloud' and falls through to local deploy.""" + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation(kind="cloud"), + ): + r = await client.post( + "/api/agents/deploy", + json={ + "name": "test-agent", + "model": "gpt-4o", + }, + ) + # Cloud models fall through; NOT a routing error. + assert r.status_code not in (404, 409) + + +# --------------------------------------------------------------------------- +# archive_smoke_check +# --------------------------------------------------------------------------- + +# NOTE: archive_smoke_check is exercised during the POST /api/agents/deploy +# response construction (after the agent record is saved and the background +# task is spawned). We test it here with controller-local model resolution so +# the deploy path actually completes, and we inspect archive_smoke_ok in the +# response. The tests below verify that the smoke-check flag reflects archive +# health. End-to-end archive correctness is tested in tests/routes/archive. + + +class TestArchiveSmokeCheck: + """Tests for agent_deploy.archive_smoke_check via the deploy endpoint.""" + + @pytest.mark.asyncio + async def test_archive_smoke_ok_true(self, client): + """When archive.record and query succeed, archive_smoke_ok is True.""" + mock_archive = AsyncMock() + mock_archive.record = AsyncMock() + mock_archive.query = AsyncMock(return_value=[{"id": 1}]) + + app = _app(client) + app.state.archive = mock_archive + + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation(kind="controller"), + ): + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent"}, + ) + if r.status_code == 200: + body = r.json() + assert body.get("archive_smoke_ok") is True + + @pytest.mark.asyncio + async def test_archive_smoke_ok_false_on_record_failure(self, client): + """When archive.record raises, archive_smoke_ok is False.""" + mock_archive = AsyncMock() + mock_archive.record = AsyncMock(side_effect=Exception("disk full")) + mock_archive.query = AsyncMock(return_value=[]) + + app = _app(client) + app.state.archive = mock_archive + + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation(kind="controller"), + ): + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent"}, + ) + if r.status_code == 200: + body = r.json() + assert body.get("archive_smoke_ok") is False + + @pytest.mark.asyncio + async def test_archive_smoke_ok_false_when_no_archive(self, client): + """When archive is None on app.state, archive_smoke_ok is False.""" + app = _app(client) + app.state.archive = None + + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation(kind="controller"), + ): + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent"}, + ) + if r.status_code == 200: + body = r.json() + assert body.get("archive_smoke_ok") is False + + @pytest.mark.asyncio + async def test_archive_smoke_ok_false_on_empty_query(self, client): + """When archive.query returns empty list, archive_smoke_ok is False.""" + mock_archive = AsyncMock() + mock_archive.record = AsyncMock() + mock_archive.query = AsyncMock(return_value=[]) + + app = _app(client) + app.state.archive = mock_archive + + with patch( + "tinyagentos.cluster.model_resolver.resolve_model_location", + return_value=ModelLocation(kind="controller"), + ): + r = await client.post( + "/api/agents/deploy", + json={"name": "test-agent"}, + ) + if r.status_code == 200: + body = r.json() + assert body.get("archive_smoke_ok") is False diff --git a/tests/test_routes_apps.py b/tests/test_routes_apps.py index 3e9b77014..2e02f005d 100644 --- a/tests/test_routes_apps.py +++ b/tests/test_routes_apps.py @@ -108,21 +108,21 @@ async def test_empty_returns_empty_list(self, client): @pytest.mark.asyncio async def test_install_then_listed(self, client): - resp = await client.post("/api/apps/optional/reddit/install") + resp = await client.post("/api/apps/optional/coding-studio/install") assert resp.status_code == 200 - assert resp.json()["app_id"] == "reddit" + assert resp.json()["app_id"] == "coding-studio" resp = await client.get("/api/apps/optional/installed") - assert resp.json()["installed"] == ["reddit"] + assert resp.json()["installed"] == ["coding-studio"] @pytest.mark.asyncio async def test_uninstall_removes(self, client): - await client.post("/api/apps/optional/x-monitor/install") + await client.post("/api/apps/optional/coding-studio/install") resp = await client.get("/api/apps/optional/installed") - assert "x-monitor" in resp.json()["installed"] - resp = await client.post("/api/apps/optional/x-monitor/uninstall") + assert "coding-studio" in resp.json()["installed"] + resp = await client.post("/api/apps/optional/coding-studio/uninstall") assert resp.status_code == 200 resp = await client.get("/api/apps/optional/installed") - assert "x-monitor" not in resp.json()["installed"] + assert "coding-studio" not in resp.json()["installed"] @pytest.mark.asyncio async def test_unknown_app_rejected_install(self, client): @@ -136,10 +136,10 @@ async def test_unknown_app_rejected_uninstall(self, client): @pytest.mark.asyncio async def test_optional_app_not_in_installed_services(self, client): - await client.post("/api/apps/optional/github-browser/install") + await client.post("/api/apps/optional/coding-studio/install") resp = await client.get("/api/apps/installed") ids = {i["app_id"] for i in resp.json()} - assert "github-browser" not in ids + assert "coding-studio" not in ids class TestOptionalCatalog: @@ -166,18 +166,18 @@ async def test_catalog_item_shape(self, client): async def test_install_records_version(self, client): from tinyagentos.routes.apps import APP_VERSIONS store = client._transport.app.state.installed_apps - resp = await client.post("/api/apps/optional/reddit/install") + resp = await client.post("/api/apps/optional/coding-studio/install") assert resp.status_code == 200 rows = await store.list_installed() - row = next((r for r in rows if r["app_id"] == "reddit"), None) + row = next((r for r in rows if r["app_id"] == "coding-studio"), None) assert row is not None - assert row["version"] == APP_VERSIONS["reddit"] + assert row["version"] == APP_VERSIONS["coding-studio"] @pytest.mark.asyncio async def test_update_available_false_for_fresh_install(self, client): - await client.post("/api/apps/optional/youtube-library/install") + await client.post("/api/apps/optional/coding-studio/install") resp = await client.get("/api/apps/optional/catalog") - app = next(a for a in resp.json()["apps"] if a["id"] == "youtube-library") + app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio") assert app["installed"] is True assert app["update_available"] is False @@ -187,14 +187,14 @@ async def test_update_available_true_when_stored_version_older(self, client): store = client._transport.app.state.installed_apps await store._db.execute( "INSERT OR REPLACE INTO installed_apps (app_id, installed_at, version, metadata) VALUES (?, ?, ?, ?)", - ("x-monitor", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})), + ("coding-studio", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})), ) await store._db.commit() resp = await client.get("/api/apps/optional/catalog") - app = next(a for a in resp.json()["apps"] if a["id"] == "x-monitor") + app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio") assert app["installed"] is True assert app["update_available"] is True - assert app["version"] == APP_VERSIONS["x-monitor"] + assert app["version"] == APP_VERSIONS["coding-studio"] @pytest.mark.asyncio async def test_catalog_does_not_leak_unknown_ids(self, client): @@ -208,3 +208,23 @@ async def test_catalog_does_not_leak_unknown_ids(self, client): returned_ids = {a["id"] for a in resp.json()["apps"]} assert "unknown-app" not in returned_ids assert returned_ids == OPTIONAL_FRONTEND_APPS + + +class TestSocialAppsDeseeded: + """The platform social apps were removed from the default store.""" + + def test_social_apps_not_in_allowlist(self): + for app_id in ("reddit", "x-monitor", "github-browser", "youtube-library"): + assert app_id not in OPTIONAL_FRONTEND_APPS + + @pytest.mark.asyncio + async def test_social_app_install_404(self, client): + for app_id in ("reddit", "x-monitor", "github-browser", "youtube-library"): + resp = await client.post(f"/api/apps/optional/{app_id}/install") + assert resp.status_code == 404, app_id + + @pytest.mark.asyncio + async def test_social_apps_absent_from_catalog(self, client): + resp = await client.get("/api/apps/optional/catalog") + ids = {a["id"] for a in resp.json()["apps"]} + assert ids.isdisjoint({"reddit", "x-monitor", "github-browser", "youtube-library"}) diff --git a/tests/test_routes_browser_sessions.py b/tests/test_routes_browser_sessions.py index 1e4737141..d45684d7b 100644 --- a/tests/test_routes_browser_sessions.py +++ b/tests/test_routes_browser_sessions.py @@ -81,3 +81,64 @@ async def test_get_session_not_found(self, client, monkeypatch): assert resp.status_code == 404 body = resp.json() assert body["error"] == "not_found" + + +class TestCreateSessionHostPlacement: + """A RAM-capable controller host serves a streamed session itself, even with + no Tier-2 browser workers (the 'Pi isn't capable' regression).""" + + @pytest.mark.asyncio + async def test_create_places_on_capable_host(self, client): + app = client._transport.app # noqa: SLF001 + # Capable host (16GB), no browser workers. + app.state._state["host_hardware"] = {"ram_mb": 16000} # noqa: SLF001 + runner = AsyncMock() + app.state._state["browser_container_runner"] = runner # noqa: SLF001 + + mgr = AsyncMock() + mgr.create_session = AsyncMock(return_value={"id": "sess-1"}) + mgr.start_on_host = AsyncMock( + return_value={ + "id": "sess-1", "owner_type": "user", "owner_id": "admin", + "status": "running", "node": "host", "neko_url": "http://host:8801", + "container_id": "c1", "cdp_url": None, "is_mobile": False, + "profile_name": "default", "url": "http://example.com", + } + ) + app.state._state["browser_sessions"] = mgr # noqa: SLF001 + + resp = await client.post("/api/browser/sessions", json={"url": "http://example.com"}) + assert resp.status_code == 201, resp.text + assert resp.json()["node"] == "host" + mgr.start_on_host.assert_awaited_once() + mgr.start_on_worker.assert_not_awaited() + + @pytest.mark.asyncio + async def test_create_409_when_no_host_and_no_workers(self, client): + app = client._transport.app # noqa: SLF001 + # Non-capable host (4GB), no workers -> nowhere to place. + app.state._state["host_hardware"] = {"ram_mb": 4096} # noqa: SLF001 + mgr = AsyncMock() + mgr.create_session = AsyncMock(return_value={"id": "sess-2"}) + app.state._state["browser_sessions"] = mgr # noqa: SLF001 + resp = await client.post("/api/browser/sessions", json={"url": "http://example.com"}) + assert resp.status_code == 409 + assert resp.json()["error"] == "no_capable_node" + + +@pytest.mark.asyncio +async def test_create_worker_lookup_failure_leaves_no_orphan(client, monkeypatch): + """If the chosen worker can't be resolved, 409 without creating a session row.""" + import tinyagentos.routes.browser_sessions as bs + + # Force placement onto a 'worker' that the cluster can't resolve. + monkeypatch.setattr(bs, "resolve_browser_target", lambda *a, **k: ("worker", "ghost")) + app = client._transport.app # noqa: SLF001 + app.state.cluster_manager.get_worker = lambda name: None # noqa: SLF001 + mgr = AsyncMock() + app.state._state["browser_sessions"] = mgr # noqa: SLF001 + + resp = await client.post("/api/browser/sessions", json={"url": "http://example.com"}) + assert resp.status_code == 409 + assert resp.json()["error"] == "no_capable_node" + mgr.create_session.assert_not_awaited() diff --git a/tests/test_routes_cluster_capability.py b/tests/test_routes_cluster_capability.py new file mode 100644 index 000000000..17b87d1ea --- /dev/null +++ b/tests/test_routes_cluster_capability.py @@ -0,0 +1,272 @@ +"""Tests for the cluster capability-map endpoints. + +Coverage: +- heartbeat requires worker HMAC; unsigned -> 401 +- heartbeat node_id must match the authenticated worker name -> 403 on mismatch +- a signed heartbeat upserts and the row is readable via admin list +- list filters by status +- set-status validates the value and 404s an unknown node +- prune drops stale rows +- reads/mutations require an admin session +""" +from __future__ import annotations + +import pytest + +from test_routes_cluster_pairing import pair_worker, sign_worker_request + + +async def _signed_heartbeat(client, key, node): + import json + + path = "/api/cluster/capability/heartbeat" + body = json.dumps(node).encode() + headers = sign_worker_request(key, node["node_id"], "POST", path, body) + headers["Content-Type"] = "application/json" + return await client.post(path, content=body, headers=headers) + + +@pytest.mark.asyncio +async def test_heartbeat_requires_hmac(client, app): + """An unsigned heartbeat is rejected by the worker HMAC gate.""" + await app.state.capability_map.init() + resp = await client.post( + "/api/cluster/capability/heartbeat", + json={"node_id": "node-a"}, + ) + assert resp.status_code == 401 + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_heartbeat_name_mismatch_rejected(client, app): + """The signed worker name must equal the heartbeat node_id.""" + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000") + # Sign as node-a but claim node_id node-b in the body. + import json + + path = "/api/cluster/capability/heartbeat" + body = json.dumps({"node_id": "node-b"}).encode() + headers = sign_worker_request(key, "node-a", "POST", path, body) + headers["Content-Type"] = "application/json" + resp = await client.post(path, content=body, headers=headers) + assert resp.status_code == 403 + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_heartbeat_upserts_and_lists(client, app): + """A signed heartbeat upserts a row visible to the admin list.""" + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000") + resp = await _signed_heartbeat( + client, + key, + { + "node_id": "node-a", + "hostname": "pi5", + "ram_mb": 16000, + "gpu": {"name": "mali"}, + "npu": {"tops": 6}, + "status": "online", + }, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["node_id"] == "node-a" + assert resp.json()["gpu"] == {"name": "mali"} + + resp = await client.get("/api/cluster/capability") + assert resp.status_code == 200 + nodes = resp.json()["nodes"] + assert any(n["node_id"] == "node-a" and n["ram_mb"] == 16000 for n in nodes) + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_list_filters_by_status(client, app): + """?status= filters the returned rows.""" + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key_a = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000") + key_b = await pair_worker(client, app, "node-b", "http://10.0.0.6:9000") + await _signed_heartbeat(client, key_a, {"node_id": "node-a", "status": "online"}) + await _signed_heartbeat(client, key_b, {"node_id": "node-b", "status": "draining"}) + + resp = await client.get("/api/cluster/capability", params={"status": "draining"}) + assert resp.status_code == 200 + nodes = resp.json()["nodes"] + assert [n["node_id"] for n in nodes] == ["node-b"] + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_set_status_validates_and_404s(client, app): + """set-status rejects bad values and unknown nodes.""" + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000") + await _signed_heartbeat(client, key, {"node_id": "node-a", "status": "online"}) + + resp = await client.post( + "/api/cluster/capability/node-a/status", json={"status": "bogus"} + ) + assert resp.status_code == 400 + + resp = await client.post( + "/api/cluster/capability/node-a/status", json={"status": "draining"} + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "draining" + + resp = await client.post( + "/api/cluster/capability/ghost/status", json={"status": "online"} + ) + assert resp.status_code == 404 + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_heartbeat_preserves_admin_draining(client, app): + """A routine heartbeat must not clear an admin-set 'draining' status.""" + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000") + await _signed_heartbeat(client, key, {"node_id": "node-a", "status": "online"}) + r = await client.post( + "/api/cluster/capability/node-a/status", json={"status": "draining"} + ) + assert r.json()["status"] == "draining" + # A subsequent heartbeat (defaults status=online) must keep it draining. + r = await _signed_heartbeat(client, key, {"node_id": "node-a", "status": "online"}) + assert r.json()["status"] == "draining" + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_prune_drops_stale(client, app): + """prune removes rows older than the cutoff.""" + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000") + # last_seen far in the past so any positive cutoff prunes it. + await _signed_heartbeat( + client, key, {"node_id": "node-a", "status": "online"} + ) + # Force the row stale directly via the store, then prune. + await app.state.capability_map.upsert( + {"node_id": "node-a", "status": "online", "last_seen": 1} + ) + resp = await client.post( + "/api/cluster/capability/prune", json={"older_than_s": 60} + ) + assert resp.status_code == 200 + assert resp.json()["pruned"] == 1 + resp = await client.get("/api/cluster/capability") + assert all(n["node_id"] != "node-a" for n in resp.json()["nodes"]) + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_worker_registration_populates_capability_map(client, app): + """A paired worker's HMAC registration records its hardware into the map.""" + import json + + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "rig-1", "http://10.2.0.1:9000") + reg = json.dumps( + { + "name": "rig-1", + "url": "http://10.2.0.1:9000", + "platform": "linux", + "host_lan_ip": "10.2.0.9", + "hardware": { + "cpu": {"cores": 8}, + "ram_mb": 16000, + "gpu": {"name": "rtx3060"}, + "npu": {}, + }, + } + ).encode() + headers = sign_worker_request(key, "rig-1", "POST", "/api/cluster/workers", reg) + headers["Content-Type"] = "application/json" + resp = await client.post("/api/cluster/workers", content=reg, headers=headers) + assert resp.status_code == 200, resp.text + + nodes = (await client.get("/api/cluster/capability")).json()["nodes"] + node = next((n for n in nodes if n["node_id"] == "rig-1"), None) + assert node is not None + assert node["status"] == "online" + assert node["ram_mb"] == 16000 + assert node["gpu"] == {"name": "rtx3060"} + assert node["hostname"] == "10.2.0.9" + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_registration_does_not_revive_drained_node(client, app): + """If an admin drained a node, re-registration keeps it draining.""" + import json + + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "rig-2", "http://10.2.0.2:9000") + await app.state.capability_map.upsert({"node_id": "rig-2", "status": "draining"}) + + reg = json.dumps({"name": "rig-2", "url": "http://10.2.0.2:9000", "platform": "linux"}).encode() + headers = sign_worker_request(key, "rig-2", "POST", "/api/cluster/workers", reg) + headers["Content-Type"] = "application/json" + resp = await client.post("/api/cluster/workers", content=reg, headers=headers) + assert resp.status_code == 200 + + node = await app.state.capability_map.get("rig-2") + assert node["status"] == "draining" + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_reregistration_without_hardware_preserves_stored(client, app): + """A re-register with empty hardware must not wipe previously-detected fields.""" + import json + + await app.state.capability_map.init() + await app.state.cluster_pairing.init() + key = await pair_worker(client, app, "rig-3", "http://10.3.0.1:9000") + # First register with full hardware. + reg1 = json.dumps({ + "name": "rig-3", "url": "http://10.3.0.1:9000", "platform": "linux", + "hardware": {"cpu": {"cores": 12}, "ram_mb": 32000, "gpu": {"name": "rtx4090"}, "npu": {}}, + }).encode() + h1 = sign_worker_request(key, "rig-3", "POST", "/api/cluster/workers", reg1) + h1["Content-Type"] = "application/json" + assert (await client.post("/api/cluster/workers", content=reg1, headers=h1)).status_code == 200 + + # Re-register with NO hardware (legacy/flat-mode worker). + reg2 = json.dumps({"name": "rig-3", "url": "http://10.3.0.1:9000", "platform": "linux"}).encode() + h2 = sign_worker_request(key, "rig-3", "POST", "/api/cluster/workers", reg2) + h2["Content-Type"] = "application/json" + assert (await client.post("/api/cluster/workers", content=reg2, headers=h2)).status_code == 200 + + node = await app.state.capability_map.get("rig-3") + assert node["ram_mb"] == 32000 # preserved + assert node["gpu"] == {"name": "rtx4090"} # preserved + assert node["status"] == "online" + await app.state.capability_map.close() + + +@pytest.mark.asyncio +async def test_sweep_offlines_stale_online_nodes(client, app): + """The sweep endpoint flips stale online nodes offline, keeping the row.""" + await app.state.capability_map.init() + await app.state.capability_map.upsert({"node_id": "n-live", "status": "online"}) + await app.state.capability_map.upsert({"node_id": "n-gone", "status": "online", "last_seen": 1}) + resp = await client.post("/api/cluster/capability/sweep", json={"older_than_s": 60}) + assert resp.status_code == 200 + assert resp.json()["offlined"] == 1 + nodes = {n["node_id"]: n["status"] for n in (await client.get("/api/cluster/capability")).json()["nodes"]} + assert nodes["n-gone"] == "offline" + assert nodes["n-live"] == "online" + await app.state.capability_map.close() diff --git a/tests/test_routes_taos_agent.py b/tests/test_routes_taos_agent.py new file mode 100644 index 000000000..25e54bd67 --- /dev/null +++ b/tests/test_routes_taos_agent.py @@ -0,0 +1,334 @@ +"""Endpoint tests for tinyagentos/routes/taos_agent.py. + +Covers the endpoints that are testable in-process with the FastAPI +test client (no live LLM / opencode / container needed): + + GET /api/taos-agent/config + PUT /api/taos-agent/permitted-models + PUT /api/taos-agent/persona + PATCH /api/taos-agent/settings (validation) + POST /api/taos-agent/chat (guard responses) + +Endpoints exercised in separate modules: + - GET/PATCH settings, attachments upload/serve: test_taos_agent_route.py + - POST /api/taos-agent/chat streaming happy path: test_taos_agent_chat.py +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio +import yaml +from httpx import ASGITransport, AsyncClient + +from tinyagentos.app import create_app +import tinyagentos.cluster.model_resolver as _model_resolver_mod +from tinyagentos.cluster.model_resolver import ModelLocation + + +# --------------------------------------------------------------------------- +# Fixtures (same pattern as tests/test_taos_agent_route.py) +# --------------------------------------------------------------------------- + +@pytest.fixture +def tmp_data_dir(tmp_path): + config = { + "server": {"host": "0.0.0.0", "port": 6969}, + "backends": [ + {"name": "test-backend", "type": "rkllama", "url": "http://localhost:8080", "priority": 1} + ], + "qmd": {"url": "http://localhost:7832"}, + "agents": [], + "metrics": {"poll_interval": 30, "retention_days": 30}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(config)) + (tmp_path / ".setup_complete").touch() + return tmp_path + + +@pytest.fixture +def app(tmp_data_dir): + return create_app(data_dir=tmp_data_dir) + + +@pytest_asyncio.fixture +async def client(app): + ds = app.state.desktop_settings + if ds._db is not None: + await ds.close() + await ds.init() + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + record = app.state.auth.find_user("admin") + uid = record["id"] if record else "" + token = app.state.auth.create_session(user_id=uid, long_lived=True) + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, + base_url="http://test", + cookies={"taos_session": token}, + ) as c: + yield c + await ds.close() + await app.state.http_client.aclose() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _fake_proxy(running: bool = True) -> MagicMock: + proxy = MagicMock() + proxy.is_running.return_value = running + return proxy + + +# --------------------------------------------------------------------------- +# GET /api/taos-agent/config +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_config_returns_full_payload(client, app): + """GET /config returns model, permitted_models, persona, key_masked, + framework, and system.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + resp = await client.get("/api/taos-agent/config") + assert resp.status_code == 200 + data = resp.json() + assert set(data.keys()) == { + "model", "permitted_models", "persona", + "key_masked", "framework", "system", + } + assert data["model"] == "gpt-4o" + assert data["framework"] == "opencode" + assert isinstance(data["permitted_models"], list) + assert isinstance(data["persona"], str) + assert data["system"] is True + + +@pytest.mark.asyncio +async def test_config_key_masked_none_when_no_key(client, app): + """When taos_opencode_key is absent, key_masked is None.""" + resp = await client.get("/api/taos-agent/config") + assert resp.status_code == 200 + assert resp.json()["key_masked"] is None + + +@pytest.mark.asyncio +async def test_config_key_masked_scrubs_long_key(client, app): + """A real-looking key is masked (first 6 + ellipsis + last 4).""" + app.state.taos_opencode_key = "sk-1234567890abcdef" + resp = await client.get("/api/taos-agent/config") + assert resp.status_code == 200 + masked = resp.json()["key_masked"] + assert masked is not None + assert masked.startswith("sk-123") + assert masked.endswith("cdef") + + +@pytest.mark.asyncio +async def test_config_key_masked_short_key_returns_ellipsis(client, app): + """A key shorter than 12 chars is replaced with the ellipsis sentinel.""" + app.state.taos_opencode_key = "short" + resp = await client.get("/api/taos-agent/config") + assert resp.status_code == 200 + assert resp.json()["key_masked"] == "…" + + +# --------------------------------------------------------------------------- +# PUT /api/taos-agent/permitted-models +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_put_permitted_models_happy_path(client, app, monkeypatch): + """Setting permitted models with a reachable model returns the new set.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + + monkeypatch.setattr( + _model_resolver_mod, "resolve_model_location", + lambda request, model_id: ModelLocation(kind="cloud"), + ) + + resp = await client.put( + "/api/taos-agent/permitted-models", + json={"models": ["gpt-4o", "claude-4"]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "gpt-4o" in data["permitted_models"] + assert "claude-4" in data["permitted_models"] + + +@pytest.mark.asyncio +async def test_put_permitted_models_empty_list_returns_400(client): + """An empty models list must be rejected with 400.""" + resp = await client.put( + "/api/taos-agent/permitted-models", + json={"models": []}, + ) + assert resp.status_code == 400 + assert "empty" in resp.json()["error"].lower() + + +@pytest.mark.asyncio +async def test_put_permitted_models_unreachable_returns_409(client, app, monkeypatch): + """A model that resolves to not_found returns 409.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + + monkeypatch.setattr( + _model_resolver_mod, "resolve_model_location", + lambda request, model_id: ModelLocation(kind="not_found"), + ) + + resp = await client.put( + "/api/taos-agent/permitted-models", + json={"models": ["does-not-exist"]}, + ) + assert resp.status_code == 409 + error = resp.json()["error"].lower() + assert "not reachable" in error or "not_found" in error + + +@pytest.mark.asyncio +async def test_put_permitted_models_prepends_current_model(client, app, monkeypatch): + """The current primary model is always included even if not in the list.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + + monkeypatch.setattr( + _model_resolver_mod, "resolve_model_location", + lambda request, model_id: ModelLocation(kind="cloud"), + ) + + resp = await client.put( + "/api/taos-agent/permitted-models", + json={"models": ["claude-4"]}, + ) + assert resp.status_code == 200 + permitted = resp.json()["permitted_models"] + assert permitted[0] == "gpt-4o" + assert "claude-4" in permitted + + +@pytest.mark.asyncio +async def test_put_permitted_models_re_scopes_key(client, app, monkeypatch): + """When proxy + key are present, key_rescoped reflects proxy.update_agent_key.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _fake_proxy(running=True) + app.state.taos_opencode_key = "sk-1234567890abcdef" + + monkeypatch.setattr( + _model_resolver_mod, "resolve_model_location", + lambda request, model_id: ModelLocation(kind="cloud"), + ) + + proxy = app.state.llm_proxy + proxy.update_agent_key = AsyncMock(return_value=True) + + resp = await client.put( + "/api/taos-agent/permitted-models", + json={"models": ["gpt-4o"]}, + ) + assert resp.status_code == 200 + assert resp.json()["key_rescoped"] is True + proxy.update_agent_key.assert_called_once() + + +# --------------------------------------------------------------------------- +# PUT /api/taos-agent/persona +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_put_persona_happy_path(client): + """Setting a persona returns it back.""" + resp = await client.put( + "/api/taos-agent/persona", + json={"persona": "You are a helpful pirate."}, + ) + assert resp.status_code == 200 + assert resp.json()["persona"] == "You are a helpful pirate." + + +@pytest.mark.asyncio +async def test_put_persona_persists_across_get_config(client, app): + """After PUT persona, GET /config reflects the saved persona.""" + await client.put( + "/api/taos-agent/persona", + json={"persona": "Be concise."}, + ) + resp = await client.get("/api/taos-agent/config") + assert resp.status_code == 200 + assert resp.json()["persona"] == "Be concise." + + +@pytest.mark.asyncio +async def test_put_persona_empty_string_accepted(client): + """An empty persona string is accepted (it clears the override).""" + resp = await client.put( + "/api/taos-agent/persona", + json={"persona": ""}, + ) + assert resp.status_code == 200 + assert resp.json()["persona"] == "" + + +# --------------------------------------------------------------------------- +# PATCH /api/taos-agent/settings validation +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_patch_settings_missing_model_returns_422(client): + """Omitting the required `model` field returns 422.""" + resp = await client.patch("/api/taos-agent/settings", json={}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_patch_settings_non_string_model_returns_422(client): + """A non-string model value returns 422.""" + resp = await client.patch( + "/api/taos-agent/settings", + json={"model": 123}, + ) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# POST /api/taos-agent/chat guards +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_chat_no_model_returns_400(client): + """POST /chat with no model configured returns 400.""" + resp = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status_code == 400 + assert "model" in resp.json()["error"].lower() + + +@pytest.mark.asyncio +async def test_chat_proxy_not_running_returns_503(client, app): + """POST /chat when proxy is not running returns 503.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _fake_proxy(running=False) + + resp = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status_code == 503 + error = resp.json()["error"].lower() + assert "proxy" in error or "lite" in error + + +@pytest.mark.asyncio +async def test_chat_missing_messages_field_returns_422(client): + """POST /chat with a body missing `messages` returns 422.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + resp = await client.post( + "/api/taos-agent/chat", + json={}, + ) + assert resp.status_code == 422 diff --git a/tests/test_torrent_settings.py b/tests/test_torrent_settings.py new file mode 100644 index 000000000..d8ef06d7b --- /dev/null +++ b/tests/test_torrent_settings.py @@ -0,0 +1,140 @@ +import json +from pathlib import Path + +import pytest + +from tinyagentos.torrent_settings import TorrentSettings, TorrentSettingsStore + + +class TestTorrentSettingsDataclass: + def test_defaults(self): + s = TorrentSettings() + assert s.seed_enabled is True + assert s.upload_rate_limit_kbps == 5000 + assert s.max_active_seeds == 20 + + def test_custom_values(self): + s = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=1000, max_active_seeds=5) + assert s.seed_enabled is False + assert s.upload_rate_limit_kbps == 1000 + assert s.max_active_seeds == 5 + + def test_to_dict(self): + s = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=2048, max_active_seeds=10) + d = s.to_dict() + assert d == { + "seed_enabled": False, + "upload_rate_limit_kbps": 2048, + "max_active_seeds": 10, + } + + def test_to_dict_roundtrip(self): + original = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=1234, max_active_seeds=7) + restored = TorrentSettings(**original.to_dict()) + assert restored == original + + +class TestTorrentSettingsStoreLoad: + def test_load_missing_file_returns_defaults(self, tmp_path): + store = TorrentSettingsStore(tmp_path / "nonexistent" / "settings.json") + s = store.load() + assert s == TorrentSettings() + + def test_load_returns_defaults_when_file_empty(self, tmp_path): + p = tmp_path / "settings.json" + p.write_text("") + store = TorrentSettingsStore(p) + s = store.load() + assert s == TorrentSettings() + + def test_load_returns_defaults_when_file_invalid_json(self, tmp_path): + p = tmp_path / "settings.json" + p.write_text("{not valid json") + store = TorrentSettingsStore(p) + s = store.load() + assert s == TorrentSettings() + + def test_load_discard_unknown_keys(self, tmp_path): + p = tmp_path / "settings.json" + p.write_text(json.dumps({"seed_enabled": True, "unknown_field": 42})) + store = TorrentSettingsStore(p) + s = store.load() + assert s.seed_enabled is True + assert s.upload_rate_limit_kbps == 5000 + assert s.max_active_seeds == 20 + + def test_load_partial_values_use_defaults_for_rest(self, tmp_path): + p = tmp_path / "settings.json" + p.write_text(json.dumps({"seed_enabled": False})) + store = TorrentSettingsStore(p) + s = store.load() + assert s.seed_enabled is False + assert s.upload_rate_limit_kbps == 5000 + assert s.max_active_seeds == 20 + + def test_load_full_values(self, tmp_path): + p = tmp_path / "settings.json" + payload = {"seed_enabled": False, "upload_rate_limit_kbps": 8000, "max_active_seeds": 50} + p.write_text(json.dumps(payload)) + store = TorrentSettingsStore(p) + s = store.load() + assert s.seed_enabled is False + assert s.upload_rate_limit_kbps == 8000 + assert s.max_active_seeds == 50 + + def test_load_coerces_string_to_bool_and_int(self, tmp_path): + p = tmp_path / "settings.json" + payload = {"seed_enabled": "yes", "upload_rate_limit_kbps": "3000", "max_active_seeds": "15"} + p.write_text(json.dumps(payload)) + store = TorrentSettingsStore(p) + s = store.load() + assert s.seed_enabled is True + assert s.upload_rate_limit_kbps == 3000 + assert s.max_active_seeds == 15 + + +class TestTorrentSettingsStoreSave: + def test_save_creates_parent_directories(self, tmp_path): + p = tmp_path / "deep" / "nested" / "settings.json" + store = TorrentSettingsStore(p) + settings = TorrentSettings(seed_enabled=False) + store.save(settings) + assert p.exists() + + def test_save_writes_indented_json(self, tmp_path): + p = tmp_path / "settings.json" + store = TorrentSettingsStore(p) + settings = TorrentSettings(upload_rate_limit_kbps=9999) + store.save(settings) + raw = json.loads(p.read_text()) + assert raw["upload_rate_limit_kbps"] == 9999 + assert raw["seed_enabled"] is True + assert raw["max_active_seeds"] == 20 + + def test_save_then_load_roundtrip(self, tmp_path): + p = tmp_path / "settings.json" + store = TorrentSettingsStore(p) + original = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=3000, max_active_seeds=5) + store.save(original) + loaded = store.load() + assert loaded == original + + +class TestTorrentSettingsStoreSaveThenReloadReturnTrip: + def test_separate_instances_share_file(self, tmp_path): + p = tmp_path / "settings.json" + store_a = TorrentSettingsStore(p) + store_b = TorrentSettingsStore(p) + settings = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=7777, max_active_seeds=3) + store_a.save(settings) + loaded = store_b.load() + assert loaded == settings + + def test_overwrite_existing(self, tmp_path): + p = tmp_path / "settings.json" + store = TorrentSettingsStore(p) + store.save(TorrentSettings(seed_enabled=True)) + store.save(TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=100)) + loaded = store.load() + assert loaded.seed_enabled is False + assert loaded.upload_rate_limit_kbps == 100 diff --git a/tinyagentos/__init__.py b/tinyagentos/__init__.py index 957c26a8e..1fa92a3d0 100644 --- a/tinyagentos/__init__.py +++ b/tinyagentos/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0-beta.5" +__version__ = "1.0.0-beta.6" diff --git a/tinyagentos/agent_tools/__init__.py b/tinyagentos/agent_tools/__init__.py new file mode 100644 index 000000000..d306712d6 --- /dev/null +++ b/tinyagentos/agent_tools/__init__.py @@ -0,0 +1 @@ +"""Agent execution primitives (workspace-jailed file tools, etc.).""" diff --git a/tinyagentos/agent_tools/coding_loop.py b/tinyagentos/agent_tools/coding_loop.py new file mode 100644 index 000000000..1ac1d4f27 --- /dev/null +++ b/tinyagentos/agent_tools/coding_loop.py @@ -0,0 +1,116 @@ +"""The Coding Studio agent tool-calling loop (#86). + +This is the loop the coding epic is named for. It sits on top of the dispatch +step (coding_tools.dispatch) and is deliberately model-agnostic: it drives any +``model_step`` callable that, given the running transcript, either asks for tool +calls or returns a final answer. The model glue (Anthropic / litellm / a local +model) provides ``model_step`` in a later slice; the loop, the tool execution, +and the iteration guard live here so they are testable without a live model. + +Flow per turn: + model_step(transcript) -> {"type": "tool_calls", "calls": [...]} or + {"type": "final", "text": "..."} + each call -> coding_tools.dispatch(workspace_root, name, arguments) + -> appended to the transcript as a tool_result + repeat until the model returns a final answer or max_iterations is reached. +""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable + +from tinyagentos.agent_tools import coding_tools + +# A model_step is an async callable: (transcript) -> step dict. +ModelStep = Callable[[list[dict]], Awaitable[dict]] + + +async def run_tool_loop( + workspace_root, + model_step: ModelStep, + *, + initial_transcript: list[dict] | None = None, + max_iterations: int = 8, +) -> dict[str, Any]: + """Drive a tool-calling loop to completion against one workspace. + + Returns: + { + "final": , # the model's final answer, or None if the + # iteration guard tripped first + "iterations": , # model_step turns taken + "stopped": <"final"|"max_iterations">, + "transcript": [...], # full message list, including tool results + } + + The transcript grows with two message shapes the model glue produces/reads: + {"role": "assistant", "tool_calls": [{"id","name","arguments"}, ...]} + {"role": "tool", "tool_call_id": , "name": , "result": } + A final answer is recorded as {"role": "assistant", "content": }. + """ + transcript: list[dict] = list(initial_transcript or []) + iterations = 0 + + while iterations < max_iterations: + iterations += 1 + step = await model_step(transcript) + # A misbehaving model_step that returns a non-mapping must not crash the + # loop (its whole contract is to stay resilient); treat it as a safe stop. + kind = step.get("type") if isinstance(step, dict) else None + + if kind == "final": + text = step.get("text", "") + transcript.append({"role": "assistant", "content": text}) + return { + "final": text, + "iterations": iterations, + "stopped": "final", + "transcript": transcript, + } + + if kind == "tool_calls": + calls = step.get("calls") or [] + transcript.append({"role": "assistant", "tool_calls": calls}) + for call in calls: + # A non-dict call entry (bare string, None) must surface as a soft + # error, not crash the loop via .get on a non-mapping. + if not isinstance(call, dict): + transcript.append( + { + "role": "tool", + "tool_call_id": None, + "name": None, + "result": {"ok": False, "error": "malformed tool call"}, + } + ) + continue + result = coding_tools.dispatch( + workspace_root, call.get("name"), call.get("arguments") + ) + transcript.append( + { + "role": "tool", + "tool_call_id": call.get("id"), + "name": call.get("name"), + "result": result, + } + ) + continue + + # An unrecognised step shape is treated as a final, empty answer rather + # than looping forever on a misbehaving model_step. + transcript.append({"role": "assistant", "content": ""}) + return { + "final": None, + "iterations": iterations, + "stopped": "final", + "transcript": transcript, + } + + # Iteration guard tripped: the model never produced a final answer. + return { + "final": None, + "iterations": iterations, + "stopped": "max_iterations", + "transcript": transcript, + } diff --git a/tinyagentos/agent_tools/coding_model.py b/tinyagentos/agent_tools/coding_model.py new file mode 100644 index 000000000..4201e8590 --- /dev/null +++ b/tinyagentos/agent_tools/coding_model.py @@ -0,0 +1,157 @@ +"""A concrete ``model_step`` for the coding tool-calling loop (#86). + +The loop engine (coding_loop.run_tool_loop) is model-agnostic; this module +supplies the real driver: it converts the loop transcript to OpenAI-style chat +messages, hands the model the workspace tools, calls the completion, and parses +the reply back into the loop's step shape ({"type": "tool_calls"|"final"}). + +The completion call is injectable (``completion_fn``) so this is testable +without a live model or a network round trip; the default lazily imports +litellm so importing this module never requires litellm to be installed. +""" + +from __future__ import annotations + +import json +from typing import Any + +from tinyagentos.agent_tools.coding_tools import TOOL_SCHEMAS + + +def to_openai_tools(schemas: list[dict[str, Any]] | None = None) -> list[dict]: + """Convert the Anthropic-style tool schemas to OpenAI function-tool format.""" + out = [] + for s in schemas if schemas is not None else TOOL_SCHEMAS: + out.append( + { + "type": "function", + "function": { + "name": s["name"], + "description": s.get("description", ""), + "parameters": s.get("input_schema", {"type": "object", "properties": {}}), + }, + } + ) + return out + + +def transcript_to_messages(transcript: list[dict], system: str | None = None) -> list[dict]: + """Render the loop transcript as OpenAI chat messages. + + Maps the loop's internal message shapes: + {"role": "user"|"assistant", "content": ...} -> passthrough + {"role": "assistant", "tool_calls": [{id,name,arguments}]} -> assistant w/ tool_calls + {"role": "tool", "tool_call_id", "name", "result": {...}} -> tool message (JSON result) + """ + messages: list[dict] = [] + if system: + messages.append({"role": "system", "content": system}) + for m in transcript: + role = m.get("role") + if role == "assistant" and "tool_calls" in m: + messages.append( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": c.get("id"), + "type": "function", + "function": { + "name": c.get("name"), + "arguments": json.dumps(c.get("arguments") or {}), + }, + } + for c in m["tool_calls"] + ], + } + ) + elif role == "tool": + messages.append( + { + "role": "tool", + "tool_call_id": m.get("tool_call_id"), + "name": m.get("name"), + "content": json.dumps(m.get("result")), + } + ) + else: + messages.append({"role": role, "content": m.get("content", "")}) + return messages + + +def _parse_arguments(raw) -> dict: + """Tool-call arguments arrive as a JSON string from the model; tolerate dicts.""" + if isinstance(raw, dict): + return raw + if not raw: + return {} + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + +def _message_field(message, key): + """Read a field off either an object-style or dict-style completion message.""" + if isinstance(message, dict): + return message.get(key) + return getattr(message, key, None) + + +def parse_completion(response) -> dict: + """Turn a chat-completion response into the loop's step shape. + + A content-filtered or error-shaped response can come back with no choices or + no message; fall back to an empty final answer rather than raising, so the + loop's never-raise contract holds. + """ + choices = (response.get("choices") if isinstance(response, dict) else getattr(response, "choices", None)) or [] + if not choices: + return {"type": "final", "text": ""} + first = choices[0] + message = (first.get("message") if isinstance(first, dict) else getattr(first, "message", None)) + if message is None: + return {"type": "final", "text": ""} + tool_calls = _message_field(message, "tool_calls") + if tool_calls: + calls = [] + for tc in tool_calls: + fn = tc["function"] if isinstance(tc, dict) else tc.function + calls.append( + { + "id": (tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)), + "name": (fn["name"] if isinstance(fn, dict) else fn.name), + "arguments": _parse_arguments( + fn["arguments"] if isinstance(fn, dict) else fn.arguments + ), + } + ) + return {"type": "tool_calls", "calls": calls} + return {"type": "final", "text": _message_field(message, "content") or ""} + + +async def _default_completion(model: str, messages: list[dict], tools: list[dict]): + # Lazy import so this module never hard-requires litellm at import time. + import litellm + + return await litellm.acompletion(model=model, messages=messages, tools=tools) + + +def make_litellm_model_step(model: str, *, system: str | None = None, completion_fn=None): + """Build a model_step for run_tool_loop backed by a chat-completions model. + + completion_fn(model, messages, tools) -> response is injectable (defaults to + litellm.acompletion). The returned async callable takes the loop transcript + and returns the next step. + """ + call = completion_fn or _default_completion + tools = to_openai_tools() + + async def model_step(transcript: list[dict]) -> dict: + messages = transcript_to_messages(transcript, system=system) + response = await call(model, messages, tools) + return parse_completion(response) + + return model_step diff --git a/tinyagentos/agent_tools/coding_tools.py b/tinyagentos/agent_tools/coding_tools.py new file mode 100644 index 000000000..7d0667e14 --- /dev/null +++ b/tinyagentos/agent_tools/coding_tools.py @@ -0,0 +1,137 @@ +"""Tool dispatch for the Coding Studio agent loop (#86). + +This is the substrate the tool-calling loop sits on: a registry of the +workspace file primitives (read/write/exists/list) described as JSON tool +schemas an LLM can be handed, plus a single ``dispatch`` step that validates a +proposed tool call and executes it against a jailed workspace root. + +Keeping execution here (rather than in the model glue) means the loop is the +same whether driven by a real model, a test, or a replayed transcript: propose +a call -> dispatch -> feed the result back. +""" + +from __future__ import annotations + +from typing import Any + +from tinyagentos.agent_tools import fs_tools +from tinyagentos.agent_tools.fs_tools import JailViolation + +# Cap how much a single read pulls into memory, matching the HTTP read route's +# guard so the agent loop cannot fault the controller by reading a huge file. +MAX_READ_BYTES = 2_000_000 + + +class _ReadTooLarge(ValueError): + """A read_file target exceeds MAX_READ_BYTES.""" + +# Anthropic/OpenAI-style function schemas. Handed to the model so it knows the +# available tools and their arguments; the names map 1:1 to _HANDLERS below. +TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "name": "read_file", + "description": "Read a UTF-8 text file inside the workspace.", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string", "description": "Workspace-relative path."}}, + "required": ["path"], + }, + }, + { + "name": "write_file", + "description": "Create or overwrite a UTF-8 text file inside the workspace.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Workspace-relative path."}, + "content": {"type": "string", "description": "Full file contents to write."}, + }, + "required": ["path", "content"], + }, + }, + { + "name": "file_exists", + "description": "Check whether a workspace-relative path is an existing file.", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + { + "name": "list_dir", + "description": "List the entries of a workspace directory (defaults to the root).", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string", "description": "Workspace-relative dir; '.' for root."}}, + "required": [], + }, + }, +] + +TOOL_NAMES = {t["name"] for t in TOOL_SCHEMAS} + + +def _h_read_file(root, args): + # Resolve through the same jail fs_tools uses, then size-check before reading. + target = fs_tools._resolve(root, args["path"]) + if target.stat().st_size > MAX_READ_BYTES: + raise _ReadTooLarge(f"file exceeds {MAX_READ_BYTES} byte read limit") + return target.read_text() + + +def _h_write_file(root, args): + return fs_tools.write_file(root, args["path"], args["content"]) + + +def _h_file_exists(root, args): + return fs_tools.file_exists(root, args["path"]) + + +def _h_list_dir(root, args): + return fs_tools.list_dir(root, args.get("path", ".")) + + +_HANDLERS = { + "read_file": (_h_read_file, ("path",)), + "write_file": (_h_write_file, ("path", "content")), + "file_exists": (_h_file_exists, ("path",)), + "list_dir": (_h_list_dir, ()), +} + + +def dispatch(workspace_root, name: str, arguments: dict | None) -> dict: + """Execute one tool call against the workspace. + + Returns a structured, always-JSON-serialisable result: + {"ok": True, "result": } on success + {"ok": False, "error": } on an unknown tool, missing argument, + jail violation, or filesystem error. + + Never raises: the loop feeds the error string back to the model so it can + recover, rather than crashing the turn. + """ + handler = _HANDLERS.get(name) + if handler is None: + return {"ok": False, "error": f"unknown tool: {name!r}"} + fn, required = handler + args = arguments or {} + if not isinstance(args, dict): + return {"ok": False, "error": "arguments must be an object"} + missing = [k for k in required if k not in args] + if missing: + return {"ok": False, "error": f"missing argument(s): {', '.join(missing)}"} + try: + return {"ok": True, "result": fn(workspace_root, args)} + except JailViolation as exc: + return {"ok": False, "error": str(exc)} + except _ReadTooLarge as exc: + return {"ok": False, "error": str(exc)} + except UnicodeDecodeError: + # read_file decodes as UTF-8; a binary/non-UTF-8 file must come back as a + # soft error, not crash the loop turn. + return {"ok": False, "error": "binary or non-UTF-8 file"} + except FileNotFoundError: + return {"ok": False, "error": "file not found"} + except OSError as exc: + return {"ok": False, "error": f"filesystem error: {exc.strerror or exc}"} diff --git a/tinyagentos/agent_tools/fs_tools.py b/tinyagentos/agent_tools/fs_tools.py new file mode 100644 index 000000000..ba8926133 --- /dev/null +++ b/tinyagentos/agent_tools/fs_tools.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +from tinyagentos.routes.coding import _resolve_jailed + + +class JailViolation(ValueError): + """Raised when a path escapes the workspace, targets .git, or is otherwise refused. + + These are the file primitives the Coding Studio agent calls for live edits. + Every path is resolved through the same workspace jail the coding routes use + (_resolve_jailed), so an escape via ``..``, an absolute path, a symlink that + resolves outside the workspace, or anything under ``.git`` is refused. + """ + + +def _resolve(workspace_root, rel_path: str, *, allow_root: bool = False) -> Path: + root = Path(workspace_root).resolve() + target = _resolve_jailed(root, rel_path, allow_root=allow_root) + if target is None: + raise JailViolation(f"path refused (escapes workspace or targets .git): {rel_path!r}") + return target + + +def read_file(workspace_root, rel_path: str) -> str: + return _resolve(workspace_root, rel_path).read_text() + + +def write_file(workspace_root, rel_path: str, content: str) -> int: + target = _resolve(workspace_root, rel_path) + target.parent.mkdir(parents=True, exist_ok=True) + return target.write_text(content) + + +def file_exists(workspace_root, rel_path: str) -> bool: + try: + return _resolve(workspace_root, rel_path).is_file() + except JailViolation: + return False + + +def list_dir(workspace_root, rel_path: str = ".") -> list[str]: + target = _resolve(workspace_root, rel_path, allow_root=True) + if not target.is_dir(): + raise JailViolation(f"not a directory: {rel_path!r}") + return sorted(p.name for p in target.iterdir()) diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 6158f63b7..8560803e5 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -258,6 +258,8 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> agent_grants_store = AgentGrantsStore(data_dir / "agent_grants.db") from tinyagentos.cluster.pairing_store import ClusterPairingStore cluster_pairing_store = ClusterPairingStore(data_dir / "cluster_pairing.db") + from tinyagentos.cluster.capability_map import CapabilityMap + capability_map_store = CapabilityMap(data_dir / "capability_map.db") metrics_store = MetricsStore(data_dir / "metrics.db") notif_store = NotificationStore(data_dir / "notifications.db") @@ -336,7 +338,11 @@ async def _probe_backend(backend: dict) -> dict: project_event_broker = ProjectEventBroker() from tinyagentos.desktop_control import DesktopCommandBroker desktop_command_broker = DesktopCommandBroker() - project_task_store = ProjectTaskStore(data_dir / "projects.db", broker=project_event_broker) + from tinyagentos.board_audit import BoardAuditLog + board_audit_store = BoardAuditLog(data_dir / "board_audit.db") + project_task_store = ProjectTaskStore( + data_dir / "projects.db", broker=project_event_broker, audit=board_audit_store + ) project_canvas_store = ProjectCanvasStoreImpl(data_dir / "projects.db", broker=project_event_broker) projects_root = data_dir / "projects" chat_hub = ChatHub() @@ -411,6 +417,8 @@ async def lifespan(app: FastAPI): app.state.agent_grants = agent_grants_store await cluster_pairing_store.init() app.state.cluster_pairing = cluster_pairing_store + await capability_map_store.init() + app.state.capability_map = capability_map_store await metrics_store.init() await notif_store.init() await qmd_client.init() @@ -430,6 +438,8 @@ async def lifespan(app: FastAPI): await chat_messages.init() await chat_channels.init() await project_store.init() + await board_audit_store.init() + app.state.board_audit = board_audit_store await project_task_store.init() await project_canvas_store.init() projects_root.mkdir(parents=True, exist_ok=True) @@ -1315,6 +1325,7 @@ async def dispatch(self, request, call_next): app.state.chat_messages = chat_messages app.state.chat_channels = chat_channels app.state.project_store = project_store + app.state.board_audit = board_audit_store app.state.project_task_store = project_task_store app.state.project_event_broker = project_event_broker app.state.desktop_command_broker = desktop_command_broker @@ -1370,6 +1381,7 @@ async def dispatch(self, request, call_next): app.state.auth_requests = auth_requests_store app.state.agent_grants = agent_grants_store app.state.cluster_pairing = cluster_pairing_store + app.state.capability_map = capability_map_store # Detect and set container runtime (eager, so tests work without lifespan) try: @@ -1401,6 +1413,15 @@ async def dispatch(self, request, call_next): def main(): + import sys + # `taos rollback [ref]` -- undo the last update (restore branch + version) and + # restart. Delegates to the pure-shell recovery script so it behaves the same + # whether the app is healthy or a bad update left it broken. + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + import subprocess + script = PROJECT_DIR / "scripts" / "rollback.sh" + raise SystemExit(subprocess.call(["bash", str(script), *sys.argv[2:]])) + import uvicorn config = load_config(PROJECT_DIR / "data" / "config.yaml") app = create_app() diff --git a/tinyagentos/board_audit.py b/tinyagentos/board_audit.py new file mode 100644 index 000000000..35b6b4f27 --- /dev/null +++ b/tinyagentos/board_audit.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import datetime +import json +import secrets + +from tinyagentos.base_store import BaseStore + +_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" + +BOARD_AUDIT_SCHEMA = """ +CREATE TABLE IF NOT EXISTS board_audit ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + event TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + from_status TEXT, + to_status TEXT, + detail TEXT NOT NULL DEFAULT '{}', + ts TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_board_audit_task ON board_audit(task_id); +CREATE INDEX IF NOT EXISTS idx_board_audit_ts ON board_audit(ts); +""" +# The project_id index is created in _post_init (not SCHEMA): on an existing +# board_audit table that predates the column, running it here would fail before +# the guarded ALTER had a chance to add the column. + +_COLS = "id, task_id, project_id, event, actor, from_status, to_status, detail, ts" + + +def _now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def _new_id() -> str: + return "ba-" + "".join(secrets.choice(_ALPHABET) for _ in range(8)) + + +def _row(r) -> dict: + return { + "id": r[0], "task_id": r[1], "project_id": r[2], "event": r[3], "actor": r[4], + "from_status": r[5], "to_status": r[6], "detail": json.loads(r[7] or "{}"), "ts": r[8], + } + + +class BoardAuditLog(BaseStore): + """Append-only audit trail of board task state changes (#105). + + The seed of the nothing-is-ever-deleted / Time Machine story: every status + change is recorded and never updated or deleted. There is deliberately no + public mutate or delete method. History is returned in insertion order + (SQLite rowid) so it is stable even when two events share a timestamp. + + Each row carries the owning project_id (so a project-scoped activity feed + never leaks another project's events) and a free-form JSON ``detail`` blob + for event-specific context that does not fit the status columns. + """ + + SCHEMA = BOARD_AUDIT_SCHEMA + + async def _post_init(self) -> None: + # project_id + detail were added after the initial board_audit ship. + # Guarded ALTER so existing databases gain them without a destructive + # migration (SQLite lacks ADD COLUMN IF NOT EXISTS before 3.37). + cols = {row[1] for row in await (await self._db.execute("PRAGMA table_info(board_audit)")).fetchall()} + if "project_id" not in cols: + await self._db.execute( + "ALTER TABLE board_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''" + ) + if "detail" not in cols: + await self._db.execute( + "ALTER TABLE board_audit ADD COLUMN detail TEXT NOT NULL DEFAULT '{}'" + ) + # The column is guaranteed present now (fresh via SCHEMA or just ALTERed), + # so the project_id index can be created idempotently for both paths. + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_board_audit_project ON board_audit(project_id)" + ) + await self._db.commit() + + async def record( + self, + task_id: str, + event: str, + actor: str = "", + from_status: str | None = None, + to_status: str | None = None, + ts: str | None = None, + project_id: str = "", + detail: dict | None = None, + ) -> str: + if not task_id: + raise ValueError("task_id is required") + if not event: + raise ValueError("event is required") + eid = _new_id() + when = ts or _now_iso() + await self._db.execute( + f"INSERT INTO board_audit ({_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (eid, task_id, project_id, event, actor, from_status, to_status, + json.dumps(detail or {}), when), + ) + await self._db.commit() + return eid + + async def history(self, task_id: str) -> list[dict]: + async with self._db.execute( + f"SELECT {_COLS} FROM board_audit WHERE task_id = ? ORDER BY rowid ASC", + (task_id,), + ) as cur: + rows = await cur.fetchall() + return [_row(r) for r in rows] + + async def recent_for_project(self, project_id: str, limit: int = 100) -> list[dict]: + """Newest-first activity feed for one project (capped).""" + async with self._db.execute( + f"SELECT {_COLS} FROM board_audit WHERE project_id = ? ORDER BY rowid DESC LIMIT ?", + (project_id, limit), + ) as cur: + rows = await cur.fetchall() + return [_row(r) for r in rows] + + async def all_since(self, ts: str) -> list[dict]: + async with self._db.execute( + f"SELECT {_COLS} FROM board_audit WHERE ts >= ? ORDER BY rowid ASC", (ts,) + ) as cur: + rows = await cur.fetchall() + return [_row(r) for r in rows] + + async def get(self, event_id: str) -> dict | None: + async with self._db.execute( + f"SELECT {_COLS} FROM board_audit WHERE id = ?", (event_id,) + ) as cur: + r = await cur.fetchone() + return _row(r) if r else None diff --git a/tinyagentos/cluster/capability_map.py b/tinyagentos/cluster/capability_map.py new file mode 100644 index 000000000..ac2a77ac3 --- /dev/null +++ b/tinyagentos/cluster/capability_map.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import time + +from tinyagentos.base_store import BaseStore + +CAPABILITY_MAP_SCHEMA = """ +CREATE TABLE IF NOT EXISTS capability_map ( + node_id TEXT PRIMARY KEY, + hostname TEXT NOT NULL DEFAULT '', + cpu TEXT NOT NULL DEFAULT '{}', + ram_mb INTEGER NOT NULL DEFAULT 0, + gpu TEXT NOT NULL DEFAULT '{}', + npu TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'offline', + last_seen INTEGER NOT NULL DEFAULT 0 +); +""" + +VALID_STATUS = {"online", "offline", "draining"} +_COLS = "node_id, hostname, cpu, ram_mb, gpu, npu, status, last_seen" + + +def _row(r) -> dict: + return { + "node_id": r[0], + "hostname": r[1], + "cpu": json.loads(r[2] or "{}"), + "ram_mb": r[3], + "gpu": json.loads(r[4] or "{}"), + "npu": json.loads(r[5] or "{}"), + "status": r[6], + "last_seen": r[7], + } + + +class CapabilityMap(BaseStore): + """Per-node hardware capability map for the cluster. + + Records each node's CPU/GPU/NPU/RAM plus a live status (online/offline/ + draining) and last_seen heartbeat. The foundation the scheduler and + placement logic read from. CPU/GPU/NPU are stored as JSON columns so the + shapes match HardwareProfile without a rigid schema. + """ + + SCHEMA = CAPABILITY_MAP_SCHEMA + + async def upsert(self, node: dict) -> dict: + node_id = node.get("node_id") + if not node_id: + raise ValueError("node requires a non-empty node_id") + status = node.get("status", "offline") + if status not in VALID_STATUS: + raise ValueError(f"invalid status: {status!r}") + ls = node.get("last_seen") + last_seen = int(ls) if ls is not None else int(time.time()) + await self._db.execute( + f"""INSERT INTO capability_map ({_COLS}) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(node_id) DO UPDATE SET + hostname=excluded.hostname, cpu=excluded.cpu, + ram_mb=excluded.ram_mb, gpu=excluded.gpu, npu=excluded.npu, + status=excluded.status, last_seen=excluded.last_seen""", + ( + node_id, node.get("hostname", ""), json.dumps(node.get("cpu", {})), + int(node.get("ram_mb", 0)), json.dumps(node.get("gpu", {})), + json.dumps(node.get("npu", {})), status, last_seen, + ), + ) + await self._db.commit() + return await self.get(node_id) + + async def get(self, node_id: str) -> dict | None: + async with self._db.execute( + f"SELECT {_COLS} FROM capability_map WHERE node_id = ?", (node_id,) + ) as cur: + r = await cur.fetchone() + return _row(r) if r else None + + async def list(self, status: str | None = None) -> list[dict]: + sql = f"SELECT {_COLS} FROM capability_map" + params: list = [] + if status is not None: + sql += " WHERE status = ?" + params.append(status) + sql += " ORDER BY node_id ASC" + async with self._db.execute(sql, params) as cur: + rows = await cur.fetchall() + return [_row(r) for r in rows] + + async def set_status(self, node_id: str, status: str) -> dict | None: + if status not in VALID_STATUS: + raise ValueError(f"invalid status: {status!r}") + if await self.get(node_id) is None: + return None + await self._db.execute( + "UPDATE capability_map SET status = ? WHERE node_id = ?", (status, node_id) + ) + await self._db.commit() + return await self.get(node_id) + + async def prune_stale(self, older_than_s: int) -> int: + cutoff = int(time.time()) - older_than_s + async with self._db.execute( + "DELETE FROM capability_map WHERE last_seen < ?", (cutoff,) + ) as cur: + count = cur.rowcount + await self._db.commit() + return count + + async def mark_stale_offline(self, older_than_s: int) -> int: + """Mark nodes that stopped heartbeating as offline (row preserved). + + The non-destructive counterpart to prune_stale: an 'online' node whose + last_seen is older than the cutoff is flipped to 'offline' so the + scheduler stops placing work on it, but the row (and its hardware) stays + for history and for when it comes back. 'draining' is an admin intent + and is left untouched; only 'online' nodes go stale-offline. Returns the + number of nodes flipped. + """ + cutoff = int(time.time()) - older_than_s + async with self._db.execute( + "UPDATE capability_map SET status = 'offline' " + "WHERE status = 'online' AND last_seen < ?", + (cutoff,), + ) as cur: + count = cur.rowcount + await self._db.commit() + return count diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index 1b159714d..e7f7ac0f3 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import time from typing import TYPE_CHECKING @@ -9,8 +10,11 @@ from tinyagentos.projects.ids import new_id if TYPE_CHECKING: + from tinyagentos.board_audit import BoardAuditLog from tinyagentos.projects.events import ProjectEventBroker +logger = logging.getLogger(__name__) + TASK_SCHEMA = """ CREATE TABLE IF NOT EXISTS project_tasks ( id TEXT PRIMARY KEY, @@ -86,15 +90,51 @@ def _row_to_task(row, description) -> dict: class ProjectTaskStore(BaseStore): SCHEMA = TASK_SCHEMA - def __init__(self, db_path, *, broker: "ProjectEventBroker | None" = None) -> None: + def __init__( + self, + db_path, + *, + broker: "ProjectEventBroker | None" = None, + audit: "BoardAuditLog | None" = None, + ) -> None: super().__init__(db_path) self._broker = broker + self._audit = audit async def _publish(self, project_id: str, kind: str, payload: dict) -> None: if self._broker is not None: from tinyagentos.projects.events import ProjectEvent await self._broker.publish(project_id, ProjectEvent(kind=kind, payload=payload)) + async def _record_audit( + self, + task_id: str, + event: str, + actor: str, + from_status: str | None, + to_status: str | None, + project_id: str = "", + ) -> None: + """Append a status transition to the board audit log (best effort). + + The audit log lives in its own store; a failure to record must never + roll back or break the task mutation that already committed. project_id + is recorded so the project-scoped activity feed never crosses projects. + """ + if self._audit is None: + return + try: + await self._audit.record( + task_id=task_id, + event=event, + actor=actor, + from_status=from_status, + to_status=to_status, + project_id=project_id, + ) + except Exception: + logger.warning("board audit record failed for task %s", task_id, exc_info=True) + async def create_task( self, project_id: str, @@ -121,6 +161,7 @@ async def create_task( await self._db.commit() new_task = await self.get_task(tid) await self._publish(project_id, "task.created", {"id": new_task["id"], "task": new_task}) + await self._record_audit(tid, "task.created", created_by, None, "open", project_id=project_id) return new_task async def get_task(self, task_id: str) -> dict | None: @@ -204,6 +245,10 @@ async def claim_task(self, task_id: str, claimer_id: str) -> bool: existing = await self.get_task(task_id) if existing is not None: await self._publish(existing["project_id"], "task.claimed", {"id": task_id, "claimed_by": claimer_id}) + await self._record_audit( + task_id, "task.claimed", claimer_id, "open", "claimed", + project_id=existing["project_id"] if existing else "", + ) return changed async def release_task(self, task_id: str, releaser_id: str) -> bool: @@ -224,6 +269,10 @@ async def release_task(self, task_id: str, releaser_id: str) -> bool: "task.released", {"id": task_id, "releaser_id": releaser_id}, ) + await self._record_audit( + task_id, "task.released", releaser_id, "claimed", "open", + project_id=existing["project_id"] if existing else "", + ) return changed async def close_task( @@ -245,6 +294,14 @@ async def close_task( existing = await self.get_task(task_id) if existing is not None: await self._publish(existing["project_id"], "task.closed", {"id": task_id, "closed_by": closed_by}) + # Derive the pre-close status race-free from the committed row rather + # than a separate pre-read (which would have a TOCTOU gap). close does + # not clear claimed_by, so a set claimer means it was 'claimed'. + from_status = "claimed" if existing and existing.get("claimed_by") else "open" + await self._record_audit( + task_id, "task.closed", closed_by, from_status, "closed", + project_id=existing["project_id"] if existing else "", + ) return changed async def reopen_task(self, task_id: str, reopened_by: str) -> bool: @@ -265,6 +322,10 @@ async def reopen_task(self, task_id: str, reopened_by: str) -> bool: existing = await self.get_task(task_id) if existing is not None: await self._publish(existing["project_id"], "task.reopened", {"id": task_id, "reopened_by": reopened_by}) + await self._record_audit( + task_id, "task.reopened", reopened_by, "closed", "open", + project_id=existing["project_id"] if existing else "", + ) return changed async def add_relationship( diff --git a/tinyagentos/rollback.py b/tinyagentos/rollback.py new file mode 100644 index 000000000..899fdbaff --- /dev/null +++ b/tinyagentos/rollback.py @@ -0,0 +1,63 @@ +"""Update rollback state for taOS. + +Before every update, the updater records the exact branch + commit it is leaving +so a later ``taos rollback`` can restore BOTH (the previous version and the +previous branch, even if both changed). The record is written as a tiny +shell-sourceable file so ``scripts/rollback.sh`` can read it with no Python and +no dashboard, which is the whole point: rollback must work when an update has +broken the app. + +File: ``/.taos-rollback`` (single record, overwritten each update). +""" + +from __future__ import annotations + +from pathlib import Path + +ROLLBACK_FILE = ".taos-rollback" + + +def _shq(value: str) -> str: + """Single-quote a value so the file stays safe to `source` in bash.""" + return "'" + str(value).replace("'", "'\\''") + "'" + + +def record_pre_update(project_dir, *, branch: str, sha: str, ts: int) -> Path: + """Write the pre-update branch + commit so a rollback can restore both. + + Overwrites any prior record: rollback targets the state immediately before + the most recent update, which is the one a user would want to undo. + """ + path = Path(project_dir) / ROLLBACK_FILE + path.write_text( + "# taOS rollback target -- the branch + commit the last update left.\n" + "# Shell-sourceable on purpose so scripts/rollback.sh needs no Python.\n" + f"prev_branch={_shq(branch)}\n" + f"prev_sha={_shq(sha)}\n" + f"prev_ts={_shq(ts)}\n" + ) + return path + + +def read_rollback_target(project_dir) -> dict | None: + """Read the recorded rollback target, or None if there is no record. + + Returns ``{"branch": str, "sha": str, "ts": str}``. Parses the simple + ``key='value'`` lines without sourcing (so it is safe to call on any input). + """ + path = Path(project_dir) / ROLLBACK_FILE + if not path.is_file(): + return None + out: dict[str, str] = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, raw = line.partition("=") + val = raw.strip() + if len(val) >= 2 and val[0] == val[-1] == "'": + val = val[1:-1].replace("'\\''", "'") + out[key.strip()] = val + if "prev_branch" not in out or "prev_sha" not in out: + return None + return {"branch": out["prev_branch"], "sha": out["prev_sha"], "ts": out.get("prev_ts", "")} diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 4257a133c..baa8ddfd9 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -111,6 +111,9 @@ def register_all_routers(app): from tinyagentos.routes.cluster_migrate import router as cluster_migrate_router app.include_router(cluster_migrate_router) + from tinyagentos.routes.cluster_capability import router as cluster_capability_router + app.include_router(cluster_capability_router) + from tinyagentos.routes.training import router as training_router app.include_router(training_router) diff --git a/tinyagentos/routes/apps.py b/tinyagentos/routes/apps.py index 61a92280b..ef53498c8 100644 --- a/tinyagentos/routes/apps.py +++ b/tinyagentos/routes/apps.py @@ -26,10 +26,13 @@ # runtime location). The frontend owns name/icon/cover; the backend only tracks # which ids are installed, gated to this allowlist so the endpoint can't be used # to write arbitrary install rows. +# The platform social apps (reddit, youtube-library, github-browser, x-monitor) +# were DE-SEEDED from the default store: they are unfinished and now live as the +# operator's private App Studio drafts to be finished + published on stream, not +# offered to every user. The Creative Studios remain installable optional apps. OPTIONAL_FRONTEND_APPS = { - "reddit", "youtube-library", "github-browser", "x-monitor", - # Creative Studios install the same way: a frontend-only optional app whose - # install row just flips the launcher visibility, no service spawned. + # Creative Studios: a frontend-only optional app whose install row just + # flips the launcher visibility, no service spawned. "coding-studio", "design-studio", "music-studio", "app-studio", "office-suite", } _FRONTEND_APP_KIND = "frontend-app" @@ -37,10 +40,6 @@ # In-core version for each optional app. When an app becomes a real .taosapp # package, the package version will win instead of this value. APP_VERSIONS: dict[str, str] = { - "reddit": "1.0.0", - "youtube-library": "1.0.0", - "github-browser": "1.0.0", - "x-monitor": "1.0.0", "coding-studio": "1.0.0", "design-studio": "1.0.0", "music-studio": "1.0.0", @@ -50,10 +49,6 @@ # Trust level for each optional app (all current optional apps are first-party). APP_TRUST: dict[str, str] = { - "reddit": "first-party", - "youtube-library": "first-party", - "github-browser": "first-party", - "x-monitor": "first-party", "coding-studio": "first-party", "design-studio": "first-party", "music-studio": "first-party", diff --git a/tinyagentos/routes/browser_sessions.py b/tinyagentos/routes/browser_sessions.py index 8fde6b4b3..c30649aa8 100644 --- a/tinyagentos/routes/browser_sessions.py +++ b/tinyagentos/routes/browser_sessions.py @@ -116,33 +116,47 @@ async def create_session( return JSONResponse({"error": "session has no user id"}, status_code=401) cluster = request.app.state.cluster_manager - if body.node is not None: - capable_names = {n["name"] for n in list_browser_nodes(cluster)} - if body.node not in capable_names: - return JSONResponse({"error": "no_capable_node"}, status_code=409) - node = body.node - else: - node = pick_browser_node(cluster) - if node is None: - return JSONResponse({"error": "no_capable_node"}, status_code=409) - - worker = cluster.get_worker(node) - if worker is None: + # Placement: explicit worker -> host (if RAM-capable) -> best worker. The + # host branch is what lets a capable controller (e.g. a 16GB Pi) serve a + # streamed session itself; the previous worker-only path returned + # no_capable_node on a host with no Tier-2 browser workers. + host_hw = getattr(request.app.state, "host_hardware", None) + target = resolve_browser_target(cluster, host_hw, explicit_node=body.node) + if target is None: return JSONResponse({"error": "no_capable_node"}, status_code=409) + kind, node = target + + # Resolve the worker BEFORE creating the session row, so a failed lookup + # returns 409 without leaving an orphaned session record in the store. + worker = None + if kind != "host": + worker = cluster.get_worker(node) + if worker is None: + return JSONResponse({"error": "no_capable_node"}, status_code=409) mgr = request.app.state.browser_sessions session = await mgr.create_session( "user", user_id, body.url, body.profile or "default" ) + vol = f"taos-browser-{session['id']}" auth_token = getattr(request.app.state, "browser_worker_auth_token", None) try: - session = await mgr.start_on_worker( - session["id"], - node=node, - worker_url=worker.url, - profile_volume=f"taos-browser-{session['id']}", - auth_token=auth_token, - ) + if kind == "host": + runner = request.app.state.browser_container_runner + # _connecting_host_ip does a blocking DNS lookup; offload it so the + # event loop is not stalled while the host is resolved. + nat1to1_ip = await asyncio.to_thread(_connecting_host_ip, request) + session = await mgr.start_on_host( + session["id"], profile_volume=vol, runner=runner, nat1to1_ip=nat1to1_ip + ) + else: + session = await mgr.start_on_worker( + session["id"], + node=node, + worker_url=worker.url, + profile_volume=vol, + auth_token=auth_token, + ) except BrowserWorkerError: return JSONResponse({"error": "worker_start_failed"}, status_code=502) return JSONResponse(session, status_code=201) diff --git a/tinyagentos/routes/cluster.py b/tinyagentos/routes/cluster.py index 315babfa1..3b31f0700 100644 --- a/tinyagentos/routes/cluster.py +++ b/tinyagentos/routes/cluster.py @@ -267,11 +267,51 @@ async def register_worker(request: Request, body: WorkerRegister): signing_key=signing_key, ) await cluster.register_worker(info) + await _record_worker_capability(request.app, body.name, body.host_lan_ip, body.hardware) if body.pending_storage_backup: await _surface_storage_backup(request.app, body.name, body.pending_storage_backup) return {"status": "registered", "name": body.name} +async def _record_worker_capability(app, name: str, host_lan_ip: str, hardware: dict) -> None: + """Populate the capability map from a registering worker (best effort). + + The worker's detected hardware dict already carries cpu/ram_mb/gpu/npu, the + same shape the capability map stores, so registration doubles as a heartbeat + that marks the node online. A failure here must never fail registration; an + explicit admin set-status still owns 'draining'. + """ + store = getattr(app.state, "capability_map", None) + if store is None: + return + hw = hardware or {} + try: + current = await store.get(name) + status = "draining" if current is not None and current["status"] == "draining" else "online" + # The store does a full-row overwrite, so a legacy/flat-mode worker that + # re-registers without hardware would wipe previously-detected fields. + # Carry forward each field the incoming hardware omits. + prev = current or {} + + def _keep(key, default): + val = hw.get(key) + return val if val else prev.get(key, default) + + await store.upsert( + { + "node_id": name, + "hostname": host_lan_ip or prev.get("hostname") or name, + "cpu": _keep("cpu", {}), + "ram_mb": _keep("ram_mb", 0), + "gpu": _keep("gpu", {}), + "npu": _keep("npu", {}), + "status": status, + } + ) + except Exception: # noqa: BLE001 + logger.warning("capability-map upsert on worker registration failed for %s", name) + + async def _surface_storage_backup(app, worker_name: str, marker: dict) -> None: """Materialise an install-worker storage-backup marker as both a notification and a workspace text file so the user finds out about diff --git a/tinyagentos/routes/cluster_capability.py b/tinyagentos/routes/cluster_capability.py new file mode 100644 index 000000000..30f16a369 --- /dev/null +++ b/tinyagentos/routes/cluster_capability.py @@ -0,0 +1,134 @@ +"""Cluster capability-map endpoints. + +Thin HTTP surface over CapabilityMap (tinyagentos/cluster/capability_map.py): +workers push a hardware/status heartbeat; admins read the live map and adjust +node status. The scheduler and placement logic read the same store directly. + +Auth mirrors the existing cluster routes: heartbeats are gated by the worker +HMAC (a node may only write its own row), reads and admin mutations require an +admin session. No new auth machinery is introduced here. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from tinyagentos.cluster.worker_auth import _HMACError, require_worker_hmac +from tinyagentos.routes.auth import _require_admin + +router = APIRouter() + +# Heartbeats older than this are considered stale and pruned on demand. +DEFAULT_STALE_S = 900 + + +class CapabilityHeartbeat(BaseModel): + node_id: str + hostname: str = "" + cpu: dict = Field(default_factory=dict) + ram_mb: int = 0 + gpu: dict = Field(default_factory=dict) + npu: dict = Field(default_factory=dict) + status: str = "online" + + +class StatusUpdate(BaseModel): + status: str + + +class PruneRequest(BaseModel): + older_than_s: int = DEFAULT_STALE_S + + +def _store(request: Request): + return getattr(request.app.state, "capability_map", None) + + +@router.post("/api/cluster/capability/heartbeat") +async def capability_heartbeat(request: Request, body: CapabilityHeartbeat): + """Worker HMAC — a node upserts its own capability row + liveness.""" + try: + await require_worker_hmac(request) + except _HMACError as exc: + return exc.response + # A node may only write its own row: the authenticated worker name must + # match the heartbeat's node_id (same rule as worker registration). + if getattr(request.state, "hmac_worker_name", None) != body.node_id: + return JSONResponse( + {"error": "Worker name in header does not match node_id"}, + status_code=403, + ) + store = _store(request) + if store is None: + return JSONResponse({"error": "capability map unavailable"}, status_code=503) + payload = body.model_dump() + # "draining" is an admin decision to stop scheduling new work on a node that + # is still alive and heartbeating. A routine heartbeat (status defaults to + # "online") must not silently clear that intent; only an explicit admin + # set-status call clears draining. Preserve it across heartbeats. + current = await store.get(body.node_id) + if current is not None and current["status"] == "draining": + payload["status"] = "draining" + try: + node = await store.upsert(payload) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + return node + + +@router.get("/api/cluster/capability") +async def capability_list(request: Request, status: str | None = None): + """Admin — list capability rows, optionally filtered by status.""" + ok, err = _require_admin(request) + if not ok: + return err + store = _store(request) + if store is None: + return JSONResponse({"error": "capability map unavailable"}, status_code=503) + return {"nodes": await store.list(status)} + + +@router.post("/api/cluster/capability/{node_id}/status") +async def capability_set_status(request: Request, node_id: str, body: StatusUpdate): + """Admin — set a node's status (online/offline/draining).""" + ok, err = _require_admin(request) + if not ok: + return err + store = _store(request) + if store is None: + return JSONResponse({"error": "capability map unavailable"}, status_code=503) + try: + node = await store.set_status(node_id, body.status) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + if node is None: + return JSONResponse({"error": "unknown node"}, status_code=404) + return node + + +@router.post("/api/cluster/capability/prune") +async def capability_prune(request: Request, body: PruneRequest): + """Admin — drop rows whose last heartbeat is older than older_than_s.""" + ok, err = _require_admin(request) + if not ok: + return err + store = _store(request) + if store is None: + return JSONResponse({"error": "capability map unavailable"}, status_code=503) + removed = await store.prune_stale(body.older_than_s) + return {"pruned": removed} + + +@router.post("/api/cluster/capability/sweep") +async def capability_sweep(request: Request, body: PruneRequest): + """Admin — flip online nodes with no recent heartbeat to offline (row kept).""" + ok, err = _require_admin(request) + if not ok: + return err + store = _store(request) + if store is None: + return JSONResponse({"error": "capability map unavailable"}, status_code=503) + flipped = await store.mark_stale_offline(body.older_than_s) + return {"offlined": flipped} diff --git a/tinyagentos/routes/coding.py b/tinyagentos/routes/coding.py index 7551702a1..2ff9be205 100644 --- a/tinyagentos/routes/coding.py +++ b/tinyagentos/routes/coding.py @@ -35,6 +35,11 @@ class ApplyBlocksBody(BaseModel): blocks: list[ApplyBlock] +class ToolCallBody(BaseModel): + name: str + arguments: dict | None = None + + # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- @@ -450,3 +455,32 @@ async def apply_blocks(request: Request, workspace_id: str, body: ApplyBlocksBod applied.append(rel) return {"applied": applied} + + +# --------------------------------------------------------------------------- +# Agent tool-calling loop (#86) +# --------------------------------------------------------------------------- + +@router.get("/api/coding/tools") +async def list_coding_tools(request: Request): + """The workspace tool schemas the agent loop hands to the model.""" + from tinyagentos.agent_tools.coding_tools import TOOL_SCHEMAS + + return {"tools": TOOL_SCHEMAS} + + +@router.post("/api/coding/workspaces/{workspace_id}/tool") +async def run_coding_tool(request: Request, workspace_id: str, body: ToolCallBody): + """Execute one agent tool call against a jailed workspace. + + The execution half of the tool-calling loop: the model proposes a call, the + controller runs it here and feeds the structured result back. Errors are + returned as {"ok": false, "error": ...} (HTTP 200) so the loop can recover; + only an unknown workspace is a hard 404. + """ + from tinyagentos.agent_tools.coding_tools import dispatch + + root, err = await _workspace_root(request, workspace_id) + if err is not None: + return err + return dispatch(root, body.name, body.arguments) diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py index 68863773a..b90c01236 100644 --- a/tinyagentos/routes/projects.py +++ b/tinyagentos/routes/projects.py @@ -654,6 +654,49 @@ async def reopen_task( return await store.get_task(task_id) +@router.get("/api/projects/{project_id}/audit") +async def project_audit_feed( + project_id: str, + request: Request, + user: CurrentUser = Depends(current_user), + limit: int = 100, +): + """Project-wide board activity feed, newest first (owner-gated, #105). + + Scoped to the project so it never surfaces another project's events. limit + is clamped to a sane ceiling to keep the response bounded. + """ + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + audit = getattr(request.app.state, "board_audit", None) + capped = max(1, min(limit, 500)) + events = await audit.recent_for_project(project_id, capped) if audit is not None else [] + return {"project_id": project_id, "events": events} + + +@router.get("/api/projects/{project_id}/tasks/{task_id}/audit") +async def task_audit_history( + project_id: str, + task_id: str, + request: Request, + user: CurrentUser = Depends(current_user), +): + """Append-only audit trail for a task: every status transition in order (#105).""" + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + store = request.app.state.project_task_store + existing = await store.get_task(task_id) + if existing is None or existing["project_id"] != project_id: + return JSONResponse({"error": "not found"}, status_code=404) + audit = getattr(request.app.state, "board_audit", None) + events = await audit.history(task_id) if audit is not None else [] + return {"task_id": task_id, "events": events} + + @router.post("/api/projects/{project_id}/tasks/{task_id}/relationships") async def add_relationship( project_id: str, diff --git a/tinyagentos/update_runner.py b/tinyagentos/update_runner.py index b0fb49d83..6961fe7f7 100644 --- a/tinyagentos/update_runner.py +++ b/tinyagentos/update_runner.py @@ -36,6 +36,21 @@ class UpdateResult: ok: bool = True # False when a step failed and no (or partial) switch happened +def _record_rollback_target(project_dir, branch: str, sha: str, ts: int) -> None: + """Persist the pre-update branch + commit for `taos rollback` (best effort). + + Always called before an update mutates the tree, so even a clean + fast-forward (no recovery tag) leaves a restore point. + """ + if not branch or not sha: + return + try: + from tinyagentos.rollback import record_pre_update + record_pre_update(project_dir, branch=branch, sha=sha, ts=ts) + except Exception: # noqa: BLE001 + logger.warning("update_runner: failed to record rollback target", exc_info=True) + + async def _run(args: list[str], cwd: Path) -> tuple[int, str]: """Run a subprocess safely (no shell) and return (returncode, output).""" proc = await asyncio.create_subprocess_exec( @@ -83,6 +98,10 @@ async def update_to_master(project_dir: Path) -> UpdateResult: ) dirty = bool(status_out.strip()) + # Record the rollback target AFTER the dirty probe so the (gitignored) state + # file never counts as a dirty working tree and triggers a spurious stash. + _record_rollback_target(project_dir, branch, current_sha, ts) + result = UpdateResult(previous_sha=current_sha, new_sha=current_sha) # 3. Switch to master if on another branch @@ -184,6 +203,8 @@ async def switch_to_branch(branch: str, project_dir: Path) -> UpdateResult: return UpdateResult(previous_sha="", new_sha="", ok=False, message=f"Fetch failed — no changes applied. ({out.strip()[:200]})") + _, cur_branch_out = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], project_dir) + cur_branch = cur_branch_out.strip() _, sha_out = await _run(["git", "rev-parse", "HEAD"], project_dir) current_sha = sha_out.strip() short_sha = current_sha[:7] @@ -191,6 +212,10 @@ async def switch_to_branch(branch: str, project_dir: Path) -> UpdateResult: _, status_out = await _run(["git", "status", "--porcelain", "-u"], project_dir) dirty = bool(status_out.strip()) + # After the dirty probe (see update_to_master) so the gitignored state file + # never triggers a spurious stash. + _record_rollback_target(project_dir, cur_branch, current_sha, ts) + result = UpdateResult(previous_sha=current_sha, new_sha=current_sha) recovery_tag = f"taos-pre-switch-{short_sha}-{ts}" diff --git a/tinyagentos/worker/browser_container.py b/tinyagentos/worker/browser_container.py index 4c7afaef8..48fe112fd 100644 --- a/tinyagentos/worker/browser_container.py +++ b/tinyagentos/worker/browser_container.py @@ -90,12 +90,13 @@ def build_nat1to1(node_ip: str, connecting_host_ip: str | None = None) -> str: # Requires --shm-size=4g at container run time. # Built for arm64 (RK3588 primary) + amd64. DEFAULT_NEKO_CDP_IMAGE = "ghcr.io/jaylfc/taos-neko-cdp:latest" -# RK3588 uses the CDP image so agent automation is available out of the box -# on the Pi. The rkmpp HW-encode layer (#624) will extend this image once -# the GStreamer Rockchip packages are validated; for now software encode is -# the fallback (RK3588 hardware decode of page content still works via the -# kernel driver even without GStreamer rkmpp). -DEFAULT_NEKO_RK3588_IMAGE = DEFAULT_NEKO_CDP_IMAGE +# RK3588 uses a dedicated image built on top of the CDP image (so it keeps +# Chromium >=148 + CDP) plus the Rockchip MPP + gstreamer-rockchip mpph264enc +# plugin for VPU H.264 encode (#624). Validated live on the Pi: mpph264enc +# encodes 720p on the VPU when /dev/mpp_service, /dev/dri and /dev/rga are +# passed (resolve_neko_image supplies all three). rk3588 hosts always resolve +# to this image; there is no automatic resolver-level fallback to the CDP image. +DEFAULT_NEKO_RK3588_IMAGE = "ghcr.io/jaylfc/taos-neko-rk3588:latest" NEKO_SCREEN = "1280x720@30" NEKO_SCREEN_MOBILE = "800x1600@30" NEKO_PROFILE_MOUNT = "/home/neko" @@ -339,9 +340,10 @@ async def start(self, *, session_id: str, profile_volume: str, mobile: bool = Fa # The port is bound to 127.0.0.1 inside the container; the launcher # accesses it via `docker exec` / a loopback port binding on the host. # None for images that don't expose CDP (stock Neko, GPU image). + # The RK3588 image is built FROM the CDP image, so it also exposes CDP. cdp_url = ( "http://127.0.0.1:9222" - if spec.image == DEFAULT_NEKO_CDP_IMAGE + if spec.image in (DEFAULT_NEKO_CDP_IMAGE, DEFAULT_NEKO_RK3588_IMAGE) else None ) diff --git a/uv.lock b/uv.lock index adc9ee095..9e0be5cf0 100644 --- a/uv.lock +++ b/uv.lock @@ -1442,7 +1442,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.89.2" +version = "1.89.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1458,9 +1458,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/29/865d38325f9c424daf0bcc7ab61908cf51a87862b3a0dfebd059b60dac97/litellm-1.89.2.tar.gz", hash = "sha256:b2534d69568eed026310f4e006407db2d46494eb629bd1e71eb9603ec146540d", size = 14079449, upload-time = "2026-06-18T02:26:03.64Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/f1/f7cfead063f2ab1877c8fb465d0d7fe300b75f081bcb73525f6d550aeb1c/litellm-1.89.3.tar.gz", hash = "sha256:8fcdb2b7a0ef3381d41adf164443842e31ef9f0cd5bcda6fc3c0bd8bc2959510", size = 14080611, upload-time = "2026-06-20T22:42:26.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/c8/51c93e8d017e7af02600171b5b6cc3805b07507acb2b94de7235ce764015/litellm-1.89.2-py3-none-any.whl", hash = "sha256:07e8e43b1a70fe919021376742897d18ffe7577ccfbb84632c949670f9abdc03", size = 15492797, upload-time = "2026-06-18T02:25:59.863Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f1/34d174ff1d84e459b30f971606ac9cb7078ad24cd7661e9786b25adf7def/litellm-1.89.3-py3-none-any.whl", hash = "sha256:414ef5aee504b2b3eb1b219d39f1c11902db399cbdbc06e5fb550c15d731abeb", size = 15495226, upload-time = "2026-06-20T22:42:23.156Z" }, ] [package.optional-dependencies] @@ -3574,7 +3574,7 @@ wheels = [ [[package]] name = "tinyagentos" -version = "1.0.0b5" +version = "1.0.0b6" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -3629,7 +3629,7 @@ requires-dist = [ { name = "httpx", marker = "extra == 'dev'" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "libtorrent", specifier = ">=2.0.9" }, - { name = "litellm", extras = ["proxy"], marker = "extra == 'proxy'", specifier = ">=1.89.2" }, + { name = "litellm", extras = ["proxy"], marker = "extra == 'proxy'", specifier = ">=1.89.3" }, { name = "lxml", specifier = ">=5.0.0" }, { name = "pillow", specifier = ">=10.0" }, { name = "pillow", marker = "extra == 'worker'", specifier = ">=10.0" },