diff --git a/.husky/pre-push b/.husky/pre-push index 94c5da8422..6cf3dd2458 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -43,6 +43,12 @@ if [ "${KILO_CLOUD_AGENT:-}" = "1" ] || [ "${KILO_CLOUD_AGENT:-}" = "true" ]; th exit $? fi +# Skip heavy checks in CI/container environments (they run in CI pipeline instead) +if [ -n "${CI:-}" ] || [ -n "${GASTOWN_CONTAINER:-}" ] || [ -n "${KILO_AGENT:-}" ] || [ -f /.dockerenv ]; then + command -v git-lfs >/dev/null 2>&1 && git lfs pre-push "$@" + exit 0 +fi + pnpm format:check & pid_format=$! diff --git a/.oxlintrc.json b/.oxlintrc.json index 0b446e6112..852c2fec24 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,8 @@ "**/.wrangler/**", "supabase/functions/**", "**/types/opencode.gen.ts", - "**/gastown/types/**" + "**/gastown/types/**", + "**/wasteland/types/**" ], "rules": { "constructor-super": "off", @@ -188,6 +189,7 @@ "services/cloud-agent/src/session/queries/*.ts", "services/cloud-agent-next/src/session/queries/*.ts", "services/gastown/**/*.ts", + "services/wasteland/**/*.ts", "services/webhook-agent-ingest/**/*.ts", "services/kiloclaw/**/*.ts" ], diff --git a/.plans/wasteland-gastown-poc.md b/.plans/wasteland-gastown-poc.md new file mode 100644 index 0000000000..dd16991dd5 --- /dev/null +++ b/.plans/wasteland-gastown-poc.md @@ -0,0 +1,207 @@ +# Wasteland ↔ Gastown POC Plan + +## Current State + +### What's Built + +| Area | Status | Details | +| ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wasteland service** | Complete | WastelandDO (config, members, credentials, connected towns, wanted board), WastelandRegistryDO, WastelandContainerDO, tRPC router (25+ procedures), container image with `wl` CLI + control server | +| **Wasteland UI** | Complete | List/create wastelands, settings (config, DoltHub connection, credentials, connected towns, members, delete), wanted board browse/claim/post/done | +| **Gastown mayor tools** | Complete | 4 container-side tools (`gt_wasteland_browse`, `claim`, `post`, `done`), HTTP client, worker handler routes | +| **Gastown wasteland client** | Complete | Typed HTTP client with service binding + HTTP fallback, mirrors all wasteland tRPC procedures | +| **Container bootstrap** | Complete | `storeCredential` → `setEnvVar` on container → `POST /wl/init` → `wl join` → ready. Env vars persisted in DO storage for cold-start recovery | +| **Town settings UI** | Stub | "Wasteland" section with a "Connect" button that is not wired up | + +### What's Missing (the POC gaps) + +| # | Gap | Impact | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| 1 | **Town has no wasteland state** — `TownConfigSchema` has zero wasteland fields; Town DO stores nothing about connected wastelands | Mayor tools require a `wasteland_id` param but the town doesn't know its wasteland. Mayor can't auto-discover it. | +| 2 | **No onboarding flow** — The "Connect" button in town settings is a TODO. There's no UI to collect DoltHub token, org, rig handle, upstream and bootstrap the connection from within gastown | Users have to manually create a wasteland in the separate wasteland UI, then somehow connect it | +| 3 | **Beads have no wasteland linkage** — No `wasteland_wanted_id` or similar field on beads. Claiming a wanted item doesn't create a bead. Closing a bead doesn't trigger `wl done` | The whole point of the integration is absent — work in the town doesn't flow back to the wasteland | +| 4 | **No auto-evidence on PR** — When a bead associated to a wasteland wanted item gets a PR created/merged, there's no automatic `wl done` with the PR URL as evidence | Manual evidence submission defeats the purpose | +| 5 | **No UI for wasteland-linked beads** — No badge/indicator showing a bead is associated with a wasteland wanted item. No link to the upstream DoltHub PR | Users can't see which work items came from the wasteland | +| 6 | **Mayor doesn't auto-know its wasteland** — System prompt doesn't include wasteland context. Mayor can't proactively browse or suggest wasteland work | The mayor is blind to the wasteland unless explicitly told the wasteland ID each time | + +## POC Scope + +Focus: **Connect to Commons** — a single wasteland upstream (`hop/wl-commons`). No "create your own" flow. + +### Workstreams + +--- + +### WS1: Town DO Wasteland State + +Add wasteland connection state to the Town DO so it persists across alarms and the mayor can auto-discover it. + +**Schema changes** — new `town_wasteland_connections` table in Town DO SQLite: + +``` +town_wasteland_connections + connection_id TEXT PK + wasteland_id TEXT NOT NULL + upstream TEXT NOT NULL -- e.g. "hop/wl-commons" + rig_handle TEXT NOT NULL + dolthub_org TEXT NOT NULL + connected_at TEXT NOT NULL (ISO) + status TEXT NOT NULL CHECK (status IN ('active', 'disconnecting')) +``` + +**Town config additions** — add to `TownConfigSchema`: + +```ts +wasteland_connection?: { + wasteland_id: string; + upstream: string; + rig_handle: string; + dolthub_org: string; +} +``` + +**Town DO sub-module** — `services/gastown/src/dos/town/wasteland.ts`: + +- `connectWasteland(sql, { wasteland_id, upstream, rig_handle, dolthub_org })` +- `disconnectWasteland(sql, wasteland_id)` +- `getWastelandConnection(sql) → connection | null` + +**tRPC procedures** on gastown router: + +- `connectTownToWasteland` — stores connection in Town DO, calls wasteland service `connectKiloTown` +- `disconnectTownFromWasteland` — removes connection, calls wasteland service `disconnectKiloTown` +- `getTownWastelandConnection` — returns current connection (or null) + +--- + +### WS2: Onboarding Flow (Town Settings → Wasteland) + +The Connect button in town settings opens a multi-step dialog that never leaves the gastown UI. + +**Step 1: DoltHub Credentials** + +- DoltHub API token (password field) +- DoltHub org / username +- Dolt credential JWK (password field, optional) + +**Step 2: Rig Identity** + +- Rig handle (auto-suggested from town name, e.g. `kilo-{town-name}`) +- Dolt user name (pre-filled from user profile) +- Dolt user email (pre-filled from user email) + +**Step 3: Confirm & Connect** + +- Shows summary: "Connecting to hop/wl-commons as `{rig_handle}` via `{dolthub_org}`" +- On confirm: + 1. Call wasteland `storeCredential` (encrypts token, pushes env vars to container, triggers `wl join`) + 2. Call wasteland `connectKiloTown` (creates town↔wasteland association) + 3. Call gastown `connectTownToWasteland` (persists in Town DO) + 4. Wait for container init to complete (poll `containerStatus`) +- Success state: "Town connected to hop/wl-commons. Agents now have access to wasteland tools. Try asking the mayor to browse the wasteland." + +**After connection, the settings section shows:** + +- Connected wasteland name + upstream +- Rig handle +- Disconnect button + +--- + +### WS3: Bead ↔ Wasteland Linkage + +When the mayor claims a wasteland item and creates work in the town, the resulting bead must be linked. + +**Bead metadata fields** (stored in `beads.metadata` JSON): + +```ts +{ + wasteland_wanted_id?: string; // e.g. "w-abc123" + wasteland_id?: string; // which wasteland + wasteland_upstream?: string; // e.g. "hop/wl-commons" + wasteland_evidence_url?: string; // set when evidence submitted + wasteland_published_at?: string; // ISO timestamp of wl done +} +``` + +No schema migration needed — `metadata` is already a JSON column. + +**Mayor tool update** — when `gt_wasteland_claim` succeeds, the mayor should create a bead (via sling) with the wasteland metadata attached. The claim tool response should include the wanted item details so the mayor can create an appropriate bead. + +**Auto-evidence on PR** — in the bead lifecycle, when a bead with `wasteland_wanted_id` gets a PR created: + +- Store the PR URL as `wasteland_evidence_url` on bead metadata +- When the bead closes (or PR merges), auto-call `wl done` with the PR URL as evidence +- This likely hooks into the existing reconciler/alarm where PR status is checked + +--- + +### WS4: Mayor Wasteland Context + +The mayor needs to know about its connected wasteland without being told each time. + +**System prompt injection** — when generating the mayor's system prompt, if the town has a wasteland connection, append: + +``` +## Wasteland + +This town is connected to the wasteland "hop/wl-commons" (wasteland ID: {id}). +You have tools to browse the wanted board, claim items, post new items, and +submit evidence of completion. When a user asks about wasteland work or +available bounties, use gt_wasteland_browse. When creating work from a +wasteland item, note the wasteland_wanted_id in the bead metadata. +``` + +**Tool availability** — the wasteland tools (`gt_wasteland_browse`, etc.) should only be included in the mayor's tool list when a wasteland connection exists. Currently they're always registered — gate them behind `townConfig.wasteland_connection != null`. + +**Auto-populate `wasteland_id` param** — the mayor tools currently require `wasteland_id` as an explicit parameter. Since the town knows its connected wasteland, auto-fill this from town config so the mayor doesn't have to specify it. + +--- + +### WS5: UI for Wasteland-Linked Beads + +**Bead card indicator** — when a bead has `wasteland_wanted_id` in its metadata, show a small wasteland badge/icon on the bead card (e.g., a Globe icon with "Wasteland" tooltip). + +**Bead detail** — in the bead detail view, if wasteland metadata exists, show: + +- "Wasteland Item: {wanted_id}" (linked to the wasteland wanted board) +- Evidence status (submitted / pending) +- If there's an upstream DoltHub PR, link to it + +**Town overview** — optionally, show a count of active wasteland-linked beads in the town dashboard. + +--- + +### WS6: Wasteland Service Fixes + +From issue exploration and current state: + +- **Container cold-start reconciliation** — when a container wakes from sleep, the `selfInit()` already handles this via env vars persisted in DO storage. Verify this works end-to-end. +- **Dolt credential JWK** — the UI field was changed to password-masked input. Verify the credential flow works with JWK provided (needed for `dolt push` in PR-mode mutations). + +--- + +## Implementation Order + +| Phase | Workstreams | What it enables | +| ----------- | ------------------------------------------- | --------------------------------------------------------------------------------- | +| **Phase A** | WS1 (Town DO state) + WS2 (Onboarding flow) | User can connect a town to the commons from the gastown UI | +| **Phase B** | WS4 (Mayor context) + WS3 (Bead linkage) | Mayor auto-knows the wasteland, claimed items become beads, completions flow back | +| **Phase C** | WS5 (UI indicators) | Users can see which beads came from the wasteland | +| **Phase D** | WS6 (Service fixes) | Polish and reliability | + +## Ideas from Issues + +### From #1810 (Hosted Wasteland Service) + +- ✅ Already implemented: per-wasteland containers, encrypted credential storage, `sleepAfter: 30m`, control server with 8 endpoints, DoltHub REST API reads from DO (no container needed for browse) +- Relevant for POC: the "hybrid read" model — browse reads from DO's DoltHub SQL API (instant, no cold start), writes go through container. Already built. + +### From #1040 (Wasteland Integration — Town Side) + +- **Reconciler events** (`wasteland_completion_ready`, `wasteland_published`, etc.) — good design but heavier than needed for POC. For POC, the auto-evidence can be simpler: hook into the existing PR-merge detection in the alarm loop. +- **TownConfig schema** from #1040 proposed multi-wasteland connections. For POC: single connection is sufficient. The schema should allow for future multi-connection but the UI only supports one. +- **Rig registration** details: `rig_type = 'agent'`, `parent_rig = `, `trust_level = 1`. We should set these during onboarding. The `wl join` command handles rig registration — need to verify it accepts these fields or if we need to configure them separately. +- **`rig_links` table** — linking the agent rig to the human owner's rig. This is a nice-to-have for the POC, not blocking. +- **Sandbox fields** (`sandbox_required`, `sandbox_scope`, `sandbox_min_tier`) on wanted items — noted in bead metadata for future container env config. Not needed for POC. +- **Stale completion escalation** (published > 7d, not validated → escalation bead for mayor) — good idea, defer to post-POC. diff --git a/apps/web/.env.development.local.example b/apps/web/.env.development.local.example index 1cf8d79b9a..a9d1dc6e56 100644 --- a/apps/web/.env.development.local.example +++ b/apps/web/.env.development.local.example @@ -52,3 +52,6 @@ NEXT_PUBLIC_KILO_CHAT_URL=http://localhost:8808 # @url event-service NEXT_PUBLIC_EVENT_SERVICE_URL=ws://localhost:8809 + +# @url wasteland +# NEXT_PUBLIC_WASTELAND_URL=http://localhost:8790 diff --git a/apps/web/src/app/(app)/components/AppSidebar.tsx b/apps/web/src/app/(app)/components/AppSidebar.tsx index b82e0f8da1..0496ffa13f 100644 --- a/apps/web/src/app/(app)/components/AppSidebar.tsx +++ b/apps/web/src/app/(app)/components/AppSidebar.tsx @@ -7,6 +7,7 @@ import { useUrlOrganizationId } from '@/hooks/useUrlOrganizationId'; import PersonalAppSidebar from './PersonalAppSidebar'; import OrganizationAppSidebar from './OrganizationAppSidebar'; import { GastownTownSidebar } from '@/components/gastown/GastownTownSidebar'; +import { WastelandSidebar } from '@/components/wasteland/WastelandSidebar'; const UUID = '[0-9a-f-]{36}'; @@ -26,6 +27,18 @@ function isKiloClawNewPath(pathname: string): boolean { return pathname === '/claw/new' || new RegExp(`^/organizations/${UUID}/claw/new$`).test(pathname); } +/** Extract the wastelandId from a /wasteland/[wastelandId] pathname, or null. */ +function extractWastelandId(pathname: string): string | null { + const match = pathname.match(new RegExp(`^/wasteland/(${UUID})`)); + return match ? match[1] : null; +} + +/** Extract {orgId, wastelandId} from an /organizations/[id]/wasteland/[wastelandId] pathname, or null. */ +function extractOrgWastelandId(pathname: string): { orgId: string; wastelandId: string } | null { + const match = pathname.match(new RegExp(`^/organizations/(${UUID})/wasteland/(${UUID})`)); + return match ? { orgId: match[1], wastelandId: match[2] } : null; +} + export default function AppSidebar(props: React.ComponentProps) { const currentOrgId = useUrlOrganizationId(); const pathname = usePathname(); @@ -78,6 +91,26 @@ export default function AppSidebar(props: React.ComponentProps) ); } + // Personal wasteland — show the wasteland-specific sidebar + const wastelandId = extractWastelandId(pathname); + if (wastelandId) { + return ; + } + + // Org wasteland — show the same sidebar with org-prefixed paths + const orgWasteland = extractOrgWastelandId(pathname); + if (orgWasteland) { + const orgBase = `/organizations/${orgWasteland.orgId}`; + return ( + + ); + } + // Render organization sidebar if viewing an organization if (currentOrgId) { return ; diff --git a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx index 374b3a466a..27c650fe9e 100644 --- a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx @@ -23,6 +23,7 @@ import { ListChecks, Wrench, Webhook, + Skull, Settings, MessageSquare, ChevronLeft, @@ -57,6 +58,7 @@ export default function OrganizationAppSidebar({ // Feature flags const isAutoTriageFeatureEnabled = useFeatureFlagEnabled('auto-triage-feature'); + const isWastelandEnabled = useFeatureFlagEnabled('wasteland-access'); const isAppBuilderEnabled = useFeatureFlagEnabled('app-builder-feature'); const isDevelopment = process.env.NODE_ENV === 'development'; @@ -194,6 +196,16 @@ export default function OrganizationAppSidebar({ }, ] : []), + // Wasteland requires feature flag + non-billing_manager role + ...((isWastelandEnabled || isDevelopment) && currentRole !== 'billing_manager' + ? [ + { + title: 'Wastelands', + icon: Skull, + url: `/organizations/${organizationId}/wasteland`, + }, + ] + : []), { title: 'Code Reviewer', icon: Bot, diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index e916c3702c..1522df7bd1 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -26,6 +26,7 @@ import { Wrench, Webhook, Factory, + Skull, Settings, CreditCard, MessageSquare, @@ -50,6 +51,7 @@ export default function PersonalAppSidebar(props: React.ComponentProps(sectionIds[0]); - const suppressRef = useRef(false); - - useEffect(() => { - const observer = new IntersectionObserver( - entries => { - if (suppressRef.current) return; - // Find the topmost visible section - const visible = entries - .filter(e => e.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - if (visible.length > 0) { - setActiveId(visible[0].target.id); - } - }, - { rootMargin: '-56px 0px -60% 0px', threshold: 0 } - ); - - for (const id of sectionIds) { - const el = document.getElementById(id); - if (el) observer.observe(el); - } - - return () => observer.disconnect(); - }, [sectionIds]); - - function scrollTo(id: string) { - const el = document.getElementById(id); - const header = document.getElementById('settings-sticky-header'); - if (!el) return; - - // Immediately highlight the target and suppress observer during scroll - setActiveId(id); - suppressRef.current = true; - - const headerHeight = header?.getBoundingClientRect().height ?? 0; - const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 24; - window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); - - // Re-enable observer after scroll settles - setTimeout(() => { - suppressRef.current = false; - }, 1000); - } - - return { activeId, scrollTo }; -} - export function TownSettingsPageClient({ townId, readOnly = false, organizationId }: Props) { const trpc = useGastownTRPC(); const queryClient = useQueryClient(); @@ -338,7 +299,8 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI } const { activeId: activeSection, scrollTo: scrollToSection } = useScrollSpy( - SECTIONS.map(s => s.id) + SECTIONS.map(s => s.id), + { stickyHeaderId: 'settings-sticky-header' } ); function handleSave() { @@ -519,35 +481,28 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI return (
- {/* Top bar */} -
-
- - -

