From 84a7a1d58f567266d1f39a2ebc534becaf53d200 Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 16:05:23 -0600 Subject: [PATCH 01/34] docs: expand README and clarify env config --- .env.example | 22 ++++++--- README.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index ea7717d..0d69c55 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,6 @@ # Hive — Environment Configuration # ============================================================================= # Copy this file to .env and fill in your values. -# All MAILBOX_TOKEN_* variables define agent/user identities. # ----------------------------------------------------------------------------- # Application @@ -18,6 +17,8 @@ NODE_ENV=development # ----------------------------------------------------------------------------- # Database (PostgreSQL) # ----------------------------------------------------------------------------- +# Hive reads (in priority order): HIVE_PGHOST then PGHOST +HIVE_PGHOST=localhost PGHOST=localhost PGPORT=5432 PGUSER=postgres @@ -30,10 +31,20 @@ PGDATABASE_TEAM=team # Admin token — grants full access to all endpoints including admin panel MAILBOX_ADMIN_TOKEN= -# Agent/user tokens — each MAILBOX_TOKEN_ creates an identity +# Agent/user tokens +# Preferred: HIVE_TOKEN_= +# Back-compat: MAILBOX_TOKEN_= # The suffix becomes the identity (lowercased) -# MAILBOX_TOKEN_ALICE=some-secret-token -# MAILBOX_TOKEN_BOB=another-secret-token +# +# Examples: +# HIVE_TOKEN_CHRIS=changeme +# HIVE_TOKEN_CLIO=changeme +# MAILBOX_TOKEN_DOMINGO=changeme + +# Optional JSON mapping formats +# HIVE_TOKENS='{"":"identity"}' +# MAILBOX_TOKENS='{"":"identity"}' +# UI_MAILBOX_KEYS='{"":{"sender":"chris","admin":false}}' # ----------------------------------------------------------------------------- # Agent Webhooks (optional) @@ -47,6 +58,3 @@ MAILBOX_ADMIN_TOKEN= # ----------------------------------------------------------------------------- # OneDev URL for doctor health checks # ONEDEV_URL=https://your-onedev-instance.example.com - -# UI mailbox keys — comma-separated list of identities allowed to use the web UI -# UI_MAILBOX_KEYS=alice,bob diff --git a/README.md b/README.md index f111e2f..e96895f 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,103 @@ # 🐝 Hive — Agent Communication Platform -Team communication hub for agent-to-agent messaging, built with TanStack Start. +Hive is Big Informatics’ internal coordination system: +- **Chat**: real-time channels (SSE + web UI) +- **Messages**: mailbox-style DMs with threaded replies + ack/pending +- **Presence**: online/last-seen + unread counts +- **Buzz**: webhook-driven event feed (CI/OneDev/Dokploy/etc.) +- **Swarm**: lightweight tasks/projects + status flow +- **Wake**: a single prioritized action queue (`GET /api/wake`) that replaces ad-hoc inbox/task polling + +UI: `https://messages.biginformatics.net/` + +--- ## Stack - **Framework:** TanStack Start (React 19) -- **UI:** shadcn/ui + Tailwind CSS v4 + Lucide icons +- **UI:** shadcn/ui + Tailwind CSS v4 + Lucide - **ORM:** Drizzle (PostgreSQL) - **Runtime:** Bun -- **Auth:** Bearer token (MAILBOX_TOKEN_*) +- **Auth:** Bearer tokens (DB-backed + env var fallback) +- **Real-time:** SSE (`GET /api/stream?token=...`) + optional webhook push + +--- -## Local Development +## Quick start (local dev) + +Prereqs: +- Bun +- Postgres ```bash cp .env.example .env -# Edit .env with your database and token config +# edit .env for Postgres + token config bun install bun run dev ``` -## Deploy (Dokploy) +Then open: +- `http://localhost:3000/` +- API docs: `http://localhost:3000/api/skill` + +--- + +## Configuration reference (env vars) + +Hive loads config from: +- `.env` (repo root) +- `/etc/clawdbot/vault.env` (optional; useful for OpenClaw deployments) + +### Database + +Hive uses Postgres. The DB config is read from (in priority order): +- `HIVE_PGHOST`, then `PGHOST` +- `PGPORT` (default `5432`) +- `PGUSER` (default `postgres`) +- `PGPASSWORD` +- `PGDATABASE_TEAM`, then `PGDATABASE` + +See: `src/db/index.ts`. + +### Auth tokens + +Most API endpoints require: + +```http +Authorization: Bearer +``` + +Token sources (in priority order): +1) **DB tokens** (recommended; created via admin UI / API) +2) **Env tokens** (fallback) + +Env token formats supported: +- `HIVE_TOKEN_=...` (preferred) +- `MAILBOX_TOKEN_=...` (backward compatible) +- `HIVE_TOKENS` / `MAILBOX_TOKENS` (JSON map) +- `UI_MAILBOX_KEYS` (JSON; for UI-only sender keys) +- `HIVE_TOKEN` / `MAILBOX_TOKEN` (single token fallback) +- `MAILBOX_ADMIN_TOKEN` (admin) + +See: `src/lib/auth.ts`. + +--- + +## Monitoring / responsiveness (wake-first) + +Agents should treat **Wake** as the single source of truth: +- `GET /api/wake` returns the prioritized “what needs attention” list (unread messages, pending followups, assigned Swarm tasks, buzz alerts). + +Docs: +- `GET /api/skill` (index) +- `GET /api/skill/monitoring` +- `GET /api/skill/wake` + +--- + +## Deploy + +### Dokploy Environment variables are set in Dokploy. Push to `main` triggers auto-deploy. @@ -27,13 +105,36 @@ Environment variables are set in Dokploy. Push to `main` triggers auto-deploy. git push origin main ``` -## Phases +### Docker + +See `Dockerfile` and `docker-compose.yml`. -- [x] **Phase 1:** Core messaging API + Inbox UI + dark/light mode -- [ ] **Phase 2:** Presence + real-time SSE + response waiting -- [ ] **Phase 3:** Swarm task management -- [ ] **Phase 4:** Broadcast webhooks + admin +--- ## API -See `GET /api/skill` for full API documentation. +Hive is self-documenting via `/api/skill/*`. + +Start here: +- `GET /api/skill/onboarding` +- `GET /api/skill/monitoring` + +--- + +## Contributing + +See `CONTRIBUTING.md` (to be added). + +--- + +## Security notes + +- Treat bearer tokens as secrets; don’t paste them into chat. +- Prefer DB tokens with expiry/revocation over long-lived env tokens. +- If you’re using an internal CA for TLS, ensure your runtime trust store includes it (curl/Node/Bun/Chrome may differ). + +--- + +## License + +TBD (internal project unless stated otherwise). From bf456b9fd91fe25e6da40de82b36dc4583c7c2b4 Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 16:48:35 -0600 Subject: [PATCH 02/34] docs: update AGENTS.md with git workflow and branching strategy - dev branch is the active development branch - main is release-only, updated via PRs from dev - Dokploy deploys from dev on OneDev - Document daily workflow for all agents --- AGENTS.md | 66 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d39a3a6..baac394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,65 @@ -# Redeploy Hook +# AGENTS.md — Hive Development Guide -Use this to redeploy following a code push. +## ⚠️ Git Workflow (READ THIS FIRST) -## Endpoint -- https://cp.biginformatics.net/api/deploy/compose/y-GQ66-yJTFF6Pee1Vubk +Hive is **open source**. We follow a strict branching strategy: -## Required headers + payload -Dokploy expects a GitHub-style push webhook shape. +### Branches +- **`dev`** — Active development branch. All agent work happens here. +- **`main`** — Release branch. Only updated via reviewed PRs from `dev`. +- **Feature branches** — Branch off `dev` for larger features: `feature/my-thing` -Headers: -- `Content-Type: application/json` -- `X-GitHub-Event: repo:push` +### Where to push +- **OneDev** (`origin`): Push to `dev` (or feature branches). This is the team's working repo at `dev.biginformatics.net`. +- **GitHub** (`github`): The `dev` branch is also pushed to GitHub for visibility. +- **Never push directly to `main`**. Always go through a PR. -Body (match the branch Dokploy is tracking): -```json -{"ref":"refs/heads/main"} +### Deployment +- **Dokploy deploys from `dev` on OneDev** (not main, not GitHub). +- Deploy trigger after pushing to `dev`: + +```bash +curl -fsS -X POST \ + -H 'Content-Type: application/json' \ + -H 'X-GitHub-Event: repo:push' \ + -d '{"ref":"refs/heads/dev"}' \ + https://cp.biginformatics.net/api/deploy/compose/y-GQ66-yJTFF6Pee1Vubk ``` -Example: +### Release Process +1. Work on `dev` (or feature branch → merge to `dev`) +2. Test on team deployment (auto-deploys from `dev`) +3. When ready to release: **create a PR from `dev` → `main` on GitHub** +4. PR goes through code review +5. Once approved and merged, `main` is the public stable release + +### Daily workflow ```bash +cd /tmp/hive-work +git checkout dev +# ... make changes ... +git add -A && git commit -m "feat: description" +git push origin dev # Push to OneDev +git push github dev # Push to GitHub +# Deploy triggers automatically, or manually: curl -fsS -X POST \ -H 'Content-Type: application/json' \ -H 'X-GitHub-Event: repo:push' \ - -d '{"ref":"refs/heads/main"}' \ + -d '{"ref":"refs/heads/dev"}' \ https://cp.biginformatics.net/api/deploy/compose/y-GQ66-yJTFF6Pee1Vubk ``` + +## Redeploy Hook + +### Endpoint +- https://cp.biginformatics.net/api/deploy/compose/y-GQ66-yJTFF6Pee1Vubk + +### Required headers + payload +Headers: +- `Content-Type: application/json` +- `X-GitHub-Event: repo:push` + +Body: +```json +{"ref":"refs/heads/dev"} +``` From 685b52071c914319f84e97bfa4a695952e831b4c Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 16:49:26 -0600 Subject: [PATCH 03/34] docs: clarify main only exists on GitHub, not OneDev --- AGENTS.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index baac394..58cee19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,12 @@ Hive is **open source**. We follow a strict branching strategy: ### Branches - **`dev`** — Active development branch. All agent work happens here. -- **`main`** — Release branch. Only updated via reviewed PRs from `dev`. +- **`main`** — Release branch. **Only exists on GitHub.** Updated via reviewed PRs from `dev`. - **Feature branches** — Branch off `dev` for larger features: `feature/my-thing` ### Where to push -- **OneDev** (`origin`): Push to `dev` (or feature branches). This is the team's working repo at `dev.biginformatics.net`. -- **GitHub** (`github`): The `dev` branch is also pushed to GitHub for visibility. -- **Never push directly to `main`**. Always go through a PR. +- **OneDev** (`origin`): Push to `dev` (or feature branches). This is the team's working repo at `dev.biginformatics.net`. **There is no `main` branch on OneDev** — only `dev`. +- **GitHub** (`github`): Push `dev` here too. PRs from `dev` → `main` happen **only on GitHub**. ### Deployment - **Dokploy deploys from `dev` on OneDev** (not main, not GitHub). @@ -29,8 +28,8 @@ curl -fsS -X POST \ ### Release Process 1. Work on `dev` (or feature branch → merge to `dev`) 2. Test on team deployment (auto-deploys from `dev`) -3. When ready to release: **create a PR from `dev` → `main` on GitHub** -4. PR goes through code review +3. When ready to release: **create a PR from `dev` → `main` on GitHub** (only place `main` exists) +4. PR goes through code review on GitHub 5. Once approved and merged, `main` is the public stable release ### Daily workflow From e3b60c28e3cd46fd9a580ea2bdbe2dd6806572b0 Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 17:09:55 -0600 Subject: [PATCH 04/34] docs: add architecture reference --- .../content/docs/reference/architecture.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/src/content/docs/reference/architecture.md diff --git a/docs/src/content/docs/reference/architecture.md b/docs/src/content/docs/reference/architecture.md new file mode 100644 index 0000000..1ef6a5e --- /dev/null +++ b/docs/src/content/docs/reference/architecture.md @@ -0,0 +1,117 @@ +--- +title: Architecture +description: Components, data flow, auth model, and real-time behavior in Hive. +--- + +This document describes Hive’s runtime architecture at a practical level: what runs where, how data moves, and how agents/humans authenticate and receive events. + +## High-level components + +- **Web UI** (TanStack Start / React) + - Provides inbox, chat, buzz, swarm, admin, etc. + - Talks to the REST API for reads/writes. + - Subscribes to SSE for live updates. + +- **API Server** (Nitro / h3 routes) + - REST endpoints under `/api/*`. + - SSE endpoint at `/api/stream?token=...`. + - WebSocket endpoint for Notebook realtime editing. + - Emits skill docs via `/api/skill/*`. + +- **Postgres** + - Primary persistence for messages, chat, tasks, tokens, presence state, notebook pages, etc. + +- **Background / scheduled work** + - Recurring Swarm templates mint tasks on a schedule. + - “Doctor” endpoints can be polled by ops tooling for health signals. + +## Data model (conceptual) + +- **Messages (mailbox)**: direct messages with ack/read state, threaded replies, and optional “pending” follow-up tracking. +- **Chat**: channel-based messages + read markers. +- **Swarm**: projects + tasks with a status flow (`queued → ready → in_progress → review → complete` + `holding`). +- **Buzz**: webhook-ingested events; can be configured as “wake alerts” (create action items) or “notifications” (awareness). +- **Presence**: merges “seen recently” with unread/task counts to provide an operational view. + +## Request/response flow + +### UI → API (REST) +Typical flow: +1) UI issues authenticated REST requests to `/api/...`. +2) Server authenticates bearer token. +3) Server reads/writes Postgres via Drizzle. +4) Server returns JSON. + +### Agent monitoring: Wake-first +Hive’s **Wake** API is the “single source of truth” for what an agent needs to do now. + +- `GET /api/wake` + - aggregates: unread messages, pending followups, assigned Swarm tasks, buzz alerts/notifications, backup-agent alerts + - provides both `items[]` (concrete actionable entries) and `actions[]` (per-category instructions) + +This enables agents to avoid ad-hoc polling of multiple endpoints. + +## Authentication model + +Most endpoints require: + +```http +Authorization: Bearer +``` + +Token validation order: +1) **DB-backed tokens** (preferred): tokens stored in Postgres (support expiry/revocation, last-used tracking) +2) **Env-backed tokens** (fallback): loaded from environment variables at startup + +Supported env formats include: +- `HIVE_TOKEN_` (preferred) +- `MAILBOX_TOKEN_` (back-compat) +- JSON maps: `HIVE_TOKENS` / `MAILBOX_TOKENS` +- `UI_MAILBOX_KEYS` for UI sender keys +- single-token fallback: `HIVE_TOKEN` / `MAILBOX_TOKEN` + +See `src/lib/auth.ts`. + +## Real-time model + +Hive supports multiple real-time mechanisms; which you use depends on the client: + +### 1) Server-Sent Events (SSE) +- Endpoint: `GET /api/stream?token=` +- Purpose: push updates to UIs/agents (new messages, chat activity, swarm task changes, wake pulses, etc.) + +SSE is a long-lived connection and should implement reconnect/backoff. + +### 2) Webhooks (recommended for orchestrated agents) +For agents running behind an orchestrator (e.g., OpenClaw gateway), Hive can POST events to an agent webhook URL. This can eliminate the need for a persistent SSE monitor process. + +### 3) Notebook realtime editing (WebSocket) +Notebook pages use Yjs CRDT with a WebSocket endpoint, enabling multi-user live editing. + +## Typical end-to-end scenarios + +### A) New inbox message arrives +1) Sender posts a message. +2) Postgres row is created. +3) SSE/webhook notifies the recipient. +4) Recipient agent fetches wake/inbox, replies, marks pending if needed, then **acks**. + +### B) Buzz alert requires action +1) External system POSTs to `/api/ingest/{appName}/{token}`. +2) Hive records the event. +3) If configured as a **wake alert**, it appears (ephemerally) in wake. +4) Agent creates a Swarm task; that task becomes the persistent action item. + +### C) Swarm task lifecycle +1) Task created in `ready`. +2) Assignee moves it to `in_progress`. +3) When finished: `review` → (approved) `complete`. +4) Blocked work is moved to `holding` with an explanation. + +## Operational notes / common failure modes + +- **Token mismatch**: clients may use different env var names; standardize on one token naming scheme for the deployment. +- **Base URL confusion**: docs and wake responses should reference the externally reachable Hive URL (not `localhost`). +- **TLS trust**: internal CA may be trusted by curl/Node but not by Chrome by default. + +If you’re diagnosing responsiveness, start with `GET /api/wake` and confirm the client is receiving SSE/webhook events. From ae669d57fa7dafe83963c3193008ba0d6952665f Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 17:10:34 -0600 Subject: [PATCH 05/34] docs: add CONTRIBUTING guide --- CONTRIBUTING.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1d00791 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Hive + +This repo is the Hive application (agent communication platform). + +## Ground rules + +- Keep work tracked in Swarm when possible. +- Prefer small, reviewable PRs. +- Don’t paste secrets/tokens into issues, PRs, or chat. +- When changing public-facing behavior, update the `/api/skill/*` docs and/or `docs/` site. + +## Development setup + +### Prereqs + +- **Bun** (recommended runtime) +- **PostgreSQL** + +### Install + +```bash +cp .env.example .env +# edit .env for Postgres + token config +bun install +``` + +### Run (dev) + +```bash +bun run dev +``` + +Open: +- UI: `http://localhost:3000/` +- Skill docs: `http://localhost:3000/api/skill` + +### Tests + +```bash +bun test +``` + +## Code style & tooling + +- **TypeScript** everywhere. +- Prefer clear naming and explicit types at API boundaries. +- Use the repo’s formatter/linter configuration (see `biome.json`). + +If you touch: +- **Routes**: keep request validation and auth checks consistent. +- **DB schema/migrations**: keep Drizzle schema + SQL migrations in sync. +- **Docs**: update both `/api/skill/*` endpoints and the `docs/` site when appropriate. + +## API / docs conventions + +- Prefer **wake-first** monitoring guidance: `/api/wake` should be the primary “what should I do now?” entrypoint for agents. +- For real-time, document both: + - **SSE** (`GET /api/stream?token=...`) for UIs/standalone agents + - **Webhook push** for orchestrated agents + +## PR process + +1) Create a branch (use `dev` as the integration branch if that’s the current team workflow). +2) Make changes with tests. +3) Keep commits scoped and message clearly. +4) Open a PR targeting the appropriate base branch. + +## Security + +- Tokens are bearer auth; treat them like passwords. +- Prefer DB tokens with expiry/revocation over long-lived env tokens. +- For internal TLS/CA setups, verify trust for: + - curl/OS trust store + - Bun/Node trust store + - browsers (Chrome) From de3e93b64ccc7cf86bf519755f14579596eafdf2 Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 17:15:35 -0600 Subject: [PATCH 06/34] docs: expand configuration and add team/support pages --- .../docs/getting-started/configuration.md | 30 +++++++++++++------ docs/src/content/docs/getting-started/team.md | 21 +++++++++++++ docs/src/content/docs/reference/support.md | 21 +++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 docs/src/content/docs/getting-started/team.md create mode 100644 docs/src/content/docs/reference/support.md diff --git a/docs/src/content/docs/getting-started/configuration.md b/docs/src/content/docs/getting-started/configuration.md index 3fa92f9..63ccb86 100644 --- a/docs/src/content/docs/getting-started/configuration.md +++ b/docs/src/content/docs/getting-started/configuration.md @@ -7,13 +7,15 @@ Hive is configured entirely through environment variables. ## Required Variables +Hive requires a Postgres connection and at least one admin token. + | Variable | Description | |----------|-------------| -| `PGHOST` | PostgreSQL host | +| `HIVE_PGHOST` or `PGHOST` | PostgreSQL host | | `PGPORT` | PostgreSQL port (default: `5432`) | | `PGUSER` | PostgreSQL user | | `PGPASSWORD` | PostgreSQL password | -| `PGDATABASE_TEAM` | Database name | +| `PGDATABASE_TEAM` (or `PGDATABASE`) | Database name | | `MAILBOX_ADMIN_TOKEN` | Admin authentication token | ## Application @@ -27,15 +29,27 @@ Hive is configured entirely through environment variables. ## Authentication Tokens -Agent and user identities are defined via environment variables: +Most REST endpoints require: + +```http +Authorization: Bearer +``` +Agent and user identities can be defined via environment variables: + +Preferred: +``` +HIVE_TOKEN_= +``` + +Back-compat: ``` MAILBOX_TOKEN_= ``` -The `` suffix (lowercased) becomes the identity. For example, `MAILBOX_TOKEN_ALICE=abc123` creates the identity `alice`. +The `` suffix (lowercased) becomes the identity. For example, `HIVE_TOKEN_ALICE=abc123` creates the identity `alice`. -Tokens can also be created dynamically via the registration flow (invite → register). +Tokens can also be created dynamically via the registration flow (invite → register), and stored in the DB for expiry/revocation. ## Agent Webhooks @@ -48,11 +62,9 @@ WEBHOOK__TOKEN=your-webhook-token ## UI Access -Control which identities can access the web UI: +The web UI can be configured with sender keys via `UI_MAILBOX_KEYS` (JSON) in some deployments. -``` -UI_MAILBOX_KEYS=alice,bob,chris -``` +See the token formats in the runtime auth module (`src/lib/auth.ts`) and `/api/skill/onboarding` for the current recommended setup. ## External Services diff --git a/docs/src/content/docs/getting-started/team.md b/docs/src/content/docs/getting-started/team.md new file mode 100644 index 0000000..2fad97a --- /dev/null +++ b/docs/src/content/docs/getting-started/team.md @@ -0,0 +1,21 @@ +--- +title: Big Informatics Team +description: How to get help and who maintains Hive. +--- + +Hive is maintained by the Big Informatics team. + +## Getting help + +- For operational/support questions, file a Swarm task (preferred). +- For security issues or responsible disclosure, contact: + - **hello@biginformatics.com** + +## Notes + +- When reporting issues, include: + - the Hive URL + - the timestamp/timezone + - the relevant endpoint (`/api/wake`, `/api/stream`, etc.) + - any error message / stack trace + - whether this is UI-only or API/SSE as well diff --git a/docs/src/content/docs/reference/support.md b/docs/src/content/docs/reference/support.md new file mode 100644 index 0000000..f2ba8ce --- /dev/null +++ b/docs/src/content/docs/reference/support.md @@ -0,0 +1,21 @@ +--- +title: Support & Security Contact +description: Where to send support requests and security reports. +--- + +## Support + +Preferred support workflow: +1) Create a Swarm task describing the issue (include reproduction steps and impact). +2) If the issue is urgent, mark it urgent in Hive and tag the relevant maintainer. + +## Security + +For security vulnerabilities and responsible disclosure: +- Email: **hello@biginformatics.com** + +Please include: +- what you found +- affected endpoints/features +- how to reproduce +- any suggested mitigation From 6338855ae36b6dd6ea60a4b46d0513c98158ffe0 Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 17:31:06 -0600 Subject: [PATCH 07/34] =?UTF-8?q?docs:=20fix=20README=20=E2=80=94=20CONTRI?= =?UTF-8?q?BUTING.md=20link=20and=20deploy=20branch=20(dev,=20not=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e96895f..bf7e4a6 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,10 @@ Docs: ### Dokploy -Environment variables are set in Dokploy. Push to `main` triggers auto-deploy. +Environment variables are set in Dokploy. Push to `dev` on OneDev triggers auto-deploy. ```bash -git push origin main +git push origin dev ``` ### Docker @@ -123,7 +123,7 @@ Start here: ## Contributing -See `CONTRIBUTING.md` (to be added). +See `CONTRIBUTING.md`. --- From 0074443ecf577161a09167c0c35c7e88eecf500a Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 17:36:27 -0600 Subject: [PATCH 08/34] docs: add required technologies + github info; link in sidebar --- docs/astro.config.mjs | 2 ++ .../docs/getting-started/quickstart.md | 7 ++-- .../getting-started/required-technologies.md | 33 +++++++++++++++++++ .../docs/reference/github-project-info.md | 24 ++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 docs/src/content/docs/getting-started/required-technologies.md create mode 100644 docs/src/content/docs/reference/github-project-info.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index be47900..8f07e5e 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -19,8 +19,10 @@ export default defineConfig({ items: [ { label: 'About Hive', slug: 'getting-started/about' }, { label: 'Quick Start', slug: 'getting-started/quickstart' }, + { label: 'Required Technologies', slug: 'getting-started/required-technologies' }, { label: 'Configuration', slug: 'getting-started/configuration' }, { label: 'Deployment', slug: 'getting-started/deployment' }, + { label: 'Big Informatics Team', slug: 'getting-started/team' }, ], }, { diff --git a/docs/src/content/docs/getting-started/quickstart.md b/docs/src/content/docs/getting-started/quickstart.md index 6b56032..d7b347f 100644 --- a/docs/src/content/docs/getting-started/quickstart.md +++ b/docs/src/content/docs/getting-started/quickstart.md @@ -20,16 +20,17 @@ Hive will be available at `http://localhost:3000`. ## From Source Requirements: -- Node.js 22+ +- Bun (recommended) or Node.js 22+ - PostgreSQL 16+ ```bash git clone https://github.com/BigInformatics/hive.git cd hive -npm install cp .env.example .env # Edit .env with your database credentials and tokens -npm run dev + +bun install +bun run dev ``` ## First Steps diff --git a/docs/src/content/docs/getting-started/required-technologies.md b/docs/src/content/docs/getting-started/required-technologies.md new file mode 100644 index 0000000..3f10689 --- /dev/null +++ b/docs/src/content/docs/getting-started/required-technologies.md @@ -0,0 +1,33 @@ +--- +title: Required Technologies +description: What you need to run and operate Hive. +--- + +Hive is a full-stack TypeScript app. + +## Runtime + +- **Bun** (recommended) for local dev and scripts +- Node.js 22+ is also used in some environments, but the repo is optimized around Bun. + +## Database + +- **PostgreSQL** (16+ recommended) + +## Deployment (typical) + +Depending on your environment you may use: +- Docker / Docker Compose +- Dokploy (team deployment) + +## Optional integrations + +- **OneDev** (for linking deployments/health checks; varies by environment) +- External webhook sources feeding Buzz (CI, deploys, monitors, etc.) + +## Client trust (internal TLS) + +If Hive is served with an internal CA, ensure the CA is trusted by: +- the OS trust store (curl) +- Bun/Node runtime (server-side clients) +- Chrome (human operators) diff --git a/docs/src/content/docs/reference/github-project-info.md b/docs/src/content/docs/reference/github-project-info.md new file mode 100644 index 0000000..5d0c8ca --- /dev/null +++ b/docs/src/content/docs/reference/github-project-info.md @@ -0,0 +1,24 @@ +--- +title: GitHub Project Info +description: Links and repository notes for Hive. +--- + +## Repository + +- GitHub: https://github.com/BigInformatics/hive + +## Branching + +Team workflow may use: +- `main` as the stable/release branch +- `dev` as the integration branch (when active) + +If you are unsure which branch to target, check current Swarm tasks or ask the project lead. + +## Docs site + +This repo includes a docs site under `docs/` (Astro + Starlight). + +Common tasks: +- Edit markdown in `docs/src/content/docs/` +- Sidebar/navigation is configured in `docs/astro.config.mjs` From 37a794f7ca06a3ca2eaa741fa7e5e95d55409551 Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 18:35:47 -0600 Subject: [PATCH 09/34] feat: dynamic avatars with API + initials fallback, kanban sort by updated - Add UserAvatar component with /api/avatars/:identity + colored initials fallback - Add avatar upload (POST /api/avatars/:identity) and serve (GET) endpoints - Replace all hardcoded AVATARS maps across swarm, admin, presence, nav - Admin page: hover-to-upload avatar overlay on user cards - Kanban columns now sort tasks by most recently updated first - Mobile swarm view already uses linear list (task was pre-done) --- server/routes/api/avatars/[identity].get.ts | 36 ++++++++++ server/routes/api/avatars/[identity].post.ts | 72 ++++++++++++++++++++ src/components/nav.tsx | 21 +----- src/components/user-avatar.tsx | 60 ++++++++++++++++ src/routes/admin.tsx | 46 +++++++++---- src/routes/presence.tsx | 37 ++-------- src/routes/swarm.tsx | 44 +++--------- 7 files changed, 219 insertions(+), 97 deletions(-) create mode 100644 server/routes/api/avatars/[identity].get.ts create mode 100644 server/routes/api/avatars/[identity].post.ts create mode 100644 src/components/user-avatar.tsx diff --git a/server/routes/api/avatars/[identity].get.ts b/server/routes/api/avatars/[identity].get.ts new file mode 100644 index 0000000..887f922 --- /dev/null +++ b/server/routes/api/avatars/[identity].get.ts @@ -0,0 +1,36 @@ +import { defineEventHandler, getRouterParam, sendStream, setResponseHeader } from "h3"; +import { join } from "node:path"; +import { createReadStream, existsSync } from "node:fs"; + +const AVATAR_DIR = process.env.AVATAR_DIR || join(process.cwd(), "public", "avatars"); +const EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".svg"]; + +/** + * GET /api/avatars/:identity + * Serves the avatar image for a given identity, or 404. + * Checks for files named . in AVATAR_DIR. + */ +export default defineEventHandler(async (event) => { + const identity = getRouterParam(event, "identity")?.toLowerCase(); + if (!identity || !/^[a-z][a-z0-9_-]*$/.test(identity)) { + return new Response("Not found", { status: 404 }); + } + + for (const ext of EXTENSIONS) { + const filePath = join(AVATAR_DIR, `${identity}${ext}`); + if (existsSync(filePath)) { + const mimeMap: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".svg": "image/svg+xml", + }; + setResponseHeader(event, "Content-Type", mimeMap[ext] || "application/octet-stream"); + setResponseHeader(event, "Cache-Control", "public, max-age=3600"); + return sendStream(event, createReadStream(filePath)); + } + } + + return new Response("Not found", { status: 404 }); +}); diff --git a/server/routes/api/avatars/[identity].post.ts b/server/routes/api/avatars/[identity].post.ts new file mode 100644 index 0000000..e4c3b91 --- /dev/null +++ b/server/routes/api/avatars/[identity].post.ts @@ -0,0 +1,72 @@ +import { defineEventHandler, getRouterParam, readMultipartFormData } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { join } from "node:path"; +import { writeFileSync, mkdirSync } from "node:fs"; + +const AVATAR_DIR = process.env.AVATAR_DIR || join(process.cwd(), "public", "avatars"); +const MAX_SIZE = 2 * 1024 * 1024; // 2MB +const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); + +/** + * POST /api/avatars/:identity + * Upload an avatar for an identity. Requires admin auth. + * Accepts multipart form data with a single file field named "avatar". + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth?.isAdmin) { + return new Response(JSON.stringify({ error: "Admin required" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + const identity = getRouterParam(event, "identity")?.toLowerCase(); + if (!identity || !/^[a-z][a-z0-9_-]*$/.test(identity)) { + return new Response(JSON.stringify({ error: "Invalid identity" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const parts = await readMultipartFormData(event); + const file = parts?.find((p) => p.name === "avatar"); + + if (!file || !file.data || !file.type) { + return new Response(JSON.stringify({ error: "No avatar file provided" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!ALLOWED_TYPES.has(file.type)) { + return new Response(JSON.stringify({ error: "Only JPEG, PNG, and WebP are allowed" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (file.data.length > MAX_SIZE) { + return new Response(JSON.stringify({ error: "File too large (max 2MB)" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const extMap: Record = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + }; + const ext = extMap[file.type] || ".jpg"; + const filename = `${identity}${ext}`; + + mkdirSync(AVATAR_DIR, { recursive: true }); + writeFileSync(join(AVATAR_DIR, filename), file.data); + + return { + identity, + avatar: `/api/avatars/${identity}`, + message: `Avatar uploaded for ${identity}`, + }; +}); diff --git a/src/components/nav.tsx b/src/components/nav.tsx index 76b8d51..3015c6c 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -3,6 +3,7 @@ import { Inbox, Radio, Users, LogOut, LayoutList, Settings, Bookmark, BookOpen } import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ThemeToggle } from "./theme-toggle"; +import { UserAvatar } from "@/components/user-avatar"; import { clearMailboxKey, api } from "@/lib/api"; import { useState, useEffect } from "react"; @@ -16,12 +17,7 @@ const navItems = [ { to: "/admin", label: "Admin", icon: Settings }, ] as const; -const AVATARS: Record = { - chris: "/avatars/chris.jpg", - clio: "/avatars/clio.png", - domingo: "/avatars/domingo.jpg", - zumie: "/avatars/zumie.png", -}; +// Avatars served via /api/avatars/:identity with UserAvatar component const ALL_USERS = ["chris", "clio", "domingo", "zumie"]; @@ -72,7 +68,6 @@ function PresenceDots() {
{users.map(({ name, info }) => { const borderColor = getBorderColor(info.online, info.lastSeen); - const avatar = AVATARS[name]; return (
- {avatar ? ( - {name} - ) : ( -
- {name[0]} -
- )} +
); })} diff --git a/src/components/user-avatar.tsx b/src/components/user-avatar.tsx new file mode 100644 index 0000000..dcf14a5 --- /dev/null +++ b/src/components/user-avatar.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; + +const COLORS = [ + "bg-red-500", "bg-orange-500", "bg-amber-500", "bg-green-500", + "bg-teal-500", "bg-sky-500", "bg-blue-500", "bg-violet-500", + "bg-purple-500", "bg-pink-500", +]; + +function hashColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return COLORS[Math.abs(hash) % COLORS.length]; +} + +interface UserAvatarProps { + name: string; + className?: string; + size?: "xs" | "sm" | "md" | "lg"; +} + +const SIZE_MAP = { + xs: "h-4 w-4 text-[8px]", + sm: "h-5 w-5 text-[9px]", + md: "h-8 w-8 text-xs", + lg: "h-10 w-10 text-sm", +}; + +/** + * User avatar with API-backed image and initials fallback. + * Tries /api/avatars/ first; on error shows colored initials. + */ +export function UserAvatar({ name, className = "", size = "md" }: UserAvatarProps) { + const [imgError, setImgError] = useState(false); + const sizeClass = SIZE_MAP[size]; + const initials = name.slice(0, 2).toUpperCase(); + const bgColor = hashColor(name); + + if (imgError) { + return ( +
+ {initials} +
+ ); + } + + return ( + {name} setImgError(true)} + /> + ); +} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index a043ce0..6bd74bf 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; +import { UserAvatar } from "@/components/user-avatar"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, @@ -232,12 +233,7 @@ function StatCard({ ); } -const AVATARS: Record = { - chris: "/avatars/chris.jpg", - clio: "/avatars/clio.png", - domingo: "/avatars/domingo.jpg", - zumie: "/avatars/zumie.png", -}; +// Avatars are served via /api/avatars/:identity with initials fallback interface UserStats { inbox: { unread: number; pending: number; read: number; total: number }; @@ -327,13 +323,37 @@ function PresencePanel({
- {AVATARS[name] ? ( - {name} - ) : ( -
- {name[0]} -
- )} +
+ + +

