From 49b17a9cc6159087258486feaeee3ef8864d75c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 10:54:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Demo=20dataset=20is=20now=20opt-in=20?= =?UTF-8?q?=E2=80=94=20fresh=20installs=20ship=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up through Phase 9 the dev seed ran on every server start in non-production mode and the CLI didn't set NODE_ENV, so every clawcontrol-start ended with the Nova SaaS Co fixtures loaded. That's wrong for users — production deployments shouldn't auto-populate fictional org / agents / tasks / goals. Fix: • bin/clawcontrol.js sets NODE_ENV=production when spawning the bundled server (unless the caller has already set it). Cold installs land on an empty dashboard. Contributors running via `pnpm start` / tsx watch still get the auto-seed. • packages/server/src/db/seed.ts gains clearDemo() and hasDemo(). SEED_IDS is the canonical list of stable IDs the seed inserts; clearDemo deletes only those rows, so user-created data is preserved. Counts the rows present *before* deletion to give an honest report (some FKs cascade — direct DELETE.changes under-counts). • packages/server/src/routes/system.ts: GET /api/system/info demo_loaded + per-table counts POST /api/system/load-demo idempotent (already_loaded:true on second call) POST /api/system/clear-demo idempotent inverse • bin/clawcontrol.js gains the demo subcommand: clawcontrol demo load clawcontrol demo --clear remove clawcontrol demo --status loaded? + counts table • Settings page gets a Demo dataset card with per-table count chips, Load demo data button (disabled when already loaded), and Clear demo with confirmation. • api-client gets api.system.{info,loadDemo,clearDemo}. Tests: packages/server/tests/system.test.ts (6 cases) covers the full lifecycle: empty DB info → load → counts match the brief (1 org / 7 agents / 15 goals / 10 tasks / etc.) → second load is a no-op → clear removes only seed IDs → user-created agent survives a clear → clear on empty DB is a no-op. Docs: USER_GUIDE.md gains a "10. Demo dataset" section, the CLI reference table picks up demo / --status / --clear, the table of contents is renumbered. README's quickstart calls out that fresh installs are empty and how to opt in. Verified end-to-end on a cold ~/.clawcontrol/: • clawcontrol start → empty dashboard, /api/agents = [] • clawcontrol demo --status → "not loaded", all counts 0 • clawcontrol demo → "demo dataset loaded" • /api/agents → 7 agents • clawcontrol demo (again) → "already loaded" • clawcontrol demo --clear → "removed 70 demo rows" (correct sum across 9 tables) • /api/agents → [] Tests: 34 server (was 28) + 15 UI = 49 passing. Typecheck clean. --- README.md | 9 +++ bin/clawcontrol.js | 60 ++++++++++++++++ docs/USER_GUIDE.md | 82 +++++++++++++++++++--- packages/server/src/db/seed.ts | 81 ++++++++++++++++++++++ packages/server/src/routes/index.ts | 2 + packages/server/src/routes/system.ts | 74 ++++++++++++++++++++ packages/server/tests/system.test.ts | 100 +++++++++++++++++++++++++++ packages/ui/src/api/api-client.ts | 9 +++ packages/ui/src/pages/Settings.tsx | 71 +++++++++++++++++++ 9 files changed, 480 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/routes/system.ts create mode 100644 packages/server/tests/system.test.ts diff --git a/README.md b/README.md index 023927e..74e8659 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ npm install -g clawcontrol clawcontrol start # wizard on first run, then opens http://localhost:3000 ``` +Fresh installs land on an **empty** dashboard. To explore every screen +with sample data, opt in to the Nova SaaS Co demo: + +```sh +clawcontrol demo # load 7 agents, 15-node goal tree, 10 tasks, etc. +# or: Settings → Demo dataset → Load demo data +clawcontrol demo --clear # remove the demo (your own data is preserved) +``` + ### Docker ```sh diff --git a/bin/clawcontrol.js b/bin/clawcontrol.js index 007779a..274d482 100755 --- a/bin/clawcontrol.js +++ b/bin/clawcontrol.js @@ -335,6 +335,12 @@ async function startCmd({ skipWizard = false, openBrowser = true } = {}) { const log = fs.openSync(LOG_FILE, 'a'); const env = { ...process.env }; + // Force production mode so the server doesn't auto-seed demo data. + // Fresh installs ship empty — `clawcontrol demo` (or the Settings UI) + // loads the Nova SaaS Co fixtures on demand. Contributors running the + // server via `pnpm start` set NODE_ENV themselves (or leave it unset) + // and still get the seed. + if (!env.NODE_ENV) env.NODE_ENV = 'production'; // Tell the server where to find the UI bundle (Phase 9 doesn't require it // to serve UI yet, but we wire the env in so a future static-serving // change has the path it needs). @@ -463,6 +469,56 @@ async function updateCmd() { console.log(c.green(' ✓ staged at ') + (inst.body.stagedAt || '')); } +// ── demo ───────────────────────────────────────────────────────────────── +// Loads the Nova SaaS Co fixture set (1 org, 7 named agents, 4-level goal +// tree, 10 tasks, 4 heartbeats, 4 memory collections, 6 skills, audit +// trail, 3 pending board approvals) into a running server. Idempotent. +async function demoCmd(args) { + const clear = args.includes('--clear'); + const status = args.includes('--status'); + + if (status) { + const info = await api('GET', '/api/system/info'); + if (!info.ok) { + console.error(c.red('cannot reach the server.') + ' Start it with ' + c.cyan('clawcontrol start') + ' first.'); + process.exit(1); + } + const b = info.body; + console.log(c.bold('Demo dataset: ') + (b.demo_loaded ? c.green('LOADED') : c.gray('not loaded'))); + console.log(''); + console.log(c.bold('Counts')); + for (const [k, v] of Object.entries(b.counts)) { + console.log(' ' + k.padEnd(22) + c.cyan(String(v))); + } + return; + } + + if (clear) { + const r = await api('POST', '/api/system/clear-demo'); + if (!r.ok) { console.error(c.red('clear failed: ') + JSON.stringify(r.body)); process.exit(1); } + if (!r.body.had_demo) { + console.log(c.gray(' no demo data was loaded — nothing to remove')); + return; + } + const removed = r.body.removed || {}; + const total = Object.values(removed).reduce((a, b) => a + Number(b || 0), 0); + console.log(c.green(` ✓ removed ${total} demo row${total === 1 ? '' : 's'}`)); + for (const [k, v] of Object.entries(removed)) if (v) console.log(' ' + c.gray(`${k}: ${v}`)); + return; + } + + const r = await api('POST', '/api/system/load-demo'); + if (!r.ok) { console.error(c.red('load failed: ') + JSON.stringify(r.body)); process.exit(1); } + if (r.body.already_loaded) { + console.log(c.yellow(' demo data is already loaded')); + console.log(c.gray(' use ') + c.cyan('clawcontrol demo --clear') + c.gray(' to remove it first')); + return; + } + console.log(c.green(' ✓ demo dataset loaded')); + console.log(c.gray(' Nova SaaS Co + 7 agents + goal tree + 10 tasks + heartbeats + audit trail')); + console.log(c.gray(' open the UI to explore:') + ' ' + c.cyan('http://localhost:3000')); +} + // ── reset ──────────────────────────────────────────────────────────────── async function resetCmd(args) { const hard = args.includes('--hard'); @@ -558,6 +614,9 @@ function helpCmd() { ` ${c.cyan('update')} Check + install updates`, ` ${c.cyan('reset')} Delete config.json (keep DB / secrets / backups)`, ` ${c.cyan('reset --hard')} Delete EVERYTHING in ~/.clawcontrol/`, + ` ${c.cyan('demo')} Load the Nova SaaS Co demo dataset into the running server`, + ` ${c.cyan('demo --clear')} Remove demo rows (user data is preserved)`, + ` ${c.cyan('demo --status')} Show whether demo data is loaded + per-table row counts`, ` ${c.cyan('export ')} tar.gz the entire ~/.clawcontrol/ directory`, ` ${c.cyan('import ')} Restore from a previous export`, ` ${c.cyan('logs')} Tail ~/.clawcontrol/server.log`, @@ -593,6 +652,7 @@ async function main() { case 'doctor': await doctorCmd(); return; case 'update': await updateCmd(); return; case 'reset': await resetCmd(args); return; + case 'demo': await demoCmd(args); return; case 'export': await exportCmd(args); return; case 'import': await importCmd(args); return; case 'logs': await logsCmd(); return; diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7b16776..a5b5e86 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -16,10 +16,11 @@ check reference. 7. [Configuration](#7-configuration) 8. [Backups & restore](#8-backups--restore) 9. [Updates](#9-updates) -10. [One-click scaling](#10-one-click-scaling) -11. [Going to production](#11-going-to-production) -12. [Troubleshooting](#12-troubleshooting) -13. [FAQ](#13-faq) +10. [Demo dataset](#10-demo-dataset) +11. [One-click scaling](#11-one-click-scaling) +12. [Going to production](#12-going-to-production) +13. [Troubleshooting](#13-troubleshooting) +14. [FAQ](#14-faq) ## 1. Install @@ -330,6 +331,9 @@ the run and replays it the moment OpenClaw reconnects. The | `clawcontrol backup` | POSTs to `/api/backups` with `type:'manual'`. Prints the archive path. | | `clawcontrol doctor` | GETs `/api/doctor`, prints a colored PASS/WARN/FAIL table. Exits non-zero on any FAIL — drop it in CI to fail builds when the host is unhealthy. | | `clawcontrol update` | Checks for updates, asks for confirmation, runs the staged install pipeline. | +| `clawcontrol demo` | Loads the Nova SaaS Co demo dataset into the running server. Idempotent — reports `already_loaded: true` if nothing was added. | +| `clawcontrol demo --status` | Shows whether the demo is loaded plus per-table row counts. | +| `clawcontrol demo --clear` | Removes the seed rows (and only those). User-created data is preserved. | | `clawcontrol reset` | Deletes `config.json` (and the `pending-keys.json` if any). Keeps DB, secrets, and backups. Asks for confirmation. | | `clawcontrol reset --hard` | Deletes the entire `~/.clawcontrol/` directory. Asks for confirmation. | | `clawcontrol export ` | tar-czf the entire `~/.clawcontrol/` directory (excluding `server.pid` and `server.log`). The server must be stopped first. | @@ -522,7 +526,69 @@ Set `updates.auto_install = true` in `config.json`. The 03:00 daily cron will then run the full pipeline whenever a new release is found. Most deployments leave this off and review changelogs manually. -## 10. One-click scaling +## 10. Demo dataset + +A fresh install ships **empty** — no agents, no goals, no tasks. That's +deliberate: production deployments shouldn't auto-populate fictional +data. When you want to explore every screen without setting up real +agents first, opt in to the demo set. + +### What the demo contains + +- **1 organization** — Nova SaaS Co with mission *"Build the #1 AI + productivity suite to $1M MRR"*. +- **7 named agents** with reports-to relationships: Atlas (CEO), Nova + (CTO), Echo (CMO), Cipher + Lyra (Engineers), Muse (Content writer), + Sage (SEO). +- **15-node 4-level goal tree**: 1 mission → 3 projects → 5 agent goals + → 6 task-link goals. +- **10 tasks** spread across every status column. +- **4 heartbeats** with cron schedules. +- **4 memory collections** + **6 skills** + audit history of the last + 7 days + 3 pending board approvals. + +### Loading the demo + +Three equivalent paths: + +```sh +# CLI +clawcontrol demo +# or +clawcontrol demo --status # is it loaded? per-table counts +clawcontrol demo --clear # remove only the seeded rows +``` + +```sh +# REST +curl -X POST http://localhost:3001/api/system/load-demo +curl -X POST http://localhost:3001/api/system/clear-demo +curl http://localhost:3001/api/system/info +``` + +``` +UI → Settings → Demo dataset → Load demo data / Clear demo +``` + +`load-demo` is idempotent — calling it twice in a row reports +`already_loaded: true` and changes nothing. + +### What `clear-demo` removes (and what it doesn't) + +`clear-demo` deletes only the rows the seed inserts, identified by their +stable IDs (`org_nova`, `ag_atlas`, `gl_mission`, `tk_01`-`tk_10`, +`hb_muse`, …). **User-created agents, goals, tasks, audit entries, and +backups are preserved.** Run it freely. + +### Why isn't the demo loaded automatically? + +Up through Phase 9 the dev seed ran on every server start in non-production +mode. From Phase 10+ the CLI sets `NODE_ENV=production` when spawning the +server, so a `clawcontrol start` cold install lands you on an empty +dashboard. Contributors running the server via `pnpm start` (or `tsx +watch`) without setting `NODE_ENV` still get the auto-seed for convenience. + +## 11. One-click scaling For workloads that outgrow a single OpenClaw instance: @@ -550,7 +616,7 @@ curl -X DELETE http://localhost:3001/api/instances/ `listInstances()` prunes dead PIDs on every read so the registry never drifts from reality. -## 11. Going to production +## 12. Going to production A handful of changes you'll want before exposing ClawControl beyond localhost. @@ -617,7 +683,7 @@ echo "exit=$?" `clawcontrol doctor` returns non-zero when any check is `fail`. Wire it into a deploy gate or a periodic external probe. -## 12. Troubleshooting +## 13. Troubleshooting The first thing to try, almost always, is `clawcontrol doctor` — its 10 checks cover most failure modes and three of them have one-click fixes. @@ -724,7 +790,7 @@ clawcontrol reset --hard # nukes ~/.clawcontrol/ entirely Both prompt for confirmation. -## 13. FAQ +## 14. FAQ **Why does the brief say "OpenClaw 3001" but my config says 3002?** Phase-1 brief had a typo (`ws://localhost:3001` would conflict with the diff --git a/packages/server/src/db/seed.ts b/packages/server/src/db/seed.ts index a1b50a2..4e7a03c 100644 --- a/packages/server/src/db/seed.ts +++ b/packages/server/src/db/seed.ts @@ -16,6 +16,87 @@ export function isDbEmpty(db: Database.Database): boolean { return row.c === 0; } +// Stable IDs the seed inserts. Listed here so clearDemo() can target only +// these rows and never touch user-created data. +const SEED_IDS = { + organizations: ['org_nova'], + agents: ['ag_atlas', 'ag_nova', 'ag_echo', 'ag_cipher', 'ag_lyra', 'ag_muse', 'ag_sage'], + goals: [ + 'gl_mission', + 'gl_collab', 'gl_growth', 'gl_soc2', + 'gl_sync', 'gl_cursors', 'gl_seo_posts', 'gl_keywords', 'gl_audit_pipe', + 'gl_ws_handler', 'gl_crdt_tests', 'gl_palette', + 'gl_post_tips', 'gl_post_onboarding', 'gl_comp_audit', + ], + tasks: [ + 'tk_01', 'tk_02', 'tk_03', 'tk_04', 'tk_05', + 'tk_06', 'tk_07', 'tk_08', 'tk_09', 'tk_10', + ], + heartbeats: ['hb_muse', 'hb_sage', 'hb_echo', 'hb_cipher'], + memoryCollections: ['mc_nova_context', 'mc_compliance', 'mc_runbooks', 'mc_brand_voice'], + skills: ['sk_web_fetch', 'sk_browser_nav', 'sk_mmr_query', 'sk_mmr_ingest', 'sk_telegram', 'sk_doc_write'], + audit: [ + 'al_01', 'al_02', 'al_03', 'al_04', 'al_05', 'al_06', 'al_07', 'al_08', 'al_09', 'al_10', + 'al_11', 'al_12', 'al_13', 'al_14', 'al_15', 'al_16', 'al_17', 'al_18', 'al_19', 'al_20', + ], + boardApprovals: ['ba_01', 'ba_02', 'ba_03'], +}; + +/** + * Removes exactly the rows seedDev() inserts. User-created agents, + * tasks, audit entries, etc. are untouched because we only delete by + * the specific seed IDs. + * + * Returns a per-table count of removed rows. Idempotent — calling it + * twice in a row is a no-op the second time. + */ +export function clearDemo(db: Database.Database): Record { + const removed: Record = {}; + const tx = db.transaction(() => { + // Some tables (notably goals) have ON DELETE CASCADE on their parent + // FK. After we delete the root row, child rows vanish and a follow-up + // DELETE WHERE id IN (...) reports 0 changes for them — even though + // they belonged to the seed. Count the rows that actually exist + // *before* the delete so the response reports the truth. + // + // Order still matters for tables linked by ON DELETE SET NULL (e.g. + // audit_log → agents): wipe dependents first so we don't briefly + // leave nulled columns dangling. + const sweep = (table: string, ids: readonly string[]): number => { + if (ids.length === 0) return 0; + const placeholders = ids.map(() => '?').join(', '); + const present = (db + .prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE id IN (${placeholders})`) + .get(...ids) as { c: number }).c; + db.prepare(`DELETE FROM ${table} WHERE id IN (${placeholders})`).run(...ids); + return present; + }; + removed.audit_log = sweep('audit_log', SEED_IDS.audit); + removed.board_approvals = sweep('board_approvals', SEED_IDS.boardApprovals); + removed.tasks = sweep('tasks', SEED_IDS.tasks); + removed.heartbeats = sweep('heartbeats', SEED_IDS.heartbeats); + removed.memory_collections = sweep('memory_collections', SEED_IDS.memoryCollections); + removed.skills = sweep('skills', SEED_IDS.skills); + removed.goals = sweep('goals', SEED_IDS.goals); + removed.agents = sweep('agents', SEED_IDS.agents); + removed.organizations = sweep('organizations', SEED_IDS.organizations); + }); + tx(); + return removed; +} + +/** + * Quick yes/no — does this DB look like it has the demo dataset loaded? + * The org with id `org_nova` is unique to the seed, so its presence is a + * reliable proxy. + */ +export function hasDemo(db: Database.Database): boolean { + const row = db + .prepare('SELECT 1 AS ok FROM organizations WHERE id = ? LIMIT 1') + .get(SEED_IDS.organizations[0]) as { ok?: number } | undefined; + return !!row?.ok; +} + export function seedDev(db: Database.Database): void { if (!isDbEmpty(db)) return; diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 01933c1..db807aa 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -13,6 +13,7 @@ import { instancesRouter } from './instances.js'; import { memoryRouter } from './memory.js'; import { organizationsRouter } from './organizations.js'; import { skillsRouter } from './skills.js'; +import { systemRouter } from './system.js'; import { tasksRouter } from './tasks.js'; import { updatesRouter } from './updates.js'; @@ -38,3 +39,4 @@ apiRouter.use('/doctor', doctorRouter); apiRouter.use('/backups', backupsRouter); apiRouter.use('/updates', updatesRouter); apiRouter.use('/instances', instancesRouter); +apiRouter.use('/system', systemRouter); diff --git a/packages/server/src/routes/system.ts b/packages/server/src/routes/system.ts new file mode 100644 index 0000000..5d472ee --- /dev/null +++ b/packages/server/src/routes/system.ts @@ -0,0 +1,74 @@ +import { Router } from 'express'; +import { getDb } from '../db/index.js'; +import { clearDemo, hasDemo, seedDev } from '../db/seed.js'; +import { logAudit } from '../lib/audit.js'; +import { asyncHandler } from '../lib/async.js'; + +export const systemRouter = Router(); + +// GET /api/system/info — counts per resource + demo status. Used by the +// Settings page and the dashboard's empty state to decide whether to +// surface "Load demo data" CTAs. +systemRouter.get( + '/info', + asyncHandler(async (_req, res) => { + const db = getDb(); + const count = (table: string): number => + (db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get() as { c: number }).c; + res.json({ + demo_loaded: hasDemo(db), + counts: { + organizations: count('organizations'), + agents: count('agents'), + goals: count('goals'), + tasks: count('tasks'), + heartbeats: count('heartbeats'), + memory_collections: count('memory_collections'), + skills: count('skills'), + api_keys: count('api_keys'), + channels: count('channels'), + backups: count('backups'), + audit_log: count('audit_log'), + board_approvals: count('board_approvals'), + }, + }); + }), +); + +// POST /api/system/load-demo — load the Nova SaaS Co fixture set. Idempotent: +// the underlying seedDev() guards on an empty organizations table, so a +// second call is a no-op. Reload by clearing first. +systemRouter.post( + '/load-demo', + asyncHandler(async (_req, res) => { + const db = getDb(); + const before = hasDemo(db); + seedDev(db); + const after = hasDemo(db); + if (after && !before) { + logAudit({ action: 'system.demo_loaded', status: 'ok' }); + } + res.json({ + ok: after, + already_loaded: before, + loaded: after && !before, + }); + }), +); + +// POST /api/system/clear-demo — remove only the seeded rows. User-created +// entities are preserved. +systemRouter.post( + '/clear-demo', + asyncHandler(async (_req, res) => { + const db = getDb(); + const before = hasDemo(db); + const removed = clearDemo(db); + if (before) logAudit({ action: 'system.demo_cleared', tool_calls: [{ removed }], status: 'ok' }); + res.json({ + ok: true, + had_demo: before, + removed, + }); + }), +); diff --git a/packages/server/tests/system.test.ts b/packages/server/tests/system.test.ts new file mode 100644 index 0000000..79cb462 --- /dev/null +++ b/packages/server/tests/system.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import request from 'supertest'; +import { makeApp, type TestEnv } from './_helpers.js'; + +// system tests: cover the demo lifecycle on a fresh DB. +// • /info on an empty DB shows demo_loaded:false + zero counts. +// • /load-demo populates the brief's expected fixture set. +// • /load-demo a second time is a no-op (already_loaded:true). +// • /clear-demo removes ONLY the seed rows. +// • A hand-created agent survives clear-demo (preserves user data). + +let env: TestEnv; +beforeEach(() => { env = makeApp({ seed: false }); }); +afterEach(() => env.cleanup()); + +describe('system / demo lifecycle', () => { + test('info on a fresh DB reports demo_loaded:false + zero counts', async () => { + const r = await request(env.app).get('/api/system/info'); + expect(r.status).toBe(200); + expect(r.body.demo_loaded).toBe(false); + expect(r.body.counts.agents).toBe(0); + expect(r.body.counts.organizations).toBe(0); + expect(r.body.counts.goals).toBe(0); + expect(r.body.counts.tasks).toBe(0); + }); + + test('load-demo populates the brief-specified fixtures', async () => { + const load = await request(env.app).post('/api/system/load-demo'); + expect(load.status).toBe(200); + expect(load.body.loaded).toBe(true); + expect(load.body.already_loaded).toBe(false); + + const info = await request(env.app).get('/api/system/info'); + expect(info.body.demo_loaded).toBe(true); + expect(info.body.counts.organizations).toBe(1); + expect(info.body.counts.agents).toBe(7); + expect(info.body.counts.goals).toBe(15); + expect(info.body.counts.tasks).toBe(10); + expect(info.body.counts.heartbeats).toBe(4); + expect(info.body.counts.memory_collections).toBe(4); + expect(info.body.counts.skills).toBe(6); + expect(info.body.counts.audit_log).toBeGreaterThanOrEqual(20); // seed + the demo_loaded audit row + expect(info.body.counts.board_approvals).toBe(3); + + // Spot-check a known seed agent. + const atlas = await request(env.app).get('/api/agents/ag_atlas'); + expect(atlas.status).toBe(200); + expect(atlas.body.agent.name).toBe('Atlas'); + }); + + test('load-demo is idempotent', async () => { + await request(env.app).post('/api/system/load-demo'); + const second = await request(env.app).post('/api/system/load-demo'); + expect(second.body.already_loaded).toBe(true); + expect(second.body.loaded).toBe(false); + }); + + test('clear-demo removes the seed rows', async () => { + await request(env.app).post('/api/system/load-demo'); + const clear = await request(env.app).post('/api/system/clear-demo'); + expect(clear.status).toBe(200); + expect(clear.body.had_demo).toBe(true); + expect(clear.body.removed.organizations).toBe(1); + expect(clear.body.removed.agents).toBe(7); + expect(clear.body.removed.goals).toBe(15); + expect(clear.body.removed.tasks).toBe(10); + + const info = await request(env.app).get('/api/system/info'); + expect(info.body.demo_loaded).toBe(false); + expect(info.body.counts.agents).toBe(0); + expect((await request(env.app).get('/api/agents/ag_atlas')).status).toBe(404); + }); + + test('clear-demo on a fresh DB is a no-op', async () => { + const r = await request(env.app).post('/api/system/clear-demo'); + expect(r.status).toBe(200); + expect(r.body.had_demo).toBe(false); + const total = Object.values(r.body.removed as Record) + .reduce((a, b) => a + Number(b), 0); + expect(total).toBe(0); + }); + + test('clear-demo preserves user-created agents', async () => { + await request(env.app).post('/api/system/load-demo'); + const created = await request(env.app) + .post('/api/agents') + .send({ name: 'My Real Agent', budget_cents: 1000 }); + expect(created.status).toBe(201); + const myId = created.body.agent.id; + + await request(env.app).post('/api/system/clear-demo'); + + const stillThere = await request(env.app).get(`/api/agents/${myId}`); + expect(stillThere.status).toBe(200); + expect(stillThere.body.agent.name).toBe('My Real Agent'); + + // And the seed agents are gone. + expect((await request(env.app).get('/api/agents/ag_atlas')).status).toBe(404); + }); +}); diff --git a/packages/ui/src/api/api-client.ts b/packages/ui/src/api/api-client.ts index d9cb89e..02d89f4 100644 --- a/packages/ui/src/api/api-client.ts +++ b/packages/ui/src/api/api-client.ts @@ -328,6 +328,15 @@ export const api = { autoConfig: () => request<{ auto_check: boolean; auto_install: boolean; repo_url: string }>('GET', '/api/updates/auto-config'), }, + system: { + info: () => request<{ + demo_loaded: boolean; + counts: Record; + }>('GET', '/api/system/info'), + loadDemo: () => request<{ ok: boolean; already_loaded: boolean; loaded: boolean }>('POST', '/api/system/load-demo'), + clearDemo: () => request<{ ok: boolean; had_demo: boolean; removed: Record }>('POST', '/api/system/clear-demo'), + }, + instances: { list: () => request<{ instances: Array<{ id: string; pid: number; port: number; home: string; bin: string; startedAt: number }> }>('GET', '/api/instances'), create: (body: { port: number; bin?: string; home?: string }) => diff --git a/packages/ui/src/pages/Settings.tsx b/packages/ui/src/pages/Settings.tsx index bb17a29..6b14c62 100644 --- a/packages/ui/src/pages/Settings.tsx +++ b/packages/ui/src/pages/Settings.tsx @@ -5,14 +5,21 @@ import { useSystemStatus } from '../hooks/index.js'; import { Button, Card, Chip, ErrorPanel, SectionHeader, Skeleton } from '../components/primitives.js'; import { Icon } from '../components/Icon.js'; +interface SystemInfo { demo_loaded: boolean; counts: Record } + export function SettingsPage() { const sys = useSystemStatus(); const [installing, setInstalling] = useState(false); const [autoConfig, setAutoConfig] = useState<{ auto_check: boolean; auto_install: boolean; repo_url: string } | null>(null); const [autoLoading, setAutoLoading] = useState(true); + const [info, setInfo] = useState(null); + const [demoBusy, setDemoBusy] = useState(false); + + const refreshInfo = () => api.system.info().then(setInfo).catch(() => null); useEffect(() => { api.updates.autoConfig().then(setAutoConfig).catch(() => null).finally(() => setAutoLoading(false)); + refreshInfo(); }, []); return ( @@ -59,6 +66,70 @@ export function SettingsPage() { {sys.update?.changelogUrl && View changelog →} + +
+

