diff --git a/chat/server.js b/chat/server.js index e487f08..560ec93 100644 --- a/chat/server.js +++ b/chat/server.js @@ -15,6 +15,7 @@ const jwt = require('jsonwebtoken'); const nodemailer = require('nodemailer'); const PDFDocument = require('pdfkit'); const security = require('./security'); +const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing'); let sharp = null; try { @@ -165,6 +166,7 @@ const CHUTES_API_URL = process.env.CHUTES_API_URL || 'https://api.chutes.ai/v1'; const PROVIDER_LIMITS_FILE = path.join(STATE_DIR, 'provider-limits.json'); const PROVIDER_USAGE_FILE = path.join(STATE_DIR, 'provider-usage.json'); const TOKEN_USAGE_FILE = path.join(STATE_DIR, 'token-usage.json'); +const EXTERNAL_TEST_USAGE_FILE = path.join(STATE_DIR, 'external-test-usage.json'); const TOPUP_SESSIONS_FILE = path.join(STATE_DIR, 'topup-sessions.json'); const TOPUP_PENDING_FILE = path.join(STATE_DIR, 'topup-pending.json'); const PAYG_SESSIONS_FILE = path.join(STATE_DIR, 'payg-sessions.json'); @@ -427,6 +429,12 @@ const PLAN_TOKEN_LIMITS = { professional: 5_000_000, enterprise: 20_000_000, }; +const EXTERNAL_TEST_LIMITS = { + hobby: 3, + starter: 50, + professional: Infinity, + enterprise: Infinity, +}; // Default token rates (price per 1M tokens in minor units/cents) const DEFAULT_TOKEN_RATES = { @@ -1544,6 +1552,9 @@ let providerUsage = {}; let tokenUsage = {}; let processedTopups = {}; let pendingTopups = {}; +let externalTestUsage = {}; + +const externalWpTester = createExternalWpTester({ logger: log }); let processedPayg = {}; let pendingPayg = {}; let processedSubscriptions = {}; @@ -4967,6 +4978,7 @@ async function serializeAccount(user) { uploadZipBytes: MAX_UPLOAD_ZIP_SIZE, }, tokenUsage: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN), + externalTestingUsage: getExternalTestUsageSummary(user.id, user.plan || DEFAULT_PLAN), paymentMethod, }; } @@ -5919,6 +5931,46 @@ function ensureTokenUsageBucket(userId) { return tokenUsage[key]; } +function ensureExternalTestUsageBucket(userId) { + const key = String(userId || ''); + if (!key) return null; + const month = currentMonthKey(); + if (!externalTestUsage[key] || externalTestUsage[key].month !== month) { + externalTestUsage[key] = { month, count: 0 }; + } else { + const entry = externalTestUsage[key]; + entry.count = typeof entry.count === 'number' ? entry.count : 0; + } + return externalTestUsage[key]; +} + +function getExternalTestUsageSummary(userId, plan) { + const bucket = ensureExternalTestUsageBucket(userId) || { count: 0, month: currentMonthKey() }; + const normalizedPlan = normalizePlanSelection(plan) || DEFAULT_PLAN; + const limit = EXTERNAL_TEST_LIMITS[normalizedPlan] ?? EXTERNAL_TEST_LIMITS[DEFAULT_PLAN]; + const used = Math.max(0, Number(bucket.count || 0)); + const remaining = Number.isFinite(limit) ? Math.max(0, limit - used) : 0; + const percent = Number.isFinite(limit) && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0; + return { + month: bucket.month, + plan: normalizedPlan, + used, + limit, + remaining, + percent, + }; +} + +function canUseExternalTesting(userId, plan, unlimited = false) { + if (unlimited) return { allowed: true, summary: getExternalTestUsageSummary(userId, plan) }; + const summary = getExternalTestUsageSummary(userId, plan); + if (!Number.isFinite(summary.limit)) return { allowed: true, summary }; + if (summary.used >= summary.limit) { + return { allowed: false, summary }; + } + return { allowed: true, summary }; +} + function normalizeTier(tier) { const normalized = (tier || '').toLowerCase(); return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free'; @@ -5943,6 +5995,20 @@ async function loadTokenUsage() { } } +async function loadExternalTestUsage() { + try { + await ensureStateFile(); + const raw = await fs.readFile(EXTERNAL_TEST_USAGE_FILE, 'utf8').catch(() => null); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') externalTestUsage = parsed; + } + } catch (error) { + log('Failed to load external test usage, starting empty', { error: String(error) }); + externalTestUsage = {}; + } +} + async function persistTokenUsage() { await ensureStateFile(); const payload = JSON.stringify(tokenUsage, null, 2); @@ -5953,6 +6019,16 @@ async function persistTokenUsage() { } } +async function persistExternalTestUsage() { + await ensureStateFile(); + const payload = JSON.stringify(externalTestUsage, null, 2); + try { + await safeWriteFile(EXTERNAL_TEST_USAGE_FILE, payload); + } catch (err) { + log('Failed to persist external test usage', { error: String(err) }); + } +} + async function loadTopupSessions() { try { await ensureStateFile(); @@ -6579,6 +6655,14 @@ function getTokenUsageSummary(userId, plan) { }; } +async function recordExternalTestUsage(userId) { + const bucket = ensureExternalTestUsageBucket(userId); + if (!bucket) return null; + bucket.count += 1; + await persistExternalTestUsage(); + return bucket; +} + function resolveUserCurrency(user) { const currency = String(user?.subscriptionCurrency || user?.billingCurrency || '').toLowerCase(); return SUPPORTED_CURRENCIES.includes(currency) ? currency : 'usd'; @@ -7353,6 +7437,45 @@ function decodeBase64Payload(raw) { return Buffer.from(base64, 'base64'); } +async function loadExternalTestingSpec(workspaceDir) { + if (!workspaceDir) return null; + const specPath = path.join(workspaceDir, 'external-wp-tests.json'); + try { + const raw = await fs.readFile(specPath, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch (_) { + return null; + } +} + +async function resolvePluginRoot(workspaceDir) { + if (!workspaceDir) return null; + const validFiles = []; + await collectValidFiles(workspaceDir, workspaceDir, validFiles, [ + 'node_modules', + '.git', + '.data', + 'uploads', + '*.zip', + '*.log' + ]); + + for (const fileInfo of validFiles) { + if (!fileInfo.fullPath.endsWith('.php')) continue; + try { + const content = await fs.readFile(fileInfo.fullPath, 'utf8'); + if (content.includes('Plugin Name:') && content.includes('Plugin URI:')) { + return path.dirname(fileInfo.fullPath); + } + } catch (_) { + // skip unreadable files + } + } + return workspaceDir; +} + // Basic ZIP signature check: PK\x03\x04 (local header) or PK\x05\x06 (empty archives) function isLikelyZip(buffer) { if (!buffer || buffer.length < 4) return false; @@ -10015,6 +10138,53 @@ async function queueMessage(sessionId, message) { sessionQueues.set(sessionId, next); return next; } + +function formatExternalTestingSummary(result) { + if (!result) return ''; + if (result.skipped) { + const limit = Number.isFinite(result.summary?.limit) ? result.summary.limit : 'unlimited'; + return `\n\n---\nExternal WP CLI Testing\nStatus: Skipped\nReason: ${result.reason || 'Not available'}\nUsage: ${result.summary?.used || 0} / ${limit}`; + } + if (!result.ok) { + const errorText = Array.isArray(result.errors) && result.errors.length + ? result.errors.join(' | ') + : 'Unknown error'; + return `\n\n---\nExternal WP CLI Testing\nStatus: Failed\nErrors: ${errorText}`; + } + const cli = result.test_results?.cli_tests || { passed: 0, failed: 0 }; + const usage = result.usageSummary; + const limit = Number.isFinite(usage?.limit) ? usage.limit : 'unlimited'; + return `\n\n---\nExternal WP CLI Testing\nStatus: ${cli.failed === 0 ? 'Passed' : 'Failed'}\nCLI Tests: ${cli.passed} passed, ${cli.failed} failed\nSubsite: ${result.subsite_url || 'n/a'}\nUsage: ${usage?.used || 0} / ${limit}`; +} + +async function runExternalTestingForSession(session, message, plan) { + if (!message?.externalTestingEnabled) return null; + if (!session?.workspaceDir) return { ok: false, errors: ['Workspace directory not available'] }; + + const user = findUserById(session.userId); + const limitCheck = canUseExternalTesting(session.userId, plan, user?.unlimitedUsage === true); + if (!limitCheck.allowed) { + return { skipped: true, reason: 'External testing limit reached. Upgrade to continue.', summary: limitCheck.summary }; + } + + const pluginRoot = await resolvePluginRoot(session.workspaceDir); + const spec = await loadExternalTestingSpec(session.workspaceDir); + const testInput = { + plugin_path: pluginRoot, + plugin_slug: session.pluginSlug, + test_mode: 'cli', + required_plugins: spec?.required_plugins || [], + test_scenarios: spec?.test_scenarios || [], + }; + + await recordExternalTestUsage(session.userId); + trackFeatureUsage('external_wp_testing', session.userId, plan); + + const result = await externalWpTester.runTest(testInput, { configOverrides: {} }); + result.usageSummary = getExternalTestUsageSummary(session.userId, plan); + return result; +} + async function processMessage(sessionId, message) { const session = getSession(sessionId); if (!session) return; @@ -10117,7 +10287,7 @@ async function processMessage(sessionId, message) { log('opencode session ensured (or pending)', { sessionId, opencodeSessionId, model: message.model, workspaceDir: session.workspaceDir }); const opencodeResult = await sendToOpencodeWithFallback({ session, model: message.model, content: message.content, message, cli: activeCli, opencodeSessionId, plan: sessionPlan }); - const reply = opencodeResult.reply; + let reply = opencodeResult.reply; if (opencodeResult.model) { message.model = opencodeResult.model; } @@ -10252,6 +10422,17 @@ async function processMessage(sessionId, message) { message.potentiallyIncomplete = true; } + if (message.isProceedWithBuild && message.externalTestingEnabled) { + try { + const testingResult = await runExternalTestingForSession(session, message, sessionPlan); + message.externalTesting = testingResult; + reply = `${reply || ''}${formatExternalTestingSummary(testingResult)}`.trim(); + } catch (testErr) { + message.externalTesting = { ok: false, errors: [testErr.message || String(testErr)] }; + reply = `${reply || ''}\n\n---\nExternal WP CLI Testing\nStatus: Failed\nErrors: ${testErr.message || String(testErr)}`.trim(); + } + } + message.status = 'done'; message.reply = reply; message.finishedAt = new Date().toISOString(); @@ -11031,10 +11212,11 @@ async function handleAccountUsage(req, res) { const user = findUserById(resolvedUserId); const plan = user?.plan || DEFAULT_PLAN; const summary = getTokenUsageSummary(resolvedUserId, plan); + const externalTesting = getExternalTestUsageSummary(resolvedUserId, plan); const payg = PAYG_ENABLED && isPaidPlan(plan) && !user?.unlimitedUsage ? computePaygSummary(resolvedUserId, plan) : null; - return sendJson(res, 200, { ok: true, summary, payg, legacy: !authed }); + return sendJson(res, 200, { ok: true, summary: { ...summary, externalTesting }, payg, legacy: !authed }); } async function handleAccountPlans(_req, res) { @@ -14819,6 +15001,72 @@ async function handleAdminResources(req, res) { } } +async function handleAdminExternalTestingStatus(req, res) { + const adminSession = requireAdminAuth(req, res); + if (!adminSession) return; + const config = getExternalTestingConfig(); + sendJson(res, 200, { + ok: true, + config: { + wpHost: config.wpHost || '', + wpPath: config.wpPath || '', + wpBaseUrl: config.wpBaseUrl || '', + enableMultisite: !!config.enableMultisite, + subsiteMode: config.subsiteMode, + subsiteDomain: config.subsiteDomain || '', + maxConcurrentTests: config.maxConcurrentTests, + autoCleanup: !!config.autoCleanup, + cleanupDelayMs: config.cleanupDelayMs, + sshKeyConfigured: Boolean(config.wpSshKey), + } + }); +} + +async function handleAdminExternalTestingSelfTest(req, res) { + const adminSession = requireAdminAuth(req, res); + if (!adminSession) return; + + let tempDir; + try { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pc-external-test-')); + const pluginSlug = 'pc-external-test'; + const pluginDir = path.join(tempDir, pluginSlug); + await fs.mkdir(pluginDir, { recursive: true }); + const pluginFile = path.join(pluginDir, `${pluginSlug}.php`); + const pluginContents = ` {}); + } + } +} + async function handleAdminOpenRouterSettingsPost(req, res) { const session = requireAdminAuth(req, res); if (!session) return; @@ -14975,6 +15223,10 @@ async function handleNewMessage(req, res, sessionId, userId) { const cli = normalizeCli(body.cli || session.cli); const now = new Date().toISOString(); const message = { id: randomUUID(), role: 'user', content, displayContent, model, cli, status: 'queued', createdAt: now, updatedAt: now, opencodeTokensUsed: null }; + if (body.isProceedWithBuild) message.isProceedWithBuild = true; + if (body.externalTestingEnabled !== undefined) { + message.externalTestingEnabled = body.externalTestingEnabled === true; + } // Copy continuation-related fields for background continuations if (body.isContinuation) message.isContinuation = true; if (body.isBackgroundContinuation) message.isBackgroundContinuation = true; @@ -15086,6 +15338,10 @@ async function handleMessageStream(req, res, sessionId, messageId, userId) { log('SSE stream opened', { sessionId, messageId, activeStreams: activeStreams.size }); + // Declare timers before cleanupStream to avoid hoisting issues + let heartbeat = null; + let streamTimeout = null; + // Helper to cleanup this specific stream const cleanupStream = () => { clearInterval(heartbeat); @@ -15147,7 +15403,7 @@ async function handleMessageStream(req, res, sessionId, messageId, userId) { // Stream timeout - close streams that have been open too long (30 minutes max) const STREAM_MAX_DURATION_MS = 30 * 60 * 1000; - const streamTimeout = setTimeout(() => { + streamTimeout = setTimeout(() => { try { const timeoutData = JSON.stringify({ type: 'timeout', @@ -15164,7 +15420,7 @@ async function handleMessageStream(req, res, sessionId, messageId, userId) { // Keep connection alive with heartbeat/pings. // Send a small data event periodically so proxies/load balancers don't treat the stream as idle. let heartbeatCount = 0; - const heartbeat = setInterval(() => { + heartbeat = setInterval(() => { try { heartbeatCount++; @@ -16603,6 +16859,8 @@ async function routeInternal(req, res, url, pathname) { if (req.method === 'PUT' && pathname === '/api/admin/withdrawals') return handleAdminWithdrawalUpdate(req, res); if (req.method === 'GET' && pathname === '/api/admin/tracking') return handleAdminTrackingStats(req, res); if (req.method === 'GET' && pathname === '/api/admin/resources') return handleAdminResources(req, res); + if (req.method === 'GET' && pathname === '/api/admin/external-testing-status') return handleAdminExternalTestingStatus(req, res); + if (req.method === 'POST' && pathname === '/api/admin/external-testing-self-test') return handleAdminExternalTestingSelfTest(req, res); if (req.method === 'POST' && pathname === '/api/upgrade-popup-tracking') return handleUpgradePopupTracking(req, res); if (req.method === 'POST' && pathname === '/api/admin/cancel-messages') return handleAdminCancelMessages(req, res); const adminDeleteMatch = pathname.match(/^\/api\/admin\/models\/([a-f0-9\-]+)$/i); @@ -16797,6 +17055,11 @@ async function routeInternal(req, res, url, pathname) { if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html'); return serveFile(res, safeStaticPath('admin-resources.html'), 'text/html'); } + if (pathname === '/admin/external-testing') { + const session = getAdminSession(req); + if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html'); + return serveFile(res, safeStaticPath('admin-external-testing.html'), 'text/html'); + } if (pathname === '/admin/contact-messages') { const session = getAdminSession(req); if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html'); diff --git a/opencode/packages/opencode/test/tool/fixtures/models-api.json b/opencode/packages/opencode/test/tool/fixtures/models-api.json index 1983692..3dd7487 100644 --- a/opencode/packages/opencode/test/tool/fixtures/models-api.json +++ b/opencode/packages/opencode/test/tool/fixtures/models-api.json @@ -27325,7 +27325,7 @@ "open_weights": false, "cost": { "input": 0, "output": 0 }, "limit": { "context": 200000, "output": 131000 }, - "status": "alpha" + "status": "beta" }, "z-ai/glm-4.7": { "id": "z-ai/glm-4.7",