Settings

- {townQuery.data?.name} -
- {!effectiveReadOnly && ( - - )} - {effectiveReadOnly && ( - - View only — only town creators and org owners can edit - - )} -
+ } + actions={ + !effectiveReadOnly ? ( + + ) : ( + + View only — only town creators and org owners can edit + + ) + } + /> {/* Two-column body — viewport scrolls so sticky works */}
@@ -1215,6 +1170,17 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
+ {/* ── Wasteland ──────────────────────────────────────── */} + + + + {/* ── Custom Instructions ────────────────────────────────── */}
- {/* Right sidebar — sticky scrollspy nav */} -
- -
+ + + {updateConfig.isPending ? 'Saving...' : 'Save'} + + ) : null + } + /> ); } -// ── Shared sub-components ──────────────────────────────────────────────── - -function SettingsSection({ - id, - title, - description, - icon: Icon, - index, - action, - children, -}: { - id: string; - title: string; - description: string; - icon: typeof Settings; - index: number; - action?: React.ReactNode; - children: React.ReactNode; -}) { - return ( - -
-
-
- -
-
-

{title}

-

{description}

-
-
- {action} -
-
{children}
-
- ); -} - -function FieldGroup({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); -} +// ── Local sub-components ───────────────────────────────────────────────── function MergeStrategyOption({ selected, diff --git a/apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx b/apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx new file mode 100644 index 0000000000..dc11093920 --- /dev/null +++ b/apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx @@ -0,0 +1,926 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useGastownTRPC, useGastownTRPCClient } from '@/lib/gastown/trpc'; +import { useWastelandTRPC, useWastelandTRPCClient } from '@/lib/wasteland/trpc'; +import { useUser } from '@/hooks/useUser'; +import { Button } from '@/components/Button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Globe, + Loader2, + CheckCircle2, + Unlink, + Skull, + Users, + Rocket, + ChevronLeft, + ArrowRight, +} from 'lucide-react'; +import Link from 'next/link'; + +const DEFAULT_UPSTREAM = 'hop/wl-commons'; + +type WastelandConnection = { + connection_id: string; + wasteland_id: string; + upstream: string; + rig_handle: string; + dolthub_org: string; + connected_at: string; + status: 'active' | 'disconnecting'; +}; + +export function WastelandSettingsSection({ + townId, + readOnly, +}: { + townId: string; + readOnly: boolean; +}) { + const gastownTrpc = useGastownTRPC(); + const queryClient = useQueryClient(); + + const connectionQuery = useQuery( + gastownTrpc.gastown.getTownWastelandConnection.queryOptions({ townId }) + ); + + const connection = connectionQuery.data; + const isLoading = connectionQuery.isLoading; + + if (isLoading) { + return ( +
+ + Checking connection... +
+ ); + } + + if (connection) { + return ( + + ); + } + + return ; +} + +// ── Connected State ────────────────────────────────────────────────────── + +function ConnectedState({ + townId, + connection, + readOnly, + queryClient, +}: { + townId: string; + connection: WastelandConnection; + readOnly: boolean; + queryClient: ReturnType; +}) { + const gastownTrpc = useGastownTRPC(); + + const disconnect = useMutation( + gastownTrpc.gastown.disconnectTownFromWasteland.mutationOptions({ + onSuccess: () => { + toast.success('Disconnected from wasteland'); + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + }, + onError: err => toast.error(`Failed to disconnect: ${err.message}`), + }) + ); + + // Admin-mode toggle lives on the wasteland settings page (which owns the + // credential + upstream config), not here. This section only shows + // whether the town is wired to a wasteland. + return ( +
+ + +
+