Demo dataset

+ {info && ( + + {info.demo_loaded ? 'loaded' : 'not loaded'} + + )} +
+

+ The demo loads Nova SaaS Co — 1 organization, + 7 named agents (Atlas, Nova, Echo, Cipher, Lyra, Muse, Sage) with reports-to relationships, + a 4-level goal tree, 10 sample tasks, 4 heartbeats, 4 memory collections, 6 skills, audit + history, and 3 pending board approvals. Use it to explore every screen without real + OpenClaw / model-provider state. Clearing only removes the seed rows — your own agents + and tasks stay. +

+ {info && ( +
+ {Object.entries(info.counts).map(([k, v]) => ( +
+
{k.replace(/_/g, ' ')}
+
{v}
+
+ ))} +
+ )} +
+ + +
+
+

Auth

From 27e869a39410251c3130cf69bef357af11cd7f22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 04:33:04 +0000 Subject: [PATCH 2/3] First-install onboarding + editable gateway + bidirectional agent sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses four asks: a setup guide on first install, a properly configurable OpenClaw gateway, agents that stay in sync with OpenClaw, and a gateway that can re-establish on demand. Schema Migration v3 adds agents.sync_status ('pending' | 'synced' | 'failed'), last_synced_at, sync_error. Existing rows default to 'pending' so every agent gets pushed to OpenClaw on the next reconnect. OpenClaw client • reconnectNow(newUrl?) resets the backoff counter and triggers an immediate connect — exposed for the UI's "Reconnect now" button and the gateway-URL change endpoint. • setUrl(url) hot-swaps the gateway URL without a server restart. • syncAgent(agent) and deleteAgent(id) push agent definitions over the WS — the missing inverse of the existing inbound agent.* events. • Closing a CONNECTING socket no longer leaks an unhandled 'error' event (previously crashed the server when a PATCH triggered an immediate reconnect — caught during the cold-boot smoke). Agent ↔ OpenClaw sync (openclaw/agent-sync.ts) Single chokepoint. The agents route calls pushAgent(id) after every create / update / clone and pushAgentDeletion(id) on delete. The helper marks the row 'synced' on success, 'pending' (with a reason in sync_error) when OpenClaw is offline, 'failed' when the push raised. startAutoSync() subscribes to openclaw-client state changes and runs syncAllPending() the moment the gateway flips to 'connected'. System endpoints (routes/system.ts) GET /api/system/openclaw-config — current + live URL + state PATCH /api/system/openclaw-config — write config.json + reconnect POST /api/system/openclaw-reconnect — kick the client now POST /api/system/sync-all — re-push pending/failed agents GET /api/system/onboarding — { completed, completed_at } POST /api/system/onboarding/complete — flip the flag in system_config DELETE /api/system/onboarding — clear the flag (re-run wizard) POST /api/agents/:id/sync — manual single-agent push UI surfaces • OfflineBanner gains a "Reconnect now" button alongside Run Doctor. • Settings → "OpenClaw gateway" card: editable URL, live state chip, Save & reconnect, Reconnect now, Re-sync agents. Drift indicator when the live URL has diverged from config. • New /welcome page (4 steps: hello → connect to OpenClaw with live test → choose starting state [demo / first agent / skip] → done). Layout checks /api/system/onboarding on mount and redirects to /welcome the first time. Fail-open if the probe errors so a flaky server never wedges the user out. Bug fixes • Both the openclaw-client and the Doctor's gateway probe used to leak a "WebSocket was closed before the connection was established" error after closing a CONNECTING socket — Node treated it as unhandled because removeAllListeners() ran first. Now we install a no-op error handler before close(). • backup-service path drift fix from the previous commit confirmed against the new test setup. Tests (44 passing, was 34) packages/server/tests/sync-onboarding.test.ts (9 cases) covers the new endpoints: openclaw-config GET / PATCH (with URL-scheme validation), reconnect, sync-all on empty + after agent create (verifies sync_status 'pending' when OpenClaw is offline), manual /agents/:id/sync, onboarding flag lifecycle (start uncompleted, POST flips, DELETE clears). Test harness now installs a real openclaw-client per test (pointed at a random unused port so reconnect attempts no-op safely) and resets the singleton in cleanup. Docs USER_GUIDE.md: §2 split into "CLI wizard" + "In-app onboarding" with the 4-step UI walkthrough and the curl recipe for re-running it. §3 picks up "Settings → OpenClaw gateway" with the three new buttons and the agent sync_status semantics. Verified end-to-end on a fresh ~/.clawcontrol/: • clawcontrol start → server up, dashboard redirects to /welcome. • /api/system/onboarding → { completed: false } • /api/system/openclaw-config → live state + URL • PATCH gateway URL → saves + reconnects (no crash) • POST openclaw-reconnect → state: connecting • POST sync-all on empty DB → 0/0/0 • Create agent → sync_status: pending, sync_error: "OpenClaw offline — queued for reconnect" • POST onboarding/complete → next refresh lands on / --- docs/USER_GUIDE.md | 63 +++- packages/server/src/db/schema.ts | 14 + packages/server/src/doctor/checks/gateway.ts | 8 +- packages/server/src/index.ts | 5 + packages/server/src/lib/rows.ts | 7 + packages/server/src/openclaw/agent-sync.ts | 146 ++++++++ .../server/src/openclaw/openclaw-client.ts | 59 +++- packages/server/src/routes/agents.ts | 19 ++ packages/server/src/routes/system.ts | 139 ++++++++ packages/server/tests/_helpers.ts | 22 ++ packages/server/tests/sync-onboarding.test.ts | 101 ++++++ packages/ui/src/App.tsx | 2 + packages/ui/src/api/api-client.ts | 11 + packages/ui/src/components/Layout.tsx | 26 +- packages/ui/src/components/OfflineBanner.tsx | 44 ++- packages/ui/src/pages/Settings.tsx | 79 ++++- packages/ui/src/pages/Welcome.tsx | 319 ++++++++++++++++++ 17 files changed, 1049 insertions(+), 15 deletions(-) create mode 100644 packages/server/src/openclaw/agent-sync.ts create mode 100644 packages/server/tests/sync-onboarding.test.ts create mode 100644 packages/ui/src/pages/Welcome.tsx diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a5b5e86..75af64f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -83,6 +83,21 @@ Requirements: Node ≥ 20, pnpm ≥ 9. ## 2. First launch — the wizard +There are **two** wizards, one per process boundary: + +- **CLI wizard** (`clawcontrol start` on first run) — collects the data + the server needs to even boot: gateway URL, optional admin password, + backup destination, default LLM provider, API key. +- **In-app onboarding** (the UI's `/welcome` page) — shown automatically + the first time you open the dashboard. Confirms the gateway URL is + right, lets you test-reconnect, and offers three starting states: + load the demo dataset, create your first agent, or skip and land on + an empty dashboard. Re-run later from + `DELETE /api/system/onboarding` (or by clearing + `system_config.onboarding_completed` directly). + +### CLI wizard + The first time you run `clawcontrol start` (and `~/.clawcontrol/config.json` doesn't exist), an interactive wizard collects everything the server needs. @@ -111,7 +126,33 @@ When the wizard finishes: `/setup` page asks for it the first time you connect. The server is then started, `/api/health` is polled until it responds, and -your default browser opens `http://localhost:3000`. +your default browser opens `http://localhost:3000` — where the **in-app +onboarding** picks up. + +### In-app onboarding + +The first GET to `/api/system/onboarding` after install returns +`{ completed: false }` and the Layout redirects to `/welcome` instead of +the dashboard. Four steps: + +1. **Hello** — what ClawControl is and what's coming next. +2. **Connect to OpenClaw** — confirm the gateway URL, save it (writes to + config.json AND reconnects the live client without a server restart), + live state chip turns green when the handshake succeeds. +3. **Pick a starting state** — three cards: *Load demo data* (Nova SaaS Co + + 7 agents + …), *Create my first agent* (inline form with name, role, + model, provider, SOUL.md), or *Skip*. +4. **Done** — flag is recorded server-side; refreshes never bounce back to + the welcome page. + +To re-show the welcome wizard later (e.g. for a coworker on the same +machine): + +```sh +curl -X DELETE http://localhost:3001/api/system/onboarding +``` + +Refresh the UI — `/welcome` returns. ## 3. Tour of the UI @@ -189,6 +230,26 @@ Sidebar groups (collapsible — click the group header): - **Settings** (`/settings`) — system status, update check + install, notes on auth. +### Settings → OpenClaw gateway + +Three live actions on the Settings page that didn't fit into the brief +narrative above but are worth knowing about: + +- **Save & reconnect** — change `openclaw.gatewayUrl` without editing + `config.json` by hand. Writes the file AND reconnects the live client. +- **Reconnect now** — resets the openclaw-client's exponential backoff + and tries to reconnect immediately. Same button lives on the + OfflineBanner so you don't have to navigate away when offline. +- **Re-sync agents** — pushes every agent whose `sync_status` isn't + `synced` back to OpenClaw. Auto-fired on every reconnect; the manual + button is for "I changed something while OpenClaw was down and I want + it pushed *now*". + +Every agent row carries `sync_status` (`pending` / `synced` / `failed`), +`last_synced_at`, and `sync_error`. New agents start `pending` until +OpenClaw acks the push. Local edits queue when OpenClaw is offline and +drain on reconnect. + ### Header chrome - Search box (placeholder — Cmd+K is the real way to search). diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 722d42d..7dac382 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -208,6 +208,20 @@ const MIGRATIONS: Migration[] = [ CREATE INDEX IF NOT EXISTS idx_doctor_created ON doctor_runs(created_at); `, }, + { + version: 3, + name: 'agent sync state', + // Two-state field: 'synced' once OpenClaw has acked, 'pending' otherwise + // (the default — covers fresh inserts and any update that hasn't reached + // OpenClaw yet). 'failed' is set when a push raised. 'unmanaged' marks + // local-only agents that intentionally don't sync. + sql: ` + ALTER TABLE agents ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'pending'; + ALTER TABLE agents ADD COLUMN last_synced_at INTEGER; + ALTER TABLE agents ADD COLUMN sync_error TEXT; + CREATE INDEX IF NOT EXISTS idx_agents_sync_status ON agents(sync_status); + `, + }, ]; const SCHEMA_VERSION_KEY = 'schema_version'; diff --git a/packages/server/src/doctor/checks/gateway.ts b/packages/server/src/doctor/checks/gateway.ts index acd5425..5084020 100644 --- a/packages/server/src/doctor/checks/gateway.ts +++ b/packages/server/src/doctor/checks/gateway.ts @@ -22,7 +22,13 @@ async function probeOnce(url: string): Promise<{ ok: boolean; latencyMs: number; const finish = (out: { ok: boolean; latencyMs: number; error?: string }) => { if (settled) return; settled = true; - try { ws.removeAllListeners(); ws.close(); } catch { /* ignore */ } + try { + ws.removeAllListeners(); + // Same reason as in openclaw-client: closing a CONNECTING ws emits + // an error after we've stripped listeners. Suppress. + ws.on('error', () => { /* swallow */ }); + ws.close(); + } catch { /* ignore */ } resolve(out); }; const t = setTimeout(() => finish({ ok: false, latencyMs: PROBE_TIMEOUT_MS, error: 'timeout' }), PROBE_TIMEOUT_MS); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 161713c..a7d3041 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,6 +10,7 @@ import { cors } from './middleware/cors.js'; import { errorHandler } from './middleware/error.js'; import { requestLogger } from './middleware/logging.js'; import { rateLimit } from './middleware/rate-limit.js'; +import { startAutoSync } from './openclaw/agent-sync.js'; import { getOpenClawClient, initOpenClawClient } from './openclaw/openclaw-client.js'; import { startLogTail } from './openclaw/process-manager.js'; import { apiRouter } from './routes/index.js'; @@ -30,6 +31,10 @@ if (process.env.NODE_ENV !== 'production') { // reconnect loop; if OpenClaw is down, system_config.openclaw_status reads // 'disconnected' and outbound calls throw OpenClawUnreachableError. initOpenClawClient(config); +// Auto-sync agents whose sync_status is 'pending' / 'failed' on every +// gateway reconnect. Safe to call before any agents exist — it runs on +// the next state-change event. +startAutoSync(); // Log tail is best-effort — if ~/.openclaw/logs/current.log doesn't exist // yet, this is a no-op until OpenClaw boots and creates it. startLogTail(); diff --git a/packages/server/src/lib/rows.ts b/packages/server/src/lib/rows.ts index 4558883..a272e91 100644 --- a/packages/server/src/lib/rows.ts +++ b/packages/server/src/lib/rows.ts @@ -15,6 +15,10 @@ export interface AgentRow { reports_to_agent_id: string | null; status: string; budget_cents: number; spent_cents: number; soul_md: string | null; approval_required: number; created_at: number; + // Migration v3 — defaults to 'pending' until OpenClaw acks the push. + sync_status?: string | null; + last_synced_at?: number | null; + sync_error?: string | null; } export const mapAgent = (r: AgentRow) => ({ id: r.id, name: r.name, role: r.role, title: r.title, @@ -24,6 +28,9 @@ export const mapAgent = (r: AgentRow) => ({ budget_cents: r.budget_cents, spent_cents: r.spent_cents, soul_md: r.soul_md, approval_required: toBool(r.approval_required), created_at: r.created_at, + sync_status: r.sync_status ?? 'pending', + last_synced_at: r.last_synced_at ?? null, + sync_error: r.sync_error ?? null, }); export interface TaskRow { diff --git a/packages/server/src/openclaw/agent-sync.ts b/packages/server/src/openclaw/agent-sync.ts new file mode 100644 index 0000000..7a18c61 --- /dev/null +++ b/packages/server/src/openclaw/agent-sync.ts @@ -0,0 +1,146 @@ +import { getDb } from '../db/index.js'; +import { logAudit } from '../lib/audit.js'; +import { mapAgent, type AgentRow } from '../lib/rows.js'; +import { broadcast } from '../ws/broadcast.js'; +import { + getOpenClawClient, OpenClawUnreachableError, +} from './openclaw-client.js'; + +// Single chokepoint for "the local agent table changed — tell OpenClaw". +// +// Agents are configurable locally via the UI / REST. Whenever a row is +// inserted, updated, or deleted, the matching change is pushed to OpenClaw +// over the WS gateway. If OpenClaw is offline, sync_status flips to +// 'pending' and the dispatcher replays the change as soon as the gateway +// reconnects (see syncAllPending() below). + +const SET_SYNCED = (id: string): void => { + getDb() + .prepare(`UPDATE agents + SET sync_status = 'synced', last_synced_at = ?, sync_error = NULL + WHERE id = ?`) + .run(Date.now(), id); + broadcast('agent:status_changed', { agentId: id, status: getStatus(id) }); +}; + +const SET_PENDING = (id: string, reason: string): void => { + getDb() + .prepare(`UPDATE agents + SET sync_status = 'pending', sync_error = ? + WHERE id = ?`) + .run(reason, id); +}; + +const SET_FAILED = (id: string, reason: string): void => { + getDb() + .prepare(`UPDATE agents + SET sync_status = 'failed', sync_error = ? + WHERE id = ?`) + .run(reason, id); +}; + +function getStatus(id: string): string { + const row = getDb().prepare('SELECT status FROM agents WHERE id = ?').get(id) as + | { status: string } | undefined; + return row?.status ?? 'unknown'; +} + +/** + * Push a single agent's current state to OpenClaw. Marks the row pending + * when OpenClaw is offline (so it'll be re-synced on reconnect), failed + * when the push raised, synced on success. + * + * Returns 'synced' | 'pending' | 'failed' so callers can react. + */ +export async function pushAgent(agentId: string): Promise<'synced' | 'pending' | 'failed'> { + const row = getDb() + .prepare('SELECT * FROM agents WHERE id = ?') + .get(agentId) as AgentRow | undefined; + if (!row) return 'failed'; + + let client; + try { client = getOpenClawClient(); } catch { client = null; } + + if (!client || !client.isConnected()) { + SET_PENDING(agentId, 'OpenClaw offline — queued for reconnect'); + return 'pending'; + } + + try { + await client.syncAgent(mapAgent(row)); + SET_SYNCED(agentId); + logAudit({ agent_id: agentId, action: 'agent.synced', status: 'ok' }); + return 'synced'; + } catch (err) { + if (err instanceof OpenClawUnreachableError) { + SET_PENDING(agentId, err.message); + return 'pending'; + } + const msg = err instanceof Error ? err.message : String(err); + SET_FAILED(agentId, msg); + logAudit({ + agent_id: agentId, action: 'agent.sync_failed', + tool_calls: [{ error: msg }], status: 'error', + }); + return 'failed'; + } +} + +/** Tell OpenClaw an agent was deleted locally. Best-effort. */ +export async function pushAgentDeletion(agentId: string): Promise { + let client; + try { client = getOpenClawClient(); } catch { return; } + if (!client.isConnected()) return; + try { + await client.deleteAgent(agentId); + } catch { + // Deletion is best-effort — if OpenClaw didn't get the message we're + // not going to keep retrying a row that no longer exists locally. + } +} + +/** + * Re-sync every agent whose sync_status isn't 'synced'. Called automatically + * when the openclaw-client transitions to 'connected', and manually via + * POST /api/system/sync-all. + */ +export async function syncAllPending(): Promise<{ synced: number; pending: number; failed: number }> { + const rows = getDb() + .prepare(`SELECT id FROM agents WHERE sync_status != 'synced' OR sync_status IS NULL`) + .all() as Array<{ id: string }>; + + let synced = 0, pending = 0, failed = 0; + for (const r of rows) { + const result = await pushAgent(r.id); + if (result === 'synced') synced++; + else if (result === 'pending') pending++; + else failed++; + if (result === 'pending') break; // OpenClaw went offline mid-flight + } + if (synced > 0) { + console.log(`[sync] reconciled ${synced} agent${synced === 1 ? '' : 's'} with OpenClaw`); + } + return { synced, pending, failed }; +} + +let drainSubscribed = false; + +/** Wires the auto-sync-on-reconnect listener. Called once at server boot. */ +export function startAutoSync(): void { + if (drainSubscribed) return; + drainSubscribed = true; + try { + getOpenClawClient().onStateChange((state) => { + if (state === 'connected') { + // Run on the next tick so the state-change broadcast lands first + // and any UI subscribers can paint the green chip before the + // sync log lines start scrolling. + setImmediate(() => { void syncAllPending(); }); + } + }); + } catch { + // Client not initialised yet (early boot order). startAutoSync is safe + // to call again from index.ts after initOpenClawClient. + drainSubscribed = false; + } +} diff --git a/packages/server/src/openclaw/openclaw-client.ts b/packages/server/src/openclaw/openclaw-client.ts index 3e8a046..4aa1190 100644 --- a/packages/server/src/openclaw/openclaw-client.ts +++ b/packages/server/src/openclaw/openclaw-client.ts @@ -47,7 +47,9 @@ class OpenClawClient { private listeners = new Set<(state: OpenClawState) => void>(); private stopped = false; - constructor(private readonly opts: ClientOptions) {} + constructor(private opts: ClientOptions) {} + + getUrl(): string { return this.opts.url; } start(): void { this.stopped = false; @@ -59,7 +61,14 @@ class OpenClawClient { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; if (this.ws) { - try { this.ws.removeAllListeners(); this.ws.close(); } catch { /* ignore */ } + try { + this.ws.removeAllListeners(); + // Closing a CONNECTING socket emits an error event AFTER we've + // removed the listeners — Node treats that as unhandled. Park a + // no-op so it dies quietly. + this.ws.on('error', () => { /* suppress post-close noise */ }); + this.ws.close(); + } catch { /* ignore */ } this.ws = null; } this.transition('disconnected'); @@ -94,6 +103,52 @@ class OpenClawClient { return this.request('agent.status', { agentId }); } + /** Push an agent definition to OpenClaw so it knows about local config changes. */ + async syncAgent(agent: unknown): Promise { + return this.request('agent.sync', { agent }); + } + + /** Tell OpenClaw to forget an agent that was deleted locally. */ + async deleteAgent(agentId: string): Promise { + return this.request('agent.delete', { agentId }); + } + + /** + * Reset the backoff counter and try to connect immediately. Used by the + * UI's "Reconnect now" button and the gateway-URL change endpoint. + * Returns the state observed after the attempt has been kicked off. + */ + reconnectNow(newUrl?: string): OpenClawState { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + try { + this.ws.removeAllListeners(); + // Closing a CONNECTING socket emits an error event AFTER we've + // removed the listeners — Node treats that as unhandled. Park a + // no-op so it dies quietly. + this.ws.on('error', () => { /* suppress post-close noise */ }); + this.ws.close(); + } catch { /* ignore */ } + this.ws = null; + } + this.failPending('reconnect requested'); + this.attempt = 0; + if (newUrl && newUrl !== this.opts.url) { + this.opts = { ...this.opts, url: newUrl }; + } + this.stopped = false; + this.connect(); + return this.state; + } + + /** Update the gateway URL. Triggers an immediate reconnect. */ + setUrl(newUrl: string): OpenClawState { + return this.reconnectNow(newUrl); + } + // ── Internals ────────────────────────────────────────────────────────── private connect(): void { diff --git a/packages/server/src/routes/agents.ts b/packages/server/src/routes/agents.ts index da5a18a..389a7bc 100644 --- a/packages/server/src/routes/agents.ts +++ b/packages/server/src/routes/agents.ts @@ -6,6 +6,7 @@ import { asyncHandler } from '../lib/async.js'; import { notFound } from '../lib/errors.js'; import { ID } from '../lib/ids.js'; import { mapAgent, type AgentRow } from '../lib/rows.js'; +import { pushAgent, pushAgentDeletion } from '../openclaw/agent-sync.js'; import { broadcast } from '../ws/broadcast.js'; export const agentsRouter = Router(); @@ -89,6 +90,9 @@ agentsRouter.post( const row = fetchAgent(id)!; logAudit({ agent_id: id, action: 'agent.created' }); broadcast('agent:status_changed', { agentId: id, status: 'idle' }); + // Push to OpenClaw asynchronously — the route doesn't block on the + // gateway, and the helper marks sync_status='pending' if offline. + void pushAgent(id); res.status(201).json({ agent: mapAgent(row) }); }), ); @@ -141,6 +145,7 @@ agentsRouter.patch( broadcast('agent:status_changed', { agentId: row.id, status: row.status }); } logAudit({ agent_id: row.id, action: 'agent.updated' }); + void pushAgent(row.id); res.json({ agent: mapAgent(row) }); }), ); @@ -155,6 +160,7 @@ agentsRouter.delete( // at insert time and then nulled by ON DELETE SET NULL. logAudit({ agent_id: req.params.id, action: 'agent.deleted' }); getDb().prepare('DELETE FROM agents WHERE id = ?').run(req.params.id); + void pushAgentDeletion(req.params.id); res.status(204).end(); }), ); @@ -217,6 +223,19 @@ agentsRouter.post( const row = fetchAgent(id)!; logAudit({ agent_id: id, action: 'agent.cloned' }); broadcast('agent:status_changed', { agentId: id, status: 'idle' }); + void pushAgent(id); res.status(201).json({ agent: mapAgent(row) }); }), ); + +// POST /api/agents/:id/sync — manually push the agent to OpenClaw. +// Useful from the UI's "Sync" button on a row stuck in 'pending' / 'failed'. +agentsRouter.post( + '/:id/sync', + asyncHandler(async (req, res) => { + const row = fetchAgent(req.params.id); + if (!row) throw notFound('agent'); + const result = await pushAgent(req.params.id); + res.json({ agent: mapAgent(fetchAgent(req.params.id)!), sync: result }); + }), +); diff --git a/packages/server/src/routes/system.ts b/packages/server/src/routes/system.ts index 5d472ee..8d8132e 100644 --- a/packages/server/src/routes/system.ts +++ b/packages/server/src/routes/system.ts @@ -1,11 +1,33 @@ import { Router } from 'express'; +import { z } from 'zod'; +import { CONFIG_PATH, loadConfig, saveConfig } from '../config.js'; import { getDb } from '../db/index.js'; import { clearDemo, hasDemo, seedDev } from '../db/seed.js'; import { logAudit } from '../lib/audit.js'; import { asyncHandler } from '../lib/async.js'; +import { badRequest } from '../lib/errors.js'; +import { syncAllPending } from '../openclaw/agent-sync.js'; +import { getOpenClawClient } from '../openclaw/openclaw-client.js'; export const systemRouter = Router(); +const ONBOARDING_KEY = 'onboarding_completed'; + +function getSysConfig(key: string): string | null { + const row = getDb() + .prepare('SELECT value FROM system_config WHERE key = ?') + .get(key) as { value: string } | undefined; + return row?.value ?? null; +} + +function setSysConfig(key: string, value: string): void { + getDb() + .prepare( + 'INSERT INTO system_config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ) + .run(key, value); +} + // GET /api/system/info — counts per resource + demo status. Used by the // Settings page and the dashboard's empty state to decide whether to // surface "Load demo data" CTAs. @@ -72,3 +94,120 @@ systemRouter.post( }); }), ); + +// ── OpenClaw gateway runtime configuration ─────────────────────────────── +// +// Today the gateway URL lives in ~/.clawcontrol/config.json. These endpoints +// let the UI edit it without an editor + restart cycle: PATCH writes the +// new value to disk AND reconnects the openclaw-client live. + +const PatchGatewayBody = z.object({ + gatewayUrl: z.string().min(1).refine( + (v) => v.startsWith('ws://') || v.startsWith('wss://'), + { message: 'gatewayUrl must start with ws:// or wss://' }, + ), +}); + +systemRouter.get( + '/openclaw-config', + asyncHandler(async (_req, res) => { + const cfg = loadConfig(); + let state: string = 'unknown'; + let liveUrl: string = cfg.openclaw.gatewayUrl; + try { + const c = getOpenClawClient(); + state = c.getState(); + liveUrl = c.getUrl(); + } catch { /* client not initialised yet */ } + res.json({ + gatewayUrl: cfg.openclaw.gatewayUrl, + liveUrl, + state, + configPath: CONFIG_PATH, + }); + }), +); + +systemRouter.patch( + '/openclaw-config', + asyncHandler(async (req, res) => { + const { gatewayUrl } = PatchGatewayBody.parse(req.body); + const cfg = loadConfig(); + cfg.openclaw.gatewayUrl = gatewayUrl; + saveConfig(cfg); + + // Reconnect live so the UI sees the new state without a server restart. + let nextState = 'unknown'; + try { + nextState = getOpenClawClient().setUrl(gatewayUrl); + } catch { /* client not initialised */ } + + logAudit({ + action: 'system.gateway_url_changed', + tool_calls: [{ gatewayUrl }], status: 'ok', + }); + res.json({ ok: true, gatewayUrl, state: nextState }); + }), +); + +// POST /api/system/openclaw-reconnect — reset the openclaw-client's +// backoff and try to connect immediately. Used by the OfflineBanner + +// Settings page. +systemRouter.post( + '/openclaw-reconnect', + asyncHandler(async (_req, res) => { + let state = 'unknown'; + try { state = getOpenClawClient().reconnectNow(); } + catch (err) { + throw badRequest('openclaw client not ready: ' + (err instanceof Error ? err.message : String(err))); + } + logAudit({ action: 'system.gateway_reconnect_requested', status: 'ok' }); + res.json({ ok: true, state }); + }), +); + +// POST /api/system/sync-all — re-push every agent whose sync_status isn't +// 'synced'. Auto-fired when the gateway reconnects; also exposed for the +// UI's "Re-sync agents" button. +systemRouter.post( + '/sync-all', + asyncHandler(async (_req, res) => { + const counts = await syncAllPending(); + res.json({ ok: true, ...counts }); + }), +); + +// ── Onboarding flag ────────────────────────────────────────────────────── +// +// Stored in system_config so the UI can show the welcome wizard exactly +// once. Toggle via PATCH; clear via DELETE (useful when the user wants +// to re-run the wizard). + +systemRouter.get( + '/onboarding', + asyncHandler(async (_req, res) => { + const completedAt = getSysConfig(ONBOARDING_KEY); + res.json({ + completed: completedAt != null, + completed_at: completedAt ? Number(completedAt) : null, + }); + }), +); + +systemRouter.post( + '/onboarding/complete', + asyncHandler(async (_req, res) => { + const ts = Date.now(); + setSysConfig(ONBOARDING_KEY, String(ts)); + logAudit({ action: 'system.onboarding_completed', status: 'ok' }); + res.json({ ok: true, completed_at: ts }); + }), +); + +systemRouter.delete( + '/onboarding', + asyncHandler(async (_req, res) => { + getDb().prepare('DELETE FROM system_config WHERE key = ?').run(ONBOARDING_KEY); + res.status(204).end(); + }), +); diff --git a/packages/server/tests/_helpers.ts b/packages/server/tests/_helpers.ts index 5025550..2038796 100644 --- a/packages/server/tests/_helpers.ts +++ b/packages/server/tests/_helpers.ts @@ -6,12 +6,15 @@ import { initDb, closeDb } from '../src/db/index.js'; import { seedDev } from '../src/db/seed.js'; import { cors } from '../src/middleware/cors.js'; import { errorHandler } from '../src/middleware/error.js'; +import { _resetOpenClawClient, initOpenClawClient } from '../src/openclaw/openclaw-client.js'; import { apiRouter } from '../src/routes/index.js'; // Each test gets: // • A fresh tmp dir as the data root. // • A fresh SQLite DB (migrations applied, optionally seeded). // • A built Express app with /api routes mounted. +// • An openclaw-client pointed at a port nobody is listening on, so any +// /api/system/openclaw-* probe gets a real (non-fake) singleton. // No middlewares we don't need (no rate-limit, no logging) so tests run fast // and assertions don't have to wade through fixture noise. @@ -22,12 +25,30 @@ export interface TestEnv { cleanup: () => void; } +// Pick a random high port for the dummy gateway so parallel test files don't +// collide. We never expect anything to be listening there — the openclaw +// client just enters its reconnect loop and outbound calls throw +// OpenClawUnreachableError, which is exactly the offline behaviour we test. +function dummyGatewayUrl(): string { + const port = 40000 + Math.floor(Math.random() * 20000); + return `ws://127.0.0.1:${port}`; +} + export function makeApp(opts: { seed?: boolean } = {}): TestEnv { const tmp = mkdtempSync(join(tmpdir(), 'clawcontrol-test-')); const dbPath = join(tmp, 'test.db'); const db = initDb(dbPath); if (opts.seed) seedDev(db); + // Reset any singleton from a previous test before installing a new one. + _resetOpenClawClient(); + initOpenClawClient({ + port: 3001, host: '127.0.0.1', authToken: null, dbPath, + openclaw: { gatewayUrl: dummyGatewayUrl() }, + backup: { schedule: '0 2 * * *', retention_days: 30, encryption_enabled: false, s3_bucket: null }, + updates: { auto_check: false, auto_install: false, repo_url: 'https://example.com' }, + }); + const app = express(); app.use(cors); app.use(express.json({ limit: '1mb' })); @@ -37,6 +58,7 @@ export function makeApp(opts: { seed?: boolean } = {}): TestEnv { return { app, dbPath, tmp, cleanup() { + try { _resetOpenClawClient(); } catch { /* ignore */ } try { closeDb(); } catch { /* ignore */ } try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } }, diff --git a/packages/server/tests/sync-onboarding.test.ts b/packages/server/tests/sync-onboarding.test.ts new file mode 100644 index 0000000..a80fe35 --- /dev/null +++ b/packages/server/tests/sync-onboarding.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import request from 'supertest'; +import { makeApp, type TestEnv } from './_helpers.js'; + +// Coverage for the new system endpoints introduced alongside agent ↔ OpenClaw +// sync and the in-app onboarding gate. + +let env: TestEnv; +beforeEach(() => { env = makeApp({ seed: false }); }); +afterEach(() => env.cleanup()); + +describe('system / openclaw-config', () => { + test('GET returns the configured gateway URL + state', async () => { + const r = await request(env.app).get('/api/system/openclaw-config'); + expect(r.status).toBe(200); + expect(r.body.gatewayUrl).toMatch(/^wss?:\/\//); + expect(typeof r.body.state).toBe('string'); + expect(typeof r.body.configPath).toBe('string'); + }); + + test('PATCH validates the URL scheme', async () => { + const r = await request(env.app) + .patch('/api/system/openclaw-config') + .send({ gatewayUrl: 'http://nope' }); + expect(r.status).toBe(400); + expect(r.body.error).toBe('validation_failed'); + }); + + test('PATCH accepts ws:// and persists', async () => { + const r = await request(env.app) + .patch('/api/system/openclaw-config') + .send({ gatewayUrl: 'ws://10.0.0.5:3002' }); + expect(r.status).toBe(200); + expect(r.body.gatewayUrl).toBe('ws://10.0.0.5:3002'); + }); +}); + +describe('system / sync-all', () => { + test('returns counts on an empty DB', async () => { + const r = await request(env.app).post('/api/system/sync-all'); + expect(r.status).toBe(200); + expect(r.body.synced + r.body.pending + r.body.failed).toBe(0); + }); + + test('marks newly-created agents as pending when OpenClaw is offline', async () => { + const created = await request(env.app) + .post('/api/agents') + .send({ name: 'test', budget_cents: 0 }); + expect(created.status).toBe(201); + // Server pushes asynchronously after responding — give it a tick to + // mark sync_status. Then verify. + await new Promise((r) => setTimeout(r, 50)); + const fetched = await request(env.app).get(`/api/agents/${created.body.agent.id}`); + expect(['pending', 'failed']).toContain(fetched.body.agent.sync_status); + expect(fetched.body.agent.last_synced_at).toBeNull(); + }); + + test('manual /agents/:id/sync responds with the dispatch status', async () => { + const created = await request(env.app) + .post('/api/agents') + .send({ name: 'manual', budget_cents: 0 }); + const r = await request(env.app).post(`/api/agents/${created.body.agent.id}/sync`); + expect(r.status).toBe(200); + expect(['synced', 'pending', 'failed']).toContain(r.body.sync); + }); +}); + +describe('system / onboarding flag', () => { + test('starts uncompleted on a fresh install', async () => { + const r = await request(env.app).get('/api/system/onboarding'); + expect(r.status).toBe(200); + expect(r.body.completed).toBe(false); + expect(r.body.completed_at).toBeNull(); + }); + + test('POST /complete records a timestamp', async () => { + const r = await request(env.app).post('/api/system/onboarding/complete'); + expect(r.status).toBe(200); + expect(r.body.ok).toBe(true); + expect(typeof r.body.completed_at).toBe('number'); + + const after = await request(env.app).get('/api/system/onboarding'); + expect(after.body.completed).toBe(true); + expect(after.body.completed_at).toBe(r.body.completed_at); + }); + + test('DELETE /onboarding clears the flag (re-runnable wizard)', async () => { + await request(env.app).post('/api/system/onboarding/complete'); + expect((await request(env.app).delete('/api/system/onboarding')).status).toBe(204); + const r = await request(env.app).get('/api/system/onboarding'); + expect(r.body.completed).toBe(false); + }); +}); + +describe('system / openclaw-reconnect', () => { + test('returns the post-reconnect state', async () => { + const r = await request(env.app).post('/api/system/openclaw-reconnect'); + expect(r.status).toBe(200); + expect(['connecting', 'connected', 'disconnected']).toContain(r.body.state); + }); +}); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8908173..1bf84fc 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -19,6 +19,7 @@ import { DoctorPage } from './pages/Doctor.js'; import { AuditPage } from './pages/Audit.js'; import { SettingsPage } from './pages/Settings.js'; import { SetupPage } from './pages/Setup.js'; +import { WelcomePage } from './pages/Welcome.js'; export const App = () => ( @@ -36,6 +37,7 @@ export const App = () => ( /> } /> + } /> }> } /> } /> diff --git a/packages/ui/src/api/api-client.ts b/packages/ui/src/api/api-client.ts index 02d89f4..648006e 100644 --- a/packages/ui/src/api/api-client.ts +++ b/packages/ui/src/api/api-client.ts @@ -100,6 +100,9 @@ export interface Agent { status: string; budget_cents: number; spent_cents: number; soul_md: string | null; approval_required: boolean; created_at: number; + sync_status?: 'pending' | 'synced' | 'failed' | string; + last_synced_at?: number | null; + sync_error?: string | null; } export interface Task { id: string; title: string; description: string | null; @@ -205,6 +208,7 @@ export const api = { resume: (id: string) => request<{ agent: Agent }>('POST', `/api/agents/${id}/resume`), restart: (id: string) => request<{ agent: Agent }>('POST', `/api/agents/${id}/restart`), clone: (id: string) => request<{ agent: Agent }>('POST', `/api/agents/${id}/clone`), + sync: (id: string) => request<{ agent: Agent; sync: 'synced' | 'pending' | 'failed' }>('POST', `/api/agents/${id}/sync`), }, tasks: { @@ -335,6 +339,13 @@ export const api = { }>('GET', '/api/system/info'), loadDemo: () => request<{ ok: boolean; already_loaded: boolean; loaded: boolean }>('POST', '/api/system/load-demo'), clearDemo: () => request<{ ok: boolean; had_demo: boolean; removed: Record }>('POST', '/api/system/clear-demo'), + openClawConfig: () => request<{ gatewayUrl: string; liveUrl: string; state: string; configPath: string }>('GET', '/api/system/openclaw-config'), + setOpenClawConfig: (gatewayUrl: string) => request<{ ok: boolean; gatewayUrl: string; state: string }>('PATCH', '/api/system/openclaw-config', { body: { gatewayUrl } }), + reconnect: () => request<{ ok: boolean; state: string }>('POST', '/api/system/openclaw-reconnect'), + syncAll: () => request<{ ok: boolean; synced: number; pending: number; failed: number }>('POST', '/api/system/sync-all'), + onboarding: () => request<{ completed: boolean; completed_at: number | null }>('GET', '/api/system/onboarding'), + completeOnboarding: () => request<{ ok: boolean; completed_at: number }>('POST', '/api/system/onboarding/complete'), + resetOnboarding: () => request('DELETE', '/api/system/onboarding'), }, instances: { diff --git a/packages/ui/src/components/Layout.tsx b/packages/ui/src/components/Layout.tsx index bd02d8c..b92fbb2 100644 --- a/packages/ui/src/components/Layout.tsx +++ b/packages/ui/src/components/Layout.tsx @@ -1,4 +1,5 @@ -import { Outlet } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; import { Header } from './Header.js'; import { OfflineBanner } from './OfflineBanner.js'; import { Sidebar } from './Sidebar.js'; @@ -6,12 +7,18 @@ import { MobileTabBar } from './MobileTabBar.js'; import { ErrorBoundary } from './ErrorBoundary.js'; import { ShortcutsProvider } from './ShortcutsProvider.js'; import { useAgents } from '../hooks/index.js'; +import { api } from '../api/api-client.js'; import { ToastBus } from '../api/toast-bus.js'; // One shared chrome around every route: sidebar + header + offline banner. // Each page is wrapped in its own ErrorBoundary so a render crash in one // section never takes down the rest of the app. // +// First-visit gate: if /api/system/onboarding reports completed:false the +// Layout redirects to /welcome before rendering any of the regular pages. +// Onboarding state is checked once on mount; the welcome page sets it via +// POST /api/system/onboarding/complete and then routes back to /. +// // At <768px the sidebar collapses out and a 5-tab bottom bar takes its // place; the full nav surface is still available via the Cmd+K palette. @@ -21,6 +28,23 @@ export function Layout() { (agents.data ?? []).map((a) => [a.id, { name: a.name, status: a.status }]), ); + // Tri-state: null = haven't checked yet, false = redirect to /welcome, + // true = render the app. Errors fail-open (true) so a flaky probe never + // wedges the user out of the app. + const [completed, setCompleted] = useState(null); + useEffect(() => { + api.system.onboarding() + .then((r) => setCompleted(r.completed)) + .catch(() => setCompleted(true)); + }, []); + + if (completed === null) { + // Brief blank frame while we check — avoids flashing the dashboard + // before the redirect lands. + return

