diff --git a/apps/cli/web-server/index.ts b/apps/cli/web-server/index.ts index 870fac0909..fdb2622c53 100644 --- a/apps/cli/web-server/index.ts +++ b/apps/cli/web-server/index.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { isAiModelId } from '@studio/common/ai/models'; import { appendModelChangeEntry, + appendStudioEntry, createAiSession, deleteAiSession, listAiSessions, @@ -25,6 +26,7 @@ import { setBroadcast, startAgentRun, } from './agent-runs'; +import { ensureWorkspace, getWorkspaceFiles } from './workspaces'; import type { AgentRunEvent } from '@studio/common/ai/agent-events'; import type { AiSessionSummary } from '@studio/common/ai/sessions/types'; import type { SitesEndpointSite } from '@studio/common/types/sync'; @@ -123,16 +125,25 @@ api.get( '/events', ( req: Request, res: Response ) => { } ); } ); -// Broadcast every agent event to all connected SSE clients, in the same -// envelope the web connector expects (channel + payload). -setBroadcast( ( event: AgentRunEvent ) => { +function broadcastSse( envelope: unknown ): void { if ( sseClients.size === 0 ) { return; } - const data = JSON.stringify( { channel: 'agent', payload: event } ); + const data = JSON.stringify( envelope ); for ( const client of sseClients ) { client.write( `data: ${ data }\n\n` ); } +} + +// Broadcast every agent event to all connected SSE clients, in the same +// envelope the web connector expects (channel + payload). When a run ends, also +// emit a `preview` signal so the client-side Playground re-fetches the session's +// workspace files and re-renders the live preview. +setBroadcast( ( event: AgentRunEvent ) => { + broadcastSse( { channel: 'agent', payload: event } ); + if ( event.event.type === 'run.exited' ) { + broadcastSse( { channel: 'preview', payload: { sessionId: event.sessionId } } ); + } } ); // --- Health ------------------------------------------------------------------ @@ -249,9 +260,20 @@ api.get( api.post( '/sessions', asyncHandler( async ( _req: Request, res: Response ) => { - // `siteId` is accepted but not yet wired: sessions start unbound and the - // agent creates/binds a site during the run. - res.json( await createAiSession( root ) ); + // Studio Web runs the agent on a per-session, git-backed workspace — the + // cloud analog of Studio App's local site, and where the live preview + // reads from. Create the session, provision its workspace, and bind it + // via a `studio.site_selected` entry so the forked agent (`code sessions + // resume`) resolves the workspace as its active site. (Seeding from an + // existing WordPress.com site via `siteId` is a later increment.) + const session = await createAiSession( root ); + const workspace = ensureWorkspace( session.id ); + await appendStudioEntry( root, session.id, 'studio.site_selected', { + siteName: workspace.name, + sitePath: workspace.path, + remote: false, + } ); + res.json( await loadAiSession( root, session.id ) ); } ) ); @@ -302,6 +324,13 @@ api.post( } ) ); +// The session workspace's files (path + base64 content). The browser overlays +// these onto a client-side WordPress Playground to render a live preview of what +// the agent built — no server-side site serving needed. +api.get( '/sessions/:id/site-files', ( req: Request, res: Response ) => { + res.json( getWorkspaceFiles( req.params.id ) ); +} ); + api.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { const { prompt, displayMessage } = req.body as { prompt?: string; displayMessage?: string }; if ( ! prompt ) { diff --git a/apps/cli/web-server/workspaces.ts b/apps/cli/web-server/workspaces.ts new file mode 100644 index 0000000000..60a3fa3145 --- /dev/null +++ b/apps/cli/web-server/workspaces.ts @@ -0,0 +1,165 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; + +/** + * Per-session workspace for Studio Web — the cloud analog of Studio App's local + * site layer, and the answer to "where does the agent build, and where does it + * persist". Each session gets its own git-backed working directory that the + * forked agent operates on as its active site. + * + * "git-backed" is deliberate: it's the project container Studio Web is intended + * to use (Matt B's git-as-project-container proposal) — `git status` is the + * change set and a later `git push` is the publish/deploy step. This increment + * only reads the workspace for the live preview; the draft/publish surface is a + * follow-up. + * + * (The workspace lives under STUDIO_SITES_ROOT so the agent's file tools — + * scoped to that root — can reach it without relocating HOME, which would move + * session storage too.) + */ + +const WORKSPACE_PREFIX = 'studio-web'; + +export interface Workspace { + name: string; + slug: string; + path: string; +} + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'Studio Web', + GIT_AUTHOR_EMAIL: 'studio-web@local', + GIT_COMMITTER_NAME: 'Studio Web', + GIT_COMMITTER_EMAIL: 'studio-web@local', +}; + +function git( cwd: string, args: string[] ): void { + execFileSync( 'git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } } ); +} + +function gitOut( cwd: string, args: string[] ): string { + return execFileSync( 'git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...GIT_ENV }, + } ).trim(); +} + +// Session ids come from HTTP requests. They're UUIDs, but validate before using +// one in a filesystem path — derive the slug from an allowlisted slice only. +function safeSessionToken( sessionId: string ): string { + if ( ! /^[a-zA-Z0-9-]+$/.test( sessionId ) ) { + throw new Error( `Invalid session id: ${ sessionId }` ); + } + // The slug only ever contains these chars — no path separators can survive. + return sessionId.replace( /[^a-zA-Z0-9]/g, '' ).slice( 0, 8 ); +} + +/** The workspace identity for a session (slug/name/path), without touching disk. */ +export function workspaceFor( sessionId: string ): Workspace { + const token = safeSessionToken( sessionId ); + const slug = `${ WORKSPACE_PREFIX }-${ token }`; + const workspacePath = path.join( STUDIO_SITES_ROOT, slug ); + // Containment check: even with the token allowlist above, confirm the + // resolved path stays inside STUDIO_SITES_ROOT before anything touches the + // filesystem with it — a crafted session id must not escape the sites root + // (defends against CodeQL js/path-injection). + const relative = path.relative( STUDIO_SITES_ROOT, workspacePath ); + if ( relative.startsWith( '..' ) || path.isAbsolute( relative ) ) { + throw new Error( `Workspace path escapes the sites root: ${ sessionId }` ); + } + return { name: `Studio Web (${ token })`, slug, path: workspacePath }; +} + +/** + * Ensure a git-backed workspace exists for `sessionId` and return it. Idempotent: + * an existing workspace is returned untouched so re-runs of a session reuse it. + */ +export function ensureWorkspace( sessionId: string ): Workspace { + const workspace = workspaceFor( sessionId ); + const workspacePath = workspace.path; + + if ( ! fs.existsSync( workspacePath ) ) { + fs.mkdirSync( workspacePath, { recursive: true } ); + } + if ( ! fs.existsSync( path.join( workspacePath, '.git' ) ) ) { + fs.writeFileSync( + path.join( workspacePath, '.gitignore' ), + // Track deployable code only, like a WordPress.com GitHub Deployments repo. + [ '/wp-content/uploads/', '/wp-content/database/', '**/*.sqlite', '.DS_Store', '' ].join( + '\n' + ) + ); + git( workspacePath, [ 'init', '-b', 'main' ] ); + git( workspacePath, [ 'add', '-A' ] ); + git( workspacePath, [ 'commit', '--allow-empty', '-m', 'studio-web: workspace baseline' ] ); + } + + return workspace; +} + +export interface WorkspaceFile { + /** Path relative to the workspace root, POSIX-separated (e.g. 'wp-content/themes/foo/style.css'). */ + path: string; + /** File contents, base64-encoded so binary assets survive the JSON round-trip. */ + contentBase64: string; +} + +// Guardrails so a runaway workspace can't produce an unbounded preview payload. +const MAX_PREVIEW_FILES = 2000; +const MAX_PREVIEW_FILE_BYTES = 10 * 1024 * 1024; // 10 MB per file (covers a small SQLite DB) + +// The SQLite database, when the agent built a real WP install in the workspace. +// It's gitignored (not deployable code), but the preview overlays it so the +// browser shows the agent's actual content, not an empty install. +const PREVIEW_DB_PATH = 'wp-content/database/.ht.sqlite'; + +function readWorkspaceFile( cwd: string, relPath: string ): WorkspaceFile | undefined { + const absPath = path.join( cwd, relPath ); + let stat: fs.Stats; + try { + stat = fs.statSync( absPath ); + } catch { + return undefined; // listed but gone (race with a concurrent agent edit) + } + if ( ! stat.isFile() || stat.size > MAX_PREVIEW_FILE_BYTES ) { + return undefined; + } + return { path: relPath, contentBase64: fs.readFileSync( absPath ).toString( 'base64' ) }; +} + +/** + * The workspace's files for the client-side Playground preview: the deployable + * code the agent wrote (tracked + untracked, .gitignore respected) plus the + * SQLite database if one exists, so the preview reflects real content. Empty for + * sessions whose workspace hasn't been created yet. Files over the per-file cap + * are skipped. + */ +export function getWorkspaceFiles( sessionId: string ): WorkspaceFile[] { + const { path: cwd } = workspaceFor( sessionId ); + if ( ! fs.existsSync( path.join( cwd, '.git' ) ) ) { + return []; + } + // `--cached --others --exclude-standard` = tracked + untracked, honoring + // .gitignore — the same deployable set publish will operate on. + const listing = gitOut( cwd, [ 'ls-files', '--cached', '--others', '--exclude-standard' ] ); + const relPaths = listing ? listing.split( '\n' ) : []; + // The DB is gitignored, so add it explicitly (preview needs it for content). + if ( fs.existsSync( path.join( cwd, PREVIEW_DB_PATH ) ) ) { + relPaths.push( PREVIEW_DB_PATH ); + } + + const files: WorkspaceFile[] = []; + for ( const relPath of relPaths ) { + if ( files.length >= MAX_PREVIEW_FILES ) { + break; + } + const file = readWorkspaceFile( cwd, relPath ); + if ( file ) { + files.push( file ); + } + } + return files; +} diff --git a/apps/ui/package.json b/apps/ui/package.json index 0896b6b00a..d9136935a1 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -35,6 +35,7 @@ "@wordpress/theme": "^0.14.0", "@wordpress/ui": "^0.14.0", "@wp-playground/blueprints": "3.1.38", + "@wp-playground/client": "3.1.38", "clsx": "^2.1.1", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/apps/ui/src/components/live-playground-preview/index.tsx b/apps/ui/src/components/live-playground-preview/index.tsx new file mode 100644 index 0000000000..b9f94968bd --- /dev/null +++ b/apps/ui/src/components/live-playground-preview/index.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef } from 'react'; +import type { SitePreviewFile } from '@/data/core'; +import type { PlaygroundClient } from '@wp-playground/client'; + +// The Playground worker is hosted remotely; the iframe loads it cross-origin. +const REMOTE_URL = 'https://playground.wordpress.net/remote.html'; + +const PREVIEW_DB_PATH = 'wp-content/database/.ht.sqlite'; + +function base64ToBytes( base64: string ): Uint8Array { + const binary = atob( base64 ); + const bytes = new Uint8Array( binary.length ); + for ( let i = 0; i < binary.length; i++ ) { + bytes[ i ] = binary.charCodeAt( i ); + } + return bytes; +} + +function posixDirname( filePath: string ): string { + const slash = filePath.lastIndexOf( '/' ); + return slash <= 0 ? '/' : filePath.slice( 0, slash ); +} + +// `mkdir` isn't guaranteed recursive, so create each parent segment in turn. +// Existing dirs throw, which we swallow. +async function ensureDir( client: PlaygroundClient, dir: string ): Promise< void > { + const parts = dir.split( '/' ).filter( Boolean ); + let current = ''; + for ( const part of parts ) { + current += `/${ part }`; + try { + await client.mkdir( current ); + } catch { + // Directory already exists. + } + } +} + +// Overlay the agent's files onto the booted WordPress install at `documentRoot`. +async function overlayFiles( + client: PlaygroundClient, + documentRoot: string, + files: SitePreviewFile[] +): Promise< void > { + for ( const file of files ) { + const absolutePath = `${ documentRoot }/${ file.path }`.replace( /\/+/g, '/' ); + await ensureDir( client, posixDirname( absolutePath ) ); + await client.writeFile( absolutePath, base64ToBytes( file.contentBase64 ) ); + } +} + +// If the agent produced exactly one theme, return its slug so we can activate it. +// Ambiguous cases (zero or many themes) keep the default theme. +function singleThemeSlug( files: SitePreviewFile[] ): string | undefined { + const slugs = new Set< string >(); + for ( const file of files ) { + const match = /^wp-content\/themes\/([^/]+)\//.exec( file.path ); + if ( match ) { + slugs.add( match[ 1 ] ); + } + } + return slugs.size === 1 ? [ ...slugs ][ 0 ] : undefined; +} + +// A cheap content signature of the preview files, used as a React `key` on the +// live preview. Playground caches its SQLite connection, so overlaying a changed +// DB in place + reloading does NOT reflect it — only a fresh boot reads the new +// DB. Keying the preview on this signature re-mounts it (and re-boots Playground) +// exactly when the files actually change, so each agent turn's edits show up. +export function livePreviewSignature( files: SitePreviewFile[] ): string { + let hash = 5381; + for ( const file of files ) { + const str = `${ file.path }:${ file.contentBase64 }`; + for ( let i = 0; i < str.length; i++ ) { + hash = ( ( hash << 5 ) + hash + str.charCodeAt( i ) ) | 0; + } + } + return `${ files.length }-${ hash }`; +} + +/** + * Renders a live, client-side WordPress Playground preview of what the agent + * built. WordPress runs entirely in the visitor's browser (PHP-WASM); the + * agent's workspace files are overlaid onto it. There's no server-side site + * serving — the preview runs on the visitor's CPU and updates as the agent edits. + * + * `files` is re-overlaid whenever its identity changes (the parent re-fetches + * after each agent turn via the `preview` signal), so the preview follows the + * agent's work without a full re-import of WordPress itself. + */ +export function LivePlaygroundPreview( { files }: { files: SitePreviewFile[] } ) { + const iframeRef = useRef< HTMLIFrameElement >( null ); + const clientRef = useRef< Promise< PlaygroundClient > | null >( null ); + + // Boot WordPress once when the iframe mounts. + useEffect( () => { + const iframe = iframeRef.current; + // Guard against React StrictMode's double-invoked effects (and any re-run): + // `startPlaygroundWeb` throws "Playground already booted" if the same iframe + // is booted twice. `clientRef.current` is already set (a pending or resolved + // boot) on the second invocation, so we skip it. + if ( ! iframe || clientRef.current ) { + return; + } + // Lazy-loaded: the Playground client is only pulled in when a preview is + // actually shown, keeping it out of the initial bundle. + clientRef.current = ( async () => { + const { startPlaygroundWeb } = await import( '@wp-playground/client' ); + const client = await startPlaygroundWeb( { + iframe, + remoteUrl: REMOTE_URL, + blueprint: { landingPage: '/', preferredVersions: { php: '8.3', wp: 'latest' } }, + } ); + await client.isReady(); + return client; + } )(); + }, [] ); + + // Overlay the agent's files (and reload) whenever they change. + useEffect( () => { + let cancelled = false; + void ( async () => { + const client = await clientRef.current; + if ( ! client || cancelled ) { + return; + } + const documentRoot = await client.documentRoot; + await overlayFiles( client, documentRoot, files ); + const hasDb = files.some( ( file ) => file.path === PREVIEW_DB_PATH ); + if ( hasDb ) { + // The overlaid SQLite DB carries the workspace site's real content, its + // active theme, and its options — including siteurl/home pointing at the + // machine that built it. Repoint those at the Playground origin so links + // and assets resolve; the DB already drives the active theme and content, + // so no switch_theme is needed. + const origin = ( await client.absoluteUrl ).replace( /\/$/, '' ); + try { + await client.run( { + code: + ` { + cancelled = true; + }; + }, [ files ] ); + + return ( +