+ Connected to {connection.upstream} +

+

+ Rig: {connection.rig_handle} + {' · '} + Org: {connection.dolthub_org} +

+
+ + + +
+ ); +} + +// ── Disconnected State ─────────────────────────────────────────────────── + +function DisconnectedState({ + townId, + readOnly, + queryClient, +}: { + townId: string; + readOnly: boolean; + queryClient: ReturnType; +}) { + const [open, setOpen] = useState(false); + + return ( + <> +
+
+

Not connected

+

+ Link this town to a Wasteland to enable community bounties and shared contributions. +

+
+ +
+ + + ); +} + +// ── Connect Dialog ─────────────────────────────────────────────────────── + +type Mode = 'join' | 'create'; +type Step = + | 'intent' + | 'select' + | 'new-details' + | 'credentials' + | 'identity' + | 'connecting' + | 'success'; + +function ConnectWastelandDialog({ + townId, + open, + onOpenChange, + queryClient, +}: { + townId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + queryClient: ReturnType; +}) { + const gastownTrpc = useGastownTRPC(); + const wastelandTrpc = useWastelandTRPC(); + const gastownClient = useGastownTRPCClient(); + const wastelandClient = useWastelandTRPCClient(); + const { data: currentUser } = useUser(); + + const [step, setStep] = useState('intent'); + const [mode, setMode] = useState('join'); + + // Existing wastelands + const wastelandsQuery = useQuery(wastelandTrpc.wasteland.listWastelands.queryOptions({})); + const wastelands = wastelandsQuery.data ?? []; + const [selectedWastelandId, setSelectedWastelandId] = useState(null); + + // Create-mode details + const [newWastelandName, setNewWastelandName] = useState(''); + const [upstreamInput, setUpstreamInput] = useState(DEFAULT_UPSTREAM); + + // Step: Credentials + const [dolthubToken, setDolthubToken] = useState(''); + const [dolthubOrg, setDolthubOrg] = useState(''); + const [doltCredsJwk, setDoltCredsJwk] = useState(''); + // User explicitly attests they own the upstream. Create mode implies + // this is true (they're creating the repo) but keep it toggleable so + // they can opt back out. + const [isUpstreamAdmin, setIsUpstreamAdmin] = useState(false); + + // Step: Identity + const [rigHandle, setRigHandle] = useState(''); + const [doltUserName, setDoltUserName] = useState(''); + const [doltUserEmail, setDoltUserEmail] = useState(''); + + const [connectedUpstream, setConnectedUpstream] = useState(DEFAULT_UPSTREAM); + /** wastelandId that just got connected — used to offer a "Visit wasteland" + * link in the success step. */ + const [connectedWastelandId, setConnectedWastelandId] = useState(null); + const [error, setError] = useState(null); + + // Pre-fill identity fields from user profile + const handleProceedToIdentity = () => { + const displayName = currentUser?.google_user_name; + const email = currentUser?.google_user_email; + if (!rigHandle && displayName) { + setRigHandle(`kilo-${displayName.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`); + } + if (!doltUserName && displayName) { + setDoltUserName(displayName); + } + if (!doltUserEmail && email) { + setDoltUserEmail(email); + } + setStep('identity'); + }; + + // Reset state when dialog closes + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setStep('intent'); + setMode('join'); + setSelectedWastelandId(null); + setConnectedUpstream(DEFAULT_UPSTREAM); + setConnectedWastelandId(null); + setUpstreamInput(DEFAULT_UPSTREAM); + setNewWastelandName(''); + setDolthubToken(''); + setDolthubOrg(''); + setDoltCredsJwk(''); + setIsUpstreamAdmin(false); + setRigHandle(''); + setDoltUserName(''); + setDoltUserEmail(''); + setError(null); + } + onOpenChange(nextOpen); + }; + + const handlePickMode = (next: Mode) => { + setMode(next); + setIsUpstreamAdmin(next === 'create'); + setStep(next === 'join' ? 'select' : 'new-details'); + }; + + const handleSelectWasteland = async (wastelandId: string) => { + setSelectedWastelandId(wastelandId); + + // If credentials are already stored for this wasteland, skip straight + // to the connection — the user has already connected their DoltHub + // account to this wasteland and doesn't need to re-enter them. + try { + const existing = await wastelandClient.wasteland.getCredentialStatus.query({ + wastelandId, + }); + if (existing) { + await connectTownToExistingWasteland( + wastelandId, + existing.rig_handle ?? '', + existing.dolthub_org + ); + return; + } + } catch (err) { + // If the check fails, fall through to the credentials step + console.error('Failed to check wasteland credentials', err); + } + + setStep('credentials'); + }; + + const connectTownToExistingWasteland = async ( + wastelandId: string, + existingRigHandle: string, + existingDolthubOrg: string + ) => { + setStep('connecting'); + setError(null); + + try { + const selectedWasteland = wastelands.find(w => w.wasteland_id === wastelandId); + const upstream = selectedWasteland?.dolthub_upstream ?? DEFAULT_UPSTREAM; + setConnectedUpstream(upstream); + setConnectedWastelandId(wastelandId); + + await wastelandClient.wasteland.connectKiloTown.mutate({ + wastelandId, + townId, + }); + + await gastownClient.gastown.connectTownToWasteland.mutate({ + townId, + wastelandId, + upstream, + rigHandle: existingRigHandle, + dolthubOrg: existingDolthubOrg, + }); + + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + + setRigHandle(existingRigHandle); + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed'); + setStep('select'); + } + }; + + const handleConnectJoin = async () => { + setStep('connecting'); + setError(null); + + try { + const wastelandId = selectedWastelandId; + const selectedWasteland = wastelands.find(w => w.wasteland_id === wastelandId); + const upstream = selectedWasteland?.dolthub_upstream ?? DEFAULT_UPSTREAM; + setConnectedUpstream(upstream); + + // Brand-new wasteland record is only created when the user picked + // a non-listed upstream — this only applies if they used the + // intent flow to "join" a new, unknown upstream. For now we still + // require a selection on the join branch. + if (!wastelandId) { + throw new Error('Select a wasteland to join, or switch to Create.'); + } + setConnectedWastelandId(wastelandId); + + await wastelandClient.wasteland.storeCredential.mutate({ + wastelandId, + dolthubToken, + dolthubOrg, + rigHandle, + doltCredsJwk: doltCredsJwk.trim() || undefined, + doltUserName: doltUserName.trim() || undefined, + doltUserEmail: doltUserEmail.trim() || undefined, + isUpstreamAdmin, + }); + + await wastelandClient.wasteland.connectKiloTown.mutate({ + wastelandId, + townId, + }); + + await gastownClient.gastown.connectTownToWasteland.mutate({ + townId, + wastelandId, + upstream, + rigHandle, + dolthubOrg, + }); + + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed'); + setStep('identity'); + } + }; + + const handleConnectCreate = async () => { + setStep('connecting'); + setError(null); + + try { + const upstream = upstreamInput.trim(); + setConnectedUpstream(upstream); + + const name = + newWastelandName.trim() || `${dolthubOrg}-${upstream.split('/')[1] ?? 'wasteland'}`; + + // 1. Create the wasteland record (auto-adds caller as owner member) + const created = await wastelandClient.wasteland.createWasteland.mutate({ + name, + ownerType: 'user', + dolthubUpstream: upstream, + }); + const wastelandId = created.wasteland_id; + setConnectedWastelandId(wastelandId); + + // 2. Store credentials with admin flag (required for createUpstream) + await wastelandClient.wasteland.storeCredential.mutate({ + wastelandId, + dolthubToken, + dolthubOrg, + rigHandle, + doltCredsJwk: doltCredsJwk.trim() || undefined, + doltUserName: doltUserName.trim() || undefined, + doltUserEmail: doltUserEmail.trim() || undefined, + isUpstreamAdmin: true, + }); + + // 3. Bootstrap the DoltHub repo + register the creator as the first rig. + await wastelandClient.wasteland.createUpstream.mutate({ + wastelandId, + upstream, + rigHandle, + rigDisplayName: doltUserName.trim() || undefined, + rigEmail: doltUserEmail.trim() || undefined, + }); + + // 4. Persist the town↔wasteland association on the Town DO. + // createWasteland already added the user as a wasteland member, + // so connectKiloTown isn't strictly required — but we still need + // the Gastown-side connection for the mayor tools. + await wastelandClient.wasteland.connectKiloTown.mutate({ + wastelandId, + townId, + }); + await gastownClient.gastown.connectTownToWasteland.mutate({ + townId, + wastelandId, + upstream, + rigHandle, + dolthubOrg, + }); + + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Create failed'); + setStep('identity'); + } + }; + + const handleConnect = () => { + if (mode === 'create') return handleConnectCreate(); + return handleConnectJoin(); + }; + + const upstreamValid = /^[^/\s]+\/[^/\s]+$/.test(upstreamInput.trim()); + const newDetailsValid = upstreamValid && newWastelandName.trim().length > 0; + const credentialsValid = dolthubToken.trim().length > 0 && dolthubOrg.trim().length > 0; + const identityValid = rigHandle.trim().length > 0; + + return ( + + + {step === 'intent' && ( + <> + + Connect to Wasteland + + Join an existing wasteland to contribute, or create your own. + + +
+ + +
+ + + + + )} + + {step === 'select' && ( + <> + + Pick a Wasteland to Join + + Select one of your existing wastelands below. + + +
+ {wastelandsQuery.isLoading ? ( +
+ + Loading wastelands... +
+ ) : wastelands.filter(w => w.status === 'active').length > 0 ? ( + wastelands + .filter(w => w.status === 'active') + .map(w => ( + + )) + ) : ( +
+ +

No wastelands to join yet.

+

+ Go back and pick "Create your own" to bootstrap one. +

+
+ )} +
+ + + + + + )} + + {step === 'new-details' && ( + <> + + Create a Wasteland + + Name your wasteland and pick the DoltHub repo to bootstrap. We'll create the repo + for you and register your first rig. + + +
+ + setNewWastelandName(e.target.value)} + placeholder="My Wasteland" + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20" + /> + + + setUpstreamInput(e.target.value)} + placeholder="my-org/my-wasteland" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + +
+ + + + + + )} + + {step === 'credentials' && ( + <> + + DoltHub Credentials + + {mode === 'create' + ? 'Enter DoltHub credentials with push access — these are used to create the repo and register the first rig.' + : 'Enter your DoltHub credentials. These are used to fork the commons and push contributions.'} + + +
+ + setDolthubToken(e.target.value)} + placeholder="Enter your DoltHub API token" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + + setDolthubOrg(e.target.value)} + placeholder="my-org" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + + setDoltCredsJwk(e.target.value)} + placeholder='{"kid":"...","kty":"OKP",...}' + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + +
+ + + + + + )} + + {step === 'identity' && ( + <> + + Rig Identity + + Choose a handle for this town's rig on the commons. This identifies your + contributions. + + +
+ + setRigHandle(e.target.value)} + placeholder="kilo-my-town" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + + setDoltUserName(e.target.value)} + placeholder="Your Name" + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20" + /> + + + setDoltUserEmail(e.target.value)} + placeholder="you@example.com" + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20" + /> + + {error && ( +
+

{error}

+
+ )} +
+ + + + + + )} + + {step === 'connecting' && ( + <> + + + {mode === 'create' ? 'Creating...' : 'Connecting...'} + + + {mode === 'create' ? ( + <> + Bootstrapping{' '} + {upstreamInput.trim()} on + DoltHub and registering{' '} + {rigHandle} as the first rig. + + ) : ( + <> + Setting up credentials, forking the commons, and joining as{' '} + {rigHandle}. + + )} + + +
+ +

This may take a minute...

+
+ + )} + + {step === 'success' && ( + <> + + + {mode === 'create' ? 'Wasteland created' : 'Connected'} + + + {mode === 'create' + ? 'Your wasteland is live. Invite contributors from settings.' + : 'This town is now connected to the wasteland.'} + + +
+ +
+

+ {mode === 'create' ? 'Your wasteland is live at ' : 'Connected to '} + {connectedUpstream}{' '} + {rigHandle && ( + <> + as {rigHandle} + + )} +

+

+ {mode === 'create' + ? "You're the owner and admin. You can invite contributors from wasteland settings." + : 'Agents now have access to wasteland tools. Try asking the mayor to browse the wasteland.'} +

+
+
+ + + {connectedWastelandId && ( + + Visit wasteland + + + )} + + + )} +
+
+ ); +} + +// ── Shared ──────────────────────────────────────────────────────────────── + +function FieldGroup({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx new file mode 100644 index 0000000000..5963ec5aed --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { Button } from '@/components/Button'; +import { Badge } from '@/components/ui/badge'; +import { SetPageTitle } from '@/components/SetPageTitle'; +import { GastownBackdrop } from '@/components/gastown/GastownBackdrop'; +import { Plus, Skull } from 'lucide-react'; +import { + WastelandCard, + WastelandListSkeleton, +} from '@/app/(app)/wasteland/_components/WastelandListComponents'; + +type OrgWastelandListPageClientProps = { + organizationId: string; +}; + +export function OrgWastelandListPageClient({ organizationId }: OrgWastelandListPageClientProps) { + const router = useRouter(); + const trpc = useWastelandTRPC(); + const newUrl = `/organizations/${organizationId}/wasteland/new`; + + const wastelandsQuery = useQuery({ + ...trpc.wasteland.listWastelands.queryOptions({ organizationId }), + refetchInterval: 30_000, + }); + + const wastelands = wastelandsQuery.data ?? []; + const activeWastelands = wastelands.filter(w => w.status === 'active'); + + return ( + + +
+ + beta + +
+
+

