feat(sandbox): Wave 4 — marketplace catalog entry (customer can pick Sandbox alongside WordPress)
Adds the Sandbox product to the marketplace storefront so a customer picks it off marketplace.<sov>/apps the same way they pick WordPress / Nextcloud. Card chrome is the existing .app-card shape verbatim — no new components per the design-system inheritance rule. The detail page gains a 6-agent picker (aider, claude-code, cursor-agent, little-coder, opencode, qwen-code) using the existing .related-card chrome with a picked state mirroring .app-card.in-cart. Picks land on cart.agents and travel through checkout into the tenant create-org payload. Tenant-service emits a sibling `tenant.sandbox_requested` event on sme.tenant.events when the cart contains the sandbox product. The event carries org slug + owner + agents list, sufficient for the sandbox-controller (or its upstream orchestrator) to mint a Sandbox CR with matching spec.agentCatalogue. The Organization CR creation path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
41eba2d436
commit
b8b80973de
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getApps, type App } from '../lib/api';
|
||||
import { readCart, toggleApp } from '../lib/cart';
|
||||
import { readCart, toggleApp, toggleAgent, SANDBOX_AGENTS } from '../lib/cart';
|
||||
|
||||
interface Props {
|
||||
slug?: string;
|
||||
@ -16,6 +16,11 @@
|
||||
const inCart = $derived(app ? cart.apps.includes(app.id) : false);
|
||||
const isService = $derived(app ? (app.system === true || app.kind === 'service') : false);
|
||||
const comingSoon = $derived(app ? (app.deployable === false && !isService) : false);
|
||||
// Sandbox product — render the 6-agent pre-select grid below the
|
||||
// features section. Cards reuse the .related-card chrome verbatim
|
||||
// (design-system inheritance rule from Wave 4 brief: no bespoke
|
||||
// components). Picks land on cart.agents via toggleAgent().
|
||||
const isSandbox = $derived(app?.slug === 'sandbox');
|
||||
|
||||
$effect(() => {
|
||||
// Read slug from URL query param (static site can't use dynamic route params)
|
||||
@ -40,6 +45,14 @@
|
||||
if (comingSoon) return;
|
||||
cart = toggleApp(app.id);
|
||||
}
|
||||
|
||||
function pickAgent(slug: string) {
|
||||
cart = toggleAgent(slug);
|
||||
}
|
||||
|
||||
function agentPicked(slug: string): boolean {
|
||||
return cart.agents.includes(slug);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail-page">
|
||||
@ -102,6 +115,41 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Sandbox: pre-select agents (Wave 4). Reuses .related-card chrome
|
||||
so we don't add a bespoke component. The 6 entries match the
|
||||
Sandbox CRD enum (products/catalyst/chart/crds/sandbox.yaml ::
|
||||
spec.agentCatalogue). Picks land on cart.agents and travel
|
||||
through checkout into the tenant create-org payload. -->
|
||||
{#if isSandbox}
|
||||
<section class="detail-section">
|
||||
<h2>Pick your agents</h2>
|
||||
<p class="detail-dependencies-hint">Choose the coding agents your Sandbox should spawn. You can change this any time from the Sandbox admin tab.</p>
|
||||
<div class="related-grid">
|
||||
{#each SANDBOX_AGENTS as ag}
|
||||
{@const picked = agentPicked(ag.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="related-card agent-card {picked ? 'picked' : ''}"
|
||||
onclick={() => pickAgent(ag.slug)}
|
||||
aria-pressed={picked}
|
||||
>
|
||||
<span class="related-icon" style="background: var(--color-accent)" aria-hidden="true">
|
||||
{#if picked}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M12 5v14M5 12h14"/></svg>
|
||||
{/if}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{ag.name}</strong>
|
||||
<p>{ag.tagline}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dependencies -->
|
||||
{#if app.dependencies && app.dependencies.length > 0}
|
||||
<section class="detail-section">
|
||||
@ -354,6 +402,29 @@
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Agent picker — reuses .related-card chrome; the only delta is a
|
||||
pressed state (mirror of .app-card.in-cart from AppsStep). No new
|
||||
tokens — color-success is the existing "selected" channel. */
|
||||
.agent-card {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.agent-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.agent-card.picked {
|
||||
border-color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--color-success) 4%, var(--color-surface));
|
||||
}
|
||||
.agent-card.picked .related-icon {
|
||||
background: var(--color-success) !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Floating nav pill */
|
||||
.float-nav {
|
||||
position: fixed;
|
||||
|
||||
@ -226,6 +226,10 @@
|
||||
plan_id: cart.plan || '',
|
||||
apps: cart.apps,
|
||||
addons: cart.addons,
|
||||
// Forward Sandbox agent picks (Wave 4). The tenant-service
|
||||
// only acts on this when `apps` contains 'sandbox'; for all
|
||||
// other carts it's persisted and ignored.
|
||||
agents: cart.agents || [],
|
||||
});
|
||||
return { id: t.id, slug: t.slug || s };
|
||||
} catch (e: any) {
|
||||
|
||||
@ -323,6 +323,11 @@ const appLogos: Record<string, string> = {
|
||||
nocodb: 'https://github.com/nocodb.png?size=64',
|
||||
'jitsi-meet': 'https://github.com/jitsi.png?size=64',
|
||||
immich: 'https://github.com/immich-app.png?size=64',
|
||||
// Wave 4 — Sandbox marketplace catalog entry. Logo is the OpenOva
|
||||
// org avatar to match how WordPress/Ghost/Nextcloud cards lean on
|
||||
// upstream-org GitHub avatars; Sandbox is our own product so the
|
||||
// OpenOva mark is the canonical brand.
|
||||
sandbox: 'https://github.com/openova-io.png?size=64',
|
||||
};
|
||||
|
||||
export interface Industry {
|
||||
@ -360,6 +365,12 @@ export interface CreateTenantRequest {
|
||||
plan_id: string;
|
||||
apps: string[];
|
||||
addons: string[];
|
||||
// Wave 4 Sandbox — forwarded only when `apps` contains 'sandbox'.
|
||||
// The tenant-service uses this to publish a `tenant.sandbox_requested`
|
||||
// event the sandbox-controller consumes to mint a Sandbox CR with
|
||||
// matching spec.agentCatalogue. Optional so legacy clients keep
|
||||
// working unchanged.
|
||||
agents?: string[];
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
|
||||
@ -11,6 +11,14 @@ export interface CartState {
|
||||
// would see .omani.rest on Review + Checkout.
|
||||
tld: string;
|
||||
email: string;
|
||||
// Sandbox-product agent picks (Wave 4 — products/sandbox/README.md).
|
||||
// When the cart contains the `sandbox` app, the customer pre-selects
|
||||
// a subset of the 6 supported agents on the Sandbox detail page. The
|
||||
// checkout/create-org payload forwards this to the tenant-service,
|
||||
// which emits a `tenant.sandbox_requested` event the sandbox-
|
||||
// controller consumes to materialize a Sandbox CR with the matching
|
||||
// spec.agentCatalogue. Empty when Sandbox isn't in the cart.
|
||||
agents: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'sme-cart';
|
||||
@ -29,8 +37,22 @@ const defaultCart: CartState = {
|
||||
subdomain: '',
|
||||
tld: DEFAULT_TLD,
|
||||
email: '',
|
||||
agents: [],
|
||||
};
|
||||
|
||||
// The 6 agents the Sandbox CRD (sandbox.openova.io/v1) accepts in
|
||||
// `spec.agentCatalogue` — kept in sync with
|
||||
// products/catalyst/chart/crds/sandbox.yaml. Single source of truth for
|
||||
// the marketplace detail-page picker.
|
||||
export const SANDBOX_AGENTS: { slug: string; name: string; tagline: string }[] = [
|
||||
{ slug: 'claude-code', name: 'Claude Code', tagline: 'Anthropic — the native CLI' },
|
||||
{ slug: 'cursor-agent', name: 'Cursor Agent', tagline: 'Cursor CLI in headless mode' },
|
||||
{ slug: 'qwen-code', name: 'Qwen Code', tagline: 'Alibaba Qwen — local-first' },
|
||||
{ slug: 'aider', name: 'Aider', tagline: 'AI pair programming in the terminal' },
|
||||
{ slug: 'opencode', name: 'Opencode', tagline: 'OSS multi-provider coding agent' },
|
||||
{ slug: 'little-coder', name: 'Little Coder', tagline: 'Lightweight scripted agent' },
|
||||
];
|
||||
|
||||
export function readCart(): CartState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@ -99,6 +121,22 @@ export function setTLD(tld: string): CartState {
|
||||
return cart;
|
||||
}
|
||||
|
||||
// toggleAgent flips one agent slug in/out of cart.agents. Used by the
|
||||
// Sandbox detail page (AppDetail.svelte) when slug === 'sandbox'. The
|
||||
// list is kept stable-ordered by toggling in-place — order in the cart
|
||||
// matches the order the user clicked.
|
||||
export function toggleAgent(agentSlug: string): CartState {
|
||||
const cart = readCart();
|
||||
const idx = cart.agents.indexOf(agentSlug);
|
||||
if (idx >= 0) {
|
||||
cart.agents.splice(idx, 1);
|
||||
} else {
|
||||
cart.agents.push(agentSlug);
|
||||
}
|
||||
writeCart(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
export function clearCart(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', { detail: defaultCart }));
|
||||
|
||||
@ -89,6 +89,65 @@ func (h *Handler) seedAllData(ctx context.Context) {
|
||||
{Slug: "nocodb", Name: "NocoDB", Tagline: "Open-source Airtable alternative on any database", Description: "Turn any SQL database into a smart spreadsheet. Grid, Kanban, Gallery, and Form views. API generation, automations, and collaboration.", Category: "database", Tags: []string{"database", "spreadsheet", "nocode", "airtable"}, Icon: "\U0001F5C3", IconBg: "#1348FC", MinimumSize: "xs", RecommendedSize: "s", Website: "https://nocodb.com", License: "AGPLv3", Featured: true, Popular: true, Free: true, Features: []string{"Spreadsheet UI on any SQL database", "Grid, Kanban, Gallery, and Form views", "Automatic REST API generation", "Automations and webhooks", "Role-based access control", "Import from Airtable, CSV, Excel"}, RelatedApps: []string{"erpnext", "twenty", "formbricks"}, RamMB: 256, CpuMilli: 250, DiskGB: 5, HelmChart: "nocodb", HelmRepo: "", CreatedAt: now, UpdatedAt: now},
|
||||
{Slug: "jitsi-meet", Name: "Jitsi Meet", Tagline: "Self-hosted video conferencing, no account required", Description: "Secure, fully-featured video conferencing that runs in your browser. No downloads, no accounts required for participants.", Category: "video-conferencing", Tags: []string{"video", "conferencing", "meetings", "webrtc"}, Icon: "\U0001F4F9", IconBg: "#17A0DB", MinimumSize: "s", RecommendedSize: "m", Website: "https://jitsi.org", License: "Apache-2.0", Featured: false, Popular: true, Free: true, Features: []string{"Browser-based, no download required", "No account needed for participants", "Screen sharing and recording", "Breakout rooms", "End-to-end encryption", "Calendar integration"}, RelatedApps: []string{"rocket-chat", "cal-com", "stalwart-mail"}, RamMB: 1024, CpuMilli: 1000, DiskGB: 5, HelmChart: "jitsi-meet", HelmRepo: "https://jitsi-contrib.github.io/jitsi-helm/", CreatedAt: now, UpdatedAt: now},
|
||||
{Slug: "immich", Name: "Immich", Tagline: "Self-hosted Google Photos with ML-powered search", Description: "High-performance photo and video management. Automatic backup from mobile, ML-powered search and face recognition, shared albums, and a beautiful timeline view.", Category: "photo-management", Tags: []string{"photos", "videos", "backup", "gallery", "ml"}, Icon: "\U0001F4F7", IconBg: "#4250AF", MinimumSize: "s", RecommendedSize: "m", Website: "https://immich.app", License: "AGPL-3.0", Featured: false, Popular: true, Free: true, Features: []string{"Automatic backup from iOS and Android", "ML-powered search (CLIP)", "Face recognition and person grouping", "Shared albums and partner sharing", "Timeline and map views", "RAW photo support"}, RelatedApps: []string{"nextcloud", "vaultwarden"}, RamMB: 1024, CpuMilli: 1000, DiskGB: 50, HelmChart: "immich", HelmRepo: "", CreatedAt: now, UpdatedAt: now},
|
||||
// Sandbox — the per-user, per-Organization coding-agent plane
|
||||
// (products/sandbox/README.md). Wave 4: marketplace catalog
|
||||
// entry so a customer can pick "Sandbox" alongside WordPress /
|
||||
// GitLab / Nextcloud. The card surfaces on /apps; the detail
|
||||
// page renders the 6 supported agents (aider, claude-code,
|
||||
// cursor-agent, little-coder, opencode, qwen-code) as a
|
||||
// pre-select grid. The cart.agents array carries the picks
|
||||
// through checkout into the tenant create-org payload, which
|
||||
// the tenant-service forwards to the sandbox-controller via a
|
||||
// `tenant.sandbox_requested` event.
|
||||
//
|
||||
// Free at marketplace level — Sandbox consumes Org resource
|
||||
// quota; metering belongs to the bp-newapi LLM gateway, not
|
||||
// here. ConfigSchema declares `agents` as an enum-array so
|
||||
// the operator-facing console can see the picked agents
|
||||
// without parsing the cart blob.
|
||||
{
|
||||
Slug: "sandbox",
|
||||
Name: "Sandbox",
|
||||
Tagline: "The cloud sandbox where your agents do real work",
|
||||
Description: "OpenOva Sandbox runs your coding agents — Claude Code, Cursor, Qwen Code, Aider, Opencode, Little-Coder — server-side inside your Sovereign, identity-scoped, cluster-aware. Open a session in the browser, hand off to your phone, ship to production. No tmux. No local install.",
|
||||
Category: "devtools",
|
||||
Tags: []string{"sandbox", "agents", "ai", "coding", "byos", "newapi"},
|
||||
Icon: "\U0001F9F0",
|
||||
IconBg: "#6366F1",
|
||||
MinimumSize: "s",
|
||||
RecommendedSize: "m",
|
||||
Website: "https://openova.io",
|
||||
License: "AGPL-3.0",
|
||||
Featured: true,
|
||||
Popular: true,
|
||||
Free: true,
|
||||
Features: []string{
|
||||
"6 coding agents — Claude Code, Cursor, Qwen Code, Aider, Opencode, Little-Coder",
|
||||
"Native TUI in the browser via xterm.js + WebSocket + PTY",
|
||||
"Card-stream view on mobile, same persistent session",
|
||||
"openova-sandbox-mcp tool surface — cluster-aware primitives",
|
||||
"BYOS / newapi-gated LLM gateway — no agent keys leak",
|
||||
"Preview deploys at <pr>.<app>.<sb-owner>.<sov>",
|
||||
},
|
||||
RelatedApps: []string{"gitea", "librechat", "dify"},
|
||||
ConfigSchema: []store.ConfigField{
|
||||
{
|
||||
Key: "agents",
|
||||
Label: "Agents",
|
||||
Type: "enum",
|
||||
Options: []string{"aider", "claude-code", "cursor-agent", "little-coder", "opencode", "qwen-code"},
|
||||
Description: "Subset of Sovereign-enabled agents this Sandbox may spawn. Maps to sandbox.openova.io/v1 Sandbox.spec.agentCatalogue.",
|
||||
Advanced: false,
|
||||
},
|
||||
},
|
||||
RamMB: 512,
|
||||
CpuMilli: 500,
|
||||
DiskGB: 50,
|
||||
HelmChart: "",
|
||||
HelmRepo: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
for i := range seedApps {
|
||||
@ -644,6 +703,14 @@ func DeployableAppSlugs() map[string]bool {
|
||||
"postgres": true,
|
||||
"mysql": true,
|
||||
"redis": true,
|
||||
// Sandbox (#1615 product + Wave 4 marketplace) — the per-user
|
||||
// coding-agent plane. Deployable=true means the marketplace
|
||||
// storefront renders the card without a "Coming soon" overlay.
|
||||
// Day-2 install is wired via the tenant.sandbox_requested
|
||||
// event the tenant-service emits when `apps` contains
|
||||
// `sandbox`; the sandbox-controller (already shipped) is the
|
||||
// consumer of the resulting Sandbox CR.
|
||||
"sandbox": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,8 @@ func TestDeployableAppSlugs_StableShape(t *testing.T) {
|
||||
"gitea", "vaultwarden", "umami", "nocodb", "cal-com",
|
||||
"invoiceshelf", "formbricks", "listmonk",
|
||||
"postgres", "mysql", "redis",
|
||||
// Wave 4 — Sandbox marketplace catalog entry.
|
||||
"sandbox",
|
||||
}
|
||||
if got, want := len(d), len(expected); got != want {
|
||||
t.Errorf("deployable map size = %d, want %d", got, want)
|
||||
|
||||
@ -165,6 +165,12 @@ func (h *Handler) CreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
PlanID string `json:"plan_id"`
|
||||
Apps []string `json:"apps"`
|
||||
AddOns []string `json:"addons"`
|
||||
// Wave 4 Sandbox — coding-agent picks from the marketplace
|
||||
// detail page. Only acted on when `Apps` contains "sandbox":
|
||||
// CreateOrg publishes an extra `tenant.sandbox_requested`
|
||||
// event the sandbox-controller consumes to mint a Sandbox CR
|
||||
// with `spec.agentCatalogue` = these slugs. Tolerated empty.
|
||||
Agents []string `json:"agents"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
@ -238,9 +244,48 @@ func (h *Handler) CreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wave 4 — Sandbox: when the cart contains the sandbox product,
|
||||
// emit a sibling `tenant.sandbox_requested` event so the
|
||||
// sandbox-controller (or its upstream orchestrator) can mint a
|
||||
// Sandbox CR with `spec.agentCatalogue` matching the picks. The
|
||||
// event carries enough to materialize the CR without re-reading
|
||||
// the tenant doc: org slug + owner email + agent list. Subscriber
|
||||
// is responsible for de-dup (sandbox CR name = sanitized email).
|
||||
if containsSlug(body.Apps, "sandbox") {
|
||||
sandboxPayload := map[string]any{
|
||||
"tenant_id": tenant.ID,
|
||||
"org_slug": tenant.Slug,
|
||||
"owner_id": userID,
|
||||
"agents": body.Agents,
|
||||
"sovereign": "", // populated by the consumer from its env / cluster context
|
||||
"requested_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
sbEvt, sbErr := events.NewEvent("tenant.sandbox_requested", "tenant-service", tenant.ID, sandboxPayload)
|
||||
if sbErr == nil {
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer pubCancel()
|
||||
if pubErr := h.Producer.Publish(pubCtx, "sme.tenant.events", sbEvt); pubErr != nil {
|
||||
slog.Error("failed to publish tenant.sandbox_requested event", "tenant_id", tenant.ID, "error", pubErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusCreated, tenant)
|
||||
}
|
||||
|
||||
// containsSlug returns true iff slug appears in slugs. Used to gate the
|
||||
// Sandbox-specific `tenant.sandbox_requested` event emission so the
|
||||
// CreateOrg hot path doesn't pay an event marshal for tenants that
|
||||
// never picked the Sandbox product.
|
||||
func containsSlug(slugs []string, slug string) bool {
|
||||
for _, s := range slugs {
|
||||
if s == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ListOrgs returns all organizations where the authenticated user is a member.
|
||||
func (h *Handler) ListOrgs(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user