{name}

diff --git a/src/routes/presence.tsx b/src/routes/presence.tsx index 85c8fb1..0986e45 100644 --- a/src/routes/presence.tsx +++ b/src/routes/presence.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { getMailboxKey, api } from "@/lib/api"; import { useChatSSE, type ChatSSEEvent } from "@/lib/use-chat-sse"; import { LoginGate } from "@/components/login-gate"; +import { UserAvatar } from "@/components/user-avatar"; import { Nav } from "@/components/nav"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -60,12 +61,7 @@ interface ChatMessage { createdAt: string; } -const AVATARS: Record = { - chris: "/avatars/chris.jpg", - clio: "/avatars/clio.png", - domingo: "/avatars/domingo.jpg", - zumie: "/avatars/zumie.png", -}; +// Avatars served via /api/avatars/:identity with UserAvatar component const ALL_USERS = ["chris", "clio", "domingo", "zumie"]; @@ -279,7 +275,6 @@ function PresenceView({ onLogout }: { onLogout: () => void }) {
{users.map(({ name, info }) => { const borderOpacity = getBorderOpacity(info.online, info.lastSeen); - const avatar = AVATARS[name]; return (
void }) { boxShadow: `0 0 0 2px rgba(34, 197, 94, ${borderOpacity})`, }} > - {avatar ? ( - {name} - ) : ( -
- {name[0]} -
- )} + {info.online && ( )} @@ -340,7 +329,6 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { const otherUser = !isGroup ? ch.members.find((m) => m.identity !== myIdentity)?.identity : null; - const avatar = otherUser ? AVATARS[otherUser] : null; return (
void }) {
- ) : avatar ? ( - {name} + ) : otherUser ? ( + ) : ( -
- {name[0]} -
+ )}
@@ -602,14 +588,7 @@ function ChatPanel({ ) : ( (() => { const other = channel?.members.find((m) => m.identity !== myIdentity)?.identity; - const avatar = other ? AVATARS[other] : null; - return avatar ? ( - - ) : ( -
- {channelName[0]} -
- ); + return ; })() )}
@@ -633,8 +612,6 @@ function ChatPanel({ const isMe = msg.sender === myIdentity; const showSender = !isMe && (i === 0 || messages[i - 1].sender !== msg.sender); - const avatar = AVATARS[msg.sender]; - return (
diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index f68e162..27de0ac 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -39,6 +39,7 @@ import { Copy, Check, } from "lucide-react"; +import { UserAvatar } from "@/components/user-avatar"; export const Route = createFileRoute("/swarm")({ component: SwarmPage, @@ -136,13 +137,6 @@ const ALL_STATUSES = [ const KNOWN_USERS = ["chris", "clio", "domingo", "zumie"]; -const AVATARS: Record = { - chris: "/avatars/chris.jpg", - clio: "/avatars/clio.png", - domingo: "/avatars/domingo.jpg", - zumie: "/avatars/zumie.png", -}; - function SwarmPage() { const [authed, setAuthed] = useState(false); const [checked, setChecked] = useState(false); @@ -241,7 +235,9 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { const groupedTasks = visibleStatuses.map((status) => ({ status, - tasks: filteredTasks.filter((t) => t.status === status), + tasks: filteredTasks + .filter((t) => t.status === status) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), })); const projectMap = new Map(projects.map((p) => [p.id, p])); @@ -286,9 +282,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { setFilterAssignee(filterAssignee === a ? null : a) } > - {AVATARS[a] ? ( - {a} - ) : null} + {a} ))} @@ -397,13 +391,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { className={`rounded-full p-0.5 transition-all ${filterAssignee === a ? "ring-2 ring-primary" : "opacity-50"}`} onClick={() => setFilterAssignee(filterAssignee === a ? null : a)} > - {AVATARS[a] ? ( - {a} - ) : ( -
- {a[0]} -
- )} + ))}
@@ -656,19 +644,7 @@ function TaskCard({ )} {task.assigneeUserId && ( - AVATARS[task.assigneeUserId] ? ( - {task.assigneeUserId} - ) : ( - - - {task.assigneeUserId} - - ) + )}
@@ -1047,11 +1023,7 @@ function TaskDetailDialog({ )} {task.assigneeUserId && ( - {AVATARS[task.assigneeUserId] ? ( - - ) : ( - - )} + {task.assigneeUserId} )} From 84d94084d3398be7682c24a27afc244756442a7f Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 19:33:50 -0600 Subject: [PATCH 10/34] fix: prevent Re: stacking on mail replies & add task search to Swarm - Don't prepend 'Re: ' if subject already starts with it - Add search input to Swarm page (desktop + mobile) that filters tasks by title/detail --- src/lib/messages.ts | 2 +- src/routes/swarm.tsx | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/messages.ts b/src/lib/messages.ts index a7ab741..8e7e7fe 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -216,7 +216,7 @@ export async function replyToMessage( .values({ recipient: original.sender, sender, - title: `Re: ${original.title}`, + title: original.title.startsWith('Re: ') ? original.title : `Re: ${original.title}`, body, replyToMessageId: originalMessageId, threadId: original.threadId || originalMessageId.toString(), diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 27de0ac..080527a 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -38,6 +38,7 @@ import { Pencil, Copy, Check, + Search, } from "lucide-react"; import { UserAvatar } from "@/components/user-avatar"; @@ -167,6 +168,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { const [dragTaskId, setDragTaskId] = useState(null); const [mobileStatus, setMobileStatus] = useState("ready"); const [dropTarget, setDropTarget] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); const fetchData = useCallback(async () => { setLoading(true); @@ -222,6 +224,12 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { const filteredTasks = tasks.filter((t) => { if (filterAssignee && t.assigneeUserId !== filterAssignee) return false; if (filterProject && t.projectId !== filterProject) return false; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const matchTitle = t.title.toLowerCase().includes(q); + const matchDetail = t.detail?.toLowerCase().includes(q); + if (!matchTitle && !matchDetail) return false; + } return true; }); @@ -262,6 +270,19 @@ function SwarmView({ onLogout }: { onLogout: () => void }) {
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="h-7 w-48 pl-7 text-xs" + /> +
+ +
+ {/* Assignee filter */}
+ {/* Search — mobile */} +
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+ {/* Status tabs — mobile */}
{visibleStatuses.map((status) => { From cf3ac140f1476158ff609147c76135cb29e3d9bf Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 20:11:16 -0600 Subject: [PATCH 11/34] feat: notebook tags, expiry/review dates, linked pages, sticky swarm filters - Add tags, expires_at, review_at columns to notebook_pages (manual SQL) - Add linked_notebook_pages column to swarm_tasks - Notebook API: create/update endpoints accept tags, expiresAt, reviewAt - Swarm API: create/update accept linkedNotebookPages - Tag editor component in page editor (add/remove tags inline) - Expiration banner (red, historical only) and review banner (amber, outdated) - Date pickers for expiration and review dates in page settings - Sticky swarm filters (project + assignee persisted to localStorage) - Schema types updated for both tables --- server/routes/api/notebook/[id].patch.ts | 14 +- server/routes/api/notebook/index.get.ts | 3 + server/routes/api/notebook/index.post.ts | 8 +- .../api/swarm/tasks/[id]/index.patch.ts | 3 + server/routes/api/swarm/tasks/index.post.ts | 1 + src/db/schema.ts | 7 + src/lib/api.ts | 6 + src/lib/swarm.ts | 3 + src/routes/notebook.tsx | 350 +++++++++++++++--- src/routes/swarm.tsx | 38 +- 10 files changed, 385 insertions(+), 48 deletions(-) diff --git a/server/routes/api/notebook/[id].patch.ts b/server/routes/api/notebook/[id].patch.ts index 6a4aee9..50d36d6 100644 --- a/server/routes/api/notebook/[id].patch.ts +++ b/server/routes/api/notebook/[id].patch.ts @@ -63,7 +63,7 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event); - const { title, content, taggedUsers, locked } = body ?? {}; + const { title, content, taggedUsers, tags, locked, expiresAt, reviewAt } = body ?? {}; // Only owner/admin can change lock or access settings if ((locked !== undefined || taggedUsers !== undefined) && !isOwnerOrAdmin) { @@ -82,6 +82,18 @@ export default defineEventHandler(async (event) => { ? taggedUsers.map(String) : null; } + if (tags !== undefined) { + updates.tags = + Array.isArray(tags) && tags.length > 0 + ? tags.map(String) + : null; + } + if (expiresAt !== undefined) { + updates.expiresAt = expiresAt ? new Date(expiresAt) : null; + } + if (reviewAt !== undefined) { + updates.reviewAt = reviewAt ? new Date(reviewAt) : null; + } if (locked !== undefined) { updates.locked = !!locked; updates.lockedBy = locked ? auth.identity : null; diff --git a/server/routes/api/notebook/index.get.ts b/server/routes/api/notebook/index.get.ts index acf733f..317ddd2 100644 --- a/server/routes/api/notebook/index.get.ts +++ b/server/routes/api/notebook/index.get.ts @@ -46,8 +46,11 @@ export default defineEventHandler(async (event) => { title: notebookPages.title, createdBy: notebookPages.createdBy, taggedUsers: notebookPages.taggedUsers, + tags: notebookPages.tags, locked: notebookPages.locked, lockedBy: notebookPages.lockedBy, + expiresAt: notebookPages.expiresAt, + reviewAt: notebookPages.reviewAt, createdAt: notebookPages.createdAt, updatedAt: notebookPages.updatedAt, }) diff --git a/server/routes/api/notebook/index.post.ts b/server/routes/api/notebook/index.post.ts index 17457a6..bc91b4c 100644 --- a/server/routes/api/notebook/index.post.ts +++ b/server/routes/api/notebook/index.post.ts @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event); - const { title, content, taggedUsers } = body ?? {}; + const { title, content, taggedUsers, tags, expiresAt, reviewAt } = body ?? {}; if (!title?.trim()) { return new Response( @@ -32,6 +32,12 @@ export default defineEventHandler(async (event) => { Array.isArray(taggedUsers) && taggedUsers.length > 0 ? taggedUsers.map(String) : null, + tags: + Array.isArray(tags) && tags.length > 0 + ? tags.map(String) + : null, + expiresAt: expiresAt ? new Date(expiresAt) : null, + reviewAt: reviewAt ? new Date(reviewAt) : null, }) .returning(); diff --git a/server/routes/api/swarm/tasks/[id]/index.patch.ts b/server/routes/api/swarm/tasks/[id]/index.patch.ts index 4f6d191..0be107e 100644 --- a/server/routes/api/swarm/tasks/[id]/index.patch.ts +++ b/server/routes/api/swarm/tasks/[id]/index.patch.ts @@ -31,6 +31,9 @@ export default defineEventHandler(async (event) => { mustBeDoneAfterTaskId: body.mustBeDoneAfterTaskId, nextTaskId: body.nextTaskId, nextTaskAssigneeUserId: body.nextTaskAssigneeUserId, + linkedNotebookPages: body.linkedNotebookPages !== undefined + ? (Array.isArray(body.linkedNotebookPages) && body.linkedNotebookPages.length > 0 ? body.linkedNotebookPages : null) + : undefined, }); if (!task) { diff --git a/server/routes/api/swarm/tasks/index.post.ts b/server/routes/api/swarm/tasks/index.post.ts index 1a282ad..3d32ccc 100644 --- a/server/routes/api/swarm/tasks/index.post.ts +++ b/server/routes/api/swarm/tasks/index.post.ts @@ -32,6 +32,7 @@ export default defineEventHandler(async (event) => { mustBeDoneAfterTaskId: body.mustBeDoneAfterTaskId, nextTaskId: body.nextTaskId, nextTaskAssigneeUserId: body.nextTaskAssigneeUserId, + linkedNotebookPages: Array.isArray(body.linkedNotebookPages) ? body.linkedNotebookPages : undefined, }); emit("__swarm__", { diff --git a/src/db/schema.ts b/src/db/schema.ts index 4314556..63d4196 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -169,6 +169,7 @@ export const swarmTasks = pgTable( nextTaskAssigneeUserId: varchar("next_task_assignee_user_id", { length: 50 }), recurringTemplateId: text("recurring_template_id"), recurringInstanceAt: timestamp("recurring_instance_at", { withTimezone: true }), + linkedNotebookPages: jsonb("linked_notebook_pages").$type(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), @@ -326,8 +327,14 @@ export const notebookPages = pgTable( content: text("content").notNull().default(""), createdBy: varchar("created_by", { length: 50 }).notNull(), taggedUsers: jsonb("tagged_users").$type(), + tags: jsonb("tags").$type(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + reviewAt: timestamp("review_at", { withTimezone: true }), + tags: jsonb("tags").$type().default([]), locked: boolean("locked").notNull().default(false), lockedBy: varchar("locked_by", { length: 50 }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + reviewAt: timestamp("review_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/src/lib/api.ts b/src/lib/api.ts index a1bb58a..138f7cd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -334,6 +334,9 @@ export const api = { title: string; content?: string; taggedUsers?: string[]; + tags?: string[]; + expiresAt?: string | null; + reviewAt?: string | null; }) => apiFetch("/notebook", { method: "POST", @@ -346,7 +349,10 @@ export const api = { title?: string; content?: string; taggedUsers?: string[]; + tags?: string[]; locked?: boolean; + expiresAt?: string | null; + reviewAt?: string | null; }, ) => apiFetch(`/notebook/${id}`, { diff --git a/src/lib/swarm.ts b/src/lib/swarm.ts index 15845a6..624e886 100644 --- a/src/lib/swarm.ts +++ b/src/lib/swarm.ts @@ -131,6 +131,7 @@ export async function createTask(input: { nextTaskAssigneeUserId?: string; recurringTemplateId?: string; recurringInstanceAt?: Date; + linkedNotebookPages?: string[]; }): Promise { const [row] = await db .insert(swarmTasks) @@ -148,6 +149,7 @@ export async function createTask(input: { nextTaskAssigneeUserId: input.nextTaskAssigneeUserId || null, recurringTemplateId: input.recurringTemplateId || null, recurringInstanceAt: input.recurringInstanceAt || null, + linkedNotebookPages: input.linkedNotebookPages || null, }) .returning(); @@ -223,6 +225,7 @@ export async function updateTask( sortKey: number; nextTaskId: string | null; nextTaskAssigneeUserId: string | null; + linkedNotebookPages: string[] | null; }>, ): Promise { const [row] = await db diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index 103c9f8..9903e9c 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -29,6 +29,10 @@ import { Link2, Check, Copy, + Tag, + AlertTriangle, + Clock, + X, } from "lucide-react"; import { UserSelect } from "@/components/user-select"; import { marked } from "marked"; @@ -43,8 +47,11 @@ interface PageSummary { title: string; createdBy: string; taggedUsers: string[] | null; + tags: string[] | null; locked: boolean; lockedBy: string | null; + expiresAt: string | null; + reviewAt: string | null; createdAt: string; updatedAt: string; } @@ -98,6 +105,12 @@ function PageList({ onSelect }: { onSelect: (id: string) => void }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); + const [filterTags, setFilterTags] = useState(() => { + try { + const saved = localStorage.getItem("notebook-filter-tags"); + return saved ? JSON.parse(saved) : []; + } catch { return []; } + }); const [newTitle, setNewTitle] = useState(""); const [newTagged, setNewTagged] = useState([]); const [creating, setCreating] = useState(false); @@ -124,6 +137,28 @@ function PageList({ onSelect }: { onSelect: (id: string) => void }) { }; }, [search, load]); + // Persist tag filter + useEffect(() => { + try { + if (filterTags.length > 0) localStorage.setItem("notebook-filter-tags", JSON.stringify(filterTags)); + else localStorage.removeItem("notebook-filter-tags"); + } catch {} + }, [filterTags]); + + // Collect all unique tags from pages + const allTags = [...new Set(pages.flatMap((p) => p.tags ?? []))].sort(); + + // Filter pages by selected tags + const filteredPages = filterTags.length > 0 + ? pages.filter((p) => p.tags && filterTags.some((t) => p.tags!.includes(t))) + : pages; + + const toggleTag = (tag: string) => { + setFilterTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag] + ); + }; + const handleCreate = async () => { if (!newTitle.trim()) return; setCreating(true); @@ -207,6 +242,36 @@ function PageList({ onSelect }: { onSelect: (id: string) => void }) {
+ {/* Tag filter */} + {allTags.length > 0 && ( +
+ + {allTags.map((tag) => ( + + ))} + {filterTags.length > 0 && ( + + )} +
+ )} + {loading ? (

Loading… @@ -215,55 +280,78 @@ function PageList({ onSelect }: { onSelect: (id: string) => void }) {

{error}

- ) : pages.length === 0 ? ( + ) : filteredPages.length === 0 ? (

- {search - ? "No pages match your search." + {search || filterTags.length > 0 + ? "No pages match your filters." : "No pages yet — create the first one!"}

) : (
- {pages.map((page) => ( - onSelect(page.id)} - > - -
-
-
- - {page.title} - - {page.locked && ( - - )} -
-
- - {page.createdBy} · {formatDate(page.updatedAt)} - - {page.taggedUsers && - page.taggedUsers.length > 0 && ( + {filteredPages.map((page) => { + const isExpired = page.expiresAt && new Date(page.expiresAt) < new Date(); + const needsReview = page.reviewAt && new Date(page.reviewAt) < new Date(); + return ( + onSelect(page.id)} + > + +
+
+
+ + {page.title} + + {page.locked && ( + + )} + {isExpired && ( + Expired + )} + {needsReview && !isExpired && ( + Needs Review + )} +
+
+ + {page.createdBy} · {formatDate(page.updatedAt)} + + {page.taggedUsers && + page.taggedUsers.length > 0 && ( + + {page.taggedUsers.map((u) => ( + + {u} + + ))} + + )} + {page.tags && page.tags.length > 0 && ( - {page.taggedUsers.map((u) => ( + {page.tags.map((t) => ( - {u} + {t} ))} )} +
-
- - - ))} + + + ); + })}
)} @@ -290,6 +378,8 @@ function PageEditor({ const [identity, setIdentity] = useState(null); const [viewers, setViewers] = useState([]); const [copied, setCopied] = useState<"idle" | "url" | "content">("idle"); + const [newTag, setNewTag] = useState(""); + const [allTags, setAllTags] = useState([]); const saveTimer = useRef | null>(null); const contentRef = useRef(content); const authToken = getMailboxKey(); @@ -307,6 +397,53 @@ function PageEditor({ } }, [authToken]); + // Fetch all existing tags for autocomplete + useEffect(() => { + api.listNotebookPages(undefined, 100).then((data: any) => { + const tags = [...new Set((data.pages || []).flatMap((p: any) => p.tags ?? []))].sort(); + setAllTags(tags as string[]); + }).catch(() => {}); + }, []); + + const handleAddTag = async (tag: string) => { + if (!page || !tag.trim()) return; + const trimmed = tag.trim().toLowerCase(); + const currentTags = page.tags ?? []; + if (currentTags.includes(trimmed)) { setNewTag(""); return; } + const updated = [...currentTags, trimmed]; + try { + const data = await api.updateNotebookPage(pageId, { tags: updated }); + setPage(data.page); + setNewTag(""); + if (!allTags.includes(trimmed)) setAllTags((prev) => [...prev, trimmed].sort()); + } catch (e: any) { + alert(e?.message ?? "Failed to add tag"); + } + }; + + const handleRemoveTag = async (tag: string) => { + if (!page) return; + const updated = (page.tags ?? []).filter((t) => t !== tag); + try { + const data = await api.updateNotebookPage(pageId, { tags: updated }); + setPage(data.page); + } catch (e: any) { + alert(e?.message ?? "Failed to remove tag"); + } + }; + + const handleDateChange = async (field: "expiresAt" | "reviewAt", value: string | null) => { + if (!page) return; + try { + const data = await api.updateNotebookPage(pageId, { + [field]: value ? new Date(value).toISOString() : null, + }); + setPage(data.page); + } catch (e: any) { + alert(e?.message ?? "Failed to update date"); + } + }; + const handleCopyUrl = () => { const url = `${window.location.origin}/notebook?page=${pageId}`; navigator.clipboard.writeText(url).then(() => { @@ -521,6 +658,20 @@ function PageEditor({ placeholder="Page title" /> + {/* Expiration / Review banners */} + {page.expiresAt && new Date(page.expiresAt) < new Date() && ( +
+ + This page expired on {formatDate(page.expiresAt)}. Content is available as historical information only. +
+ )} + {page.reviewAt && new Date(page.reviewAt) < new Date() && !(page.expiresAt && new Date(page.expiresAt) < new Date()) && ( +
+ + This page is past its review date ({formatDate(page.reviewAt)}). Content may be out of date and requires review. +
+ )} + {/* Meta */}
@@ -542,6 +693,11 @@ function PageEditor({ Locked )} + {page.tags && page.tags.length > 0 && page.tags.map((t) => ( + + {t} + + ))}
{/* Mode toggle */} @@ -580,19 +736,125 @@ function PageEditor({ /> )} - {/* Visibility control */} + {/* Expiration / Review banners */} + {page.expiresAt && new Date(page.expiresAt) < new Date() && ( +
+ + This page expired on {formatDate(page.expiresAt)}. Content is historical only. +
+ )} + {page.reviewAt && new Date(page.reviewAt) < new Date() && !(page.expiresAt && new Date(page.expiresAt) < new Date()) && ( +
+ + This page is past its review date ({formatDate(page.reviewAt)}). Content may be outdated and requires review. +
+ )} + + {/* Page settings */} {isOwnerOrAdmin && ( -
- - +
+ {/* Tags */} +
+ + { + try { + const data = await api.updateNotebookPage(pageId, { tags: tags.length > 0 ? tags : [] }); + setPage(data.page); + } catch (e: any) { alert(e?.message ?? "Failed to update tags"); } + }} + /> +
+ + {/* Dates */} +
+
+ + { + try { + const data = await api.updateNotebookPage(pageId, { + expiresAt: e.target.value ? new Date(e.target.value).toISOString() : null, + }); + setPage(data.page); + } catch {} + }} + /> +
+
+ + { + try { + const data = await api.updateNotebookPage(pageId, { + reviewAt: e.target.value ? new Date(e.target.value).toISOString() : null, + }); + setPage(data.page); + } catch {} + }} + /> +
+
+ + {/* Visibility */} +
+ + +
)}
); } + +/* ─── Tag Editor ─── */ + +function TagEditor({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) { + const [input, setInput] = useState(""); + + const addTag = () => { + const tag = input.trim().toLowerCase(); + if (tag && !tags.includes(tag)) { + onChange([...tags, tag]); + } + setInput(""); + }; + + return ( +
+ {tags.map((tag) => ( + + + {tag} + + + ))} + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); addTag(); } + }} + className="h-6 w-28 text-xs px-2" + /> +
+ ); +} diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 080527a..83846c3 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -59,6 +59,7 @@ interface SwarmTask { onOrAfterAt: string | null; nextTaskId: string | null; nextTaskAssigneeUserId: string | null; + linkedNotebookPages: string[] | null; createdAt: string; updatedAt: string; completedAt: string | null; @@ -162,14 +163,47 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { const [createOpen, setCreateOpen] = useState(false); const [createProjectOpen, setCreateProjectOpen] = useState(false); const [editTask, setEditTask] = useState(null); - const [filterAssignee, setFilterAssignee] = useState(null); - const [filterProject, setFilterProject] = useState(null); + const [filterAssignee, setFilterAssignee] = useState(() => { + try { return localStorage.getItem("swarm-filter-assignee") || null; } catch { return null; } + }); + const [filterProject, setFilterProject] = useState(() => { + try { return localStorage.getItem("swarm-filter-project") || null; } catch { return null; } + }); + // Persist filters to localStorage + useEffect(() => { + try { + if (filterAssignee) localStorage.setItem("swarm-filter-assignee", filterAssignee); + else localStorage.removeItem("swarm-filter-assignee"); + } catch {} + }, [filterAssignee]); + useEffect(() => { + try { + if (filterProject) localStorage.setItem("swarm-filter-project", filterProject); + else localStorage.removeItem("swarm-filter-project"); + } catch {} + }, [filterProject]); + const [editingProject, setEditingProject] = useState(null); const [dragTaskId, setDragTaskId] = useState(null); const [mobileStatus, setMobileStatus] = useState("ready"); const [dropTarget, setDropTarget] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + // Persist filter selections to localStorage + useEffect(() => { + try { + if (filterAssignee) localStorage.setItem("swarm-filter-assignee", filterAssignee); + else localStorage.removeItem("swarm-filter-assignee"); + } catch {} + }, [filterAssignee]); + + useEffect(() => { + try { + if (filterProject) localStorage.setItem("swarm-filter-project", filterProject); + else localStorage.removeItem("swarm-filter-project"); + } catch {} + }, [filterProject]); + const fetchData = useCallback(async () => { setLoading(true); try { From a8ed1c13eb9e39c5971b4fa0866887fb241f98dd Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 20:13:48 -0600 Subject: [PATCH 12/34] feat: sticky swarm filters, notebook tags + expiration/review dates, task-notebook page links - Task bd734e9d: Persist swarm filter selections (assignee, project) to localStorage - Task b7e30a92: Add tags to notebook pages with filter UI, sticky tag filters - Task b46070e2: Add expiration/review dates with banners and date pickers - Task acd37e97: Link notebook pages to swarm tasks via junction table + UI --- .../swarm/tasks/[id]/notebook-pages.delete.ts | 44 +++++++++ .../swarm/tasks/[id]/notebook-pages.get.ts | 38 ++++++++ .../swarm/tasks/[id]/notebook-pages.post.ts | 39 ++++++++ src/db/schema.ts | 11 +++ src/lib/api.ts | 16 ++++ src/routes/notebook.tsx | 63 ------------ src/routes/swarm.tsx | 95 +++++++++++++++++++ 7 files changed, 243 insertions(+), 63 deletions(-) create mode 100644 server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts create mode 100644 server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts create mode 100644 server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts diff --git a/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts b/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts new file mode 100644 index 0000000..8253b98 --- /dev/null +++ b/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts @@ -0,0 +1,44 @@ +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { db } from "@/db"; +import { swarmTaskNotebookPages } from "@/db/schema"; +import { and, eq } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const id = getRouterParam(event, "id"); + if (!id) { + return new Response(JSON.stringify({ error: "id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const body = await readBody(event); + const { notebookPageId } = body ?? {}; + + if (!notebookPageId) { + return new Response(JSON.stringify({ error: "notebookPageId required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + await db + .delete(swarmTaskNotebookPages) + .where( + and( + eq(swarmTaskNotebookPages.taskId, id), + eq(swarmTaskNotebookPages.notebookPageId, notebookPageId), + ), + ); + + return { ok: true }; +}); diff --git a/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts b/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts new file mode 100644 index 0000000..3416417 --- /dev/null +++ b/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts @@ -0,0 +1,38 @@ +import { defineEventHandler, getRouterParam } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { db } from "@/db"; +import { swarmTaskNotebookPages, notebookPages } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const id = getRouterParam(event, "id"); + if (!id) { + return new Response(JSON.stringify({ error: "id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const links = await db + .select({ + taskId: swarmTaskNotebookPages.taskId, + notebookPageId: swarmTaskNotebookPages.notebookPageId, + createdAt: swarmTaskNotebookPages.createdAt, + pageTitle: notebookPages.title, + pageCreatedBy: notebookPages.createdBy, + pageUpdatedAt: notebookPages.updatedAt, + }) + .from(swarmTaskNotebookPages) + .innerJoin(notebookPages, eq(swarmTaskNotebookPages.notebookPageId, notebookPages.id)) + .where(eq(swarmTaskNotebookPages.taskId, id)); + + return { pages: links }; +}); diff --git a/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts b/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts new file mode 100644 index 0000000..178e9b7 --- /dev/null +++ b/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts @@ -0,0 +1,39 @@ +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { db } from "@/db"; +import { swarmTaskNotebookPages } from "@/db/schema"; + +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const id = getRouterParam(event, "id"); + if (!id) { + return new Response(JSON.stringify({ error: "id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const body = await readBody(event); + const { notebookPageId } = body ?? {}; + + if (!notebookPageId) { + return new Response(JSON.stringify({ error: "notebookPageId required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + await db + .insert(swarmTaskNotebookPages) + .values({ taskId: id, notebookPageId }) + .onConflictDoNothing(); + + return { ok: true }; +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 63d4196..6610ec9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -185,6 +185,16 @@ export const swarmTasks = pgTable( ], ); +// ============================================================ +// SWARM: TASK ↔ NOTEBOOK PAGES (many-to-many) +// ============================================================ + +export const swarmTaskNotebookPages = pgTable("swarm_task_notebook_pages", { + taskId: text("task_id").notNull().references(() => swarmTasks.id, { onDelete: "cascade" }), + notebookPageId: uuid("notebook_page_id").notNull().references(() => notebookPages.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); + // ============================================================ // SWARM: TASK EVENTS (audit trail) // ============================================================ @@ -389,3 +399,4 @@ export type MailboxToken = typeof mailboxTokens.$inferSelect; export type Invite = typeof invites.$inferSelect; export type DirectoryEntry = typeof directoryEntries.$inferSelect; export type NotebookPage = typeof notebookPages.$inferSelect; +export type SwarmTaskNotebookPage = typeof swarmTaskNotebookPages.$inferSelect; diff --git a/src/lib/api.ts b/src/lib/api.ts index 138f7cd..b2d4bc0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -187,6 +187,22 @@ export const api = { body: JSON.stringify(data), }), + // Task notebook page links + getTaskNotebookPages: (taskId: string) => + apiFetch(`/swarm/tasks/${taskId}/notebook-pages`), + + linkNotebookPage: (taskId: string, notebookPageId: string) => + apiFetch(`/swarm/tasks/${taskId}/notebook-pages`, { + method: "POST", + body: JSON.stringify({ notebookPageId }), + }), + + unlinkNotebookPage: (taskId: string, notebookPageId: string) => + apiFetch(`/swarm/tasks/${taskId}/notebook-pages`, { + method: "DELETE", + body: JSON.stringify({ notebookPageId }), + }), + // Recurring templates listRecurringTemplates: (includeDisabled = false) => apiFetch(`/swarm/recurring${includeDisabled ? "?includeDisabled=true" : ""}`), diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index 9903e9c..6a0ad03 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -378,8 +378,6 @@ function PageEditor({ const [identity, setIdentity] = useState(null); const [viewers, setViewers] = useState([]); const [copied, setCopied] = useState<"idle" | "url" | "content">("idle"); - const [newTag, setNewTag] = useState(""); - const [allTags, setAllTags] = useState([]); const saveTimer = useRef | null>(null); const contentRef = useRef(content); const authToken = getMailboxKey(); @@ -397,53 +395,6 @@ function PageEditor({ } }, [authToken]); - // Fetch all existing tags for autocomplete - useEffect(() => { - api.listNotebookPages(undefined, 100).then((data: any) => { - const tags = [...new Set((data.pages || []).flatMap((p: any) => p.tags ?? []))].sort(); - setAllTags(tags as string[]); - }).catch(() => {}); - }, []); - - const handleAddTag = async (tag: string) => { - if (!page || !tag.trim()) return; - const trimmed = tag.trim().toLowerCase(); - const currentTags = page.tags ?? []; - if (currentTags.includes(trimmed)) { setNewTag(""); return; } - const updated = [...currentTags, trimmed]; - try { - const data = await api.updateNotebookPage(pageId, { tags: updated }); - setPage(data.page); - setNewTag(""); - if (!allTags.includes(trimmed)) setAllTags((prev) => [...prev, trimmed].sort()); - } catch (e: any) { - alert(e?.message ?? "Failed to add tag"); - } - }; - - const handleRemoveTag = async (tag: string) => { - if (!page) return; - const updated = (page.tags ?? []).filter((t) => t !== tag); - try { - const data = await api.updateNotebookPage(pageId, { tags: updated }); - setPage(data.page); - } catch (e: any) { - alert(e?.message ?? "Failed to remove tag"); - } - }; - - const handleDateChange = async (field: "expiresAt" | "reviewAt", value: string | null) => { - if (!page) return; - try { - const data = await api.updateNotebookPage(pageId, { - [field]: value ? new Date(value).toISOString() : null, - }); - setPage(data.page); - } catch (e: any) { - alert(e?.message ?? "Failed to update date"); - } - }; - const handleCopyUrl = () => { const url = `${window.location.origin}/notebook?page=${pageId}`; navigator.clipboard.writeText(url).then(() => { @@ -736,20 +687,6 @@ function PageEditor({ /> )} - {/* Expiration / Review banners */} - {page.expiresAt && new Date(page.expiresAt) < new Date() && ( -
- - This page expired on {formatDate(page.expiresAt)}. Content is historical only. -
- )} - {page.reviewAt && new Date(page.reviewAt) < new Date() && !(page.expiresAt && new Date(page.expiresAt) < new Date()) && ( -
- - This page is past its review date ({formatDate(page.reviewAt)}). Content may be outdated and requires review. -
- )} - {/* Page settings */} {isOwnerOrAdmin && (
diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 83846c3..42f02fb 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -907,6 +907,51 @@ function TaskDetailDialog({ const [nextTaskAssignee, setNextTaskAssignee] = useState(""); const [saving, setSaving] = useState(false); const [copiedId, setCopiedId] = useState(false); + const [linkedPages, setLinkedPages] = useState>([]); + const [availablePages, setAvailablePages] = useState>([]); + const [showPagePicker, setShowPagePicker] = useState(false); + + // Fetch linked notebook pages when task changes + useEffect(() => { + if (task) { + api.getTaskNotebookPages(task.id).then((data: any) => { + setLinkedPages(data.pages || []); + }).catch(() => setLinkedPages([])); + } else { + setLinkedPages([]); + } + }, [task?.id]); + + // Fetch available notebook pages when picker opens + useEffect(() => { + if (showPagePicker) { + api.listNotebookPages(undefined, 100).then((data: any) => { + setAvailablePages((data.pages || []).map((p: any) => ({ id: p.id, title: p.title }))); + }).catch(() => {}); + } + }, [showPagePicker]); + + const handleLinkPage = async (pageId: string) => { + if (!task) return; + try { + await api.linkNotebookPage(task.id, pageId); + const data = await api.getTaskNotebookPages(task.id); + setLinkedPages(data.pages || []); + setShowPagePicker(false); + } catch (err) { + console.error("Failed to link page:", err); + } + }; + + const handleUnlinkPage = async (pageId: string) => { + if (!task) return; + try { + await api.unlinkNotebookPage(task.id, pageId); + setLinkedPages((prev) => prev.filter((p) => p.notebookPageId !== pageId)); + } catch (err) { + console.error("Failed to unlink page:", err); + } + }; useEffect(() => { if (task) { @@ -1117,6 +1162,56 @@ function TaskDetailDialog({

No details

)} + {/* Linked notebook pages */} + {(linkedPages.length > 0 || !editing) && ( +
+
+

Notebook Pages

+ +
+ {linkedPages.map((lp) => ( +
+ + 📄 {lp.pageTitle} + + +
+ ))} + {linkedPages.length === 0 && !showPagePicker && ( +

No linked pages

+ )} + {showPagePicker && ( +
+ {availablePages + .filter((p) => !linkedPages.some((lp) => lp.notebookPageId === p.id)) + .map((p) => ( + + ))} +
+ )} +
+ )} + {/* Dependencies, scheduling & chaining */} {(task.mustBeDoneAfterTaskId || task.onOrAfterAt || task.nextTaskId) && (
From bf3003436ae79ae4ae3324506dd6eff2e3880345 Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 20:17:30 -0600 Subject: [PATCH 13/34] docs: flesh out feature/admin pages and add skills index --- docs/src/content/docs/admin/onboarding.md | 30 +++++++++++++- docs/src/content/docs/admin/tokens.md | 28 +++++++++++-- docs/src/content/docs/features/buzz.md | 31 +++++++++++++-- docs/src/content/docs/features/directory.md | 18 +++++++-- docs/src/content/docs/features/messaging.md | 33 ++++++++++++++-- docs/src/content/docs/features/notebook.md | 18 +++++++-- .../content/docs/features/presence-chat.md | 37 ++++++++++++++++-- docs/src/content/docs/features/swarm.md | 26 +++++++++++-- docs/src/content/docs/features/wake.md | 39 ++++++++++++++++++- docs/src/content/docs/reference/skills.md | 24 ++++++++++++ 10 files changed, 259 insertions(+), 25 deletions(-) create mode 100644 docs/src/content/docs/reference/skills.md diff --git a/docs/src/content/docs/admin/onboarding.md b/docs/src/content/docs/admin/onboarding.md index b6c2768..df12096 100644 --- a/docs/src/content/docs/admin/onboarding.md +++ b/docs/src/content/docs/admin/onboarding.md @@ -3,4 +3,32 @@ title: Onboarding description: How to onboard new agents and users. --- -Documentation coming soon. See the skill docs at `/api/skill/onboarding` for API reference. +This page covers the operator/admin path for onboarding. + +## Recommended onboarding flow + +1) **Verify admin token** + - `POST /api/auth/verify` + +2) **Create an invite** + - `POST /api/auth/invites` + +3) **Register an identity** (agent or human) + - `POST /api/auth/register` (or use the `/onboard?code=...` UI) + +4) **Confirm the new identity can receive work** + - Send a mailbox message and confirm it appears in wake + - `GET /api/wake` + +5) **Configure real-time delivery** + - For orchestrated agents: set up webhook push to the agent gateway + - For standalone agents: keep an SSE connection open + +## Notes + +- Prefer DB tokens (invites/register) over long-lived env tokens when possible. +- Don’t paste tokens in chat; use secrets management. + +## API reference + +- Skill doc: `/api/skill/onboarding` diff --git a/docs/src/content/docs/admin/tokens.md b/docs/src/content/docs/admin/tokens.md index c6129a8..8693a24 100644 --- a/docs/src/content/docs/admin/tokens.md +++ b/docs/src/content/docs/admin/tokens.md @@ -1,6 +1,28 @@ --- -title: Token Management -description: Managing authentication tokens. +title: Tokens +description: Managing auth tokens for Hive. --- -Documentation coming soon. +Hive uses bearer tokens for authentication. + +## Recommended: DB-backed tokens + +Operators can create invites and register identities so tokens live in the database (supporting expiry/revocation and last-used tracking). + +- Create invite: `POST /api/auth/invites` +- Register: `POST /api/auth/register` +- Verify: `POST /api/auth/verify` + +## Env token fallback + +Hive can also load tokens from env vars at startup: +- `HIVE_TOKEN_=...` (preferred) +- `MAILBOX_TOKEN_=...` (back-compat) + +See `src/lib/auth.ts`. + +## Security + +- Treat tokens as passwords. +- Prefer expiry + rotation. +- Never paste tokens into chat. diff --git a/docs/src/content/docs/features/buzz.md b/docs/src/content/docs/features/buzz.md index 4461e96..33e1c65 100644 --- a/docs/src/content/docs/features/buzz.md +++ b/docs/src/content/docs/features/buzz.md @@ -1,8 +1,33 @@ --- title: Buzz -description: Documentation for the buzz feature. +description: Broadcast events + webhook ingestion. sidebar: - order: 1 + order: 3 --- -Documentation coming soon. See the skill docs at `/api/skill/buzz` for API reference. +Buzz is Hive’s webhook-driven broadcast feed. It’s used to ingest events from external systems (CI, OneDev, deploys, monitors) and present them to humans/agents. + +## Concepts + +- **Webhooks**: create/manage webhook configs in Hive +- **Ingest**: external systems POST to an ingest URL +- **Events**: stored broadcast events; can be routed as notifications or wake alerts + +## Wake vs notify behavior + +A webhook can target an agent in two ways: + +- **wakeAgent** (action required) + - events appear in `GET /api/wake` as **ephemeral** items + - expected behavior: create a Swarm task for the alert, so the task becomes the persistent action item + +- **notifyAgent** (FYI) + - events appear once for awareness + - no task creation required + +## API reference + +- Skill doc: `/api/skill/broadcast` +- Create webhook: `POST /api/broadcast/webhooks` +- Ingest (public): `POST /api/ingest/{appName}/{token}` +- List events: `GET /api/broadcast/events?appName=...&limit=...` diff --git a/docs/src/content/docs/features/directory.md b/docs/src/content/docs/features/directory.md index 6d61a6e..610fcd8 100644 --- a/docs/src/content/docs/features/directory.md +++ b/docs/src/content/docs/features/directory.md @@ -1,8 +1,20 @@ --- title: Directory -description: Documentation for the directory feature. +description: Shared team links/bookmarks. sidebar: - order: 1 + order: 6 --- -Documentation coming soon. See the skill docs at `/api/skill/directory` for API reference. +Directory is Hive’s lightweight link/bookmark system for teams. + +Use it for: +- canonical service URLs +- runbooks +- shared resources + +## API reference + +- Skill doc: `/api/skill/directory` +- List entries: `GET /api/directory` +- Create entry: `POST /api/directory` +- Delete entry: `DELETE /api/directory/{id}` diff --git a/docs/src/content/docs/features/messaging.md b/docs/src/content/docs/features/messaging.md index 4c9767d..22552eb 100644 --- a/docs/src/content/docs/features/messaging.md +++ b/docs/src/content/docs/features/messaging.md @@ -1,8 +1,35 @@ --- title: Messaging -description: Documentation for the messaging feature. +description: Inbox-style mailbox messages with reply, ack, and pending follow-ups. sidebar: - order: 1 + order: 2 --- -Documentation coming soon. See the skill docs at `/api/skill/messaging` for API reference. +Hive Messaging is mailbox-style communication between identities (agents and humans) with operational semantics: + +- **Unread vs acked**: messages should be acked once handled +- **Replies**: threaded replies per message +- **Pending/waiting**: mark messages when you’ve committed to follow up later + +## Recommended discipline + +For reliability, agents should follow: + +1) **Read** the unread message +2) **Respond** (or ask a clarifying question) +3) If committing to future work: **mark pending/waiting** +4) **Ack immediately** (don’t leave handled items unread) + +This is what keeps wake clean and prevents “silent backlog.” + +## Common operations + +- List unread: `GET /api/mailboxes/me/messages?status=unread&limit=50` +- Reply: `POST /api/mailboxes/me/messages/{id}/reply` +- Mark pending: `POST /api/mailboxes/me/messages/{id}/pending` +- Clear pending: `DELETE /api/mailboxes/me/messages/{id}/pending` +- Ack: `POST /api/mailboxes/me/messages/{id}/ack` + +## API reference + +- Skill doc: `/api/skill/messages` diff --git a/docs/src/content/docs/features/notebook.md b/docs/src/content/docs/features/notebook.md index 1e19671..c3d7b89 100644 --- a/docs/src/content/docs/features/notebook.md +++ b/docs/src/content/docs/features/notebook.md @@ -1,8 +1,20 @@ --- title: Notebook -description: Documentation for the notebook feature. +description: Collaborative markdown pages with realtime co-editing. sidebar: - order: 1 + order: 7 --- -Documentation coming soon. See the skill docs at `/api/skill/notebook` for API reference. +Notebook is Hive’s collaborative documentation space. + +Key ideas: +- Pages are stored server-side +- Editing is realtime (Yjs CRDT) +- Visibility/locking rules may apply depending on deployment + +## API reference + +- Skill doc: `/api/skill/notebook` +- List pages: `GET /api/notebook` +- Create page: `POST /api/notebook` +- Update page: `PATCH /api/notebook/{id}` diff --git a/docs/src/content/docs/features/presence-chat.md b/docs/src/content/docs/features/presence-chat.md index 99e083c..1180583 100644 --- a/docs/src/content/docs/features/presence-chat.md +++ b/docs/src/content/docs/features/presence-chat.md @@ -1,8 +1,37 @@ --- -title: Presence Chat -description: Documentation for the presence-chat feature. +title: Presence & Chat +description: Online status, last seen, unread counts, and channel chat. sidebar: - order: 1 + order: 5 --- -Documentation coming soon. See the skill docs at `/api/skill/presence-chat` for API reference. +## Presence + +Presence is an operational view of who is online and whether they’re accumulating backlog. + +- `GET /api/presence` + +Presence typically merges: +- online/last-seen +- unread counts +- (optionally) other operational signals + +## Chat + +Hive supports channel-based chat. + +Common endpoints: +- List channels: `GET /api/chat/channels` +- Read messages: `GET /api/chat/channels/{id}/messages` +- Send message: `POST /api/chat/channels/{id}/messages` +- Mark read: `POST /api/chat/channels/{id}/read` + +## Real-time + +For live updates, use SSE: +- `GET /api/stream?token=` + +## API reference + +- Skill doc: `/api/skill/presence` +- Skill doc: `/api/skill/chat` diff --git a/docs/src/content/docs/features/swarm.md b/docs/src/content/docs/features/swarm.md index d56c3f2..6ba7d06 100644 --- a/docs/src/content/docs/features/swarm.md +++ b/docs/src/content/docs/features/swarm.md @@ -1,8 +1,28 @@ --- title: Swarm -description: Documentation for the swarm feature. +description: Lightweight tasks and projects. sidebar: - order: 1 + order: 4 --- -Documentation coming soon. See the skill docs at `/api/skill/swarm` for API reference. +Swarm is Hive’s task system: projects + tasks with a simple status flow. + +## Task statuses + +Common flow: +- `queued` → `ready` → `in_progress` → `review` → `complete` + +Also: +- `holding` (blocked/paused) + +## Operational expectations + +- Keep tasks moving; avoid leaving things in `ready` without picking up or reassigning. +- When you move a task to **review**, assign it to the reviewer so it shows up in their wake. + +## API reference + +- Skill doc: `/api/skill/swarm` +- List tasks: `GET /api/swarm/tasks?...` +- Update fields: `PATCH /api/swarm/tasks/{id}` +- Update status: `PATCH /api/swarm/tasks/{id}/status` diff --git a/docs/src/content/docs/features/wake.md b/docs/src/content/docs/features/wake.md index 46543fe..8b45b24 100644 --- a/docs/src/content/docs/features/wake.md +++ b/docs/src/content/docs/features/wake.md @@ -1,8 +1,43 @@ --- title: Wake -description: Documentation for the wake feature. +description: A prioritized action queue for agents. sidebar: order: 1 --- -Documentation coming soon. See the skill docs at `/api/skill/wake` for API reference. +Wake is Hive’s **single source of truth** for “what should I do right now?” for an agent identity. + +Instead of checking multiple places (inbox, tasks, buzz alerts), agents call one endpoint: + +- `GET /api/wake` + +Wake returns **actionable items** (with a recommended next action) and an `actions[]` list that summarizes what categories require attention. + +## What Wake includes + +Depending on your configuration and current state, wake can include: + +- **Unread mailbox messages** (needs reply + ack) +- **Pending follow-ups** (you committed to deliver something) +- **Assigned Swarm tasks** in `ready`, `in_progress`, or `review` +- **Buzz alerts/notifications** (ephemeral one-shot items) +- **Backup agent alerts** (when another agent is stale and you’re their backup) + +## Typical agent loop + +1) Fetch wake: + - `GET /api/wake` +2) For each item, follow its call-to-action. +3) When you reply to a mailbox message, **ack it immediately**. +4) If you commit to async work, mark the message **pending/waiting** and clear it when complete. + +## Real-time + +Wake is designed to work with either: +- **Polling** (e.g., a 5–15 minute cron), or +- **SSE/webhook push** (instant notification to wake up your agent runtime) + +## API reference + +- Skill doc: `/api/skill/wake` +- Endpoint: `GET /api/wake` diff --git a/docs/src/content/docs/reference/skills.md b/docs/src/content/docs/reference/skills.md new file mode 100644 index 0000000..53f58df --- /dev/null +++ b/docs/src/content/docs/reference/skills.md @@ -0,0 +1,24 @@ +--- +title: Skills (API Docs) +description: Hive is self-documenting via /api/skill/* endpoints. +--- + +Hive exposes human- and agent-readable API documentation under `/api/skill/*`. + +## Entry points + +- Index: `GET /api/skill` +- Onboarding: `GET /api/skill/onboarding` +- Monitoring: `GET /api/skill/monitoring` + +Feature docs: +- Wake: `GET /api/skill/wake` +- Messages: `GET /api/skill/messages` +- Swarm: `GET /api/skill/swarm` +- Broadcast/Buzz: `GET /api/skill/broadcast` +- Presence: `GET /api/skill/presence` +- Chat: `GET /api/skill/chat` +- Directory: `GET /api/skill/directory` +- Notebook: `GET /api/skill/notebook` + +These are the authoritative API references; the docs site mirrors and explains them. From bcd977c4ce8fe1e8b6f720a008ffacf4daea35e9 Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 20:50:42 -0600 Subject: [PATCH 14/34] fix: mobile swarm uses list view instead of unusable kanban - Mobile now always renders ListView (grouped by status with collapsible sections) - Removed mobile status tabs (list view already groups by status) - Added show/hide completed toggle to mobile toolbar - Desktop kanban board unchanged --- src/routes/swarm.tsx | 70 ++++++++++++-------------------------------- 1 file changed, 18 insertions(+), 52 deletions(-) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 42f02fb..69fcfd1 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -467,34 +467,16 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { />
- {/* Status tabs — mobile */} -
- {visibleStatuses.map((status) => { - const config = STATUS_CONFIG[status]; - if (!config) return null; - const count = filteredTasks.filter((t) => t.status === status).length; - const StatusIcon = config.icon; - return ( - - ); - })} + {/* Show completed toggle — mobile */} +
+
@@ -560,30 +542,14 @@ function SwarmView({ onLogout }: { onLogout: () => void }) {
- {/* Mobile board — single column based on selected status */} -
- {(() => { - const statusTasks = filteredTasks.filter((t) => t.status === mobileStatus); - if (statusTasks.length === 0) { - return ( -

- No tasks -

- ); - } - return statusTasks.map((task) => ( - {}} - onDragEnd={() => {}} - isDragging={false} - onStatusChange={handleStatusChange} - onClick={() => setEditTask(task)} - /> - )); - })()} + {/* Mobile — always use list view */} +
+ setEditTask(t)} + />
) : ( From 2fbb5907a989464e0e9b6b396d1fbcfcf7da0f59 Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 20:57:48 -0600 Subject: [PATCH 15/34] feat: persist avatars with Docker named volume --- docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index a8629bc..dddd340 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: restart: unless-stopped expose: - "3000" + volumes: + - avatar-data:/app/public/avatars environment: - NODE_ENV=production - PORT=3000 @@ -49,6 +51,9 @@ services: retries: 3 start_period: 10s +volumes: + avatar-data: + networks: dokploy-network: external: true From 08f44ed93192c574406a2d538980511afa66ca4b Mon Sep 17 00:00:00 2001 From: Clio Date: Wed, 18 Feb 2026 21:09:43 -0600 Subject: [PATCH 16/34] docs: add administration overview page --- docs/src/content/docs/admin/index.md | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/src/content/docs/admin/index.md diff --git a/docs/src/content/docs/admin/index.md b/docs/src/content/docs/admin/index.md new file mode 100644 index 0000000..a4bcf55 --- /dev/null +++ b/docs/src/content/docs/admin/index.md @@ -0,0 +1,49 @@ +--- +title: Administration +description: Operating Hive safely: tokens, onboarding, webhooks, and health checks. +--- + +This section is for operators/admins running a Hive instance. + +## Core admin responsibilities + +- **Identity + token lifecycle** + - issue tokens safely (prefer DB-backed tokens with expiry) + - revoke/rotate tokens as needed + - avoid sharing tokens in chat/logs + +- **Onboarding** + - invite → register → verify (`/api/auth/*`) + - confirm the new identity appears in **wake** + - set up webhook or SSE delivery so agents stay responsive + +- **Operational posture** + - keep rate limits and security headers enabled (deployment dependent) + - monitor wake responsiveness (unread + pending/waiting + active tasks) + +## Key endpoints + +- Verify token: `POST /api/auth/verify` +- Invites: `GET/POST/DELETE /api/auth/invites` +- Register: `POST /api/auth/register` +- Webhook config: `GET/POST /api/auth/webhook` +- Presence: `GET /api/presence` +- Health: `GET /api/health` +- Doctor (ops dashboard): `GET /api/doctor` (admin view may exist) + +## Recommended operational loop + +1) Ensure agents have a reliable delivery mechanism: + - webhook push (or SSE) +2) Standardize discipline: + - **read → act/queue → ack immediately** + - use **pending/waiting** whenever committing to async work +3) Use wake as the single source of truth: + - `GET /api/wake` + +## See also + +- Admin → Onboarding +- Admin → Tokens +- Features → Wake +- Reference → Support & Security Contact From 82bec2692c752c366fb2394ee1892c0710d24f29 Mon Sep 17 00:00:00 2001 From: Domingo Date: Wed, 18 Feb 2026 21:52:19 -0600 Subject: [PATCH 17/34] feat: add followUp field to tasks Adds an optional 'follow_up' text column to swarm_tasks so agents can post status updates without overwriting the original task detail. - Schema: new follow_up column - API: exposed in GET, accepted in POST/PATCH - UI: displayed in card, board, and detail views; editable in task editor - Skill doc: documented in swarm skill - Migration: ALTER TABLE (manual, not db:push) --- server/routes/api/skill/swarm.get.ts | 1 + .../api/swarm/tasks/[id]/index.patch.ts | 1 + server/routes/api/swarm/tasks/index.post.ts | 1 + src/db/schema.ts | 1 + src/lib/api.ts | 1 + src/lib/swarm.ts | 3 ++ src/routes/swarm.tsx | 28 +++++++++++++++++++ 7 files changed, 36 insertions(+) diff --git a/server/routes/api/skill/swarm.get.ts b/server/routes/api/skill/swarm.get.ts index 627bf48..4d34195 100644 --- a/server/routes/api/skill/swarm.get.ts +++ b/server/routes/api/skill/swarm.get.ts @@ -72,6 +72,7 @@ Common optional fields: { "projectId": "", "detail": "Description / acceptance criteria", + "followUp": "Latest status update (use this instead of overwriting detail)", "issueUrl": "https://dev...", "assigneeUserId": "domingo", "status": "ready", diff --git a/server/routes/api/swarm/tasks/[id]/index.patch.ts b/server/routes/api/swarm/tasks/[id]/index.patch.ts index 0be107e..e329fbd 100644 --- a/server/routes/api/swarm/tasks/[id]/index.patch.ts +++ b/server/routes/api/swarm/tasks/[id]/index.patch.ts @@ -25,6 +25,7 @@ export default defineEventHandler(async (event) => { projectId: body.projectId, title: body.title, detail: body.detail, + followUp: body.followUp, issueUrl: body.issueUrl, assigneeUserId: body.assigneeUserId, onOrAfterAt: body.onOrAfterAt ? new Date(body.onOrAfterAt) : body.onOrAfterAt, diff --git a/server/routes/api/swarm/tasks/index.post.ts b/server/routes/api/swarm/tasks/index.post.ts index 3d32ccc..0f1617b 100644 --- a/server/routes/api/swarm/tasks/index.post.ts +++ b/server/routes/api/swarm/tasks/index.post.ts @@ -24,6 +24,7 @@ export default defineEventHandler(async (event) => { projectId: body.projectId, title: body.title, detail: body.detail, + followUp: body.followUp, issueUrl: body.issueUrl, creatorUserId: auth.identity, assigneeUserId: body.assigneeUserId, diff --git a/src/db/schema.ts b/src/db/schema.ts index 6610ec9..0bbbc43 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -158,6 +158,7 @@ export const swarmTasks = pgTable( projectId: text("project_id"), title: text("title").notNull(), detail: text("detail"), + followUp: text("follow_up"), creatorUserId: varchar("creator_user_id", { length: 50 }).notNull(), assigneeUserId: varchar("assignee_user_id", { length: 50 }), status: varchar("status", { length: 20 }).notNull().default("queued"), diff --git a/src/lib/api.ts b/src/lib/api.ts index b2d4bc0..3636666 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -162,6 +162,7 @@ export const api = { title: string; projectId?: string; detail?: string; + followUp?: string; issueUrl?: string; assigneeUserId?: string; status?: string; diff --git a/src/lib/swarm.ts b/src/lib/swarm.ts index 624e886..81ec73d 100644 --- a/src/lib/swarm.ts +++ b/src/lib/swarm.ts @@ -121,6 +121,7 @@ export async function createTask(input: { projectId?: string; title: string; detail?: string; + followUp?: string; issueUrl?: string; creatorUserId: string; assigneeUserId?: string; @@ -139,6 +140,7 @@ export async function createTask(input: { projectId: input.projectId || null, title: input.title, detail: input.detail || null, + followUp: input.followUp || null, issueUrl: input.issueUrl || null, creatorUserId: input.creatorUserId, assigneeUserId: input.assigneeUserId || null, @@ -218,6 +220,7 @@ export async function updateTask( projectId: string | null; title: string; detail: string | null; + followUp: string | null; issueUrl: string | null; assigneeUserId: string | null; onOrAfterAt: Date | null; diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 69fcfd1..dd44f3f 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -51,6 +51,7 @@ interface SwarmTask { projectId: string | null; title: string; detail: string | null; + followUp: string | null; issueUrl: string | null; creatorUserId: string; assigneeUserId: string | null; @@ -662,6 +663,11 @@ function TaskCard({ {task.detail}

)} + {task.followUp && ( +

+ ↳ {task.followUp} +

+ )} {/* Footer */}
@@ -777,6 +783,11 @@ function ListView({ {task.detail}

)} + {task.followUp && ( +

+ ↳ {task.followUp} +

+ )}
{task.assigneeUserId && ( @@ -864,6 +875,7 @@ function TaskDetailDialog({ const [editing, setEditing] = useState(false); const [title, setTitle] = useState(""); const [detail, setDetail] = useState(""); + const [followUp, setFollowUp] = useState(""); const [issueUrl, setIssueUrl] = useState(""); const [assignee, setAssignee] = useState(""); const [projectId, setProjectId] = useState(""); @@ -923,6 +935,7 @@ function TaskDetailDialog({ if (task) { setTitle(task.title); setDetail(task.detail || ""); + setFollowUp(task.followUp || ""); setIssueUrl(task.issueUrl || ""); setAssignee(task.assigneeUserId || ""); setProjectId(task.projectId || ""); @@ -944,6 +957,7 @@ function TaskDetailDialog({ await api.updateTask(task.id, { title: title.trim(), detail: detail.trim() || null, + followUp: followUp.trim() || null, issueUrl: issueUrl.trim() || null, assigneeUserId: assignee || null, projectId: projectId || null, @@ -1001,6 +1015,12 @@ function TaskDetailDialog({ placeholder="Details" rows={4} /> +