+ A hosted bounty board backed by DoltHub. Post wanted items, claim work, and track + completions across your organization’s projects. +

+
+ + +
+ +
+
+
Wastelands
+
+ {wastelandsQuery.isLoading ? '…' : activeWastelands.length} +
+
+
+
Total
+
+ {wastelandsQuery.isLoading ? '…' : wastelands.length} +
+
+
+
Backed by
+
DoltHub
+
+
+
Scope
+
Organization
+
+
+
+
+ + {wastelandsQuery.isLoading && } + + {wastelandsQuery.data && wastelands.length === 0 && ( + +
+ +

No wastelands yet

+

+ Create a wasteland to set up a hosted bounty board for your organization. Connect it + to DoltHub to track wanted items, claims, and completions. +

+ +
+
+ )} + + {wastelands.length > 0 && ( +
+ {wastelands.map(wasteland => ( + + router.push(`/organizations/${organizationId}/wasteland/${wasteland.wasteland_id}`) + } + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx new file mode 100644 index 0000000000..ea805d39d3 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { ClaimsClient } from '@/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient'; + +export default async function OrgClaimsPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx new file mode 100644 index 0000000000..f5fe0aef63 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { WastelandTRPCProvider, createWastelandTRPCClient } from '@/lib/wasteland/trpc'; +import { HideAppTopbar } from '@/components/gastown/HideAppTopbar'; +import { WastelandDashboardHeader } from '@/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader'; + +export default function OrgWastelandLayout({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [trpcClient] = useState(() => createWastelandTRPCClient()); + + return ( + + +
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx new file mode 100644 index 0000000000..0ffde1a5ea --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { MembersClient } from '@/app/(app)/wasteland/[wastelandId]/members/MembersClient'; + +export default async function OrgMembersPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx new file mode 100644 index 0000000000..4d013e6eb0 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation'; + +export default async function OrgWastelandDashboardPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { id, wastelandId } = await params; + redirect(`/organizations/${id}/wasteland/${wastelandId}/wanted`); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx new file mode 100644 index 0000000000..806070583a --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { ReviewClient } from '@/app/(app)/wasteland/[wastelandId]/review/ReviewClient'; + +export default async function OrgReviewPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx new file mode 100644 index 0000000000..76d5a292f4 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { RigsClient } from '@/app/(app)/wasteland/[wastelandId]/rigs/RigsClient'; + +export default async function OrgRigsPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx new file mode 100644 index 0000000000..6d26cfab58 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { SettingsClient } from '@/app/(app)/wasteland/[wastelandId]/settings/SettingsClient'; + +export default async function OrgSettingsPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx new file mode 100644 index 0000000000..13d9b3e5bf --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { WantedBoardClient } from '@/app/(app)/wasteland/[wastelandId]/wanted/WantedBoardClient'; + +export default async function OrgWantedBoardPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/layout.tsx new file mode 100644 index 0000000000..8581072b3f --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/layout.tsx @@ -0,0 +1,31 @@ +import { notFound } from 'next/navigation'; +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { isWastelandEnabled } from '@/lib/wasteland/feature-flags'; + +/** + * Feature-flag gate for the org-scoped wasteland route tree. + * + * Every descendant page (the wasteland list, `new/`, and every + * `[wastelandId]/*` sub-page) inherits this gate automatically. + * Personal-scoped routes handle the same check per-page; consolidating + * it here for org routes matches the usual layout-as-auth-boundary + * pattern and avoids drift when new sub-routes are added. + * + * `isWastelandEnabled` returns true for kilo admins and in non-production + * environments; in production it checks the `wasteland-access` PostHog + * flag for the current user. + * + * Do NOT hard-code a `callbackPath` on the sign-in URL — the layout + * wraps many descendant routes, and a literal callback here would send + * users back to the parent list after sign-in regardless of which + * page they originally requested. Passing the default lets + * `appendCallbackPath` read `x-pathname` from headers and preserve the + * actual destination. + */ +export default async function OrgWastelandGateLayout({ children }: { children: React.ReactNode }) { + const user = await getUserFromAuthOrRedirect(); + if (!(await isWastelandEnabled(user.id, { isAdmin: user.is_admin }))) { + return notFound(); + } + return <>{children}; +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx new file mode 100644 index 0000000000..8724410765 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from 'next/navigation'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { NewWastelandWizardClient } from '@/app/(app)/wasteland/new/NewWastelandWizardClient'; +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { isWastelandEnabled } from '@/lib/wasteland/feature-flags'; + +export default async function OrgNewWastelandPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const user = await getUserFromAuthOrRedirect( + `/users/sign_in?callbackPath=/organizations/${id}/wasteland/new` + ); + + if (!(await isWastelandEnabled(user.id, { isAdmin: user.is_admin }))) { + return notFound(); + } + + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx new file mode 100644 index 0000000000..156ddc748d --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx @@ -0,0 +1,11 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { OrgWastelandListPageClient } from './OrgWastelandListPageClient'; + +export default async function OrgWastelandPage({ params }: { params: Promise<{ id: string }> }) { + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx b/apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx new file mode 100644 index 0000000000..913d4e2e87 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { Button } from '@/components/Button'; +import { Badge } from '@/components/ui/badge'; +import { SetPageTitle } from '@/components/SetPageTitle'; +import { GastownBackdrop } from '@/components/gastown/GastownBackdrop'; +import { Plus, Skull } from 'lucide-react'; +import { WastelandCard, WastelandListSkeleton } from './_components/WastelandListComponents'; + +export function WastelandListPageClient() { + const router = useRouter(); + const trpc = useWastelandTRPC(); + + const wastelandsQuery = useQuery({ + ...trpc.wasteland.listWastelands.queryOptions({}), + refetchInterval: 30_000, + }); + + const wastelands = wastelandsQuery.data ?? []; + + return ( + + +
+
+
+ + beta + +

+ A hosted bounty board backed by DoltHub. Post wanted items, claim work, and track + completions across your projects. +

+
+ + +
+ +
+
+
Wastelands
+
+ {wastelandsQuery.isLoading ? '…' : wastelands.length} +
+
+
+
Backed by
+
DoltHub
+
+
+
Scope
+
Personal
+
+
+
+
+ + {wastelandsQuery.isLoading && } + + {wastelandsQuery.data && wastelands.length === 0 && ( + +
+ +

No wastelands yet

+

+ Create a wasteland to set up a hosted bounty board. Connect it to DoltHub to track + wanted items, claims, and completions. +

+ +
+
+ )} + + {wastelands.length > 0 && ( +
+ {wastelands.map(wasteland => ( + router.push(`/wasteland/${wasteland.wasteland_id}`)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx new file mode 100644 index 0000000000..7b1cb8a50a --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { Skull, Globe, Lock } from 'lucide-react'; +import { useWastelandPageHeader } from './WastelandPageHeaderContext'; + +export function WastelandDashboardHeader() { + const params = useParams<{ wastelandId: string }>(); + const wastelandId = params.wastelandId; + const trpc = useWastelandTRPC(); + + const wastelandQuery = useQuery(trpc.wasteland.getWasteland.queryOptions({ wastelandId })); + const wasteland = wastelandQuery.data; + // Page-specific section contributed by the active route via + // `useSetWastelandPageHeader`. May be null during loading or if a page + // hasn't written one (e.g. placeholder routes). + const pageHeader = useWastelandPageHeader(); + + return ( +
+
+ + +
+ +
+ + {wastelandQuery.isLoading ? ( +
+ + +
+ ) : ( +
+

+ {wasteland?.name ?? 'Wasteland'} +

+ {wasteland && ( + + {wasteland.visibility === 'public' ? ( + + ) : ( + + )} + {wasteland.visibility} + + )} +
+ )} + + {/* Page-specific section — title + count + CTAs, right-aligned via flex-1. */} + {pageHeader && ( +
+
+ {pageHeader.icon} +

+ {pageHeader.title} +

+ {pageHeader.count != null && ( + {pageHeader.count} + )} +
+ {pageHeader.actions && ( +
{pageHeader.actions}
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx new file mode 100644 index 0000000000..e11f6c9f66 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx @@ -0,0 +1,144 @@ +'use client'; + +/** + * Per-wasteland-page header composition. + * + * The wasteland layout renders one top navbar (`WastelandDashboardHeader`). + * Each page contributes its own "section" (title + count + action buttons) + * into that navbar via `useSetWastelandPageHeader(section)` — mirroring + * the `SetPageTitle` / `PageTitleContext` pattern already used elsewhere + * in the app, but scoped to this subtree and shaped for the target DOM. + * + * Usage: + * useSetWastelandPageHeader({ + * title: 'Wanted Board', + * icon: , + * count: items.length, + * actions: <> + * + * + * , + * }); + * + * Implementation note: under the hood we use a mutable "latest section" + * ref + `useSyncExternalStore` rather than React state, so pages can + * pass inline JSX for `icon` and `actions` (fresh ReactNode identity + * every render) without triggering an infinite render loop. Pages + * publish the whole section every render; the navbar re-renders only + * when `title` or `count` change, or when the subscriber explicitly + * notifies (e.g. on mount/unmount). + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type ReactNode, +} from 'react'; + +export type WastelandPageHeader = { + /** Plain-text page title shown beside the wasteland identity. */ + title: string; + /** Optional leading icon (already styled by the caller). */ + icon?: ReactNode; + /** Optional count rendered after the title (items, members, rigs…). */ + count?: number | null; + /** Optional right-aligned action cluster (buttons, dialog triggers, badges). */ + actions?: ReactNode; +}; + +type Store = { + get: () => WastelandPageHeader | null; + /** Replace the whole section (or clear it). Triggers subscribers. */ + publish: (next: WastelandPageHeader | null) => void; + /** Subscribe to header changes. Called by `useSyncExternalStore`. */ + subscribe: (listener: () => void) => () => void; +}; + +function createStore(): Store { + let current: WastelandPageHeader | null = null; + const listeners = new Set<() => void>(); + return { + get: () => current, + publish(next) { + current = next; + for (const l of listeners) l(); + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +const StoreContext = createContext(undefined); + +export function WastelandPageHeaderProvider({ children }: { children: ReactNode }) { + // One store per provider instance. Stable across re-renders. + const store = useMemo(() => createStore(), []); + return {children}; +} + +function useStore(): Store { + const store = useContext(StoreContext); + if (!store) { + throw new Error( + 'useWastelandPageHeader / useSetWastelandPageHeader must be used within a WastelandPageHeaderProvider' + ); + } + return store; +} + +/** + * Read the current page header section. Returns `null` when no page has + * written one yet (loading, or off-route). Consumed by + * `WastelandDashboardHeader` to render the third slot in the navbar. + * + * Uses `useSyncExternalStore` so the navbar re-renders only when the + * store publishes — not on every page render. + */ +export function useWastelandPageHeader(): WastelandPageHeader | null { + const store = useStore(); + return useSyncExternalStore(store.subscribe, store.get, store.get); +} + +/** + * Declaratively write the current page's header section. Clears on unmount + * so stale sections don't leak across page transitions. + * + * Safe to call with inline JSX for `icon` and `actions`: each render + * publishes the fresh section, the store forwards it to the navbar's + * `useSyncExternalStore` subscription, and the navbar re-renders once. + * This hook never subscribes to the store, so publishing from here does + * NOT re-render the caller — no infinite loop. + */ +export function useSetWastelandPageHeader(section: WastelandPageHeader): void { + const store = useStore(); + + // Keep a ref to the latest section so the unmount cleanup knows what + // we last wrote and can avoid clobbering a newer page's header. + const latest = useRef(section); + latest.current = section; + + // Publish on every render. Because `publish` only notifies subscribers + // (the navbar) and doesn't touch React state owned by *this* component, + // writing here doesn't re-trigger a render on the caller. + useEffect(() => { + store.publish(latest.current); + }); + + // Cleanup on unmount only. + const cleanup = useCallback(() => { + // If a newer writer has already replaced our section, leave it. + if (store.get() === latest.current) { + store.publish(null); + } + }, [store]); + useEffect(() => cleanup, [cleanup]); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx new file mode 100644 index 0000000000..5fcce83073 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx @@ -0,0 +1,648 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import type { WastelandOutputs } from '@/lib/wasteland/trpc'; +import { useSetWastelandPageHeader } from '../WastelandPageHeaderContext'; +import { useDrawerStack } from '@/components/wasteland/drawer/WastelandDrawerStack'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + ArrowUpDown, + ExternalLink, + Hourglass, + Loader2, + MoreHorizontal, + RefreshCw, + Search, + ShieldAlert, + ScrollText, + TriangleAlert, +} from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { AnimatePresence, motion } from 'motion/react'; +import { toast } from 'sonner'; +import { parseDoltDate } from '@/lib/wasteland/date'; +import { STATUS_DOT, PRIORITY_COLORS, TYPE_COLORS } from '@/lib/wasteland/status-colors'; +import { useUser } from '@/hooks/useUser'; +import Link from 'next/link'; + +type ClaimRow = WastelandOutputs['wasteland']['listClaims'][number]; +type ClaimItem = ClaimRow['item']; + +type SortMode = 'recent' | 'priority' | 'stale'; + +const PR_KIND_LABELS: Record = { + claim: 'claim PR', + done: 'done PR', + unclaim: 'unclaim PR', +}; + +const COLD_START_DELAY_MS = 3000; + +function useSlowOperationToast(isPending: boolean) { + const timerRef = useRef | null>(null); + const toastIdRef = useRef(null); + + useEffect(() => { + if (isPending) { + timerRef.current = setTimeout(() => { + toastIdRef.current = toast.loading('Starting wasteland container...'); + }, COLD_START_DELAY_MS); + } else { + if (timerRef.current) clearTimeout(timerRef.current); + if (toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + } + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + if (toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + }; + }, [isPending]); +} + +export function ClaimsClient({ wastelandId }: { wastelandId: string }) { + const trpc = useWastelandTRPC(); + const queryClient = useQueryClient(); + const { open: openDrawer } = useDrawerStack(); + const { data: currentUser } = useUser(); + + const [search, setSearch] = useState(''); + const [sortMode, setSortMode] = useState('recent'); + const [rigHandleFilter, setRigHandleFilter] = useState(null); + const [pendingPrOnly, setPendingPrOnly] = useState(false); + const [activeMenuItemId, setActiveMenuItemId] = useState(null); + const [forceUnclaimTarget, setForceUnclaimTarget] = useState(null); + const [forceUnclaimReason, setForceUnclaimReason] = useState(''); + + useEffect(() => { + if (!activeMenuItemId) return; + const handler = () => setActiveMenuItemId(null); + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [activeMenuItemId]); + + const claimsQuery = useQuery({ + ...trpc.wasteland.listClaims.queryOptions({ + wastelandId, + ...(rigHandleFilter ? { rigHandle: rigHandleFilter } : {}), + }), + refetchInterval: 30_000, + }); + + const credentialQuery = useQuery( + trpc.wasteland.getCredentialStatus.queryOptions({ wastelandId }) + ); + const membersQuery = useQuery(trpc.wasteland.listMembers.queryOptions({ wastelandId })); + + const isUpstreamAdmin = credentialQuery.data?.is_upstream_admin === true; + const currentUserMember = membersQuery.data?.find(m => m.user_id === currentUser?.id); + const isOwner = currentUserMember?.role === 'owner' || currentUser?.is_admin === true; + const canForceUnclaim = isOwner && isUpstreamAdmin; + + const forceUnclaimMutation = useMutation({ + ...trpc.wasteland.forceUnclaimWantedItem.mutationOptions(), + onSuccess: () => { + toast.success('Claim force-released'); + setForceUnclaimTarget(null); + setForceUnclaimReason(''); + void queryClient.invalidateQueries({ + queryKey: trpc.wasteland.listClaims.queryKey({ wastelandId }), + }); + }, + onError: err => toast.error(`Force unclaim failed: ${err.message}`), + }); + + useSlowOperationToast(claimsQuery.isLoading && !claimsQuery.data); + + const claims = claimsQuery.data ?? []; + + const rigHandles = useMemo( + () => Array.from(new Set(claims.map(c => c.item.claimed_by).filter(Boolean))) as string[], + [claims] + ); + + const filteredClaims = useMemo(() => { + let result = claims; + + if (search.trim()) { + const q = search.toLowerCase(); + result = result.filter( + c => + c.item.title.toLowerCase().includes(q) || + c.item.id.toLowerCase().includes(q) || + (c.item.claimed_by ?? '').toLowerCase().includes(q) + ); + } + + if (pendingPrOnly) { + result = result.filter(c => c.pending_pr !== null); + } + + result = [...result].sort((a, b) => { + if (sortMode === 'priority') { + const priorityOrder: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + }; + const aP = priorityOrder[String(a.item.priority ?? 'medium')] ?? 2; + const bP = priorityOrder[String(b.item.priority ?? 'medium')] ?? 2; + return aP - bP; + } + if (sortMode === 'stale') { + const aTime = parseDoltDate(a.item.updated_at)?.getTime() ?? 0; + const bTime = parseDoltDate(b.item.updated_at)?.getTime() ?? 0; + return aTime - bTime; + } + const aTime = parseDoltDate(a.item.updated_at)?.getTime() ?? 0; + const bTime = parseDoltDate(b.item.updated_at)?.getTime() ?? 0; + return bTime - aTime; + }); + + return result; + }, [claims, search, sortMode, pendingPrOnly]); + + const stats = useMemo(() => { + const total = claims.length; + const pendingPr = claims.filter(c => c.pending_pr !== null).length; + const staleThreshold = Date.now() - 24 * 60 * 60 * 1000; + const stale = claims.filter(c => { + const updated = parseDoltDate(c.item.updated_at)?.getTime() ?? 0; + return updated > 0 && updated < staleThreshold; + }).length; + return { total, pendingPr, stale }; + }, [claims]); + + const handleRefresh = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: trpc.wasteland.listClaims.queryKey({ + wastelandId, + ...(rigHandleFilter ? { rigHandle: rigHandleFilter } : {}), + }), + }); + }, [queryClient, trpc, wastelandId, rigHandleFilter]); + + const handleOpenItem = useCallback( + (item: ClaimItem) => { + openDrawer({ + type: 'wanted-item', + wastelandId, + item, + actions: { + isAdmin: isUpstreamAdmin, + onDone: () => {}, + onAccept: () => {}, + onReject: () => {}, + onCloseItem: () => {}, + onUnclaim: () => {}, + }, + }); + }, + [openDrawer, wastelandId, isUpstreamAdmin] + ); + + const cycleSort = useCallback(() => { + setSortMode(prev => { + if (prev === 'recent') return 'priority'; + if (prev === 'priority') return 'stale'; + return 'recent'; + }); + }, []); + + const sortLabel: Record = { + recent: 'Recently claimed', + priority: 'Priority', + stale: 'Stale (oldest first)', + }; + + useSetWastelandPageHeader({ + title: 'Claims', + icon: , + count: claims.length, + actions: ( + <> + + {claims.length} active claim{claims.length !== 1 ? 's' : ''} + + + + ), + }); + + return ( +
+
+ {/* Filter bar */} +
+
+ + setSearch(e.target.value)} + className="w-48 bg-transparent text-xs text-white/80 outline-none placeholder:text-white/25" + /> +
+ + {/* Rig handle filter */} + + + {/* Pending PR only toggle */} + + + {/* Sort */} + +
+ + {/* Stats strip */} +
+ + + 0} /> +
+ + {/* Claims table */} +
+ {claimsQuery.isLoading && } + + {!claimsQuery.isLoading && claimsQuery.isError && ( +
+

Failed to load claims

+ +
+ )} + + {!claimsQuery.isLoading && !claimsQuery.isError && filteredClaims.length === 0 && ( +
+ +

+ {search || rigHandleFilter || pendingPrOnly + ? 'No claims match your filters.' + : 'No active claims right now'} +

+ {!search && !rigHandleFilter && !pendingPrOnly && ( + <> +

+ When a rig claims a wanted item, it'll appear here +

+ + Browse the wanted board + + + )} +
+ )} + + + {filteredClaims.map((claim, i) => ( + handleOpenItem(claim.item)} + onForceUnclaim={() => setForceUnclaimTarget(claim)} + activeMenuItemId={activeMenuItemId} + setActiveMenuItemId={setActiveMenuItemId} + delay={Math.min(i * 0.02, 0.3)} + /> + ))} + +
+
+ + { + if (!open) { + setForceUnclaimTarget(null); + setForceUnclaimReason(''); + } + }} + > + + + Force unclaim + +
+

+ Release the claim on{' '} + + {forceUnclaimTarget?.item.title} + {' '} + by{' '} + + {forceUnclaimTarget?.item.claimed_by} + + ? +

+
+ +

+ This releases the claim and creates an audit log entry. The claiming rig will + lose any in-flight work. +

+
+