; + } + if (completed === false) return ; + return (
diff --git a/packages/ui/src/components/OfflineBanner.tsx b/packages/ui/src/components/OfflineBanner.tsx index da9e776..f40c585 100644 --- a/packages/ui/src/components/OfflineBanner.tsx +++ b/packages/ui/src/components/OfflineBanner.tsx @@ -1,19 +1,35 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { api } from '../api/api-client.js'; import { useOpenClawState, useWsState } from '../api/websocket-client.js'; import { Icon } from './Icon.js'; // Renders a red ribbon at the top of the app whenever OpenClaw is -// unreachable. The Doctor link drops the user one click from "diagnose -// the problem". Suppressed once OpenClaw is connected. +// unreachable. Two one-click actions inline: kick the gateway client into +// an immediate reconnect, or jump to Doctor. Suppressed once everything is +// green. export function OfflineBanner() { const oc = useOpenClawState(); const ws = useWsState(); + const [busy, setBusy] = useState(false); if (oc === 'connected' && ws === 'open') return null; const offline = oc !== 'connected'; - const wsLost = ws !== 'open'; + + async function reconnect() { + setBusy(true); + try { + const r = await api.system.reconnect(); + toast.success(`Reconnect requested — ${r.state}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } return (
gateway: {oc} · ws: {ws} - - Run Doctor - +
+ + + Run Doctor + +
); } diff --git a/packages/ui/src/pages/Settings.tsx b/packages/ui/src/pages/Settings.tsx index 6b14c62..b904e43 100644 --- a/packages/ui/src/pages/Settings.tsx +++ b/packages/ui/src/pages/Settings.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; import { api } from '../api/api-client.js'; import { useSystemStatus } from '../hooks/index.js'; -import { Button, Card, Chip, ErrorPanel, SectionHeader, Skeleton } from '../components/primitives.js'; +import { Button, Card, Chip, ErrorPanel, Field, Input, SectionHeader, Skeleton } from '../components/primitives.js'; import { Icon } from '../components/Icon.js'; interface SystemInfo { demo_loaded: boolean; counts: Record } +interface GwConfig { gatewayUrl: string; liveUrl: string; state: string; configPath: string } export function SettingsPage() { const sys = useSystemStatus(); @@ -15,11 +16,17 @@ export function SettingsPage() { const [info, setInfo] = useState(null); const [demoBusy, setDemoBusy] = useState(false); + const [gw, setGw] = useState(null); + const [gwUrl, setGwUrl] = useState(''); + const [gwBusy, setGwBusy] = useState(false); + const refreshInfo = () => api.system.info().then(setInfo).catch(() => null); + const refreshGateway = () => api.system.openClawConfig().then((g) => { setGw(g); setGwUrl(g.gatewayUrl); }).catch(() => null); useEffect(() => { api.updates.autoConfig().then(setAutoConfig).catch(() => null).finally(() => setAutoLoading(false)); refreshInfo(); + refreshGateway(); }, []); return ( @@ -37,6 +44,76 @@ export function SettingsPage() { )} + +
+

OpenClaw gateway

+ {gw && ( + {gw.state} + )} +
+

+ The WebSocket the control plane uses to talk to OpenClaw. Saving here writes + ~/.clawcontrol/config.json and + reconnects the live client without a server restart. +

+ + setGwUrl(e.currentTarget.value)} + placeholder="ws://localhost:3002" + className="font-mono" + /> + +
+ + + +
+ {gw && gw.liveUrl !== gw.gatewayUrl && ( +

+ client connected to {gw.liveUrl} — config has {gw.gatewayUrl}; reconnect to apply. +

+ )} +
+

Updates

{autoLoading ? : autoConfig && ( diff --git a/packages/ui/src/pages/Welcome.tsx b/packages/ui/src/pages/Welcome.tsx new file mode 100644 index 0000000..d3bbfbf --- /dev/null +++ b/packages/ui/src/pages/Welcome.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import { api } from '../api/api-client.js'; +import { useOpenClawState } from '../api/websocket-client.js'; +import { + Button, Card, Chip, Field, Input, Modal, ProgressBar, Skeleton, Textarea, +} from '../components/primitives.js'; +import { Icon } from '../components/Icon.js'; + +// Standalone first-visit walkthrough. Routed at /welcome and shown by the +// Layout when /api/system/onboarding reports completed:false. Four steps: +// 1. Hello / overview +// 2. Confirm + test the OpenClaw gateway URL +// 3. Pick a starting state — load demo / create your first agent / skip +// 4. Done + +type Step = 0 | 1 | 2 | 3; + +export function WelcomePage() { + const nav = useNavigate(); + const [step, setStep] = useState(0); + const [gwUrl, setGwUrl] = useState(''); + const [gwState, setGwState] = useState('unknown'); + const [gwLoading, setGwLoading] = useState(true); + const [savingGw, setSavingGw] = useState(false); + const [demoLoading, setDemoLoading] = useState(false); + const [creating, setCreating] = useState(false); + const liveOcState = useOpenClawState(); + + // Load current gateway URL once. + useEffect(() => { + api.system.openClawConfig() + .then((g) => { setGwUrl(g.gatewayUrl); setGwState(g.state); }) + .catch(() => null) + .finally(() => setGwLoading(false)); + }, []); + + // Reflect live state on the gateway step. + useEffect(() => { setGwState(liveOcState); }, [liveOcState]); + + const total = 4; + const pct = useMemo(() => Math.round(((step + 1) / total) * 100), [step]); + + async function complete() { + try { await api.system.completeOnboarding(); } catch { /* non-fatal */ } + toast.success('Welcome aboard'); + nav('/', { replace: true }); + } + + return ( +
+ +
+ {step === 0 && setStep(1)} onSkip={complete} />} + {step === 1 && ( + { + if (!gwUrl) { toast.error('Gateway URL is required'); return; } + setSavingGw(true); + try { + const r = await api.system.setOpenClawConfig(gwUrl); + setGwState(r.state); + toast.success('Gateway URL saved'); + } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); } + finally { setSavingGw(false); } + }} + onReconnect={async () => { + setSavingGw(true); + try { + const r = await api.system.reconnect(); + setGwState(r.state); + toast.success('Reconnect requested'); + } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); } + finally { setSavingGw(false); } + }} + onBack={() => setStep(0)} onNext={() => setStep(2)} + /> + )} + {step === 2 && ( + { + setDemoLoading(true); + try { + const r = await api.system.loadDemo(); + if (r.loaded) toast.success('Demo dataset loaded — Nova SaaS Co + 7 agents'); + else toast('Demo data was already loaded', { icon: 'ℹ️' }); + setStep(3); + } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); } + finally { setDemoLoading(false); } + }} + onCreated={() => setStep(3)} + setCreating={setCreating} + onSkip={() => setStep(3)} + onBack={() => setStep(1)} + /> + )} + {step === 3 && } + +
+ ); +} + +function Header({ step, pct, total }: { step: number; pct: number; total: number }) { + return ( + <> +
+
+ +
+
+
ClawControl Mission Control
+
step {step + 1} of {total}
+
+
+ + + ); +} + +function StepHello({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) { + return ( + <> +

Welcome.

+

+ ClawControl is your mission control for OpenClaw — agents, goals, budgets, and + a Doctor that diagnoses problems even when OpenClaw itself is down. +

+

+ This 4-step walk-through gets the gateway pointed at your OpenClaw instance and + lets you start with sample data or your own first agent. You can re-run it later + from Settings → Demo dataset. +

+
+ + +
+ + ); +} + +function StepGateway({ + url, setUrl, state, loading, saving, onSave, onReconnect, onBack, onNext, +}: { + url: string; setUrl: (v: string) => void; state: string; + loading: boolean; saving: boolean; + onSave: () => void; onReconnect: () => void; + onBack: () => void; onNext: () => void; +}) { + const tone = state === 'connected' ? 'mint' : state === 'connecting' ? 'amber' : 'rose'; + return ( + <> +

Connect to OpenClaw

+

+ ClawControl talks to OpenClaw over a WebSocket. Default is + ws://localhost:3002. The control + plane stays useful even when OpenClaw is down — Doctor, backups, budgets, and the + audit log keep working. +

+ {loading ? : ( + <> + + setUrl(e.currentTarget.value)} className="font-mono" /> + +
+ Live status: + {state} + + +
+ {state !== 'connected' && ( +

+ The gateway isn't responding yet. That's fine — you can still finish setup and + ClawControl will reconnect automatically when OpenClaw comes online. +

+ )} + + )} +
+ + +
+ + ); +} + +function StepStartingState({ + busyDemo, busyAgent, onLoadDemo, onCreated, setCreating, onSkip, onBack, +}: { + busyDemo: boolean; busyAgent: boolean; + onLoadDemo: () => void; onCreated: () => void; + setCreating: (b: boolean) => void; onSkip: () => void; onBack: () => void; +}) { + const [agentOpen, setAgentOpen] = useState(false); + const [name, setName] = useState(''); + const [role, setRole] = useState(''); + const [model, setModel] = useState('claude-sonnet-4-6'); + const [provider, setProvider] = useState('anthropic'); + const [soul, setSoul] = useState(''); + + return ( + <> +

Pick a starting state

+

+ Get familiar with every screen using sample data, or jump straight in with your + own first agent. You can mix-and-match later — the demo is purely additive. +

+
+ + setAgentOpen(true)} + /> + +
+
+ +
+ + setAgentOpen(false)} title="Create your first agent" width={520}> + setName(e.currentTarget.value)} placeholder="Atlas" /> +
+ setRole(e.currentTarget.value)} placeholder="Engineering" /> + setProvider(e.currentTarget.value)} /> +
+ setModel(e.currentTarget.value)} /> +