@@ -805,9 +1091,17 @@ export function Admin() {
{(() => {
const stat = statForPoolRow(row);
- return stat
+ const health = healthForPoolRow(row);
+ const stats = stat
? t('admin.pool.stats', { projects: stat.projectCount, active: stat.activeProjectCount ?? Math.max(0, stat.projectCount - stat.archivedCount), archived: stat.archivedCount, archives: stat.readyArchiveCount })
: t('admin.pool.stats.empty');
+ const healthLabel = poolHealthLabel(health, t);
+ return (
+ <>
+ {stats}
+ {healthLabel}
+ >
+ );
})()}
void retirePoolRow(row)}>
diff --git a/frontend/src/builder_components.tsx b/frontend/src/builder_components.tsx
index 3f7ab41..8b02093 100644
--- a/frontend/src/builder_components.tsx
+++ b/frontend/src/builder_components.tsx
@@ -524,7 +524,7 @@ export function EmptyCanvas({ title, body }: { title?: string; body?: string } =
);
}
-export function CanvasLoader({ title, body, tone }: { title: string; body: string; tone?: 'error' }) {
+export function CanvasLoader({ title, body, tone }: { title: string; body?: string; tone?: 'error' }) {
return (
@@ -536,7 +536,7 @@ export function CanvasLoader({ title, body, tone }: { title: string; body: strin
{title}
-
{body}
+ {body &&
{body}
}
);
@@ -545,6 +545,21 @@ export function CanvasLoader({ title, body, tone }: { title: string; body: strin
function CanvasFrameDecor({ loading }: { loading?: boolean } = {}) {
return (
<>
+ {loading && (
+
+ )}
diff --git a/frontend/src/config.ts b/frontend/src/config.ts
index a1b8ada..c07a7cb 100644
--- a/frontend/src/config.ts
+++ b/frontend/src/config.ts
@@ -30,6 +30,6 @@ export const ADMIN_CONFIG_SECTIONS = [
{
titleKey: 'admin.config.application.title',
bodyKey: 'admin.config.application.body',
- keys: ['fibe_template_version_id', 'free_hours', 'free_hour_window_hours', 'prompt_improve_charge_minutes', 'project_cap', 'agent_artefacts', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_tls_mode']
+ keys: ['fibe_template_version_id', 'free_minutes', 'free_hour_window_hours', 'playground_idle_stop_hours', 'prompt_improve_charge_minutes', 'project_cap', 'project_quota_days', 'agent_artefacts', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_tls_mode']
}
];
diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts
index 2ad08be..2442f07 100644
--- a/frontend/src/domain.ts
+++ b/frontend/src/domain.ts
@@ -4,7 +4,7 @@ export type ProjectService = { id: string; name: string; url: string; type?: str
export type Project = { id: string; title: string; previewUrl?: string; selectedServiceName?: string; repositories?: ProjectRepository[]; services?: ProjectService[]; status: string; errorMessage?: string; playgroundLastUsedAt?: string; playgroundIdleStopAt?: string; createdAt: string; updatedAt: string };
export type HourQuota = { usedMs: number; limitMs: number; remainingMs: number; paidRemainingMs?: number; lifetimeUsedMs?: number; resetsAt?: string; windowHours?: number };
export type ProjectQuota = { used: number; limit: number; remaining: number; baseLimit: number; paidSlots: number; nextExpiresAt?: string };
-export type BillingProducts = { hourPacks: number[]; projectQuota: boolean };
+export type BillingProducts = { hourPacks: number[]; projectQuota: boolean; projectQuotaDays?: number };
export type UserNotice = { id: string; userId?: string; sender: 'admin' | 'system' | 'user'; severity: string; body: string; readAt?: string; dismissedAt?: string; unsentAt?: string; createdAt: string };
export type ProjectArchive = { id: string; projectId: string; projectTitle: string; downloadUrl?: string; githubRepoUrl?: string; status: string; error?: string; expiresAt: string; createdAt: string };
export type Me = { user: User | null; isAdmin?: boolean; githubConnected?: boolean; githubNeedsReconnect?: boolean; hourQuota?: HourQuota; projectQuota?: ProjectQuota; billingProducts?: BillingProducts; notices?: UserNotice[]; auth?: { googleConfigured: boolean; devAuth: boolean; devEmail?: string } };
@@ -29,7 +29,8 @@ export type AdminConfigEntry = { value: string; secret: boolean; set: boolean };
export type AgentPoolStatus = 'active' | 'draining' | 'retiring' | 'retired';
export type AgentAssignmentSummary = { agentId: string; serverId: string; status?: AgentPoolStatus | string; projectCount?: number };
export type AgentPoolOption = { label?: string; agentId: string; serverId: string; status: AgentPoolStatus | string; capacity?: number };
-export type AdminConfigResponse = { config: Record; adminEmail: string; agentPoolStats?: AgentPoolStat[]; agentPool?: AgentPoolOption[] };
+export type AgentPoolHealth = { label?: string; agentId: string; serverId: string; status: AgentPoolStatus | string; agentStatus?: string; agentAuthenticated?: boolean; serverStatus?: string; serverBillingRuntimeActive?: boolean; serverChatLaunchable?: boolean; ok: boolean; problems?: string[] };
+export type AdminConfigResponse = { config: Record; adminEmail: string; agentPoolStats?: AgentPoolStat[]; agentPool?: AgentPoolOption[]; agentPoolHealth?: AgentPoolHealth[] };
export type AgentPoolStat = { agentId: string; serverId: string; projectCount: number; activeProjectCount?: number; archivedCount: number; readyArchiveCount: number };
export type PoolRow = { id: string; label: string; agentId: string; serverId: string; status: AgentPoolStatus; capacity: string };
export type AdminRecoveryProject = { id: string; userId: string; title: string; status: string; cleanupLastError?: string; playgroundId?: string; playspecId?: string; propId?: string; updatedAt: string };
@@ -64,6 +65,25 @@ export type AdminUserSummary = {
export type AdminProjectSummary = { project: Project; workMs: number; assignment?: AgentAssignmentSummary };
export type AdminUserDetail = { summary: AdminUserSummary; projects: AdminProjectSummary[]; notices: UserNotice[]; agentPool?: AgentPoolOption[] };
export type AdminUsersResponse = { users: AdminUserSummary[]; agentPool?: AgentPoolOption[]; pagination: { page: number; perPage: number; total: number } };
+export type AdminBillingPayment = { id: string; userId: string; userEmail: string; providerPaymentId: string; amountCents: number; currency: string; status: string; createdAt: string };
+export type AdminProjectInternal = { userId: string; conversationId?: string; agentId?: string; serverId?: string; playgroundId?: string; playgroundName?: string; playspecId?: string; propId?: string; repoUrl?: string; provisioningLockUntil?: string; internalErrorMessage?: string; cleanupLastError?: string };
+export type AdminProjectWorkSession = { projectId: string; userId: string; sessionKey: string; startedAt: string; completedAt?: string; elapsedMs: number; freeBilledMs: number; paidBilledMs: number; billedAt?: string; createdAt: string; updatedAt: string };
+export type AdminHourCreditLedgerEntry = { id: string; userId: string; deltaMs: number; reason: string; paymentId?: string; workSessionKey?: string; createdAt: string };
+export type AdminProjectDiagnostics = { project: Project; internal: AdminProjectInternal; workSessions: AdminProjectWorkSession[]; hourLedger: AdminHourCreditLedgerEntry[]; payments: AdminBillingPayment[] };
+export type AdminProjectDiagnosticsResponse = { diagnostics: AdminProjectDiagnostics };
+export type AdminBillingHealth = {
+ checkedAt: string;
+ configured: { publishableKey: boolean; secretKey: boolean; webhookSecret: boolean };
+ products: BillingProducts;
+ prices: { oneHour: boolean; tenHours: boolean; hundredHours: boolean; projectQuota: boolean };
+ free: { minutes: number; windowHours: number };
+ issues: string[];
+ recentPayments: AdminBillingPayment[];
+};
+export type AdminBillingHealthResponse = { health: AdminBillingHealth };
+export type AdminReadinessCheck = { key: string; ok: boolean; severity: 'blocker' | 'warning' | string; detail?: string };
+export type AdminReadiness = { checkedAt: string; ready: boolean; blockerCount: number; warningCount: number; checks: AdminReadinessCheck[] };
+export type AdminReadinessResponse = { readiness: AdminReadiness };
export type AppDialogConfig = {
title: string;
body: string;
diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts
index 32d5956..6002f68 100644
--- a/frontend/src/i18n/translations.ts
+++ b/frontend/src/i18n/translations.ts
@@ -109,8 +109,8 @@ const en = {
'builder.stopAgent': 'Stop agent',
'builder.busy.queue': 'Queue new messages',
'builder.busy.steer': 'Steer this run',
- 'builder.hours.left': 'Build hours left',
- 'builder.hourQuota.tooltip': '{paid} paid hours · resets in {reset}',
+ 'builder.hours.left': 'Build time left',
+ 'builder.hourQuota.tooltip': '{paid} paid time · resets in {reset}',
'builder.profile.tooltip': 'Profile',
'builder.admin.tooltip': 'Admin',
'builder.chat.collapse': 'Collapse chat',
@@ -136,11 +136,13 @@ Describe the app you want in the composer. Fibe builds the first playground in a
- A live playground to open and use, with the frontend and any backing services.
- Source code, downloadable as a ZIP or pushed to a private GitHub repository.
- Conversation history — the playground keeps evolving as the chat continues.
+- Clear export paths when an experiment is ready to become a real Fibe project.
## Limits and lifecycle
-- Free build hours reset on a window. Paid hours roll over and are used after free time.
+- Free build time resets on a window. Paid time rolls over and is used after free time.
- Playgrounds auto-pause when inactive. Start them again from the menu.
+- Likeable is for experiments and development. It does not sell production hosting or custom domains; continue the project in Fibe when you need an always-on app, a custom domain, or production operations.
- Archived playgrounds stay downloadable for a while after archiving.
- Delete a playground or the whole account from Profile — everything goes with it.
@@ -159,7 +161,7 @@ Likeable lets you build and run application playgrounds from prompts. By signing
**Playgrounds and secrets.** Playgrounds may run generated code, install dependencies, call external services, and store files needed for the app. Do not include secrets, credentials, regulated data, or private third-party data unless you intend that data to be processed in the playground and by the build agent.
-**Billing.** Free and paid build hours, project slots, and reset windows are shown in the app. Payments are processed by Stripe. Paid features are delivered according to the limits shown at purchase; future prices and limits may change prospectively.
+**Billing.** Free and paid build time, playground slots, and reset windows are shown in the app. Payments are processed by Stripe. Likeable billing is limited to development playground capacity; production hosting, always-on runtimes, and custom domains are handled in Fibe. Paid features are delivered according to the limits shown at purchase; future prices and limits may change prospectively.
**Service availability.** Likeable is provided on a best-effort basis. Playgrounds may be queued, paused, restarted, archived, deleted, or temporarily unavailable for maintenance, security, capacity, or abuse-prevention reasons. Uninterrupted service is not guaranteed.
@@ -183,7 +185,7 @@ Last updated: May 25, 2026
- Account info from Google when you sign in (name, email, profile picture).
- Prompts, chat messages, attached files, generated source code, playground state, preview URLs, export history, and support messages.
- OAuth connection metadata for services you connect, such as GitHub export. Access tokens are stored only as needed to provide the connected feature.
-- Billing events when you buy build hours or slots. Stripe handles payment details; Likeable does not store full card numbers.
+- Billing events when you buy build time or slots. Stripe handles payment details; Likeable does not store full card numbers.
- Usage, device, and security data such as IP address, browser, timestamps, request logs, quota usage, errors, and abuse-prevention signals.
**How we use it**
@@ -264,12 +266,13 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'builder.composerHint.agentQueue': 'Agent running · message will queue',
'builder.composerHint.agentSteer': 'Agent running · message will steer',
'builder.composerHint.improvingPrompt': 'Improving prompt...',
+ 'builder.composerHint.wakingToSend': 'Starting playground · message will send',
'builder.composerHint.signIn': 'Sign in to send prompts',
'builder.composerHint.loadingProjects': 'Loading playgrounds',
'builder.composerHint.noProject': 'Create a playground first',
'builder.composerHint.starting': 'Workspace is provisioning',
'builder.composerHint.error': 'Resolve workspace error first',
- 'builder.composerHint.stopped': 'Start playground to send',
+ 'builder.composerHint.stopped': 'Send to start playground',
'builder.composerHint.archived': 'Archived playground',
'builder.preview.startingTitle': 'Starting playground',
'builder.preview.preparingTitle': 'Preparing playground',
@@ -309,8 +312,8 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'onboarding.slide.modesBody': 'Use the mode button for split or basic view. Collapse chat when you only need the canvas.',
'onboarding.slide.modesBulletOne': 'Basic overlays chat on the playground and can shrink to a small bubble.',
'onboarding.slide.modesBulletTwo': 'Split keeps chat and preview side by side on wide screens.',
- 'onboarding.slide.messagesTitle': 'Free build hours',
- 'onboarding.slide.messagesBody': 'Free build hours reset on a window. Paid hours are used after free time.',
+ 'onboarding.slide.messagesTitle': 'Free build time',
+ 'onboarding.slide.messagesBody': 'Free build time resets on a window. Paid time is used after free time.',
'onboarding.slide.messagesBulletOne': 'The composer badge shows remaining free build time.',
'onboarding.slide.messagesBulletTwo': 'Profile shows resets, lifetime use, and hour packs.',
'onboarding.slide.messagesBulletThree': 'Improve Prompt runs a separate improvement pass, so it can take a moment and may use build minutes when charging is enabled.',
@@ -443,18 +446,22 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'profile.tutorialTitle': 'Open onboarding',
'profile.tutorialBody': 'Revisit the compact guide for prompts, build minutes, exports, and lifecycle.',
'profile.tutorialOpen': 'Open',
- 'profile.hours': 'Build hours',
- 'profile.freeQuota': 'Free build-hour quota',
- 'profile.freeInWindow': '{remaining}/{limit} free in {hours}h',
+ 'profile.scope': 'Next step',
+ 'profile.scopeTitle': 'Likeable is for experiments',
+ 'profile.scopeBody': 'Buy only build time and playground slots here. For production hosting, always-on runtimes, custom domains, and operations, continue in Fibe.',
+ 'profile.openFibe': 'Open Fibe',
+ 'profile.hours': 'Build time',
+ 'profile.freeQuota': 'Free build-time quota',
+ 'profile.freeInWindow': '{remaining}/{limit} free per {hours}h window',
'profile.quotaReadout.limit': 'of {limit}',
- 'profile.quotaDetail': '{paid} paid hours · resets in {reset} · {lifetime} lifetime',
+ 'profile.quotaDetail': '{paid} paid time · resets in {reset} · {lifetime} lifetime',
'profile.paidPacksUnavailable': 'Paid packs off',
'profile.projects': 'Playgrounds',
'profile.projectQuota': 'Playground quota',
'profile.projectSlots': '{used}/{limit} playground slots',
'profile.projectSlotsAvailable': '{remaining} slots available',
'profile.projectSlotsFull': 'Project limit reached',
- 'profile.projectSlotDetail': '{paid} paid monthly slots{reset}',
+ 'profile.projectSlotDetail': '{paid} paid slots · new slots last {days}d{reset}',
'profile.projectSlotFullDetail': 'Delete or export an older playground before creating another.',
'profile.addSlot': '+1 slot',
'profile.projectSlotsUnavailable': 'Slot packs off',
@@ -478,6 +485,53 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'admin.search.placeholder': 'email, name, or user id',
'admin.access': 'Access',
'admin.billing': 'Billing',
+ 'admin.billingHealth.title': 'Billing health',
+ 'admin.billingHealth.body': 'Stripe configuration, available products, free allowance, and recent payment ledger entries.',
+ 'admin.billingHealth.refresh': 'Refresh',
+ 'admin.billingHealth.loadFailed': 'Could not load billing health',
+ 'admin.billingHealth.stripe': 'Stripe keys',
+ 'admin.billingHealth.products': 'Products',
+ 'admin.billingHealth.freeWindow': 'Free window',
+ 'admin.billingHealth.payments': 'Payments',
+ 'admin.billingHealth.hourPacks': 'hour packs: {packs}',
+ 'admin.billingHealth.noHourPacks': 'no hour packs',
+ 'admin.billingHealth.projectQuotaOn': 'project slots on',
+ 'admin.billingHealth.projectQuotaOff': 'project slots off',
+ 'admin.billingHealth.noIssues': 'Billing configuration has no obvious launch blockers.',
+ 'admin.billingHealth.issue': 'Issue',
+ 'admin.billingHealth.issue.publishable': 'Stripe publishable key is missing.',
+ 'admin.billingHealth.issue.secret': 'Stripe secret key is missing; checkout products stay disabled.',
+ 'admin.billingHealth.issue.webhook': 'Stripe webhook secret is missing; async payment delivery cannot be verified.',
+ 'admin.billingHealth.issue.hourPrices': 'No hour-pack Stripe price IDs are configured.',
+ 'admin.billingHealth.issue.projectQuota': 'Project-slot Stripe price ID is missing.',
+ 'admin.billingHealth.issue.unknown': 'Unknown billing issue: {issue}',
+ 'admin.billingHealth.recentPayments': 'Recent payments',
+ 'admin.billingHealth.checkedAt': 'Checked at {time}',
+ 'admin.billingHealth.noPayments': 'No payment records yet.',
+ 'admin.readiness.title': 'Launch readiness',
+ 'admin.readiness.body': 'Launch blockers across Stripe, Fibe playgrounds, OAuth, support mail, and access policy.',
+ 'admin.readiness.refresh': 'Refresh',
+ 'admin.readiness.loadFailed': 'Could not load launch readiness',
+ 'admin.readiness.status': 'Status',
+ 'admin.readiness.ready': 'ready',
+ 'admin.readiness.blocked': 'blocked',
+ 'admin.readiness.blockers': 'Blockers',
+ 'admin.readiness.warnings': 'Warnings',
+ 'admin.readiness.checkedAt': 'Checked at',
+ 'admin.readiness.noIssues': 'Launch checks are clear.',
+ 'admin.readiness.blocker': 'Launch blocker',
+ 'admin.readiness.warning': 'Needs attention',
+ 'admin.readiness.check.stripeSecret': 'Stripe secret key',
+ 'admin.readiness.check.stripeWebhook': 'Stripe webhook secret',
+ 'admin.readiness.check.hourPrices': 'Stripe hour-pack price',
+ 'admin.readiness.check.projectQuotaPrice': 'Stripe project-slot price',
+ 'admin.readiness.check.fibeTemplate': 'Fibe greenfield template',
+ 'admin.readiness.check.activePool': 'Active Fibe pool',
+ 'admin.readiness.check.activePoolHealth': 'Healthy active Fibe pool',
+ 'admin.readiness.check.googleOauth': 'Google OAuth',
+ 'admin.readiness.check.smtp': 'Support email delivery',
+ 'admin.readiness.check.signup': 'Signup policy',
+ 'admin.readiness.check.unknown': 'Unknown readiness check: {key}',
'admin.sort': 'Sort',
'admin.sort.newest': 'newest',
'admin.sort.hours': 'hours',
@@ -530,6 +584,12 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'admin.assignment': 'Agent pair',
'admin.assignment.none': 'No pair set',
'admin.assignment.failed': 'Could not update pair',
+ 'admin.diagnostics.show': 'Diagnostics',
+ 'admin.diagnostics.hide': 'Hide diagnostics',
+ 'admin.diagnostics.failed': 'Could not load project diagnostics',
+ 'admin.diagnostics.workSessions': 'Work sessions',
+ 'admin.diagnostics.hourLedger': 'Hour ledger',
+ 'admin.diagnostics.empty': 'No records.',
'admin.noActiveProjects': 'No active playgrounds.',
'admin.deleteProject.aria': 'Delete {title}',
'admin.loadUsersFailed': 'Could not load users',
@@ -574,6 +634,10 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'admin.pool.status.retired': 'Retired',
'admin.pool.stats': '{active}/{projects} active · {archived} archived · {archives} ZIPs',
'admin.pool.stats.empty': '0 playgrounds',
+ 'admin.pool.health.ok': 'Fibe OK',
+ 'admin.pool.health.warning': 'Fibe needs attention',
+ 'admin.pool.health.unknown': 'Fibe not checked',
+ 'admin.pool.health.problem': 'Fibe: {problem}',
'admin.pool.retire': 'Archive',
'admin.pool.retired': 'Pair archived',
'admin.pool.agentPlaceholder': 'agent_...',
@@ -615,10 +679,12 @@ Likeable uses reasonable technical and organizational safeguards for account, pr
'admin.config.stripe_price_id_100_hours': '100 hours price ID',
'admin.config.stripe_project_quota_price_id': 'Playground quota price ID',
'admin.config.stripe_webhook_secret': 'Webhook secret',
- 'admin.config.free_hours': 'Free hours per window',
- 'admin.config.free_hour_window_hours': 'Free-hour window hours',
+ 'admin.config.free_minutes': 'Free minutes per window',
+ 'admin.config.free_hour_window_hours': 'Free window hours',
+ 'admin.config.playground_idle_stop_hours': 'Playground idle stop hours',
'admin.config.prompt_improve_charge_minutes': 'Improve prompt charge minutes',
'admin.config.project_cap': 'Base playground cap',
+ 'admin.config.project_quota_days': 'Paid playground slot days',
'admin.config.agent_artefacts': 'Agent artefacts JSON'
} as const;
@@ -733,8 +799,8 @@ const uk: Record = {
'builder.stopAgent': 'Зупинити агента',
'builder.busy.queue': 'Додати в чергу',
'builder.busy.steer': 'Скоригувати запуск',
- 'builder.hours.left': 'Доступні години збірки',
- 'builder.hourQuota.tooltip': '{paid} платних годин · оновлення через {reset}',
+ 'builder.hours.left': 'Доступний час збірки',
+ 'builder.hourQuota.tooltip': '{paid} платного часу · оновлення через {reset}',
'builder.profile.tooltip': 'Профіль',
'builder.admin.tooltip': 'Адмін',
'builder.chat.collapse': 'Згорнути чат',
@@ -760,11 +826,13 @@ const uk: Record = {
- Живий майданчик, доступний для роботи, з фронтендом і всіма потрібними сервісами.
- Вихідний код — для завантаження як ZIP або відправки в приватний репозиторій GitHub.
- Історія розмови — майданчик продовжує розвиватися, поки триває діалог.
+- Зрозумілий шлях експорту, коли експеримент готовий стати повноцінним Fibe-проєктом.
## Ліміти та життєвий цикл
-- Безплатні години збірки оновлюються кожне вікно. Платні години переходять на наступний період і використовуються після безплатного часу.
+- Безплатний час збірки оновлюється кожне вікно. Платний час переходить на наступний період і використовується після безплатного часу.
- Майданчики автоматично призупиняються в стані спокою. Запустити знову — з меню.
+- Likeable призначений для експериментів і розробки. Він не продає production hosting або кастомні домени; для always-on застосунку, кастомного домену й production-операцій продовжуйте проєкт у Fibe.
- Архівовані майданчики можна завантажити обмежений час після архівування.
- Видалення майданчика чи всього акаунта — у Профілі. Усе зникає разом.
@@ -783,7 +851,7 @@ Likeable дає змогу створювати й запускати майда
**Майданчики та секрети.** Майданчики можуть запускати згенерований код, встановлювати залежності, звертатися до зовнішніх сервісів і зберігати файли, потрібні застосунку. Не додавайте секрети, облікові дані, регульовані дані або приватні дані третіх осіб, якщо ви не хочете, щоб ці дані оброблялися в майданчику та агентом збірки.
-**Білінг.** Безплатні й платні години збірки, слоти проєктів і вікна оновлення показані в застосунку. Платежі обробляє Stripe. Платні функції надаються в межах, показаних під час купівлі; майбутні ціни й ліміти можуть змінюватися для наступних покупок.
+**Білінг.** Безплатний і платний час збірки, місця майданчиків і вікна оновлення показані в застосунку. Платежі обробляє Stripe. Білінг Likeable обмежений capacity для розробки в майданчиках; production hosting, always-on runtime і кастомні домени обробляються у Fibe. Платні функції надаються в межах, показаних під час купівлі; майбутні ціни й ліміти можуть змінюватися для наступних покупок.
**Доступність сервісу.** Likeable надається за принципом найкращих зусиль. Майданчики можуть ставати в чергу, призупинятися, перезапускатися, архівуватися, видалятися або бути тимчасово недоступні через обслуговування, безпеку, навантаження чи запобігання зловживанням. Безперервна робота не гарантується.
@@ -888,12 +956,13 @@ Likeable застосовує розумні технічні та органі
'builder.composerHint.agentQueue': 'Агент працює · повідомлення стане в чергу',
'builder.composerHint.agentSteer': 'Агент працює · повідомлення скерує хід',
'builder.composerHint.improvingPrompt': 'Покращення запиту...',
+ 'builder.composerHint.wakingToSend': 'Запуск майданчика · повідомлення буде надіслано',
'builder.composerHint.signIn': 'Увійдіть, щоб надсилати запити',
'builder.composerHint.loadingProjects': 'Завантаження майданчиків',
'builder.composerHint.noProject': 'Спочатку створіть майданчик',
'builder.composerHint.starting': 'Provisioning робочого простору',
'builder.composerHint.error': 'Спочатку усуньте помилку workspace',
- 'builder.composerHint.stopped': 'Запустіть майданчик, щоб надсилати',
+ 'builder.composerHint.stopped': 'Надішліть, щоб запустити майданчик',
'builder.composerHint.archived': 'Майданчик архівовано',
'builder.preview.startingTitle': 'Запуск майданчика',
'builder.preview.preparingTitle': 'Підготовка майданчика',
@@ -933,8 +1002,8 @@ Likeable застосовує розумні технічні та органі
'onboarding.slide.modesBody': 'Кнопка режиму перемикає split або basic. Згортайте чат, коли потрібне лише полотно.',
'onboarding.slide.modesBulletOne': 'Basic накладає чат на майданчик і може згортатися в маленьку кнопку.',
'onboarding.slide.modesBulletTwo': 'Split тримає чат і превʼю поруч на широких екранах.',
- 'onboarding.slide.messagesTitle': 'Безплатні години збірки',
- 'onboarding.slide.messagesBody': 'Безплатні години збірки оновлюються за вікном. Платні години йдуть після безплатного часу.',
+ 'onboarding.slide.messagesTitle': 'Безплатний час збірки',
+ 'onboarding.slide.messagesBody': 'Безплатний час збірки оновлюється за вікном. Платний час іде після безплатного часу.',
'onboarding.slide.messagesBulletOne': 'Бейдж у полі вводу показує залишок безплатного часу.',
'onboarding.slide.messagesBulletTwo': 'Профіль показує оновлення, загальне використання й пакети годин.',
'onboarding.slide.messagesBulletThree': 'Improve Prompt запускає окреме покращення, тому може зайняти трохи часу й використовувати хвилини збірки, якщо списання увімкнене.',
@@ -1067,18 +1136,22 @@ Likeable застосовує розумні технічні та органі
'profile.tutorialTitle': 'Відкрити онбординг',
'profile.tutorialBody': 'Короткий гайд щодо запитів, хвилин збірки, експорту й життєвого циклу.',
'profile.tutorialOpen': 'Відкрити',
- 'profile.hours': 'Години збірки',
- 'profile.freeQuota': 'Безплатний ліміт годин збірки',
- 'profile.freeInWindow': '{remaining}/{limit} безплатно за {hours} год',
+ 'profile.scope': 'Наступний крок',
+ 'profile.scopeTitle': 'Likeable для експериментів',
+ 'profile.scopeBody': 'Тут купуються лише час збірки та місця майданчиків. Для production hosting, always-on runtime, кастомних доменів і операцій продовжуйте у Fibe.',
+ 'profile.openFibe': 'Відкрити Fibe',
+ 'profile.hours': 'Час збірки',
+ 'profile.freeQuota': 'Безплатний ліміт часу збірки',
+ 'profile.freeInWindow': '{remaining}/{limit} безплатно за вікно {hours} год',
'profile.quotaReadout.limit': 'з {limit}',
- 'profile.quotaDetail': '{paid} платних годин · оновлення через {reset} · {lifetime} за весь час',
+ 'profile.quotaDetail': '{paid} платного часу · оновлення через {reset} · {lifetime} за весь час',
'profile.paidPacksUnavailable': 'Платні пакети вимкнено',
'profile.projects': 'Майданчики',
'profile.projectQuota': 'Ліміт майданчиків',
'profile.projectSlots': '{used}/{limit} місць для майданчиків',
'profile.projectSlotsAvailable': 'Доступно місць: {remaining}',
'profile.projectSlotsFull': 'Ліміт проєктів досягнуто',
- 'profile.projectSlotDetail': '{paid} платних місячних місць{reset}',
+ 'profile.projectSlotDetail': '{paid} платних місць · нові місця діють {days} дн{reset}',
'profile.projectSlotFullDetail': 'Видаліть або експортуйте старіший майданчик, перш ніж створювати новий.',
'profile.addSlot': '+1 місце',
'profile.projectSlotsUnavailable': 'Пакети місць вимкнено',
@@ -1102,6 +1175,53 @@ Likeable застосовує розумні технічні та органі
'admin.search.placeholder': 'email, імʼя або id користувача',
'admin.access': 'Доступ',
'admin.billing': 'Оплата',
+ 'admin.billingHealth.title': 'Стан білінгу',
+ 'admin.billingHealth.body': 'Конфігурація Stripe, доступні продукти, безплатний ліміт і останні записи payment ledger.',
+ 'admin.billingHealth.refresh': 'Оновити',
+ 'admin.billingHealth.loadFailed': 'Не вдалося завантажити стан білінгу',
+ 'admin.billingHealth.stripe': 'Ключі Stripe',
+ 'admin.billingHealth.products': 'Продукти',
+ 'admin.billingHealth.freeWindow': 'Безплатне вікно',
+ 'admin.billingHealth.payments': 'Платежі',
+ 'admin.billingHealth.hourPacks': 'пакети годин: {packs}',
+ 'admin.billingHealth.noHourPacks': 'немає пакетів годин',
+ 'admin.billingHealth.projectQuotaOn': 'місця проєктів увімкнено',
+ 'admin.billingHealth.projectQuotaOff': 'місця проєктів вимкнено',
+ 'admin.billingHealth.noIssues': 'У конфігурації білінгу немає очевидних блокерів запуску.',
+ 'admin.billingHealth.issue': 'Проблема',
+ 'admin.billingHealth.issue.publishable': 'Немає Stripe publishable key.',
+ 'admin.billingHealth.issue.secret': 'Немає Stripe secret key; checkout продукти вимкнені.',
+ 'admin.billingHealth.issue.webhook': 'Немає Stripe webhook secret; async доставку платежів не можна перевірити.',
+ 'admin.billingHealth.issue.hourPrices': 'Не налаштовано Stripe price ID для пакетів годин.',
+ 'admin.billingHealth.issue.projectQuota': 'Немає Stripe price ID для місць проєктів.',
+ 'admin.billingHealth.issue.unknown': 'Невідома проблема білінгу: {issue}',
+ 'admin.billingHealth.recentPayments': 'Останні платежі',
+ 'admin.billingHealth.checkedAt': 'Перевірено о {time}',
+ 'admin.billingHealth.noPayments': 'Записів платежів ще немає.',
+ 'admin.readiness.title': 'Готовність до запуску',
+ 'admin.readiness.body': 'Блокери запуску у Stripe, Fibe playgrounds, OAuth, пошті підтримки та політиці доступу.',
+ 'admin.readiness.refresh': 'Оновити',
+ 'admin.readiness.loadFailed': 'Не вдалося завантажити готовність до запуску',
+ 'admin.readiness.status': 'Статус',
+ 'admin.readiness.ready': 'готово',
+ 'admin.readiness.blocked': 'заблоковано',
+ 'admin.readiness.blockers': 'Блокери',
+ 'admin.readiness.warnings': 'Попередження',
+ 'admin.readiness.checkedAt': 'Перевірено',
+ 'admin.readiness.noIssues': 'Перевірки запуску чисті.',
+ 'admin.readiness.blocker': 'Блокер запуску',
+ 'admin.readiness.warning': 'Потребує уваги',
+ 'admin.readiness.check.stripeSecret': 'Stripe secret key',
+ 'admin.readiness.check.stripeWebhook': 'Stripe webhook secret',
+ 'admin.readiness.check.hourPrices': 'Stripe price для пакетів годин',
+ 'admin.readiness.check.projectQuotaPrice': 'Stripe price для місць проєктів',
+ 'admin.readiness.check.fibeTemplate': 'Fibe greenfield template',
+ 'admin.readiness.check.activePool': 'Активний Fibe pool',
+ 'admin.readiness.check.activePoolHealth': 'Здоровий активний Fibe pool',
+ 'admin.readiness.check.googleOauth': 'Google OAuth',
+ 'admin.readiness.check.smtp': 'Доставка email підтримки',
+ 'admin.readiness.check.signup': 'Політика реєстрації',
+ 'admin.readiness.check.unknown': 'Невідома перевірка готовності: {key}',
'admin.sort': 'Сортування',
'admin.sort.newest': 'новіші',
'admin.sort.hours': 'години',
@@ -1154,6 +1274,12 @@ Likeable застосовує розумні технічні та органі
'admin.assignment': 'Пара агента',
'admin.assignment.none': 'Пара не задана',
'admin.assignment.failed': 'Не вдалося оновити пару',
+ 'admin.diagnostics.show': 'Діагностика',
+ 'admin.diagnostics.hide': 'Сховати діагностику',
+ 'admin.diagnostics.failed': 'Не вдалося завантажити діагностику проєкту',
+ 'admin.diagnostics.workSessions': 'Work sessions',
+ 'admin.diagnostics.hourLedger': 'Hour ledger',
+ 'admin.diagnostics.empty': 'Записів немає.',
'admin.noActiveProjects': 'Активних майданчиків немає.',
'admin.deleteProject.aria': 'Видалити {title}',
'admin.loadUsersFailed': 'Не вдалося завантажити користувачів',
@@ -1198,6 +1324,10 @@ Likeable застосовує розумні технічні та органі
'admin.pool.status.retired': 'Архівована',
'admin.pool.stats': '{active}/{projects} активних · {archived} архівованих · {archives} ZIP',
'admin.pool.stats.empty': '0 майданчиків',
+ 'admin.pool.health.ok': 'Fibe OK',
+ 'admin.pool.health.warning': 'Fibe потребує уваги',
+ 'admin.pool.health.unknown': 'Fibe не перевірено',
+ 'admin.pool.health.problem': 'Fibe: {problem}',
'admin.pool.retire': 'Архівувати',
'admin.pool.retired': 'Пару архівовано',
'admin.pool.agentPlaceholder': 'agent_...',
@@ -1239,10 +1369,12 @@ Likeable застосовує розумні технічні та органі
'admin.config.stripe_price_id_100_hours': 'Price ID для 100 годин',
'admin.config.stripe_project_quota_price_id': 'Price ID ліміту майданчиків',
'admin.config.stripe_webhook_secret': 'Webhook secret',
- 'admin.config.free_hours': 'Безплатних годин за вікно',
- 'admin.config.free_hour_window_hours': 'Години вікна безплатних годин',
+ 'admin.config.free_minutes': 'Безплатних хвилин за вікно',
+ 'admin.config.free_hour_window_hours': 'Години безплатного вікна',
+ 'admin.config.playground_idle_stop_hours': 'Годин до зупинки майданчика',
'admin.config.prompt_improve_charge_minutes': 'Хвилин списання за Improve Prompt',
'admin.config.project_cap': 'Базовий ліміт майданчиків',
+ 'admin.config.project_quota_days': 'Днів платного місця майданчика',
'admin.config.agent_artefacts': 'JSON артефактів агента'
};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d1cff6b..19ada71 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -303,6 +303,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
const [prompt, setPrompt] = useState('');
const [busy, setBusy] = useState(false);
const [messageSubmitting, setMessageSubmitting] = useState(false);
+ const [wakeSendProjectID, setWakeSendProjectID] = useState('');
const [promptImproving, setPromptImproving] = useState(false);
const [projectsLoaded, setProjectsLoaded] = useState(false);
const [projectsLoadedUserID, setProjectsLoadedUserID] = useState('');
@@ -412,9 +413,10 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
const idleStopTooltip = idleStopCountdown ? t('builder.idleStop.tooltip', { time: idleStopCountdown }) : '';
const promptHasText = Boolean(prompt.trim());
const hasDraft = promptHasText || attachments.length > 0;
+ const wakeSendActive = Boolean(wakeSendProjectID && activeProject?.id === wakeSendProjectID);
const composerDisabled = !signedIn || projectsLoading || noActiveProject || projectArchived;
const composerInputDisabled = composerDisabled || promptImproving;
- const canSend = signedIn && !composerDisabled && hasDraft && !busy && !messageSubmitting && !promptImproving && Boolean(activePreviewURL) && (activeProject?.status === 'ready' || previewReady);
+ const canSend = signedIn && !composerDisabled && hasDraft && !busy && !messageSubmitting && !promptImproving && (activeProject?.status === 'stopped' || (Boolean(activePreviewURL) && (activeProject?.status === 'ready' || previewReady)));
const hasActiveNotification = rows.some((row) => row.kind === 'notification' && row.active);
const canvasLoading = projectsLoading || isProjectStarting || Boolean(activePreviewURL && previewRuntimeActive && !previewMaintenance && (!previewReady || !iframeLoaded));
const brandWorking = agentWorking || hasActiveNotification || canvasLoading;
@@ -472,6 +474,8 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
: canvasStatusLabel;
const StudioNextIcon = agentActivityActive ? CircleStop : projectArchived || noActiveProject ? FolderOpen : Sparkles;
const composerHintTone = promptImproving
+ ? 'pending'
+ : wakeSendActive
? 'pending'
: !signedIn || projectsLoading || noActiveProject
? 'blocked'
@@ -484,6 +488,8 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
: '';
const composerModeHint = promptImproving
? t('builder.composerHint.improvingPrompt')
+ : wakeSendActive
+ ? t('builder.composerHint.wakingToSend')
: agentActivityActive
? busyPolicy === 'queue'
? t('builder.composerHint.agentQueue')
@@ -743,6 +749,28 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
const text = prompt.trim();
const files = attachments;
if (!text && files.length === 0) return;
+ if (activeProject.status === 'stopped') {
+ setBusy(true);
+ setMessageSubmitting(true);
+ setWakeSendProjectID(activeProject.id);
+ try {
+ const res = await api<{ project: Project }>(`/api/projects/${activeProject.id}/playground`, {
+ method: 'POST',
+ body: JSON.stringify({ action: 'start' })
+ });
+ setPreviewStatus(null);
+ setIframeLoaded(false);
+ setProjects((current) => current.map((project) => project.id === activeProject.id ? res.project : project));
+ setFeed((current) => current?.project.id === activeProject.id ? { ...current, project: res.project } : current);
+ } catch (err) {
+ setWakeSendProjectID('');
+ setDialog({ title: t('dialog.playgroundActionFailed.title'), body: err instanceof Error ? err.message : t('dialog.requestFailed.title'), tone: 'warning', confirmLabel: t('common.close') });
+ } finally {
+ setMessageSubmitting(false);
+ setBusy(false);
+ }
+ return;
+ }
const optimisticID = `optimistic-${crypto.randomUUID()}`;
const optimisticMessage: Message = {
id: optimisticID,
@@ -776,6 +804,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
if (acceptedMessage) {
setPendingMessagesByProject((current) => replacePendingMessage(current, activeProject.id, optimisticID, acceptedMessage));
}
+ setWakeSendProjectID((current) => current === activeProject.id ? '' : current);
rememberPendingAgentRun(activeProject.id);
try {
setFeed(await api(`/api/projects/${activeProject.id}/feed`));
@@ -786,6 +815,7 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
void refreshQuota();
} catch (err) {
setPendingMessagesByProject((current) => removePendingMessage(current, activeProject.id, optimisticID));
+ setWakeSendProjectID((current) => current === activeProject.id ? '' : current);
setPrompt(text);
setAttachments(files);
setDialog({ title: t('dialog.requestFailed.title'), body: messageSendErrorBody(err, t), confirmLabel: t('common.close') });
@@ -794,6 +824,24 @@ function Builder({ nav, me, profileRoute = false }: { nav: (to: string) => void;
setBusy(false);
}
};
+ useEffect(() => {
+ if (!wakeSendProjectID) return;
+ if (!activeProject || activeProject.id !== wakeSendProjectID) {
+ setWakeSendProjectID('');
+ return;
+ }
+ if (activeProject.status === 'error' || activeProject.status === 'archived' || projectArchived) {
+ setWakeSendProjectID('');
+ return;
+ }
+ if (activeProject.status !== 'ready' || !activePreviewURL || busy || messageSubmitting || promptImproving) return;
+ if (!prompt.trim() && attachments.length === 0) {
+ setWakeSendProjectID('');
+ return;
+ }
+ setWakeSendProjectID('');
+ void createOrSend();
+ }, [wakeSendProjectID, activeProject?.id, activeProject?.status, activePreviewURL, busy, messageSubmitting, promptImproving, prompt, attachments.length, projectArchived]);
const interruptAgent = async () => {
if (!signedIn || !activeProject) return;
setBusy(true);
diff --git a/frontend/src/profile_panel.tsx b/frontend/src/profile_panel.tsx
index 4f86227..af7bcf2 100644
--- a/frontend/src/profile_panel.tsx
+++ b/frontend/src/profile_panel.tsx
@@ -150,6 +150,7 @@ export function ProfilePanel({ me, onClose, onOpenTutorial, onRefreshAccount }:
const projectQuota = me.projectQuota;
const availableHourPacks = me.billingProducts?.hourPacks ?? [];
const projectQuotaPurchasable = Boolean(me.billingProducts?.projectQuota);
+ const projectQuotaDays = me.billingProducts?.projectQuotaDays ?? 30;
const quotaResetLabel = quota ? formatResetCountdown(quota.resetsAt, Date.now(), resetCountdownLabels(t)) : t('duration.fiveHours');
const quotaWindowHours = quota?.windowHours ?? 5;
const remainingHours = formatBillingDuration(quota?.remainingMs ?? 0, resetCountdownLabels(t));
@@ -203,6 +204,16 @@ export function ProfilePanel({ me, onClose, onOpenTutorial, onRefreshAccount }:
{t('profile.tutorialOpen')}
+
{t('profile.hours')}
@@ -236,7 +247,7 @@ export function ProfilePanel({ me, onClose, onOpenTutorial, onRefreshAccount }:
)}
{projectQuota &&
}
-
{projectSlotsFull ? t('profile.projectSlotFullDetail') : t('profile.projectSlotDetail', { paid: projectQuota?.paidSlots ?? 0, reset: projectQuota?.nextExpiresAt ? ` · ${t('common.nextReset', { date: formatShortDate(projectQuota.nextExpiresAt, locale) })}` : '' })}
+
{projectSlotsFull ? t('profile.projectSlotFullDetail') : t('profile.projectSlotDetail', { paid: projectQuota?.paidSlots ?? 0, days: projectQuotaDays, reset: projectQuota?.nextExpiresAt ? ` · ${t('common.nextReset', { date: formatShortDate(projectQuota.nextExpiresAt, locale) })}` : '' })}
{projectQuotaPurchasable && (
void checkoutProjectSlot()}>
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 4dfff4f..c51cb2d 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -3165,6 +3165,17 @@ html.draggingCollapsedChat * {
100% { opacity: .12; transform: scaleX(.36); }
}
+@keyframes canvasSkeletonSweep {
+ 0% { opacity: .08; transform: translateX(-115%); }
+ 42% { opacity: .75; }
+ 100% { opacity: .08; transform: translateX(115%); }
+}
+
+@keyframes canvasSkeletonWash {
+ 0%, 100% { opacity: .42; transform: translateX(-18%); }
+ 50% { opacity: .78; transform: translateX(18%); }
+}
+
@keyframes miniSlideIn {
from {
opacity: 0;
@@ -3199,6 +3210,9 @@ html.draggingCollapsedChat * {
.minimizedChatBar.working,
.canvasSignalField.loading span,
.loaderTelemetry span,
+ .canvasSkeletonScene::before,
+ .canvasSkeletonTabs span::after,
+ .canvasSkeletonBlock::after,
.composer.improving::before,
.composer.improving::after {
animation: none;
@@ -3891,6 +3905,57 @@ html.draggingCollapsedChat * {
border-radius: 8px;
padding: 8px;
}
+.adminProjectBlock {
+ display: grid;
+ gap: 8px;
+}
+.adminProjectDiagnostics {
+ display: grid;
+ gap: 10px;
+ border: 1px solid rgba(132, 147, 154, .18);
+ border-radius: 8px;
+ background: rgba(7, 14, 17, .44);
+ padding: 10px;
+}
+.diagnosticsGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+}
+.diagnosticsGrid span,
+.diagnosticsLists > div {
+ min-width: 0;
+ display: grid;
+ gap: 4px;
+}
+.diagnosticsGrid em,
+.diagnosticsLists em {
+ color: var(--muted);
+ font-size: 10px;
+ font-style: normal;
+ font-weight: 850;
+ text-transform: uppercase;
+}
+.diagnosticsGrid code,
+.diagnosticsLists code {
+ overflow: hidden;
+ border: 1px solid rgba(132, 147, 154, .16);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 11px;
+ padding: 5px 6px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.diagnosticsLists {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px;
+}
+.diagnosticsLists strong {
+ color: var(--text);
+ font-size: 12px;
+}
.adminProjectAssignment {
min-width: 0;
display: grid;
@@ -4111,11 +4176,18 @@ html.draggingCollapsedChat * {
color: rgba(216, 228, 231, .5);
font-size: 11px;
line-height: 1.25;
+ display: grid;
+ gap: 2px;
+ align-self: center;
+}
+.poolStatLine > span {
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- align-self: center;
}
+.poolHealth.ok { color: rgba(94, 212, 168, .76); }
+.poolHealth.warning { color: rgba(255, 196, 106, .82); }
.poolRetireButton {
min-height: 38px;
white-space: nowrap;
@@ -8165,6 +8237,123 @@ body {
opacity: .54;
}
+.canvasSkeletonScene {
+ position: absolute;
+ inset: 18px 28px;
+ z-index: 0;
+ pointer-events: none;
+ opacity: .8;
+ filter: saturate(1.06);
+}
+
+.canvasSkeletonScene::before {
+ content: "";
+ position: absolute;
+ inset: -22px -18px;
+ background:
+ radial-gradient(circle at 18% 12%, rgba(94, 212, 168, .12), transparent 26%),
+ radial-gradient(circle at 80% 16%, rgba(232, 181, 111, .1), transparent 24%),
+ linear-gradient(100deg, transparent 0 38%, rgba(174, 245, 255, .08) 48%, transparent 58% 100%);
+ opacity: .72;
+ transform: translateX(-18%);
+ animation: canvasSkeletonWash 3.6s ease-in-out infinite;
+}
+
+.canvasSkeletonTabs {
+ position: absolute;
+ left: clamp(10px, 3vw, 54px);
+ top: 0;
+ display: flex;
+ gap: clamp(14px, 2vw, 28px);
+}
+
+.canvasSkeletonTabs span,
+.canvasSkeletonBlock {
+ position: absolute;
+ overflow: hidden;
+ border: 1px solid rgba(174, 245, 255, .055);
+ border-radius: 9px;
+ background:
+ linear-gradient(90deg, rgba(94, 212, 168, .055), transparent 22%),
+ linear-gradient(135deg, rgba(29, 42, 55, .9), rgba(20, 30, 42, .76));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, .035),
+ 0 18px 54px rgba(0, 0, 0, .2);
+}
+
+.canvasSkeletonTabs span {
+ position: relative;
+ width: clamp(74px, 8vw, 128px);
+ height: 26px;
+ border-radius: 7px;
+ opacity: .72;
+}
+
+.canvasSkeletonTabs span:nth-child(2) { width: clamp(86px, 9vw, 148px); }
+.canvasSkeletonTabs span:nth-child(3) { width: clamp(70px, 7vw, 116px); }
+
+.canvasSkeletonTabs span::after,
+.canvasSkeletonBlock::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(105deg, transparent 0 36%, rgba(255, 255, 255, .09) 48%, transparent 60% 100%);
+ transform: translateX(-110%);
+ animation: canvasSkeletonSweep 2.4s ease-in-out infinite;
+}
+
+.canvasSkeletonBlock.hero {
+ left: clamp(14px, 4vw, 78px);
+ top: clamp(38px, 5vh, 58px);
+ width: min(54vw, 760px);
+ height: min(29vh, 260px);
+ min-height: 176px;
+ border-top-left-radius: 14px;
+ background:
+ linear-gradient(90deg, rgba(232, 181, 111, .07), transparent 24%),
+ linear-gradient(135deg, rgba(27, 38, 49, .96), rgba(20, 30, 42, .82));
+}
+
+.canvasSkeletonBlock.side {
+ right: clamp(18px, 5vw, 108px);
+ top: clamp(28px, 4.2vh, 46px);
+ width: min(27vw, 390px);
+ height: min(20vh, 178px);
+}
+
+.canvasSkeletonBlock.railA {
+ left: clamp(14px, 4vw, 78px);
+ top: calc(clamp(38px, 5vh, 58px) + min(29vh, 260px) + 18px);
+ width: min(28vw, 420px);
+ height: min(20vh, 180px);
+}
+
+.canvasSkeletonBlock.main {
+ right: clamp(40px, 7vw, 148px);
+ top: calc(clamp(38px, 5vh, 58px) + min(29vh, 260px) + 18px);
+ width: min(54vw, 760px);
+ height: min(26vh, 250px);
+ min-height: 180px;
+ background:
+ linear-gradient(90deg, rgba(94, 212, 168, .07), transparent 26%),
+ linear-gradient(135deg, rgba(30, 42, 54, .94), rgba(20, 30, 42, .82));
+}
+
+.canvasSkeletonBlock.railB {
+ left: clamp(14px, 4vw, 78px);
+ bottom: clamp(86px, 13vh, 140px);
+ width: min(28vw, 420px);
+ height: min(19vh, 178px);
+}
+
+.canvasSkeletonBlock.footer {
+ right: clamp(42px, 7vw, 150px);
+ bottom: clamp(78px, 12vh, 130px);
+ width: min(36vw, 560px);
+ height: min(12vh, 112px);
+ opacity: .56;
+}
+
.loaderRing {
width: 84px;
height: 84px;
@@ -8329,6 +8518,70 @@ body {
transform: scale(.78);
}
+ .canvasSkeletonScene {
+ inset: 14px 14px;
+ opacity: .66;
+ }
+
+ .canvasSkeletonTabs {
+ left: 12px;
+ gap: 10px;
+ }
+
+ .canvasSkeletonTabs span {
+ width: clamp(56px, 14vw, 92px);
+ height: 22px;
+ }
+
+ .canvasSkeletonTabs span:nth-child(2),
+ .canvasSkeletonTabs span:nth-child(3) {
+ width: clamp(62px, 15vw, 102px);
+ }
+
+ .canvasSkeletonBlock.hero {
+ left: 14px;
+ top: 44px;
+ width: min(64vw, 520px);
+ height: 22vh;
+ min-height: 138px;
+ }
+
+ .canvasSkeletonBlock.side {
+ right: 14px;
+ top: 44px;
+ width: 28vw;
+ height: 17vh;
+ }
+
+ .canvasSkeletonBlock.railA {
+ left: 14px;
+ top: calc(44px + max(22vh, 138px) + 14px);
+ width: 34vw;
+ height: 17vh;
+ }
+
+ .canvasSkeletonBlock.main {
+ right: 14px;
+ top: calc(44px + max(22vh, 138px) + 14px);
+ width: 56vw;
+ height: 20vh;
+ min-height: 132px;
+ }
+
+ .canvasSkeletonBlock.railB {
+ left: 14px;
+ bottom: 86px;
+ width: 38vw;
+ height: 15vh;
+ }
+
+ .canvasSkeletonBlock.footer {
+ right: 14px;
+ bottom: 84px;
+ width: 42vw;
+ height: 10vh;
+ }
+
.emptyCopy {
width: min(620px, calc(100vw - 28px));
padding: 0 12px;
@@ -8350,6 +8603,65 @@ body {
width: min(220px, 68vw);
}
+ .canvasSkeletonScene {
+ inset: 10px;
+ opacity: .56;
+ }
+
+ .canvasSkeletonTabs {
+ left: 8px;
+ gap: 7px;
+ }
+
+ .canvasSkeletonTabs span {
+ width: 54px;
+ height: 18px;
+ }
+
+ .canvasSkeletonTabs span:nth-child(2) {
+ width: 66px;
+ }
+
+ .canvasSkeletonTabs span:nth-child(3) {
+ display: none;
+ }
+
+ .canvasSkeletonBlock.hero {
+ left: 8px;
+ top: 36px;
+ width: calc(100% - 16px);
+ height: 22vh;
+ min-height: 128px;
+ }
+
+ .canvasSkeletonBlock.side,
+ .canvasSkeletonBlock.footer {
+ display: none;
+ }
+
+ .canvasSkeletonBlock.railA {
+ left: 8px;
+ top: calc(36px + max(22vh, 128px) + 12px);
+ width: calc(46% - 10px);
+ height: 15vh;
+ }
+
+ .canvasSkeletonBlock.main {
+ right: 8px;
+ top: calc(36px + max(22vh, 128px) + 12px);
+ width: calc(54% - 10px);
+ height: 19vh;
+ min-height: 120px;
+ }
+
+ .canvasSkeletonBlock.railB {
+ left: 8px;
+ right: 8px;
+ bottom: 72px;
+ width: auto;
+ height: 12vh;
+ }
+
.emptyCopy h1,
.canvasLoader .emptyCopy h1 {
font-size: 28px;
@@ -8438,6 +8750,7 @@ body {
.profileHoursCard,
.profileSlotsCard,
+.profileScopeCard,
.profileDangerCard {
grid-column: 1 / -1;
}
@@ -8451,6 +8764,21 @@ body {
min-height: 132px;
}
+.profileScopeCard {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ min-height: 104px;
+ align-items: center;
+ border-color: rgba(94, 212, 168, .18);
+ background:
+ linear-gradient(135deg, rgba(94, 212, 168, .075), rgba(232, 181, 111, .035) 48%, transparent),
+ rgba(7, 17, 18, .76);
+}
+
+.profileScopeCard::before {
+ background: linear-gradient(180deg, rgba(94, 212, 168, .7), rgba(232, 181, 111, .28));
+}
+
.profileDangerCard {
border-color: rgba(255, 122, 122, .24);
background:
@@ -8716,12 +9044,14 @@ body {
.profileActionCard,
.profileHoursCard,
.profileSlotsCard,
+ .profileScopeCard,
.profileDangerCard {
grid-column: 1;
}
.profileHoursCard,
- .profileSlotsCard {
+ .profileSlotsCard,
+ .profileScopeCard {
grid-template-columns: 1fr;
}
diff --git a/internal/domain/types.go b/internal/domain/types.go
index a345c5f..b684a59 100644
--- a/internal/domain/types.go
+++ b/internal/domain/types.go
@@ -5,7 +5,7 @@ import (
"time"
)
-const PlaygroundIdleStopAfter = 8 * time.Hour
+const DefaultPlaygroundIdleStopAfter = 8 * time.Hour
type User struct {
ID string `json:"id"`
@@ -39,12 +39,24 @@ type Project struct {
CleanupLastError string `json:"-"`
PlaygroundLastUsedAt string `json:"playgroundLastUsedAt,omitempty"`
PlaygroundIdleStopAt string `json:"playgroundIdleStopAt,omitempty"`
+ ProductionExpiresAt string `json:"-"`
+ CustomDomain string `json:"-"`
+ CustomDomainStatus string `json:"-"`
+ CustomDomainTarget string `json:"-"`
+ CustomDomainUpdatedAt string `json:"-"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func (p *Project) RefreshComputedFields() {
+ p.RefreshComputedFieldsWithIdleStopAfter(DefaultPlaygroundIdleStopAfter)
+}
+
+func (p *Project) RefreshComputedFieldsWithIdleStopAfter(idleStopAfter time.Duration) {
p.PlaygroundIdleStopAt = ""
+ if idleStopAfter <= 0 {
+ idleStopAfter = DefaultPlaygroundIdleStopAfter
+ }
if p.Status != "ready" || strings.TrimSpace(p.PlaygroundLastUsedAt) == "" {
return
}
@@ -52,7 +64,7 @@ func (p *Project) RefreshComputedFields() {
if err != nil {
return
}
- p.PlaygroundIdleStopAt = lastUsedAt.UTC().Add(PlaygroundIdleStopAfter).Format(time.RFC3339Nano)
+ p.PlaygroundIdleStopAt = lastUsedAt.UTC().Add(idleStopAfter).Format(time.RFC3339Nano)
}
type ProjectRepository struct {
@@ -198,6 +210,20 @@ type AgentPoolOption struct {
Capacity int `json:"capacity,omitempty"`
}
+type AgentPoolHealth struct {
+ Label string `json:"label,omitempty"`
+ AgentID string `json:"agentId"`
+ ServerID string `json:"serverId"`
+ Status string `json:"status"`
+ AgentStatus string `json:"agentStatus,omitempty"`
+ AgentAuthenticated bool `json:"agentAuthenticated"`
+ ServerStatus string `json:"serverStatus,omitempty"`
+ ServerBillingRuntimeActive bool `json:"serverBillingRuntimeActive"`
+ ServerChatLaunchable bool `json:"serverChatLaunchable"`
+ OK bool `json:"ok"`
+ Problems []string `json:"problems,omitempty"`
+}
+
type UserNotice struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
@@ -236,6 +262,50 @@ type AdminProjectSummary struct {
Assignment AgentAssignmentSummary `json:"assignment"`
}
+type AdminBillingPayment struct {
+ ID string `json:"id"`
+ UserID string `json:"userId"`
+ UserEmail string `json:"userEmail"`
+ ProviderPaymentID string `json:"providerPaymentId"`
+ AmountCents int64 `json:"amountCents"`
+ Currency string `json:"currency"`
+ Status string `json:"status"`
+ CreatedAt string `json:"createdAt"`
+}
+
+type AdminHourCreditLedgerEntry struct {
+ ID string `json:"id"`
+ UserID string `json:"userId"`
+ DeltaMs int64 `json:"deltaMs"`
+ Reason string `json:"reason"`
+ PaymentID string `json:"paymentId,omitempty"`
+ WorkSessionKey string `json:"workSessionKey,omitempty"`
+ CreatedAt string `json:"createdAt"`
+}
+
+type AdminProjectInternal struct {
+ UserID string `json:"userId"`
+ ConversationID string `json:"conversationId,omitempty"`
+ AgentID string `json:"agentId,omitempty"`
+ ServerID string `json:"serverId,omitempty"`
+ PlaygroundID string `json:"playgroundId,omitempty"`
+ PlaygroundName string `json:"playgroundName,omitempty"`
+ PlayspecID string `json:"playspecId,omitempty"`
+ PropID string `json:"propId,omitempty"`
+ RepoURL string `json:"repoUrl,omitempty"`
+ ProvisioningLockUntil string `json:"provisioningLockUntil,omitempty"`
+ InternalErrorMessage string `json:"internalErrorMessage,omitempty"`
+ CleanupLastError string `json:"cleanupLastError,omitempty"`
+}
+
+type AdminProjectDiagnostics struct {
+ Project Project `json:"project"`
+ Internal AdminProjectInternal `json:"internal"`
+ WorkSessions []ProjectWorkSession `json:"workSessions"`
+ HourLedger []AdminHourCreditLedgerEntry `json:"hourLedger"`
+ Payments []AdminBillingPayment `json:"payments"`
+}
+
type AdminUserDetail struct {
Summary AdminUserSummary `json:"summary"`
Projects []AdminProjectSummary `json:"projects"`
diff --git a/internal/fibe/fibe_agent.go b/internal/fibe/fibe_agent.go
index 11fb405..2326b97 100644
--- a/internal/fibe/fibe_agent.go
+++ b/internal/fibe/fibe_agent.go
@@ -130,6 +130,10 @@ func (c *Client) RestartPlayground(ctx context.Context, playgroundID string) err
return c.controlPlayground(ctx, "hard-restart", playgroundID)
}
+func (c *Client) RolloutPlayground(ctx context.Context, playgroundID string) error {
+ return c.controlPlayground(ctx, "rollout", playgroundID)
+}
+
func (c *Client) controlPlayground(ctx context.Context, action, playgroundID string) error {
playgroundID = strings.TrimSpace(playgroundID)
if playgroundID == "" {
diff --git a/internal/fibe/fibe_assignment_health.go b/internal/fibe/fibe_assignment_health.go
new file mode 100644
index 0000000..ac759cc
--- /dev/null
+++ b/internal/fibe/fibe_assignment_health.go
@@ -0,0 +1,64 @@
+package fibe
+
+import (
+ "context"
+ "strings"
+)
+
+type AssignmentHealth struct {
+ AgentID string `json:"agentId"`
+ MarqueeID string `json:"serverId"`
+ AgentStatus string `json:"agentStatus,omitempty"`
+ AgentAuthenticated bool `json:"agentAuthenticated"`
+ MarqueeStatus string `json:"serverStatus,omitempty"`
+ MarqueeBillingRuntimeActive bool `json:"serverBillingRuntimeActive"`
+ MarqueeChatLaunchable bool `json:"serverChatLaunchable"`
+ OK bool `json:"ok"`
+ Problems []string `json:"problems,omitempty"`
+}
+
+func (c *Client) AssignmentHealth(ctx context.Context) AssignmentHealth {
+ health := AssignmentHealth{
+ AgentID: c.agentID,
+ MarqueeID: c.marqueeID,
+ }
+ if strings.TrimSpace(c.agentID) == "" {
+ health.Problems = append(health.Problems, "agent ID is not configured")
+ }
+ if strings.TrimSpace(c.marqueeID) == "" {
+ health.Problems = append(health.Problems, "server ID is not configured")
+ }
+ if strings.TrimSpace(c.agentID) != "" {
+ agent, err := c.sdk.Agents.GetByIdentifier(ctx, c.agentID)
+ if err != nil {
+ health.Problems = append(health.Problems, "agent: "+wrapSDKError(err).Error())
+ } else {
+ health.AgentStatus = strings.TrimSpace(agent.Status)
+ health.AgentAuthenticated = agent.Authenticated
+ if !agent.Authenticated {
+ health.Problems = append(health.Problems, "agent is not authenticated")
+ }
+ }
+ }
+ if strings.TrimSpace(c.marqueeID) != "" {
+ marquee, err := c.sdk.Marquees.GetByIdentifier(ctx, c.marqueeID)
+ if err != nil {
+ health.Problems = append(health.Problems, "server: "+wrapSDKError(err).Error())
+ } else {
+ health.MarqueeStatus = strings.TrimSpace(marquee.Status)
+ health.MarqueeBillingRuntimeActive = marquee.BillingRuntimeActive
+ health.MarqueeChatLaunchable = marquee.ChatLaunchable
+ if !strings.EqualFold(health.MarqueeStatus, "active") {
+ health.Problems = append(health.Problems, "server is not active")
+ }
+ if !marquee.BillingRuntimeActive {
+ health.Problems = append(health.Problems, "server runtime is not funded")
+ }
+ if !marquee.ChatLaunchable {
+ health.Problems = append(health.Problems, "server chat runtime is not launchable")
+ }
+ }
+ }
+ health.OK = len(health.Problems) == 0
+ return health
+}
diff --git a/internal/fibe/fibe_cli.go b/internal/fibe/fibe_cli.go
index 13af0e5..1664dd4 100644
--- a/internal/fibe/fibe_cli.go
+++ b/internal/fibe/fibe_cli.go
@@ -45,6 +45,8 @@ func (e *PlatformError) PublicProjectErrorKind() string {
switch {
case e.Status == 401 || e.Status == 403:
return "configuration"
+ case IsRuntimeBillingRequiredError(e):
+ return "runtime_billing"
case isProvisioningConfigurationPlatformError(e, message):
return "configuration"
default:
@@ -64,6 +66,9 @@ func IsRetryableProvisioningError(err error) bool {
if !errors.As(err, &platform) {
return false
}
+ if IsRuntimeBillingRequiredError(platform) {
+ return false
+ }
code := strings.ToUpper(strings.TrimSpace(platform.Code))
message := strings.ToLower(strings.TrimSpace(platform.Message + "\n" + platform.Stderr))
if isProvisioningConfigurationPlatformError(platform, message) {
@@ -179,6 +184,41 @@ func IsPlaygroundMissingError(err error) bool {
return strings.Contains(message, "playground") && containsAny(message, "not found", "missing")
}
+func IsRuntimeBillingRequiredError(err error) bool {
+ if err == nil {
+ return false
+ }
+ var platform *PlatformError
+ if errors.As(err, &platform) {
+ code := strings.ToUpper(strings.TrimSpace(platform.Code))
+ message := strings.ToLower(strings.TrimSpace(platform.Message + "\n" + platform.Stderr))
+ if platform.Status == 402 {
+ return true
+ }
+ if code == "MARQUEE_NOT_FUNDED" || code == "PAYMENT_REQUIRED" || code == "RUNTIME_ENTITLEMENT_REQUIRED" {
+ return true
+ }
+ if containsAny(message,
+ "unexpected status 402",
+ "marquee_not_funded",
+ "not funded",
+ "payment required",
+ "runtime entitlement",
+ "billing_runtime_active",
+ ) {
+ return true
+ }
+ }
+ message := strings.ToLower(strings.TrimSpace(err.Error()))
+ return containsAny(message,
+ "unexpected status 402",
+ "marquee_not_funded",
+ "not funded",
+ "payment required",
+ "runtime entitlement",
+ )
+}
+
func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
diff --git a/internal/fibe/fibe_greenfield.go b/internal/fibe/fibe_greenfield.go
index ed352f9..a758925 100644
--- a/internal/fibe/fibe_greenfield.go
+++ b/internal/fibe/fibe_greenfield.go
@@ -134,6 +134,11 @@ func (c *Client) greenfieldParams(name string, serviceSubdomains map[string]stri
}
func greenfieldTemplateBody() (string, error) {
+ return GreenfieldTemplateBody()
+}
+
+// GreenfieldTemplateBody returns the configured inline template body used when no Fibe template version is pinned.
+func GreenfieldTemplateBody() (string, error) {
path := strings.TrimSpace(os.Getenv("LIKEABLE_GREENFIELD_TEMPLATE_BODY_PATH"))
if path == "" {
path = "/usr/local/share/likeable/go-fibe-greenfield.yaml"
diff --git a/internal/fibe/fibe_playground_custom_hosts.go b/internal/fibe/fibe_playground_custom_hosts.go
new file mode 100644
index 0000000..140da90
--- /dev/null
+++ b/internal/fibe/fibe_playground_custom_hosts.go
@@ -0,0 +1,91 @@
+package fibe
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+func (c *Client) UpdatePlaygroundServiceCustomHosts(ctx context.Context, playgroundID string, serviceHosts map[string][]string) error {
+ playgroundID = strings.TrimSpace(playgroundID)
+ if playgroundID == "" {
+ return errors.New("playground id is required")
+ }
+ services := make(map[string]map[string][]string, len(serviceHosts))
+ for serviceName, hosts := range serviceHosts {
+ serviceName = strings.TrimSpace(serviceName)
+ if serviceName == "" {
+ continue
+ }
+ services[serviceName] = map[string][]string{"custom_hosts": normalizeCustomHosts(hosts)}
+ }
+ if len(services) == 0 {
+ return nil
+ }
+ body := map[string]any{"playground": map[string]any{"services": services}}
+ return c.patchJSON(ctx, "/api/playgrounds/"+url.PathEscape(playgroundID), body)
+}
+
+func normalizeCustomHosts(hosts []string) []string {
+ out := make([]string, 0, len(hosts))
+ seen := map[string]bool{}
+ for _, host := range hosts {
+ host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), ".")
+ if host == "" || seen[host] {
+ continue
+ }
+ seen[host] = true
+ out = append(out, host)
+ }
+ return out
+}
+
+func (c *Client) patchJSON(ctx context.Context, path string, body any) error {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("fibe: marshal request body: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+path, bytes.NewReader(data))
+ if err != nil {
+ return fmt.Errorf("fibe: create request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ return platformErrorFromResponse(resp.StatusCode, resp.Status, respBody)
+}
+
+func platformErrorFromResponse(statusCode int, status string, body []byte) error {
+ var payload struct {
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Details map[string]any `json:"details"`
+ } `json:"error"`
+ }
+ _ = json.Unmarshal(body, &payload)
+ message := firstNonEmpty(payload.Error.Message, status)
+ return &PlatformError{
+ Code: firstNonEmpty(payload.Error.Code, platformCodeUnknown),
+ Status: statusCode,
+ Message: message,
+ Details: payload.Error.Details,
+ Stderr: strings.TrimSpace(string(body)),
+ }
+}
diff --git a/internal/fibe/fibe_test.go b/internal/fibe/fibe_test.go
index 8ba5489..69beca3 100644
--- a/internal/fibe/fibe_test.go
+++ b/internal/fibe/fibe_test.go
@@ -118,6 +118,11 @@ func TestIsRetryableProvisioningError(t *testing.T) {
err: &PlatformError{Code: "FIBE_CONFIGURATION_ERROR", Message: "Fibe platform is not configured"},
want: false,
},
+ {
+ name: "runtime billing required",
+ err: &PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"},
+ want: false,
+ },
{
name: "plain error",
err: errors.New("greenfield failed"),
@@ -150,6 +155,13 @@ func TestPlatformErrorPublicProjectErrorKindDoesNotClassifyMirrorLagAsConfigurat
}
}
+func TestPlatformErrorPublicProjectErrorKindClassifiesRuntimeBilling(t *testing.T) {
+ err := &PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"}
+ if got := err.PublicProjectErrorKind(); got != "runtime_billing" {
+ t.Fatalf("PublicProjectErrorKind(%v)=%q, want runtime_billing", err, got)
+ }
+}
+
func TestNewClientConfiguresSDK(t *testing.T) {
client, err := NewClient(Config{
BaseURL: testFibeBaseURL(),
@@ -209,6 +221,24 @@ func TestIsPlaygroundMissingError(t *testing.T) {
}
}
+func TestIsRuntimeBillingRequiredError(t *testing.T) {
+ for _, err := range []error{
+ &PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"},
+ &PlatformError{Code: "MARQUEE_NOT_FUNDED", Status: 422, Message: "This Marquee is not funded. Fund it to continue."},
+ errors.New("fibe: INTERNAL_ERROR (402): unexpected status 402"),
+ } {
+ if !IsRuntimeBillingRequiredError(err) {
+ t.Fatalf("IsRuntimeBillingRequiredError(%v)=false, want true", err)
+ }
+ }
+ if IsRuntimeBillingRequiredError(&PlatformError{Code: "INTERNAL_ERROR", Status: 422, Message: "unexpected status 422"}) {
+ t.Fatal("generic internal 422 must not look like runtime billing")
+ }
+ if IsRuntimeBillingRequiredError(errors.New("payment settings page is unavailable")) {
+ t.Fatal("generic payment text must not look like runtime billing")
+ }
+}
+
func TestIsAgentRuntimeUnavailableError(t *testing.T) {
for _, err := range []error{
&PlatformError{Code: "UNPROCESSABLE_ENTITY", Status: 422, Message: "No running AgentChat for Agent#1"},
@@ -263,6 +293,44 @@ func TestStartAgentChatUsesConfiguredMarquee(t *testing.T) {
}
}
+func TestAssignmentHealthChecksAgentAndMarquee(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method + " " + r.URL.Path {
+ case http.MethodGet + " /api/agents/agent-1":
+ writeJSONResponse(t, w, map[string]any{"id": 1, "status": "authenticated", "authenticated": true})
+ case http.MethodGet + " /api/marquees/32":
+ writeJSONResponse(t, w, map[string]any{"id": 32, "status": "active", "billing_runtime_active": true, "chat_launchable": true})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ health := newTestClient(t, server, "agent-1", "32").AssignmentHealth(t.Context())
+ if !health.OK || !health.AgentAuthenticated || health.AgentStatus != "authenticated" || health.MarqueeStatus != "active" || !health.MarqueeBillingRuntimeActive || !health.MarqueeChatLaunchable {
+ t.Fatalf("health=%+v, want healthy assignment", health)
+ }
+}
+
+func TestAssignmentHealthReportsUnfundedMarquee(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method + " " + r.URL.Path {
+ case http.MethodGet + " /api/agents/agent-1":
+ writeJSONResponse(t, w, map[string]any{"id": 1, "status": "authenticated", "authenticated": true})
+ case http.MethodGet + " /api/marquees/30":
+ writeJSONResponse(t, w, map[string]any{"id": 30, "status": "active", "billing_runtime_active": false, "chat_launchable": false})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ health := newTestClient(t, server, "agent-1", "30").AssignmentHealth(t.Context())
+ if health.OK || !containsString(health.Problems, "server runtime is not funded") || !containsString(health.Problems, "server chat runtime is not launchable") {
+ t.Fatalf("health=%+v, want unfunded runtime problems", health)
+ }
+}
+
func TestControlPlaygroundLifecycleUsesSDKActions(t *testing.T) {
var actions []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -287,12 +355,53 @@ func TestControlPlaygroundLifecycleUsesSDKActions(t *testing.T) {
if err := client.RestartPlayground(t.Context(), "123"); err != nil {
t.Fatal(err)
}
- want := []string{"start", "stop", "hard_restart"}
+ if err := client.RolloutPlayground(t.Context(), "123"); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"start", "stop", "hard_restart", "rollout"}
if strings.Join(actions, ",") != strings.Join(want, ",") {
t.Fatalf("actions=%v, want %v", actions, want)
}
}
+func TestUpdatePlaygroundServiceCustomHostsPatchesServices(t *testing.T) {
+ var gotAuth string
+ var gotBody map[string]any
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || r.URL.Path != "/api/playgrounds/playground-123" {
+ t.Fatalf("request=%s %s, want PATCH /api/playgrounds/playground-123", r.Method, r.URL.Path)
+ }
+ gotAuth = r.Header.Get("Authorization")
+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
+ t.Fatal(err)
+ }
+ writeJSONResponse(t, w, map[string]any{"id": 123, "status": "running"})
+ }))
+ defer server.Close()
+
+ client := newTestClient(t, server, "agent", "marquee")
+ err := client.UpdatePlaygroundServiceCustomHosts(t.Context(), "playground-123", map[string][]string{
+ "app": []string{"App.Customer.Example.", "app.customer.example"},
+ "api": []string{},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotAuth != "Bearer test" {
+ t.Fatalf("Authorization=%q, want bearer token", gotAuth)
+ }
+ playground := gotBody["playground"].(map[string]any)
+ services := playground["services"].(map[string]any)
+ app := services["app"].(map[string]any)
+ api := services["api"].(map[string]any)
+ if hosts := app["custom_hosts"].([]any); len(hosts) != 1 || hosts[0] != "app.customer.example" {
+ t.Fatalf("app custom_hosts=%#v, want normalized unique host", app["custom_hosts"])
+ }
+ if hosts := api["custom_hosts"].([]any); len(hosts) != 0 {
+ t.Fatalf("api custom_hosts=%#v, want empty clear list", api["custom_hosts"])
+ }
+}
+
func TestCreateGreenfieldUsesTemplateVersionIDOnlyWhenConfigured(t *testing.T) {
for _, tc := range []struct {
name string
diff --git a/internal/likeable/admin_handlers.go b/internal/likeable/admin_handlers.go
index 1c0e648..f1b561f 100644
--- a/internal/likeable/admin_handlers.go
+++ b/internal/likeable/admin_handlers.go
@@ -16,6 +16,21 @@ import (
const adminMaxHourGrant = 100
+type AdminReadinessCheck struct {
+ Key string `json:"key"`
+ OK bool `json:"ok"`
+ Severity string `json:"severity"`
+ Detail string `json:"detail,omitempty"`
+}
+
+type AdminReadiness struct {
+ CheckedAt string `json:"checkedAt"`
+ Ready bool `json:"ready"`
+ BlockerCount int `json:"blockerCount"`
+ WarningCount int `json:"warningCount"`
+ Checks []AdminReadinessCheck `json:"checks"`
+}
+
var secretConfigKeys = map[string]bool{
"fibe_api_key": true,
"stripe_secret_key": true,
@@ -45,7 +60,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
- writeJSON(w, http.StatusOK, map[string]any{"config": publicAdminConfig(cfg), "adminEmail": s.config.AdminEmail, "agentPoolStats": stats, "agentPool": pool})
+ writeJSON(w, http.StatusOK, map[string]any{"config": publicAdminConfig(cfg), "adminEmail": s.config.AdminEmail, "agentPoolStats": stats, "agentPool": pool, "agentPoolHealth": s.adminAgentPoolHealth(r.Context(), cfg, pool)})
case http.MethodPut:
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -151,6 +166,213 @@ func (s *Server) handleAdminRecovery(w http.ResponseWriter, r *http.Request) {
})
}
+func (s *Server) handleAdminReadiness(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+ cfg, err := s.store.ConfigMap(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ pool, err := adminAgentPoolOptionsFromConfig(cfg)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ poolHealth := s.adminAgentPoolHealth(r.Context(), cfg, activeAdminPoolOptions(pool))
+ writeJSON(w, http.StatusOK, map[string]any{"readiness": s.adminReadiness(cfg, pool, poolHealth)})
+}
+
+func (s *Server) handleAdminBillingHealth(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+ cfg, err := s.store.ConfigMap(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ stripeCfg := stripeConfigFromMap(cfg)
+ payments, err := s.store.RecentPayments(r.Context(), 10)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ priceStatus := stripePriceStatus(stripeCfg)
+ issues := stripeBillingIssues(stripeCfg, priceStatus)
+ products := billingProductsFromConfig(stripeCfg)
+ products["projectQuotaDays"] = s.projectQuotaDays(r.Context())
+ writeJSON(w, http.StatusOK, map[string]any{
+ "health": map[string]any{
+ "checkedAt": time.Now().UTC().Format(time.RFC3339Nano),
+ "configured": map[string]bool{
+ "publishableKey": strings.TrimSpace(cfg["stripe_publishable_key"]) != "",
+ "secretKey": strings.TrimSpace(stripeCfg["secret"]) != "",
+ "webhookSecret": strings.TrimSpace(stripeCfg["webhook"]) != "",
+ },
+ "products": products,
+ "prices": priceStatus,
+ "free": map[string]any{"minutes": s.freeBuildLimitMinutes(r.Context()), "windowHours": s.freeHourWindowHours(r.Context())},
+ "issues": issues,
+ "recentPayments": payments,
+ },
+ })
+}
+
+func stripePriceStatus(stripeCfg map[string]string) map[string]bool {
+ return map[string]bool{
+ "oneHour": strings.TrimSpace(stripeCfg["price_1_hour"]) != "",
+ "tenHours": strings.TrimSpace(stripeCfg["price_10_hours"]) != "",
+ "hundredHours": strings.TrimSpace(stripeCfg["price_100_hours"]) != "",
+ "projectQuota": strings.TrimSpace(stripeCfg["project_quota_price"]) != "",
+ }
+}
+
+func stripeBillingIssues(stripeCfg map[string]string, priceStatus map[string]bool) []string {
+ issues := []string{}
+ if strings.TrimSpace(stripeCfg["secret"]) == "" {
+ issues = append(issues, "stripe_secret_missing")
+ }
+ if strings.TrimSpace(stripeCfg["webhook"]) == "" {
+ issues = append(issues, "stripe_webhook_missing")
+ }
+ if !priceStatus["oneHour"] && !priceStatus["tenHours"] && !priceStatus["hundredHours"] {
+ issues = append(issues, "stripe_hour_prices_missing")
+ }
+ if !priceStatus["projectQuota"] {
+ issues = append(issues, "stripe_project_quota_price_missing")
+ }
+ return issues
+}
+
+func (s *Server) adminReadiness(cfg map[string]string, pool []AgentPoolOption, poolHealth []AgentPoolHealth) AdminReadiness {
+ stripeCfg := stripeConfigFromMap(cfg)
+ priceStatus := stripePriceStatus(stripeCfg)
+ checks := []AdminReadinessCheck{}
+ stripeSecretOK := strings.TrimSpace(stripeCfg["secret"]) != ""
+ stripeWebhookOK := strings.TrimSpace(stripeCfg["webhook"]) != ""
+ stripeHourPricesOK := priceStatus["oneHour"] || priceStatus["tenHours"] || priceStatus["hundredHours"]
+ addAdminReadinessCheck(&checks, "stripe_secret", "blocker", stripeSecretOK, missingReadinessDetail(stripeSecretOK, "set stripe_secret_key"))
+ addAdminReadinessCheck(&checks, "stripe_webhook", "blocker", stripeWebhookOK, missingReadinessDetail(stripeWebhookOK, "set stripe_webhook_secret"))
+ addAdminReadinessCheck(&checks, "stripe_hour_prices", "blocker", stripeHourPricesOK, missingReadinessDetail(stripeHourPricesOK, "set at least one hour-pack price id"))
+ addAdminReadinessCheck(&checks, "stripe_project_quota_price", "blocker", priceStatus["projectQuota"], missingReadinessDetail(priceStatus["projectQuota"], "set stripe_project_quota_price_id"))
+ greenfieldReady, greenfieldDetail := greenfieldTemplateReadiness(cfg)
+ addAdminReadinessCheck(&checks, "fibe_template_version", "blocker", greenfieldReady, greenfieldDetail)
+ activePoolCount := 0
+ for _, option := range pool {
+ if strings.TrimSpace(option.Status) == fibe.AssignmentStatusActive {
+ activePoolCount++
+ }
+ }
+ hasActivePool := activePoolCount > 0
+ addAdminReadinessCheck(&checks, "fibe_active_pool", "blocker", hasActivePool, missingReadinessDetail(hasActivePool, "configure at least one active fibe_agent_server_pool row"))
+ healthyActivePool, activePoolDetail := activePoolReadiness(poolHealth, activePoolCount)
+ addAdminReadinessCheck(&checks, "fibe_active_pool_health", "blocker", healthyActivePool, activePoolDetail)
+ googleOAuthOK := strings.TrimSpace(cfg["google_client_id"]) != "" && strings.TrimSpace(cfg["google_client_secret"]) != ""
+ smtpDeliveryOK := strings.TrimSpace(cfg["smtp_host"]) != "" && strings.TrimSpace(cfg["smtp_from_email"]) != ""
+ addAdminReadinessCheck(&checks, "google_oauth", "blocker", googleOAuthOK, missingReadinessDetail(googleOAuthOK, "set google_client_id and google_client_secret"))
+ addAdminReadinessCheck(&checks, "smtp_delivery", "warning", smtpDeliveryOK, missingReadinessDetail(smtpDeliveryOK, "set smtp_host and smtp_from_email"))
+ signupMode := strings.TrimSpace(cfg["signup_mode"])
+ if signupMode == "" {
+ signupMode = "forbidden"
+ }
+ signupEnabled := signupMode != "forbidden"
+ addAdminReadinessCheck(&checks, "signup_enabled", "warning", signupEnabled, missingReadinessDetail(signupEnabled, "set signup_mode to all or allowlist"))
+
+ readiness := AdminReadiness{
+ CheckedAt: time.Now().UTC().Format(time.RFC3339Nano),
+ Checks: checks,
+ Ready: true,
+ }
+ for _, check := range checks {
+ if check.OK {
+ continue
+ }
+ if check.Severity == "warning" {
+ readiness.WarningCount++
+ continue
+ }
+ readiness.BlockerCount++
+ readiness.Ready = false
+ }
+ return readiness
+}
+
+func addAdminReadinessCheck(checks *[]AdminReadinessCheck, key, severity string, ok bool, detail string) {
+ *checks = append(*checks, AdminReadinessCheck{
+ Key: key,
+ OK: ok,
+ Severity: severity,
+ Detail: strings.TrimSpace(detail),
+ })
+}
+
+func missingReadinessDetail(ok bool, detail string) string {
+ if ok {
+ return ""
+ }
+ return detail
+}
+
+func greenfieldTemplateReadiness(cfg map[string]string) (bool, string) {
+ if strings.TrimSpace(cfg["fibe_template_version_id"]) != "" {
+ return true, "template version id"
+ }
+ body, err := fibe.GreenfieldTemplateBody()
+ if err != nil {
+ return false, err.Error()
+ }
+ if strings.TrimSpace(body) != "" {
+ return true, "bundled template body"
+ }
+ return false, "no template version id or bundled template body"
+}
+
+func activeAdminPoolOptions(pool []AgentPoolOption) []AgentPoolOption {
+ active := []AgentPoolOption{}
+ for _, option := range pool {
+ if strings.TrimSpace(option.Status) == fibe.AssignmentStatusActive {
+ active = append(active, option)
+ }
+ }
+ return active
+}
+
+func activePoolReadiness(poolHealth []AgentPoolHealth, activePoolCount int) (bool, string) {
+ if activePoolCount == 0 {
+ return false, "no active agent/server pairs configured"
+ }
+ failures := []string{}
+ for _, health := range poolHealth {
+ if strings.TrimSpace(health.Status) != fibe.AssignmentStatusActive {
+ continue
+ }
+ pair := readinessPoolPairLabel(health.Label, health.AgentID, health.ServerID)
+ if health.OK {
+ return true, pair
+ }
+ if len(health.Problems) > 0 {
+ failures = append(failures, pair+": "+strings.Join(health.Problems, "; "))
+ } else {
+ failures = append(failures, pair)
+ }
+ }
+ if len(failures) == 0 {
+ return false, "active agent/server pairs did not return health"
+ }
+ return false, strings.Join(failures, " | ")
+}
+
+func readinessPoolPairLabel(label, agentID, serverID string) string {
+ if strings.TrimSpace(label) != "" {
+ return strings.TrimSpace(label)
+ }
+ return strings.TrimSpace(agentID) + "/" + strings.TrimSpace(serverID)
+}
+
type adminRecoveryProject struct {
ID string `json:"id"`
UserID string `json:"userId"`
@@ -289,6 +511,12 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
s.handleAdminUserGrantHours(w, r, userID)
case len(parts) == 3 && parts[1] == "projects" && r.Method == http.MethodDelete:
s.handleAdminUserProjectDelete(w, r, userID, parts[2])
+ case len(parts) == 4 && parts[1] == "projects" && parts[3] == "diagnostics" && r.Method == http.MethodGet:
+ s.handleAdminUserProjectDiagnostics(w, r, userID, parts[2])
+ case len(parts) == 4 && parts[1] == "projects" && parts[3] == "production" && r.Method == http.MethodPost:
+ s.handleAdminUserProjectProductionGrant(w, r, userID, parts[2])
+ case len(parts) == 5 && parts[1] == "projects" && parts[3] == "production" && parts[4] == "start" && r.Method == http.MethodPost:
+ s.handleAdminUserProjectProductionStart(w, r, userID, parts[2])
case len(parts) == 4 && parts[1] == "projects" && parts[3] == "assignment" && r.Method == http.MethodPatch:
s.handleAdminUserProjectAssignment(w, r, userID, parts[2])
default:
@@ -498,6 +726,27 @@ func (s *Server) handleAdminUserProjectDelete(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusAccepted, map[string]any{"project": project})
}
+func (s *Server) handleAdminUserProjectDiagnostics(w http.ResponseWriter, r *http.Request, userID, projectID string) {
+ diagnostics, err := s.store.AdminProjectDiagnostics(r.Context(), userID, projectID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "project not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics})
+}
+
+func (s *Server) handleAdminUserProjectProductionGrant(w http.ResponseWriter, r *http.Request, userID, projectID string) {
+ writeError(w, http.StatusGone, "production hosting is handled in Fibe")
+}
+
+func (s *Server) handleAdminUserProjectProductionStart(w http.ResponseWriter, r *http.Request, userID, projectID string) {
+ writeError(w, http.StatusGone, "production hosting is handled in Fibe")
+}
+
func (s *Server) handleAdminUserProjectAssignment(w http.ResponseWriter, r *http.Request, userID, projectID string) {
var body struct {
AgentID string `json:"agent_id"`
@@ -624,6 +873,42 @@ func adminAgentPoolOptionsFromConfig(cfg map[string]string) ([]AgentPoolOption,
return options, nil
}
+func (s *Server) adminAgentPoolHealth(ctx context.Context, cfg map[string]string, pool []AgentPoolOption) []AgentPoolHealth {
+ if len(pool) == 0 {
+ return []AgentPoolHealth{}
+ }
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ out := make([]AgentPoolHealth, 0, len(pool))
+ for _, option := range pool {
+ health := AgentPoolHealth{
+ Label: strings.TrimSpace(option.Label),
+ AgentID: strings.TrimSpace(option.AgentID),
+ ServerID: strings.TrimSpace(option.ServerID),
+ Status: fibe.AssignmentStatus(fibe.Assignment{Status: option.Status}),
+ }
+ client, err := s.fibeClientFromConfig(cfg, fibe.Assignment{AgentID: health.AgentID, MarqueeID: health.ServerID})
+ if err != nil {
+ health.Problems = []string{err.Error()}
+ out = append(out, health)
+ continue
+ }
+ checked := client.AssignmentHealth(ctx)
+ health.AgentStatus = checked.AgentStatus
+ health.AgentAuthenticated = checked.AgentAuthenticated
+ health.ServerStatus = checked.MarqueeStatus
+ health.ServerBillingRuntimeActive = checked.MarqueeBillingRuntimeActive
+ health.ServerChatLaunchable = checked.MarqueeChatLaunchable
+ health.Problems = checked.Problems
+ health.OK = checked.OK
+ out = append(out, health)
+ if ctx.Err() != nil {
+ break
+ }
+ }
+ return out
+}
+
func decorateAdminDetailAssignmentStatuses(detail *AdminUserDetail, pool []AgentPoolOption) {
if detail == nil {
return
@@ -691,9 +976,21 @@ func firstNonEmptyString(values ...string) string {
func publicAdminConfig(cfg map[string]string) map[string]any {
out := map[string]any{}
- for _, key := range []string{"fibe_base_url", "fibe_agent_server_pool", "fibe_template_version_id", "free_hours", "free_hour_window_hours", "prompt_improve_charge_minutes", "project_cap", "signup_mode", "signup_allowed_emails", "stripe_publishable_key", "stripe_price_id_1_hour", "stripe_price_id_10_hours", "stripe_price_id_100_hours", "stripe_project_quota_price_id", "github_client_id", "github_username", "google_client_id", "smtp_host", "smtp_port", "smtp_username", "smtp_from_email", "smtp_from_name", "smtp_tls_mode"} {
+ for _, key := range []string{"fibe_base_url", "fibe_agent_server_pool", "fibe_template_version_id", "free_minutes", "free_hour_window_hours", "playground_idle_stop_hours", "prompt_improve_charge_minutes", "project_cap", "project_quota_days", "signup_mode", "signup_allowed_emails", "stripe_publishable_key", "stripe_price_id_1_hour", "stripe_price_id_10_hours", "stripe_price_id_100_hours", "stripe_project_quota_price_id", "github_client_id", "github_username", "google_client_id", "smtp_host", "smtp_port", "smtp_username", "smtp_from_email", "smtp_from_name", "smtp_tls_mode"} {
value := cfg[key]
set := strings.TrimSpace(cfg[key]) != ""
+ if key == "free_minutes" && strings.TrimSpace(value) == "" {
+ if legacyHours := strings.TrimSpace(cfg["free_hours"]); legacyHours != "" {
+ if n, err := strconv.Atoi(legacyHours); err == nil && n >= 0 {
+ minutes := n * 60
+ if minutes > maxFreeBuildMinutes {
+ minutes = maxFreeBuildMinutes
+ }
+ value = strconv.Itoa(minutes)
+ set = true
+ }
+ }
+ }
if key == "fibe_agent_server_pool" && strings.TrimSpace(value) == "" {
value = cfg["fibe_agent_marquee_pool"]
set = strings.TrimSpace(value) != ""
@@ -714,14 +1011,18 @@ func publicAdminConfig(cfg map[string]string) map[string]any {
func publicConfigDefault(key string) string {
switch key {
- case "free_hours":
- return "5"
+ case "free_minutes":
+ return strconv.Itoa(defaultFreeBuildMinutes)
case "free_hour_window_hours":
return strconv.Itoa(defaultFreeHourWindowHours)
+ case "playground_idle_stop_hours":
+ return strconv.Itoa(defaultPlaygroundIdleStopHours)
case "prompt_improve_charge_minutes":
return "0"
case "project_cap":
return "3"
+ case "project_quota_days":
+ return strconv.Itoa(defaultProjectQuotaDays)
case "signup_mode":
return "forbidden"
case "fibe_agent_server_pool":
@@ -758,6 +1059,17 @@ func normalizeAdminConfigValues(values map[string]string) (map[string]string, er
}
case "smtp_tls_mode":
out[key] = normalizeSMTPTLSMode(value)
+ case "free_minutes":
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ out[key] = ""
+ continue
+ }
+ n, err := strconv.Atoi(trimmed)
+ if err != nil || n < 0 || n > maxFreeBuildMinutes {
+ return nil, errors.New("free_minutes must be between 0 and 1440")
+ }
+ out[key] = strconv.Itoa(n)
case "free_hour_window_hours":
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -769,6 +1081,17 @@ func normalizeAdminConfigValues(values map[string]string) (map[string]string, er
return nil, errors.New("free_hour_window_hours must be between 1 and 24")
}
out[key] = strconv.Itoa(n)
+ case "playground_idle_stop_hours":
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ out[key] = ""
+ continue
+ }
+ n, err := strconv.Atoi(trimmed)
+ if err != nil || n <= 0 || n > maxPlaygroundIdleStopHours {
+ return nil, errors.New("playground_idle_stop_hours must be between 1 and 168")
+ }
+ out[key] = strconv.Itoa(n)
case "prompt_improve_charge_minutes":
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -780,6 +1103,17 @@ func normalizeAdminConfigValues(values map[string]string) (map[string]string, er
return nil, errors.New("prompt_improve_charge_minutes must be between 0 and 60")
}
out[key] = strconv.Itoa(n)
+ case "project_quota_days":
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ out[key] = ""
+ continue
+ }
+ n, err := strconv.Atoi(trimmed)
+ if err != nil || n <= 0 || n > maxProjectQuotaDays {
+ return nil, errors.New("project_quota_days must be between 1 and 365")
+ }
+ out[key] = strconv.Itoa(n)
default:
out[key] = strings.TrimSpace(value)
}
diff --git a/internal/likeable/jobs.go b/internal/likeable/jobs.go
index 16695ec..0639c22 100644
--- a/internal/likeable/jobs.go
+++ b/internal/likeable/jobs.go
@@ -12,7 +12,6 @@ import (
"strings"
"time"
- "github.com/fibegg/likeable/internal/domain"
projecttext "github.com/fibegg/likeable/internal/project"
"github.com/hibiken/asynq"
)
@@ -29,20 +28,21 @@ const projectDeletionSweepInterval = 5 * time.Minute
const projectDeletionSweepUniqueTTL = 4 * time.Minute
const maxConcurrentProjectCleanup = 1
const defaultJobWorkerConcurrency = 32
-const idleProjectStopAfter = domain.PlaygroundIdleStopAfter
const (
- taskProvisionProject = "likeable:project:provision"
- taskRecoverProject = "likeable:project:recover"
- taskDeleteProjectResources = "likeable:project:delete_resources"
- taskDeleteAccount = "likeable:account:delete"
- taskProjectDeletionSweep = "likeable:project:deletion_sweep"
- taskArchiveDeleteProject = "likeable:project:archive_delete"
- taskStopIdleProjectsSweep = "likeable:project:stop_idle_sweep"
- taskStopIdleProject = "likeable:project:stop_idle"
- taskMonitorProjectNotifications = "likeable:project:monitor_notifications"
- taskSendEmail = "likeable:email:send"
- taskProjectQuotaSweep = "likeable:project_quota:sweep"
+ taskProvisionProject = "likeable:project:provision"
+ taskRecoverProject = "likeable:project:recover"
+ taskDeleteProjectResources = "likeable:project:delete_resources"
+ taskDeleteAccount = "likeable:account:delete"
+ taskProjectDeletionSweep = "likeable:project:deletion_sweep"
+ taskArchiveDeleteProject = "likeable:project:archive_delete"
+ taskStopIdleProjectsSweep = "likeable:project:stop_idle_sweep"
+ taskStopIdleProject = "likeable:project:stop_idle"
+ taskStartProductionProjectsSweep = "likeable:project:start_production_sweep"
+ taskMonitorProjectNotifications = "likeable:project:monitor_notifications"
+ taskSendEmail = "likeable:email:send"
+ taskProjectQuotaSweep = "likeable:project_quota:sweep"
+ taskProjectDomainVerifySweep = "likeable:project_domain:verify_sweep"
)
var errProjectCleanupConcurrencyLimit = errors.New("project cleanup concurrency limit reached")
@@ -85,6 +85,8 @@ func newJobSystem(redisOpt asynq.RedisClientOpt, s *Server) *JobSystem {
mux.HandleFunc(taskMonitorProjectNotifications, s.handleMonitorProjectNotificationsTask)
mux.HandleFunc(taskSendEmail, s.handleSendEmailTask)
mux.HandleFunc(taskProjectQuotaSweep, s.handleProjectQuotaSweepTask)
+ mux.HandleFunc(taskProjectDomainVerifySweep, s.handleProjectDomainVerifySweepTask)
+ mux.HandleFunc(taskStartProductionProjectsSweep, s.handleStartProductionProjectsSweepTask)
server := asynq.NewServer(redisOpt, asynq.Config{
Concurrency: jobWorkerConcurrency(),
ShutdownTimeout: 20 * time.Second,
@@ -613,8 +615,17 @@ func (s *Server) handleProjectQuotaSweepTask(ctx context.Context, _ *asynq.Task)
return s.cleanupExpiredArchives(ctx)
}
+func (s *Server) handleProjectDomainVerifySweepTask(ctx context.Context, _ *asynq.Task) error {
+ return nil
+}
+
+func (s *Server) handleStartProductionProjectsSweepTask(ctx context.Context, _ *asynq.Task) error {
+ return nil
+}
+
func (s *Server) handleStopIdleProjectsSweepTask(ctx context.Context, _ *asynq.Task) error {
- cutoff := time.Now().UTC().Add(-idleProjectStopAfter)
+ idleStopAfter := s.playgroundIdleStopAfter(ctx)
+ cutoff := time.Now().UTC().Add(-idleStopAfter)
projects, err := s.store.IdleProjectsForPlaygroundStop(ctx, cutoff, 100)
if err != nil {
return err
@@ -628,7 +639,7 @@ func (s *Server) handleStopIdleProjectsSweepTask(ctx context.Context, _ *asynq.T
}
return err
}
- if err := s.enqueueProjectJob(ctx, taskStopIdleProject, projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID, Reason: "idle for 8 hours"}, asynq.Queue("low"), asynq.MaxRetry(6), asynq.Timeout(2*time.Minute), asynq.Unique(30*time.Minute)); err != nil {
+ if err := s.enqueueProjectJob(ctx, taskStopIdleProject, projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID, Reason: "idle for " + idleStopAfter.String()}, asynq.Queue("low"), asynq.MaxRetry(6), asynq.Timeout(2*time.Minute), asynq.Unique(30*time.Minute)); err != nil {
return err
}
}
@@ -647,7 +658,8 @@ func (s *Server) handleStopIdleProjectTask(ctx context.Context, task *asynq.Task
}
return err
}
- idle, skipReason, err := s.store.ProjectIdleForPlaygroundStop(ctx, project.ID, time.Now().UTC().Add(-idleProjectStopAfter))
+ idleStopAfter := s.playgroundIdleStopAfter(ctx)
+ idle, skipReason, err := s.store.ProjectIdleForPlaygroundStop(ctx, project.ID, time.Now().UTC().Add(-idleStopAfter))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
@@ -668,7 +680,7 @@ func (s *Server) handleStopIdleProjectTask(ctx context.Context, task *asynq.Task
}
return err
}
- log.Printf("stop idle playground for project %s: no explicit playground activity for %s", project.ID, idleProjectStopAfter)
+ log.Printf("stop idle playground for project %s: no explicit playground activity for %s", project.ID, idleStopAfter)
_, err = s.controlProjectPlayground(ctx, user, project, "stop")
return err
}
diff --git a/internal/likeable/project_handlers.go b/internal/likeable/project_handlers.go
index dabe66f..52f4ca1 100644
--- a/internal/likeable/project_handlers.go
+++ b/internal/likeable/project_handlers.go
@@ -30,6 +30,7 @@ func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) {
return
}
projects = s.refreshProjectListResources(r.Context(), user, projects)
+ s.decorateProjectsComputedFields(r.Context(), projects)
s.recoverProjectsAsync(user.ID, user.Email, projects)
projectQuota := s.projectQuota(r.Context(), user)
writeJSON(w, http.StatusOK, map[string]any{"projects": projects, "projectCap": projectQuota["limit"], "projectQuota": projectQuota})
@@ -66,6 +67,20 @@ func (s *Server) refreshProjectListResources(ctx context.Context, user *User, pr
return out
}
+func (s *Server) decorateProjectComputedFields(ctx context.Context, project *Project) {
+ if project == nil {
+ return
+ }
+ project.RefreshComputedFieldsWithIdleStopAfter(s.playgroundIdleStopAfter(ctx))
+}
+
+func (s *Server) decorateProjectsComputedFields(ctx context.Context, projects []Project) {
+ idleStopAfter := s.playgroundIdleStopAfter(ctx)
+ for i := range projects {
+ projects[i].RefreshComputedFieldsWithIdleStopAfter(idleStopAfter)
+ }
+}
+
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request, user *User) {
var body struct {
Prompt string `json:"prompt"`
@@ -127,6 +142,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request, use
}
s.notifyProjectQuotaIfNeeded(r.Context(), user)
created, _ := s.store.ProjectForUser(r.Context(), user.ID, project.ID)
+ s.decorateProjectComputedFields(r.Context(), created)
writeJSON(w, http.StatusCreated, map[string]any{"project": created})
}
@@ -147,6 +163,7 @@ func (s *Server) handleProjectRoute(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.recoverProjectAsync(user.ID, user.Email, project)
+ s.decorateProjectComputedFields(r.Context(), project)
writeJSON(w, http.StatusOK, map[string]any{"project": project})
case http.MethodPatch:
s.handleProjectUpdate(w, r, user, project)
@@ -174,6 +191,16 @@ func (s *Server) handleProjectRoute(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not found")
case "playground":
s.handleProjectPlaygroundAction(w, r, user, project)
+ case "domain":
+ if len(parts) == 2 {
+ s.handleProjectDomain(w, r, user, project)
+ return
+ }
+ if len(parts) == 3 && parts[2] == "verify" {
+ s.handleProjectDomainVerify(w, r, user, project)
+ return
+ }
+ writeError(w, http.StatusNotFound, "not found")
case "attachments":
if len(parts) != 3 {
writeError(w, http.StatusNotFound, "not found")
@@ -229,12 +256,14 @@ func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request, use
writeError(w, http.StatusInternalServerError, err.Error())
return
}
+ s.decorateProjectComputedFields(r.Context(), updated)
writeJSON(w, http.StatusOK, map[string]any{"project": updated})
}
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request, user *User, project *Project) {
if project.Status == "deleting" {
s.deleteProjectResourcesAsync(user.ID, user.Email, project)
+ s.decorateProjectComputedFields(r.Context(), project)
writeJSON(w, http.StatusAccepted, map[string]any{"project": project})
return
}
@@ -245,6 +274,7 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request, use
project.Status = "deleting"
s.notifyProjectDeletionScheduled(r.Context(), user, project)
s.deleteProjectResourcesAsync(user.ID, user.Email, project)
+ s.decorateProjectComputedFields(r.Context(), project)
writeJSON(w, http.StatusAccepted, map[string]any{"project": project})
}
@@ -289,7 +319,8 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re
writeError(w, http.StatusBadRequest, "invalid json")
return
}
- updated, err := s.controlProjectPlayground(r.Context(), user, project, strings.ToLower(strings.TrimSpace(body.Action)))
+ action := strings.ToLower(strings.TrimSpace(body.Action))
+ updated, err := s.controlProjectPlayground(r.Context(), user, project, action)
if err != nil {
if errors.Is(err, errProjectExportOnly) || errors.Is(err, errProjectRetiring) {
writeError(w, http.StatusConflict, developmentBlockedMessage(err))
@@ -308,6 +339,7 @@ func (s *Server) handleProjectPlaygroundAction(w http.ResponseWriter, r *http.Re
return
}
s.clearPlatformBackoff()
+ s.decorateProjectComputedFields(r.Context(), updated)
writeJSON(w, http.StatusAccepted, map[string]any{"project": updated})
}
@@ -334,6 +366,15 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje
nextStatus := "launching"
switch action {
case "start":
+ if strings.TrimSpace(project.PreviewURL) != "" {
+ updated, ready, _, _, err := s.promoteProjectFromReachablePreview(actionCtx, user.ID, project)
+ if err == nil && ready && updated != nil {
+ if err := s.store.TouchProjectPlaygroundUsage(ctx, updated.ID, user.ID); err != nil {
+ return nil, err
+ }
+ return s.store.ProjectForUser(ctx, user.ID, updated.ID)
+ }
+ }
err = fibeClient.StartPlayground(actionCtx, playgroundID)
case "stop":
nextStatus = "stopped"
@@ -365,6 +406,14 @@ func (s *Server) controlProjectPlayground(ctx context.Context, user *User, proje
return s.store.ProjectForUser(ctx, user.ID, project.ID)
}
+func (s *Server) handleProjectDomain(w http.ResponseWriter, r *http.Request, user *User, project *Project) {
+ writeError(w, http.StatusGone, "custom domains are managed in Fibe")
+}
+
+func (s *Server) handleProjectDomainVerify(w http.ResponseWriter, r *http.Request, user *User, project *Project) {
+ writeError(w, http.StatusGone, "custom domains are managed in Fibe")
+}
+
func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user *User, project *Project) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -379,6 +428,7 @@ func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user
log.Printf("load project notification timings for project %s: %v", project.ID, timingErr)
timings = map[string]ProjectNotificationTiming{}
}
+ s.decorateProjectComputedFields(r.Context(), project)
writeJSON(w, http.StatusOK, map[string]any{"project": project, "localMessages": local, "messages": []any{}, "activity": []any{}, "live": nil, "notificationTimings": timings, "warning": "This project is archived. Export it or create a new project."})
return
}
@@ -392,6 +442,7 @@ func (s *Server) handleProjectFeed(w http.ResponseWriter, r *http.Request, user
}
cancel()
}
+ s.decorateProjectComputedFields(r.Context(), project)
snapshot, err := s.loadProjectFeedSnapshot(r.Context(), user, project, true)
if err != nil {
log.Printf("load project feed for project %s: %v", project.ID, err)
@@ -419,6 +470,7 @@ func (s *Server) handleProjectPreviewStatus(w http.ResponseWriter, r *http.Reque
return
}
readinessRefreshed := false
+ resourceStatusRefreshed := false
if projectNeedsReadinessRecovery(project) && user != nil {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
updated, err := s.refreshProjectReadiness(ctx, user, project)
@@ -437,11 +489,32 @@ func (s *Server) handleProjectPreviewStatus(w http.ResponseWriter, r *http.Reque
cancel()
if err == nil && updated != nil {
project = updated
+ resourceStatusRefreshed = true
} else if err != nil {
log.Printf("preview status project resource refresh %s: %v", project.ID, err)
}
}
if project.Status == "stopped" {
+ if !resourceStatusRefreshed && strings.TrimSpace(project.PreviewURL) != "" {
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ updated, ready, status, maintenance, err := s.promoteProjectFromReachablePreview(ctx, project.UserID, project)
+ cancel()
+ if err == nil && updated != nil {
+ project = updated
+ } else if err != nil {
+ log.Printf("stopped preview status probe for project %s failed: %v", project.ID, err)
+ }
+ if ready || maintenance {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ready": ready,
+ "maintenance": maintenance,
+ "status": publicPreviewProbeStatus(status),
+ "checkedAt": nowString(),
+ "project": project,
+ })
+ return
+ }
+ }
writeJSON(w, http.StatusOK, map[string]any{
"ready": false,
"status": "stopped",
diff --git a/internal/likeable/project_provisioning.go b/internal/likeable/project_provisioning.go
index 747933f..e7477cf 100644
--- a/internal/likeable/project_provisioning.go
+++ b/internal/likeable/project_provisioning.go
@@ -286,12 +286,17 @@ func (s *Server) provisionProject(ctx context.Context, userID, userEmail string,
}
func (s *Server) recordProjectProvisionFailure(ctx context.Context, userID string, project *Project, err error, retriesRemaining bool) {
+ if project == nil {
+ log.Printf("project provisioning failed without project retry=%t: %v", retriesRemaining, err)
+ return
+ }
+ log.Printf("project provisioning failed project=%s retry=%t: %v", project.ID, retriesRemaining, err)
if retriesRemaining && retryProjectProvisionLater(project, err) {
status := "creating"
if projectHasProvisionedResources(project) {
status = "launching"
}
- _ = s.store.UpdateProjectStatus(ctx, project.ID, userID, status)
+ _ = s.store.UpdateProjectProvisioningRetryError(ctx, project.ID, userID, status, err)
return
}
_ = s.store.UpdateProjectErrorFromError(ctx, project.ID, userID, err)
diff --git a/internal/likeable/project_provisioning_test.go b/internal/likeable/project_provisioning_test.go
index d39587a..74fa915 100644
--- a/internal/likeable/project_provisioning_test.go
+++ b/internal/likeable/project_provisioning_test.go
@@ -160,6 +160,13 @@ func TestRecordProjectProvisionFailureKeepsTransientPreGreenfieldFailureCreating
if stored.ErrorMessage != "" {
t.Fatalf("error_message=%q, want empty", stored.ErrorMessage)
}
+ diagnostics, err := store.AdminProjectDiagnostics(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(diagnostics.Internal.InternalErrorMessage, "unexpected status 422") {
+ t.Fatalf("internal_error_message=%q, want provisioning retry cause", diagnostics.Internal.InternalErrorMessage)
+ }
}
func TestProjectNeedsProvisioningRecoveryWaitsForFreshOrLockedProject(t *testing.T) {
diff --git a/internal/likeable/project_quota.go b/internal/likeable/project_quota.go
index 978434c..ebda841 100644
--- a/internal/likeable/project_quota.go
+++ b/internal/likeable/project_quota.go
@@ -8,11 +8,18 @@ import (
)
const (
- defaultFreeHourWindowHours = 5
- maxFreeHourWindowHours = 24
- maxPromptImproveChargeMin = 60
- freeHourWindow = time.Duration(defaultFreeHourWindowHours) * time.Hour
- msPerHour = int64(time.Hour / time.Millisecond)
+ defaultFreeBuildMinutes = 30
+ maxFreeBuildMinutes = 24 * 60
+ defaultFreeHourWindowHours = 5
+ maxFreeHourWindowHours = 24
+ defaultPlaygroundIdleStopHours = 8
+ maxPlaygroundIdleStopHours = 168
+ defaultProjectQuotaDays = 30
+ maxProjectQuotaDays = 365
+ maxPromptImproveChargeMin = 60
+ freeHourWindow = time.Duration(defaultFreeHourWindowHours) * time.Hour
+ msPerHour = int64(time.Hour / time.Millisecond)
+ msPerMinute = int64(time.Minute / time.Millisecond)
)
func (s *Server) canSendMessage(ctx context.Context, user *User) bool {
@@ -105,21 +112,37 @@ func fixedUTCHourWindow(now time.Time, interval time.Duration) (time.Time, time.
return start, end
}
-func (s *Server) freeHourLimit(ctx context.Context) int {
+func (s *Server) freeBuildLimitMinutes(ctx context.Context) int {
cfg, _ := s.store.ConfigMap(ctx)
- raw := strings.TrimSpace(cfg["free_hours"])
+ raw := strings.TrimSpace(cfg["free_minutes"])
if raw == "" {
- raw = "5"
+ legacyHours := strings.TrimSpace(cfg["free_hours"])
+ if legacyHours != "" {
+ n, err := strconv.Atoi(legacyHours)
+ if err == nil && n >= 0 {
+ minutes := n * 60
+ if minutes > maxFreeBuildMinutes {
+ return maxFreeBuildMinutes
+ }
+ return minutes
+ }
+ }
+ }
+ if raw == "" {
+ raw = strconv.Itoa(defaultFreeBuildMinutes)
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
- return 5
+ return defaultFreeBuildMinutes
+ }
+ if n > maxFreeBuildMinutes {
+ return maxFreeBuildMinutes
}
return n
}
func (s *Server) freeHourLimitMs(ctx context.Context) int64 {
- return int64(s.freeHourLimit(ctx)) * msPerHour
+ return int64(s.freeBuildLimitMinutes(ctx)) * msPerMinute
}
func (s *Server) freeHourWindowHours(ctx context.Context) int {
@@ -135,6 +158,26 @@ func (s *Server) freeHourWindowHours(ctx context.Context) int {
return n
}
+func (s *Server) playgroundIdleStopHours(ctx context.Context) int {
+ cfg, _ := s.store.ConfigMap(ctx)
+ raw := strings.TrimSpace(cfg["playground_idle_stop_hours"])
+ if raw == "" {
+ return defaultPlaygroundIdleStopHours
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n <= 0 {
+ return defaultPlaygroundIdleStopHours
+ }
+ if n > maxPlaygroundIdleStopHours {
+ return maxPlaygroundIdleStopHours
+ }
+ return n
+}
+
+func (s *Server) playgroundIdleStopAfter(ctx context.Context) time.Duration {
+ return time.Duration(s.playgroundIdleStopHours(ctx)) * time.Hour
+}
+
func (s *Server) promptImproveChargeMinutes(ctx context.Context) int {
cfg, _ := s.store.ConfigMap(ctx)
raw := strings.TrimSpace(cfg["prompt_improve_charge_minutes"])
@@ -151,6 +194,22 @@ func (s *Server) promptImproveChargeMinutes(ctx context.Context) int {
return n
}
+func (s *Server) projectQuotaDays(ctx context.Context) int {
+ cfg, _ := s.store.ConfigMap(ctx)
+ raw := strings.TrimSpace(cfg["project_quota_days"])
+ if raw == "" {
+ return defaultProjectQuotaDays
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n <= 0 {
+ return defaultProjectQuotaDays
+ }
+ if n > maxProjectQuotaDays {
+ return maxProjectQuotaDays
+ }
+ return n
+}
+
func (s *Server) projectCap(ctx context.Context) int {
return s.baseProjectCap(ctx)
}
diff --git a/internal/likeable/routes.go b/internal/likeable/routes.go
index c050b0b..a91ab05 100644
--- a/internal/likeable/routes.go
+++ b/internal/likeable/routes.go
@@ -116,6 +116,10 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
s.withAdmin(s.handleAdminConfig)(w, r)
case r.URL.Path == "/api/admin/recovery":
s.withAdmin(s.handleAdminRecovery)(w, r)
+ case r.URL.Path == "/api/admin/readiness":
+ s.withAdmin(s.handleAdminReadiness)(w, r)
+ case r.URL.Path == "/api/admin/billing/health":
+ s.withAdmin(s.handleAdminBillingHealth)(w, r)
case r.URL.Path == "/api/admin/agent-pool/retire":
s.withAdmin(s.handleAdminAgentPoolRetire)(w, r)
case r.URL.Path == "/api/admin/users" || strings.HasPrefix(r.URL.Path, "/api/admin/users/"):
diff --git a/internal/likeable/server_test.go b/internal/likeable/server_test.go
index ed2b610..9744136 100644
--- a/internal/likeable/server_test.go
+++ b/internal/likeable/server_test.go
@@ -583,8 +583,9 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) {
t.Fatal(err)
}
readProducts := func() struct {
- HourPacks []int `json:"hourPacks"`
- ProjectQuota bool `json:"projectQuota"`
+ HourPacks []int `json:"hourPacks"`
+ ProjectQuota bool `json:"projectQuota"`
+ ProjectQuotaDays int `json:"projectQuotaDays"`
} {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
@@ -596,8 +597,9 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) {
}
var body struct {
BillingProducts struct {
- HourPacks []int `json:"hourPacks"`
- ProjectQuota bool `json:"projectQuota"`
+ HourPacks []int `json:"hourPacks"`
+ ProjectQuota bool `json:"projectQuota"`
+ ProjectQuotaDays int `json:"projectQuotaDays"`
} `json:"billingProducts"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
@@ -615,11 +617,12 @@ func TestMeIncludesOnlyConfiguredBillingProducts(t *testing.T) {
"stripe_price_id_1_hour": "price_1h",
"stripe_price_id_100_hours": "price_100h",
"stripe_project_quota_price_id": "price_project_slot",
+ "project_quota_days": "21",
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
products = readProducts()
- if !reflect.DeepEqual(products.HourPacks, []int{1, 100}) || !products.ProjectQuota {
+ if !reflect.DeepEqual(products.HourPacks, []int{1, 100}) || !products.ProjectQuota || products.ProjectQuotaDays != 21 {
t.Fatalf("billing products=%+v, want configured packs and project quota", products)
}
}
@@ -1292,6 +1295,62 @@ func TestProjectPreviewStatusPromotesReachablePreviewWithoutPlatformConfig(t *te
}
}
+func TestProjectPreviewStatusPromotesStoppedReachablePreview(t *testing.T) {
+ previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte("already running "))
+ }))
+ defer previewServer.Close()
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: previewServer.Client()}
+ user, _ := store.UpsertUser(t.Context(), "stopped-preview@example.com", "Stopped Preview", "")
+ if err := store.CreateSession(t.Context(), user.ID, "stopped-preview-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{
+ ID: "project-stopped-preview-ready",
+ UserID: user.ID,
+ Title: "Stopped Preview",
+ ConversationID: "conv-stopped-preview",
+ PlaygroundID: "playground-stopped-preview",
+ PreviewURL: previewServer.URL,
+ Status: "stopped",
+ }
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/projects/project-stopped-preview-ready/preview-status", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "stopped-preview-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("preview-status returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Ready bool `json:"ready"`
+ Status string `json:"status"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if !body.Ready || body.Status != "200 OK" {
+ t.Fatalf("body=%+v, want reachable stopped preview promoted", body)
+ }
+ updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Status != "ready" {
+ t.Fatalf("status=%q, want ready", updated.Status)
+ }
+}
+
func TestProjectPreviewStatusMarksReachablePreviewReady(t *testing.T) {
previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -3766,184 +3825,198 @@ func TestProjectPlaygroundLifecycleActions(t *testing.T) {
}
}
-func TestProjectPassiveActionsDoNotTouchPlaygroundUsage(t *testing.T) {
+func TestProjectPlaygroundStartPromotesReachableStoppedPreview(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, _, _ := fakeFibeCLI(t)
+ _, logPath, _ := fakeFibeCLI(t)
+ previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte("already running "))
+ }))
+ defer previewServer.Close()
if err := store.UpsertConfig(t.Context(), map[string]string{
"fibe_base_url": "server.test:3000",
"fibe_api_key": "test-key",
- "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "activity@example.com", "Activity", "")
+ server := &Server{
+ store: store,
+ config: RuntimeConfig{BaseURL: "http://example.test"},
+ http: fakeFibeHTTPClient(previewServer.Client(), fakeFibeTransportConfig{Mode: "default", LogPath: logPath}),
+ }
+ user, err := store.UpsertUser(t.Context(), "project-start-preview@example.com", "Project Start Preview", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "activity-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "project-start-preview-token", time.Hour); err != nil {
t.Fatal(err)
}
- oldUsage := time.Now().UTC().Add(-9 * time.Hour).Format(time.RFC3339Nano)
- switchProject := &Project{ID: "project-service-activity", UserID: user.ID, Title: "Service", ConversationID: "conv-service-activity", AgentID: "agent-1", PlaygroundID: "playground-service-activity", PreviewURL: "http://app.example.test", Status: "ready", PlaygroundLastUsedAt: oldUsage}
- if err := store.CreateProject(t.Context(), switchProject); err != nil {
- t.Fatal(err)
+ project := &Project{
+ ID: "project-start-preview",
+ UserID: user.ID,
+ Title: "Start Preview",
+ ConversationID: "conv-start-preview",
+ AgentID: "agent-1",
+ MarqueeID: "server-1",
+ PlaygroundID: "playground-start-preview",
+ PreviewURL: previewServer.URL,
+ Status: "stopped",
}
- if err := store.ReplaceProjectResources(t.Context(), switchProject.ID, nil, []ProjectService{
- {ProjectID: switchProject.ID, Name: "app", URL: "http://app.example.test", Type: "dynamic", Visibility: "external"},
- {ProjectID: switchProject.ID, Name: "api", URL: "http://api.example.test", Type: "dynamic", Visibility: "external"},
- }); err != nil {
+ if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- req := httptest.NewRequest(http.MethodPatch, "/api/projects/project-service-activity", strings.NewReader(`{"selectedServiceName":"api"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "activity-token"})
+
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-start-preview/playground", strings.NewReader(`{"action":"start"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "project-start-preview-token"})
rec := httptest.NewRecorder()
server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("service switch returned %d: %s", rec.Code, rec.Body.String())
- }
- updated, err := store.ProjectForUser(t.Context(), user.ID, switchProject.ID)
- if err != nil {
- t.Fatal(err)
- }
- if updated.PlaygroundLastUsedAt != oldUsage {
- t.Fatalf("service switch changed playground_last_used_at from %q to %q", oldUsage, updated.PlaygroundLastUsedAt)
- }
- interruptProject := &Project{ID: "project-interrupt-activity", UserID: user.ID, Title: "Interrupt", ConversationID: "conv-interrupt-activity", AgentID: "agent-1", PlaygroundID: "playground-interrupt-activity", Status: "ready", PlaygroundLastUsedAt: oldUsage}
- if err := store.CreateProject(t.Context(), interruptProject); err != nil {
- t.Fatal(err)
- }
- req = httptest.NewRequest(http.MethodPost, "/api/projects/project-interrupt-activity/agent/interrupt", nil)
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "activity-token"})
- rec = httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
- t.Fatalf("interrupt returned %d: %s", rec.Code, rec.Body.String())
+ t.Fatalf("start returned %d, want 202; body=%s", rec.Code, rec.Body.String())
}
- updated, err = store.ProjectForUser(t.Context(), user.ID, interruptProject.ID)
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if updated.PlaygroundLastUsedAt != oldUsage {
- t.Fatalf("interrupt changed playground_last_used_at from %q to %q", oldUsage, updated.PlaygroundLastUsedAt)
- }
-
- passiveProject := &Project{ID: "project-passive-preview", UserID: user.ID, Title: "Passive", ConversationID: "conv-passive-preview", AgentID: "agent-1", PlaygroundID: "playground-passive-preview", Status: "ready", PlaygroundLastUsedAt: oldUsage}
- if err := store.CreateProject(t.Context(), passiveProject); err != nil {
- t.Fatal(err)
- }
- req = httptest.NewRequest(http.MethodGet, "/api/projects/project-passive-preview/preview-status", nil)
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "activity-token"})
- rec = httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("preview status returned %d: %s", rec.Code, rec.Body.String())
+ if stored.Status != "ready" || stored.PlaygroundLastUsedAt == "" {
+ t.Fatalf("project=%+v, want ready with touched usage", stored)
}
- updated, err = store.ProjectForUser(t.Context(), user.ID, passiveProject.ID)
- if err != nil {
+ if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start playground-start-preview") {
+ t.Fatalf("unexpected Fibe start for reachable preview; log=%s", log)
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
- if updated.PlaygroundLastUsedAt != oldUsage {
- t.Fatalf("passive preview changed playground_last_used_at from %q to %q", oldUsage, updated.PlaygroundLastUsedAt)
- }
}
-func TestProjectPlaygroundBlocksRetiredPoolPair(t *testing.T) {
+func TestLegacyProductionProjectPlaygroundCanBeStopped(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
+ cliPath, logPath, _ := fakeFibeCLI(t)
if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "test-key",
- "fibe_agent_server_pool": `[{"agent_id":"old-agent","server_id":"old-marquee","status":"retired"}]`,
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ user, err := store.UpsertUser(t.Context(), "production-stop@example.com", "Production Stop", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "retired-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "production-stop-token", time.Hour); err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-retired-control", UserID: user.ID, Title: "Control", ConversationID: "conv-retired-control", AgentID: "old-agent", MarqueeID: "old-marquee", PlaygroundID: "playground-1", Status: "ready"}
+ project := &Project{ID: "project-production-stop", UserID: user.ID, Title: "Production", ConversationID: "conv-production-stop", AgentID: "agent-1", PlaygroundID: "playground-production-stop", Status: "ready"}
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_stop", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("production grant=%v err=%v, want granted", granted, err)
+ }
- req := httptest.NewRequest(http.MethodPost, "/api/projects/project-retired-control/playground", strings.NewReader(`{"action":"stop"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "retired-token"})
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-production-stop/playground", strings.NewReader(`{"action":"stop"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-stop-token"})
rec := httptest.NewRecorder()
server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusConflict {
- t.Fatalf("stop returned %d, want 409; body=%s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("stop returned %d, want 202; body=%s", rec.Code, rec.Body.String())
}
- updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if updated.Status != "archived" {
- t.Fatalf("status=%q, want archived", updated.Status)
+ if stored.Status != "stopped" || stored.ProductionExpiresAt == "" {
+ t.Fatalf("project=%+v, want stopped legacy production project", stored)
+ }
+ if log, err := os.ReadFile(logPath); err == nil && !strings.Contains(string(log), "playgrounds stop playground-production-stop") {
+ t.Fatalf("missing stop command for legacy production project; log=%s", string(log))
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
}
}
-func TestProjectPlaygroundStopTreatsAlreadyStoppedAsSuccess(t *testing.T) {
+func TestLegacyProductionProjectPlaygroundStartRuntimeBillingIsGenericFailure(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, logPath := fakeAlreadyStoppedFibeCLI(t)
if err := store.UpsertConfig(t.Context(), map[string]string{
"fibe_base_url": "server.test:3000",
"fibe_api_key": "test-key",
- "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ logPath := filepath.Join(t.TempDir(), "fibe.log")
+ server := &Server{
+ store: store,
+ config: RuntimeConfig{BaseURL: "http://example.test"},
+ http: fakeFibeHTTPClient(http.DefaultClient, fakeFibeTransportConfig{Mode: "runtime-billing-required", LogPath: logPath}),
+ }
+ user, err := store.UpsertUser(t.Context(), "production-start-billing@example.com", "Production Start Billing", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "project-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "production-start-billing-token", time.Hour); err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-already-stopped-control", UserID: user.ID, Title: "Control", ConversationID: "conv-already-stopped-control", AgentID: "agent-1", PlaygroundID: "playground-1", Status: "ready"}
+ project := &Project{
+ ID: "project-production-start-billing",
+ UserID: user.ID,
+ Title: "Production Billing",
+ ConversationID: "conv-production-start-billing",
+ AgentID: "agent-1",
+ MarqueeID: "server-1",
+ PlaygroundID: "playground-production-start-billing",
+ Status: "stopped",
+ }
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_start_billing", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("production grant=%v err=%v, want granted", granted, err)
+ }
- req := httptest.NewRequest(http.MethodPost, "/api/projects/project-already-stopped-control/playground", strings.NewReader(`{"action":"stop"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "project-token"})
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-production-start-billing/playground", strings.NewReader(`{"action":"start"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-start-billing-token"})
rec := httptest.NewRecorder()
server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusAccepted {
- t.Fatalf("stop returned %d, want 202; body=%s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("start returned %d, want 502; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "could not update the playground") {
+ t.Fatalf("body=%s, want generic playground update failure", rec.Body.String())
}
stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
if stored.Status != "stopped" {
- t.Fatalf("status=%q, want stopped", stored.Status)
+ t.Fatalf("status=%q, want stopped after blocked start", stored.Status)
}
- if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-1") {
- t.Fatalf("missing stop command; log=%s", log)
+ notices, err := store.NoticesForUser(t.Context(), user.ID, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(notices) != 0 {
+ t.Fatalf("notices=%+v, want no production runtime warning", notices)
+ }
+ if log := readFile(t, logPath); strings.Count(log, "playgrounds start playground-production-start-billing") != 1 {
+ t.Fatalf("log=%s, want one start attempt", log)
}
}
-func TestIdleProjectStopTaskStopsPlayground(t *testing.T) {
+func TestProductionProjectStartSweepIsDisabled(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
@@ -3958,133 +4031,160 @@ func TestIdleProjectStopTaskStopsPlayground(t *testing.T) {
t.Fatal(err)
}
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "idle@example.com", "Idle", "")
+ user, err := store.UpsertUser(t.Context(), "production-sweep@example.com", "Production Sweep", "")
if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-idle", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)}
- if err := store.CreateProject(t.Context(), project); err != nil {
- t.Fatal(err)
+ production := &Project{ID: "project-production-sweep", UserID: user.ID, Title: "Production Sweep", ConversationID: "conv-production-sweep", AgentID: "agent-1", PlaygroundID: "playground-production-sweep", Status: "stopped"}
+ ordinary := &Project{ID: "project-ordinary-sweep", UserID: user.ID, Title: "Ordinary Sweep", ConversationID: "conv-ordinary-sweep", AgentID: "agent-1", PlaygroundID: "playground-ordinary-sweep", Status: "stopped"}
+ for _, project := range []*Project{production, ordinary} {
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
}
- if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-idleProjectStopAfter-time.Minute).Format(time.RFC3339Nano)); err != nil {
- t.Fatal(err)
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, production.ID, "cs_production_sweep", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("production grant=%v err=%v, want granted", granted, err)
}
- payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
- if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
- t.Fatal(err)
+ if err := server.handleStartProductionProjectsSweepTask(t.Context(), asynq.NewTask(taskStartProductionProjectsSweep, nil)); err != nil {
+ t.Fatalf("production start sweep returned error: %v", err)
}
- stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ storedProduction, err := store.ProjectForUser(t.Context(), user.ID, production.ID)
if err != nil {
t.Fatal(err)
}
- if stored.Status != "stopped" {
- t.Fatalf("status=%q, want stopped", stored.Status)
+ if storedProduction.Status != "stopped" || storedProduction.ProductionExpiresAt == "" {
+ t.Fatalf("production project=%+v, want unchanged stopped legacy production project", storedProduction)
}
- if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-idle") {
- t.Fatalf("missing stop command; log=%s", log)
+ storedOrdinary, err := store.ProjectForUser(t.Context(), user.ID, ordinary.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if storedOrdinary.Status != "stopped" {
+ t.Fatalf("ordinary project status=%q, want stopped", storedOrdinary.Status)
+ }
+ if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start") {
+ t.Fatalf("unexpected start command from disabled production sweep; log=%s", string(log))
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
}
}
-func TestIdleProjectStopTaskSkipsAfterRecentUsageReset(t *testing.T) {
+func TestProductionProjectStartSweepDoesNotPromoteReachableStoppedPreview(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, logPath, _ := fakeFibeCLI(t)
- if err := os.WriteFile(logPath, nil, 0o644); err != nil {
- t.Fatal(err)
- }
+ _, logPath, _ := fakeFibeCLI(t)
+ previewServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte("already running "))
+ }))
+ defer previewServer.Close()
if err := store.UpsertConfig(t.Context(), map[string]string{
"fibe_base_url": "server.test:3000",
"fibe_api_key": "test-key",
- "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "idle-reset@example.com", "Idle", "")
+ server := &Server{
+ store: store,
+ config: RuntimeConfig{BaseURL: "http://example.test"},
+ http: fakeFibeHTTPClient(previewServer.Client(), fakeFibeTransportConfig{Mode: "default", LogPath: logPath}),
+ }
+ user, err := store.UpsertUser(t.Context(), "production-sweep-preview@example.com", "Production Sweep Preview", "")
if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-idle-reset", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-reset", AgentID: "agent-1", PlaygroundID: "playground-idle-reset", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)}
+ project := &Project{
+ ID: "project-production-sweep-preview",
+ UserID: user.ID,
+ Title: "Production Sweep Preview",
+ ConversationID: "conv-production-sweep-preview",
+ AgentID: "agent-1",
+ MarqueeID: "server-1",
+ PlaygroundID: "playground-production-sweep-preview",
+ PreviewURL: previewServer.URL,
+ Status: "stopped",
+ }
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
- if err := store.TouchProjectPlaygroundUsage(t.Context(), project.ID, user.ID); err != nil {
- t.Fatal(err)
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_sweep_preview", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("production grant=%v err=%v, want granted", granted, err)
}
- if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
- t.Fatal(err)
+ if err := server.handleStartProductionProjectsSweepTask(t.Context(), asynq.NewTask(taskStartProductionProjectsSweep, nil)); err != nil {
+ t.Fatalf("production start sweep returned error: %v", err)
}
stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if stored.Status != "ready" {
- t.Fatalf("status=%q, want ready", stored.Status)
+ if stored.Status != "stopped" || stored.ProductionExpiresAt == "" {
+ t.Fatalf("project=%+v, want unchanged stopped legacy production project", stored)
}
- if log := readFile(t, logPath); strings.Contains(log, "playgrounds stop playground-idle-reset") {
- t.Fatalf("unexpected stop command after recent usage reset; log=%s", log)
+ if log, err := os.ReadFile(logPath); err == nil && strings.Contains(string(log), "playgrounds start playground-production-sweep-preview") {
+ t.Fatalf("unexpected Fibe start for reachable production preview; log=%s", log)
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
}
}
-func TestIdleProjectStopTaskTreatsAlreadyStoppedAsSuccess(t *testing.T) {
+func TestProjectCustomDomainEndpointsAreDisabled(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, logPath := fakeAlreadyStoppedFibeCLI(t)
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "test-key",
- "fibe_cli_path": cliPath,
- }, secretConfigKeys); err != nil {
- t.Fatal(err)
- }
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "idle@example.com", "Idle", "")
+ user, err := store.UpsertUser(t.Context(), "domain-user@example.com", "Domain User", "")
if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-idle-already-stopped", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-already-stopped", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)}
- if err := store.CreateProject(t.Context(), project); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "domain-token", time.Hour); err != nil {
t.Fatal(err)
}
- if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-idleProjectStopAfter-time.Minute).Format(time.RFC3339Nano)); err != nil {
+ project := &Project{ID: "project-domain-locked", UserID: user.ID, Title: "Domain", ConversationID: "conv-domain-locked", Status: "ready", PreviewURL: "https://target.example.test"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
- if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
- t.Fatal(err)
+ for _, tc := range []struct {
+ name string
+ method string
+ path string
+ body string
+ }{
+ {name: "save", method: http.MethodPut, path: "/api/projects/project-domain-locked/domain", body: `{"domain":"app.example.com"}`},
+ {name: "delete", method: http.MethodDelete, path: "/api/projects/project-domain-locked/domain"},
+ {name: "verify", method: http.MethodPost, path: "/api/projects/project-domain-locked/domain/verify"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "domain-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusGone {
+ t.Fatalf("domain endpoint returned %d, want 410; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "Fibe") {
+ t.Fatalf("body=%s, want Fibe handoff message", rec.Body.String())
+ }
+ })
}
+}
- stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
- if err != nil {
- t.Fatal(err)
- }
- if stored.Status != "stopped" {
- t.Fatalf("status=%q, want stopped", stored.Status)
- }
- if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-idle") {
- t.Fatalf("missing stop command; log=%s", log)
- }
-}
-
-func TestIdleProjectStopTaskTreatsMissingPlaygroundAsStopped(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestProjectPassiveActionsDoNotTouchPlaygroundUsage(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, logPath := fakeMissingPlaygroundFibeCLI(t)
+ cliPath, _, _ := fakeFibeCLI(t)
if err := store.UpsertConfig(t.Context(), map[string]string{
"fibe_base_url": "server.test:3000",
"fibe_api_key": "test-key",
@@ -4093,601 +4193,1549 @@ func TestIdleProjectStopTaskTreatsMissingPlaygroundAsStopped(t *testing.T) {
t.Fatal(err)
}
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "idle-missing@example.com", "Idle", "")
+ user, err := store.UpsertUser(t.Context(), "activity@example.com", "Activity", "")
if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-idle-missing", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-missing", AgentID: "agent-1", PlaygroundID: "playground-missing", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-idleProjectStopAfter - time.Minute).Format(time.RFC3339Nano)}
- if err := store.CreateProject(t.Context(), project); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "activity-token", time.Hour); err != nil {
t.Fatal(err)
}
- if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-idleProjectStopAfter-time.Minute).Format(time.RFC3339Nano)); err != nil {
+ oldUsage := time.Now().UTC().Add(-9 * time.Hour).Format(time.RFC3339Nano)
+ switchProject := &Project{ID: "project-service-activity", UserID: user.ID, Title: "Service", ConversationID: "conv-service-activity", AgentID: "agent-1", PlaygroundID: "playground-service-activity", PreviewURL: "http://app.example.test", Status: "ready", PlaygroundLastUsedAt: oldUsage}
+ if err := store.CreateProject(t.Context(), switchProject); err != nil {
t.Fatal(err)
}
- payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
-
- if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
+ if err := store.ReplaceProjectResources(t.Context(), switchProject.ID, nil, []ProjectService{
+ {ProjectID: switchProject.ID, Name: "app", URL: "http://app.example.test", Type: "dynamic", Visibility: "external"},
+ {ProjectID: switchProject.ID, Name: "api", URL: "http://api.example.test", Type: "dynamic", Visibility: "external"},
+ }); err != nil {
t.Fatal(err)
}
-
- stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ req := httptest.NewRequest(http.MethodPatch, "/api/projects/project-service-activity", strings.NewReader(`{"selectedServiceName":"api"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "activity-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("service switch returned %d: %s", rec.Code, rec.Body.String())
+ }
+ updated, err := store.ProjectForUser(t.Context(), user.ID, switchProject.ID)
if err != nil {
t.Fatal(err)
}
- if stored.Status != "stopped" {
- t.Fatalf("status=%q, want stopped", stored.Status)
- }
- if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-missing") {
- t.Fatalf("missing stop command; log=%s", log)
+ if updated.PlaygroundLastUsedAt != oldUsage {
+ t.Fatalf("service switch changed playground_last_used_at from %q to %q", oldUsage, updated.PlaygroundLastUsedAt)
}
-}
-func TestAgentProjectPromptIncludesTargetContext(t *testing.T) {
- project := &Project{
- ID: "project-1",
- Title: "Starter",
- ConversationID: "likeable-project-1",
- PlaygroundID: "10",
- PlaygroundName: "starter-10",
- RepoURL: "http://gitea.test/owner/repo",
- PreviewURL: "http://starter.test",
- SelectedService: "admin",
- Services: []ProjectService{
- {Name: "app", URL: "http://starter.test", Type: "dynamic", Visibility: "external"},
- {Name: "admin", URL: "http://starter-admin.test", Type: "dynamic", Visibility: "external"},
- },
- Repositories: []ProjectRepository{
- {Role: "backend", RepoURL: "http://gitea.test/owner/backend", ServiceNames: []string{"api", "worker"}},
- {Role: "app", RepoURL: "http://gitea.test/owner/app", ServiceNames: []string{"app"}},
- {Role: "admin", RepoURL: "http://gitea.test/owner/admin", ServiceNames: []string{"admin"}},
- },
+ interruptProject := &Project{ID: "project-interrupt-activity", UserID: user.ID, Title: "Interrupt", ConversationID: "conv-interrupt-activity", AgentID: "agent-1", PlaygroundID: "playground-interrupt-activity", Status: "ready", PlaygroundLastUsedAt: oldUsage}
+ if err := store.CreateProject(t.Context(), interruptProject); err != nil {
+ t.Fatal(err)
}
- prompt := projecttext.AgentPrompt(project, "Change the heading")
- for _, want := range []string{
- "target Fibe playground_id: 10",
- "target Fibe playground_name: starter-10",
- "target private source repo: http://gitea.test/owner/repo",
- "target preview_url: http://starter.test",
- "target app subdomain: lk-a33e35d302125bbd",
- "selected service: admin http://starter-admin.test",
- "- app: http://starter.test",
- "- admin: http://starter-admin.test",
- "- backend [api,worker]: http://gitea.test/owner/backend",
- "- app [app]: http://gitea.test/owner/app",
- "- admin [admin]: http://gitea.test/owner/admin",
- "Preserve the current product/domain and working behavior unless the user explicitly asks to replace it.",
- "Run the available build/test/start command after code changes.",
- "[[LIKEABLE_USER_CONTEXT_START]]",
- "User request:\nChange the heading",
- "[[LIKEABLE_USER_CONTEXT_END]]",
- } {
- if !strings.Contains(prompt, want) {
- t.Fatalf("prompt missing %q:\n%s", want, prompt)
- }
+ req = httptest.NewRequest(http.MethodPost, "/api/projects/project-interrupt-activity/agent/interrupt", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "activity-token"})
+ rec = httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("interrupt returned %d: %s", rec.Code, rec.Body.String())
}
-}
-
-func TestAgentProjectPromptIncludesResolvedArtefactsInSystemContext(t *testing.T) {
- project := &Project{ID: "project-prompt", Title: "Prompt app", ConversationID: "conv-prompt"}
- prompt := projecttext.AgentPromptWithArtefacts(project, "Use [artefact:cookie-master]", []projecttext.PromptArtefact{
- {Name: "cookie-master", Content: "session=abc123"},
- })
- for _, want := range []string{
- "- prompt artefacts:",
- "[[LIKEABLE_ARTEFACT_START name=\"cookie-master\"]]",
- "session=abc123",
- "[[LIKEABLE_ARTEFACT_END name=\"cookie-master\"]]",
- "Use prompt artefacts only when Likeable expands them in the system context.",
- "User request:\nUse [artefact:cookie-master]",
- } {
- if !strings.Contains(prompt, want) {
- t.Fatalf("prompt missing %q:\n%s", want, prompt)
- }
+ updated, err = store.ProjectForUser(t.Context(), user.ID, interruptProject.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.PlaygroundLastUsedAt != oldUsage {
+ t.Fatalf("interrupt changed playground_last_used_at from %q to %q", oldUsage, updated.PlaygroundLastUsedAt)
}
-}
-func TestAgentProjectPromptIncludesUserAttachmentsInSystemContext(t *testing.T) {
- project := &Project{ID: "project-attachments", Title: "Prompt app", ConversationID: "conv-attachments"}
- prompt := projecttext.AgentPromptWithArtefactsAndAttachments(project, "Match the attached image", nil, []projecttext.PromptAttachment{
- {Filename: "IMG_8364.png", ContentType: "image/png", Size: 1234, Kind: "image"},
- })
- for _, want := range []string{
- "- user attachments:",
- `filename: "IMG_8364.png", kind: "image", content_type: "image/png", size_bytes: 1234`,
- "When the request refers to an attached image, screenshot, or file, use the user attachment context delivered with this same message.",
- "Do not report a missing prompt artefact for ordinary attachments.",
- "User request:\nMatch the attached image",
- } {
- if !strings.Contains(prompt, want) {
- t.Fatalf("prompt missing %q:\n%s", want, prompt)
- }
+ passiveProject := &Project{ID: "project-passive-preview", UserID: user.ID, Title: "Passive", ConversationID: "conv-passive-preview", AgentID: "agent-1", PlaygroundID: "playground-passive-preview", Status: "ready", PlaygroundLastUsedAt: oldUsage}
+ if err := store.CreateProject(t.Context(), passiveProject); err != nil {
+ t.Fatal(err)
+ }
+ req = httptest.NewRequest(http.MethodGet, "/api/projects/project-passive-preview/preview-status", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "activity-token"})
+ rec = httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("preview status returned %d: %s", rec.Code, rec.Body.String())
+ }
+ updated, err = store.ProjectForUser(t.Context(), user.ID, passiveProject.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.PlaygroundLastUsedAt != oldUsage {
+ t.Fatalf("passive preview changed playground_last_used_at from %q to %q", oldUsage, updated.PlaygroundLastUsedAt)
}
}
-func TestResolvePromptArtefactMacrosLoadsConfiguredArtefacts(t *testing.T) {
+func TestProjectPlaygroundBlocksRetiredPoolPair(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.UpsertConfig(t.Context(), map[string]string{
- "agent_artefacts": `{"cookie-master":"session=abc123"}`,
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_agent_server_pool": `[{"agent_id":"old-agent","server_id":"old-marquee","status":"retired"}]`,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- text, artefacts, err := server.resolvePromptArtefactMacros(t.Context(), "Use {|artefact:cookie-master|} for auth")
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- if text != "Use [artefact:cookie-master] for auth" {
- t.Fatalf("text=%q, want macro placeholder", text)
+ if err := store.CreateSession(t.Context(), user.ID, "retired-token", time.Hour); err != nil {
+ t.Fatal(err)
}
- if len(artefacts) != 1 || artefacts[0].Name != "cookie-master" || artefacts[0].Content != "session=abc123" {
- t.Fatalf("artefacts=%+v, want configured cookie-master", artefacts)
+ project := &Project{ID: "project-retired-control", UserID: user.ID, Title: "Control", ConversationID: "conv-retired-control", AgentID: "old-agent", MarqueeID: "old-marquee", PlaygroundID: "playground-1", Status: "ready"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
}
-}
-func TestResolvePromptArtefactMacrosRejectsMissingArtefact(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-retired-control/playground", strings.NewReader(`{"action":"stop"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "retired-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("stop returned %d, want 409; body=%s", rec.Code, rec.Body.String())
+ }
+ updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- _, _, err = server.resolvePromptArtefactMacros(t.Context(), "Use {|artefact:cookie-master|}")
- if err == nil || !strings.Contains(err.Error(), "cookie-master") {
- t.Fatalf("err=%v, want missing artefact error", err)
+ if updated.Status != "archived" {
+ t.Fatalf("status=%q, want archived", updated.Status)
}
}
-func TestPromptImproveUsesAssignedAgent(t *testing.T) {
+func TestProjectPlaygroundStopTreatsAlreadyStoppedAsSuccess(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, logPath, stdinPath := fakePromptImproveFibeCLI(t)
+ cliPath, logPath := fakeAlreadyStoppedFibeCLI(t)
if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "key",
- "fibe_cli_path": cliPath,
- "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "prompt-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "project-token", time.Hour); err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-prompt-improve", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ project := &Project{ID: "project-already-stopped-control", UserID: user.ID, Title: "Control", ConversationID: "conv-already-stopped-control", AgentID: "agent-1", PlaygroundID: "playground-1", Status: "ready"}
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-improve/prompt-improve", strings.NewReader(`{"text":"add some cars and enhance ux","locale":"uk"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-token"})
- rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-already-stopped-control/playground", strings.NewReader(`{"action":"stop"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "project-token"})
+ rec := httptest.NewRecorder()
server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
- }
- var body struct {
- Text string `json:"text"`
- Source string `json:"source"`
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("stop returned %d, want 202; body=%s", rec.Code, rec.Body.String())
}
- if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
t.Fatal(err)
}
- if body.Source != "agent" || !strings.Contains(body.Text, "Improve the existing car sharing webapp") {
- t.Fatalf("body=%+v, want agent-improved prompt", body)
- }
- commands := readFile(t, logPath)
- for _, want := range []string{
- "agents create-conversation agent-1 --conversation-id likeable-prompt-improve-",
- "agents send-message agent-1 --conversation-id likeable-prompt-improve-",
- "agents messages agent-1 --conversation-id likeable-prompt-improve-",
- "agents delete-conversation agent-1 --conversation-id likeable-prompt-improve-",
- } {
- if !strings.Contains(commands, want) {
- t.Fatalf("commands missing %q:\n%s", want, commands)
- }
+ if stored.Status != "stopped" {
+ t.Fatalf("status=%q, want stopped", stored.Status)
}
- payload := readFile(t, stdinPath)
- for _, want := range []string{
- "You are Likeable's prompt-improvement agent.",
- "Do not edit files, do not run tools, do not build",
- "Preferred UI language: Ukrainian",
- "Current app title: car sharing webapp",
- `User draft:\nadd some cars and enhance ux`,
- } {
- if !strings.Contains(payload, want) {
- t.Fatalf("prompt payload missing %q:\n%s", want, payload)
- }
+ if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-1") {
+ t.Fatalf("missing stop command; log=%s", log)
}
}
-func TestPromptImproveRejectsEmptyDraft(t *testing.T) {
+func TestIdleProjectStopTaskStopsPlayground(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
- if err != nil {
- t.Fatal(err)
+ cliPath, logPath, _ := fakeFibeCLI(t)
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "prompt-empty-token", time.Hour); err != nil {
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "idle@example.com", "Idle", "")
+ if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-prompt-empty", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt-empty", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ project := &Project{ID: "project-idle", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)}
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-empty/prompt-improve", strings.NewReader(`{"text":" ","locale":"en"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-empty-token"})
- rec := httptest.NewRecorder()
+ if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour-time.Minute).Format(time.RFC3339Nano)); err != nil {
+ t.Fatal(err)
+ }
+ payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
- server.routes().ServeHTTP(rec, req)
+ if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
+ t.Fatal(err)
+ }
- if rec.Code != http.StatusBadRequest {
- t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(rec.Body.String(), "prompt text is required") {
- t.Fatalf("body=%s, want prompt text validation", rec.Body.String())
+ if stored.Status != "stopped" {
+ t.Fatalf("status=%q, want stopped", stored.Status)
+ }
+ if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-idle") {
+ t.Fatalf("missing stop command; log=%s", log)
}
}
-func TestPromptImproveChargesConfiguredMinutes(t *testing.T) {
+func TestIdleProjectStopTaskSkipsAfterRecentUsageReset(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, _, _ := fakePromptImproveFibeCLI(t)
+ cliPath, logPath, _ := fakeFibeCLI(t)
+ if err := os.WriteFile(logPath, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "key",
- "fibe_cli_path": cliPath,
- "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
- "prompt_improve_charge_minutes": "7",
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "idle-reset@example.com", "Idle", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "prompt-charge-token", time.Hour); err != nil {
+ project := &Project{ID: "project-idle-reset", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-reset", AgentID: "agent-1", PlaygroundID: "playground-idle-reset", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)}
+ if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-prompt-improve-charge", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt-charge", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
- if err := store.CreateProject(t.Context(), project); err != nil {
+ payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
+ if err := store.TouchProjectPlaygroundUsage(t.Context(), project.ID, user.ID); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-improve-charge/prompt-improve", strings.NewReader(`{"text":"add some cars","locale":"en"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-charge-token"})
- rec := httptest.NewRecorder()
-
- server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
- }
- var body struct {
- Source string `json:"source"`
- ChargedMs int64 `json:"chargedMs"`
- }
- if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
t.Fatal(err)
}
- wantMs := (7 * time.Minute).Milliseconds()
- if body.Source != "agent" || body.ChargedMs != wantMs {
- t.Fatalf("body=%+v, want agent source and %d charged ms", body, wantMs)
- }
- lifetimeMs, err := store.UserLifetimeWorkMs(t.Context(), user.ID, time.Now().UTC())
+
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if lifetimeMs != wantMs {
- t.Fatalf("lifetime work ms=%d, want %d", lifetimeMs, wantMs)
+ if stored.Status != "ready" {
+ t.Fatalf("status=%q, want ready", stored.Status)
+ }
+ if log := readFile(t, logPath); strings.Contains(log, "playgrounds stop playground-idle-reset") {
+ t.Fatalf("unexpected stop command after recent usage reset; log=%s", log)
}
}
-func TestPromptImproveChargeRequiresHourAllowanceBeforeAgent(t *testing.T) {
+func TestIdleProjectStopTaskStopsLegacyProductionProject(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- cliPath, logPath, _ := fakePromptImproveFibeCLI(t)
+ cliPath, logPath, _ := fakeFibeCLI(t)
+ if err := os.WriteFile(logPath, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "key",
- "fibe_cli_path": cliPath,
- "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
- "free_hours": "0",
- "prompt_improve_charge_minutes": "1",
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "idle-production@example.com", "Idle Production", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "prompt-no-credit-token", time.Hour); err != nil {
- t.Fatal(err)
- }
- project := &Project{ID: "project-prompt-improve-no-credit", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt-no-credit", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ project := &Project{ID: "project-idle-production", UserID: user.ID, Title: "Production", ConversationID: "conv-idle-production", AgentID: "agent-1", PlaygroundID: "playground-idle-production", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)}
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-improve-no-credit/prompt-improve", strings.NewReader(`{"text":"add some cars","locale":"en"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-no-credit-token"})
- rec := httptest.NewRecorder()
-
- server.routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusPaymentRequired {
- t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
- }
- commands, err := os.ReadFile(logPath)
- if err == nil && strings.TrimSpace(string(commands)) != "" {
- t.Fatalf("agent CLI was called despite no hour allowance:\n%s", commands)
+ expiresAt := time.Now().UTC().Add(30 * 24 * time.Hour)
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_production_idle", expiresAt); err != nil || !granted {
+ t.Fatalf("production grant=%v err=%v, want granted", granted, err)
}
-}
+ payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
-func TestFallbackImprovedPromptKeepsCyrillicLanguage(t *testing.T) {
- ru := fallbackImprovedPrompt("добавь машины и улучши ux", "car sharing webapp")
- if !strings.Contains(ru, "Запрошенное изменение") || !strings.Contains(ru, "car sharing webapp") {
- t.Fatalf("ru fallback=%q, want Russian prompt with app context", ru)
- }
- uk := fallbackImprovedPrompt("add billing polish", "Likeable", "uk")
- if !strings.Contains(uk, "Запитана зміна") || !strings.Contains(uk, "Likeable") {
- t.Fatalf("uk fallback=%q, want Ukrainian prompt with app context", uk)
- }
- if got := promptImprovePreferredLanguage("uk-UA"); got != "Ukrainian" {
- t.Fatalf("preferred language=%q, want Ukrainian", got)
+ if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
+ t.Fatal(err)
}
-}
-func fakePromptImproveFibeCLI(t *testing.T) (string, string, string) {
- t.Helper()
- dir := t.TempDir()
- path := filepath.Join(dir, "fibe")
- logPath := filepath.Join(dir, "commands.log")
- stdinPath := filepath.Join(dir, "stdin.json")
- script := `#!/bin/sh
-printf '%s\n' "$*" >> "` + logPath + `"
-case "$*" in
- *"agents send-message"*)
- cat > "` + stdinPath + `"
- echo '{"ok":true}'
- ;;
- *"agents messages"*)
- echo '{"content":[{"role":"assistant","body":"` + promptImproveStart + `\\nImprove the existing car sharing webapp by adding a clear vehicle inventory section, more polished ride/request UX, responsive spacing, empty/loading states, and a quick visual verification pass. Keep the current car sharing product intact and do not replace it with another app.\\n` + promptImproveEnd + `"}]}'
- ;;
- *"agents create-conversation"*|*"agents delete-conversation"*)
- echo '{"ok":true}'
- ;;
- *)
- echo "unexpected command: $*" >&2
- exit 64
- ;;
-esac
-`
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
t.Fatal(err)
}
- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
- installFakeFibeTransport(t, fakeFibeTransportConfig{
- Mode: "default",
- LogPath: logPath,
- StdinPath: stdinPath,
- })
- return path, logPath, stdinPath
-}
-
-func stringSliceContains(values []string, target string) bool {
- for _, value := range values {
- if value == target {
- return true
- }
+ if stored.Status != "stopped" || stored.ProductionExpiresAt == "" {
+ t.Fatalf("project=%+v, want stopped legacy production project", stored)
+ }
+ if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-idle-production") {
+ t.Fatalf("missing stop command for legacy production project; log=%s", log)
}
- return false
}
-func TestSignupPolicyDefaultsClosedButAllowsAdminExistingAndAllowlist(t *testing.T) {
+func TestIdleProjectStopTaskTreatsAlreadyStoppedAsSuccess(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
-
- allowed, err := server.canSignInEmail(t.Context(), "new@example.com")
- if err != nil {
+ cliPath, logPath := fakeAlreadyStoppedFibeCLI(t)
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
+ }, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if allowed {
- t.Fatal("new non-admin user should be rejected when signup mode is unset")
- }
-
- allowed, err = server.canSignInEmail(t.Context(), "admin@example.com")
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "idle@example.com", "Idle", "")
if err != nil {
t.Fatal(err)
}
- if !allowed {
- t.Fatal("admin should be allowed even when signup is closed")
+ project := &Project{ID: "project-idle-already-stopped", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-already-stopped", AgentID: "agent-1", PlaygroundID: "playground-idle", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
}
- admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
- if err != nil {
+ if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour-time.Minute).Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
- if _, err := store.UpdateUserAccess(t.Context(), admin.ID, "restricted", "manual restriction"); err != nil {
+ payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
+
+ if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
t.Fatal(err)
}
- allowed, err = server.canSignInEmail(t.Context(), "admin@example.com")
+
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if allowed {
- t.Fatal("restricted admin account should stay blocked")
+ if stored.Status != "stopped" {
+ t.Fatalf("status=%q, want stopped", stored.Status)
}
-
- if _, err := store.UpsertUser(t.Context(), "existing@example.com", "Existing", ""); err != nil {
- t.Fatal(err)
+ if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-idle") {
+ t.Fatalf("missing stop command; log=%s", log)
}
- allowed, err = server.canSignInEmail(t.Context(), "existing@example.com")
+}
+
+func TestIdleProjectStopTaskTreatsMissingPlaygroundAsStopped(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- if !allowed {
- t.Fatal("existing users should be allowed to sign back in")
- }
-
+ defer store.Close()
+ cliPath, logPath := fakeMissingPlaygroundFibeCLI(t)
if err := store.UpsertConfig(t.Context(), map[string]string{
- "signup_mode": "allowlist",
- "signup_allowed_emails": "pilot@gmail.com, @trusted.test",
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- for _, email := range []string{"pilot@gmail.com", "crew@trusted.test"} {
- allowed, err = server.canSignInEmail(t.Context(), email)
- if err != nil {
- t.Fatal(err)
- }
- if !allowed {
- t.Fatalf("%s should be allowed by allowlist", email)
- }
- }
- allowed, err = server.canSignInEmail(t.Context(), "stranger@gmail.com")
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "idle-missing@example.com", "Idle", "")
if err != nil {
t.Fatal(err)
}
- if allowed {
- t.Fatal("unlisted user should be rejected in allowlist mode")
+ project := &Project{ID: "project-idle-missing", UserID: user.ID, Title: "Idle", ConversationID: "conv-idle-missing", AgentID: "agent-1", PlaygroundID: "playground-missing", Status: "ready", PlaygroundLastUsedAt: time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour - time.Minute).Format(time.RFC3339Nano)}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
}
-}
+ if _, err := store.AddMessageAt(t.Context(), project.ID, "user", "old", time.Now().UTC().Add(-defaultPlaygroundIdleStopHours*time.Hour-time.Minute).Format(time.RFC3339Nano)); err != nil {
+ t.Fatal(err)
+ }
+ payload, _ := json.Marshal(projectJobPayload{UserID: user.ID, UserEmail: user.Email, ProjectID: project.ID})
+
+ if err := server.handleStopIdleProjectTask(t.Context(), asynq.NewTask(taskStopIdleProject, payload)); err != nil {
+ t.Fatal(err)
+ }
+
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Status != "stopped" {
+ t.Fatalf("status=%q, want stopped", stored.Status)
+ }
+ if log := readFile(t, logPath); !strings.Contains(log, "playgrounds stop playground-missing") {
+ t.Fatalf("missing stop command; log=%s", log)
+ }
+}
+
+func TestAgentProjectPromptIncludesTargetContext(t *testing.T) {
+ project := &Project{
+ ID: "project-1",
+ Title: "Starter",
+ ConversationID: "likeable-project-1",
+ PlaygroundID: "10",
+ PlaygroundName: "starter-10",
+ RepoURL: "http://gitea.test/owner/repo",
+ PreviewURL: "http://starter.test",
+ SelectedService: "admin",
+ Services: []ProjectService{
+ {Name: "app", URL: "http://starter.test", Type: "dynamic", Visibility: "external"},
+ {Name: "admin", URL: "http://starter-admin.test", Type: "dynamic", Visibility: "external"},
+ },
+ Repositories: []ProjectRepository{
+ {Role: "backend", RepoURL: "http://gitea.test/owner/backend", ServiceNames: []string{"api", "worker"}},
+ {Role: "app", RepoURL: "http://gitea.test/owner/app", ServiceNames: []string{"app"}},
+ {Role: "admin", RepoURL: "http://gitea.test/owner/admin", ServiceNames: []string{"admin"}},
+ },
+ }
+ prompt := projecttext.AgentPrompt(project, "Change the heading")
+ for _, want := range []string{
+ "target Fibe playground_id: 10",
+ "target Fibe playground_name: starter-10",
+ "target private source repo: http://gitea.test/owner/repo",
+ "target preview_url: http://starter.test",
+ "target app subdomain: lk-a33e35d302125bbd",
+ "selected service: admin http://starter-admin.test",
+ "- app: http://starter.test",
+ "- admin: http://starter-admin.test",
+ "- backend [api,worker]: http://gitea.test/owner/backend",
+ "- app [app]: http://gitea.test/owner/app",
+ "- admin [admin]: http://gitea.test/owner/admin",
+ "Preserve the current product/domain and working behavior unless the user explicitly asks to replace it.",
+ "Run the available build/test/start command after code changes.",
+ "[[LIKEABLE_USER_CONTEXT_START]]",
+ "User request:\nChange the heading",
+ "[[LIKEABLE_USER_CONTEXT_END]]",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("prompt missing %q:\n%s", want, prompt)
+ }
+ }
+}
+
+func TestAgentProjectPromptIncludesResolvedArtefactsInSystemContext(t *testing.T) {
+ project := &Project{ID: "project-prompt", Title: "Prompt app", ConversationID: "conv-prompt"}
+ prompt := projecttext.AgentPromptWithArtefacts(project, "Use [artefact:cookie-master]", []projecttext.PromptArtefact{
+ {Name: "cookie-master", Content: "session=abc123"},
+ })
+ for _, want := range []string{
+ "- prompt artefacts:",
+ "[[LIKEABLE_ARTEFACT_START name=\"cookie-master\"]]",
+ "session=abc123",
+ "[[LIKEABLE_ARTEFACT_END name=\"cookie-master\"]]",
+ "Use prompt artefacts only when Likeable expands them in the system context.",
+ "User request:\nUse [artefact:cookie-master]",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("prompt missing %q:\n%s", want, prompt)
+ }
+ }
+}
+
+func TestAgentProjectPromptIncludesUserAttachmentsInSystemContext(t *testing.T) {
+ project := &Project{ID: "project-attachments", Title: "Prompt app", ConversationID: "conv-attachments"}
+ prompt := projecttext.AgentPromptWithArtefactsAndAttachments(project, "Match the attached image", nil, []projecttext.PromptAttachment{
+ {Filename: "IMG_8364.png", ContentType: "image/png", Size: 1234, Kind: "image"},
+ })
+ for _, want := range []string{
+ "- user attachments:",
+ `filename: "IMG_8364.png", kind: "image", content_type: "image/png", size_bytes: 1234`,
+ "When the request refers to an attached image, screenshot, or file, use the user attachment context delivered with this same message.",
+ "Do not report a missing prompt artefact for ordinary attachments.",
+ "User request:\nMatch the attached image",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("prompt missing %q:\n%s", want, prompt)
+ }
+ }
+}
+
+func TestResolvePromptArtefactMacrosLoadsConfiguredArtefacts(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "agent_artefacts": `{"cookie-master":"session=abc123"}`,
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ text, artefacts, err := server.resolvePromptArtefactMacros(t.Context(), "Use {|artefact:cookie-master|} for auth")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if text != "Use [artefact:cookie-master] for auth" {
+ t.Fatalf("text=%q, want macro placeholder", text)
+ }
+ if len(artefacts) != 1 || artefacts[0].Name != "cookie-master" || artefacts[0].Content != "session=abc123" {
+ t.Fatalf("artefacts=%+v, want configured cookie-master", artefacts)
+ }
+}
+
+func TestResolvePromptArtefactMacrosRejectsMissingArtefact(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ _, _, err = server.resolvePromptArtefactMacros(t.Context(), "Use {|artefact:cookie-master|}")
+ if err == nil || !strings.Contains(err.Error(), "cookie-master") {
+ t.Fatalf("err=%v, want missing artefact error", err)
+ }
+}
+
+func TestPromptImproveUsesAssignedAgent(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ cliPath, logPath, stdinPath := fakePromptImproveFibeCLI(t)
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "key",
+ "fibe_cli_path": cliPath,
+ "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "prompt-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-prompt-improve", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-improve/prompt-improve", strings.NewReader(`{"text":"add some cars and enhance ux","locale":"uk"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Text string `json:"text"`
+ Source string `json:"source"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Source != "agent" || !strings.Contains(body.Text, "Improve the existing car sharing webapp") {
+ t.Fatalf("body=%+v, want agent-improved prompt", body)
+ }
+ commands := readFile(t, logPath)
+ for _, want := range []string{
+ "agents create-conversation agent-1 --conversation-id likeable-prompt-improve-",
+ "agents send-message agent-1 --conversation-id likeable-prompt-improve-",
+ "agents messages agent-1 --conversation-id likeable-prompt-improve-",
+ "agents delete-conversation agent-1 --conversation-id likeable-prompt-improve-",
+ } {
+ if !strings.Contains(commands, want) {
+ t.Fatalf("commands missing %q:\n%s", want, commands)
+ }
+ }
+ payload := readFile(t, stdinPath)
+ for _, want := range []string{
+ "You are Likeable's prompt-improvement agent.",
+ "Do not edit files, do not run tools, do not build",
+ "Preferred UI language: Ukrainian",
+ "Current app title: car sharing webapp",
+ `User draft:\nadd some cars and enhance ux`,
+ } {
+ if !strings.Contains(payload, want) {
+ t.Fatalf("prompt payload missing %q:\n%s", want, payload)
+ }
+ }
+}
+
+func TestPromptImproveRejectsEmptyDraft(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "prompt-empty-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-prompt-empty", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt-empty", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-empty/prompt-improve", strings.NewReader(`{"text":" ","locale":"en"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-empty-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "prompt text is required") {
+ t.Fatalf("body=%s, want prompt text validation", rec.Body.String())
+ }
+}
+
+func TestPromptImproveChargesConfiguredMinutes(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ cliPath, _, _ := fakePromptImproveFibeCLI(t)
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "key",
+ "fibe_cli_path": cliPath,
+ "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ "prompt_improve_charge_minutes": "7",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "prompt-charge-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-prompt-improve-charge", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt-charge", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-improve-charge/prompt-improve", strings.NewReader(`{"text":"add some cars","locale":"en"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-charge-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Source string `json:"source"`
+ ChargedMs int64 `json:"chargedMs"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ wantMs := (7 * time.Minute).Milliseconds()
+ if body.Source != "agent" || body.ChargedMs != wantMs {
+ t.Fatalf("body=%+v, want agent source and %d charged ms", body, wantMs)
+ }
+ lifetimeMs, err := store.UserLifetimeWorkMs(t.Context(), user.ID, time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lifetimeMs != wantMs {
+ t.Fatalf("lifetime work ms=%d, want %d", lifetimeMs, wantMs)
+ }
+}
+
+func TestPromptImproveChargeRequiresHourAllowanceBeforeAgent(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ cliPath, logPath, _ := fakePromptImproveFibeCLI(t)
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "key",
+ "fibe_cli_path": cliPath,
+ "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ "free_hours": "0",
+ "prompt_improve_charge_minutes": "1",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "prompt-no-credit-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-prompt-improve-no-credit", UserID: user.ID, Title: "car sharing webapp", ConversationID: "conv-prompt-no-credit", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready", PreviewURL: "http://preview.test"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/project-prompt-improve-no-credit/prompt-improve", strings.NewReader(`{"text":"add some cars","locale":"en"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "prompt-no-credit-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusPaymentRequired {
+ t.Fatalf("prompt improve returned %d: %s", rec.Code, rec.Body.String())
+ }
+ commands, err := os.ReadFile(logPath)
+ if err == nil && strings.TrimSpace(string(commands)) != "" {
+ t.Fatalf("agent CLI was called despite no hour allowance:\n%s", commands)
+ }
+}
+
+func TestFallbackImprovedPromptKeepsCyrillicLanguage(t *testing.T) {
+ ru := fallbackImprovedPrompt("добавь машины и улучши ux", "car sharing webapp")
+ if !strings.Contains(ru, "Запрошенное изменение") || !strings.Contains(ru, "car sharing webapp") {
+ t.Fatalf("ru fallback=%q, want Russian prompt with app context", ru)
+ }
+ uk := fallbackImprovedPrompt("add billing polish", "Likeable", "uk")
+ if !strings.Contains(uk, "Запитана зміна") || !strings.Contains(uk, "Likeable") {
+ t.Fatalf("uk fallback=%q, want Ukrainian prompt with app context", uk)
+ }
+ if got := promptImprovePreferredLanguage("uk-UA"); got != "Ukrainian" {
+ t.Fatalf("preferred language=%q, want Ukrainian", got)
+ }
+}
+
+func fakePromptImproveFibeCLI(t *testing.T) (string, string, string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "fibe")
+ logPath := filepath.Join(dir, "commands.log")
+ stdinPath := filepath.Join(dir, "stdin.json")
+ script := `#!/bin/sh
+printf '%s\n' "$*" >> "` + logPath + `"
+case "$*" in
+ *"agents send-message"*)
+ cat > "` + stdinPath + `"
+ echo '{"ok":true}'
+ ;;
+ *"agents messages"*)
+ echo '{"content":[{"role":"assistant","body":"` + promptImproveStart + `\\nImprove the existing car sharing webapp by adding a clear vehicle inventory section, more polished ride/request UX, responsive spacing, empty/loading states, and a quick visual verification pass. Keep the current car sharing product intact and do not replace it with another app.\\n` + promptImproveEnd + `"}]}'
+ ;;
+ *"agents create-conversation"*|*"agents delete-conversation"*)
+ echo '{"ok":true}'
+ ;;
+ *)
+ echo "unexpected command: $*" >&2
+ exit 64
+ ;;
+esac
+`
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ installFakeFibeTransport(t, fakeFibeTransportConfig{
+ Mode: "default",
+ LogPath: logPath,
+ StdinPath: stdinPath,
+ })
+ return path, logPath, stdinPath
+}
+
+func stringSliceContains(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
+
+func readinessCheck(t *testing.T, readiness AdminReadiness, key string) AdminReadinessCheck {
+ t.Helper()
+ for _, check := range readiness.Checks {
+ if check.Key == key {
+ return check
+ }
+ }
+ t.Fatalf("readiness checks=%+v, want key %q", readiness.Checks, key)
+ return AdminReadinessCheck{}
+}
+
+func TestSignupPolicyDefaultsClosedButAllowsAdminExistingAndAllowlist(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+
+ allowed, err := server.canSignInEmail(t.Context(), "new@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if allowed {
+ t.Fatal("new non-admin user should be rejected when signup mode is unset")
+ }
+
+ allowed, err = server.canSignInEmail(t.Context(), "admin@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !allowed {
+ t.Fatal("admin should be allowed even when signup is closed")
+ }
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.UpdateUserAccess(t.Context(), admin.ID, "restricted", "manual restriction"); err != nil {
+ t.Fatal(err)
+ }
+ allowed, err = server.canSignInEmail(t.Context(), "admin@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if allowed {
+ t.Fatal("restricted admin account should stay blocked")
+ }
+
+ if _, err := store.UpsertUser(t.Context(), "existing@example.com", "Existing", ""); err != nil {
+ t.Fatal(err)
+ }
+ allowed, err = server.canSignInEmail(t.Context(), "existing@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !allowed {
+ t.Fatal("existing users should be allowed to sign back in")
+ }
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "signup_mode": "allowlist",
+ "signup_allowed_emails": "pilot@gmail.com, @trusted.test",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ for _, email := range []string{"pilot@gmail.com", "crew@trusted.test"} {
+ allowed, err = server.canSignInEmail(t.Context(), email)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !allowed {
+ t.Fatalf("%s should be allowed by allowlist", email)
+ }
+ }
+ allowed, err = server.canSignInEmail(t.Context(), "stranger@gmail.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if allowed {
+ t.Fatal("unlisted user should be rejected in allowlist mode")
+ }
+}
+
+func TestLoginRetiresPendingDeletionUserBeforeSignIn(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.UpsertConfig(t.Context(), map[string]string{"signup_mode": "all"}, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", DevAuth: true}, http: http.DefaultClient}
+ oldUser, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.UpdateUserAccess(t.Context(), oldUser.ID, "restricted", accountDeletionAccessNote); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/dev/login?email=pilot@example.com", nil)
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("dev login returned %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ retired, err := store.UserByID(t.Context(), oldUser.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if retired.Email == "pilot@example.com" || retired.AccessStatus != "restricted" {
+ t.Fatalf("retired user=%+v, want restricted tombstone", retired)
+ }
+ newUser, err := store.UserByEmail(t.Context(), "pilot@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if newUser.ID == oldUser.ID {
+ t.Fatal("login reused pending-deletion user id")
+ }
+ if newUser.AccessStatus != "active" {
+ t.Fatalf("new user access_status=%q, want active", newUser.AccessStatus)
+ }
+}
+
+func TestDevLoginRestrictsPublicHTTPSBaseURLToAdmin(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.UpsertConfig(t.Context(), map[string]string{"signup_mode": "all"}, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "https://likeable.test", AdminEmail: "admin@example.com", DevAuth: true}, http: http.DefaultClient}
+
+ req := httptest.NewRequest(http.MethodPost, "/api/dev/login?email=pilot@example.com", nil)
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("public dev login returned %d, want 403; body=%s", rec.Code, rec.Body.String())
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/dev/login?email=admin@example.com", nil)
+ rec = httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin dev login returned %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestNormalizeAdminConfigValuesFormatsAllowlistAndPool(t *testing.T) {
+ values, err := normalizeAdminConfigValues(map[string]string{
+ "signup_allowed_emails": "Pilot@Gmail.com, @Trusted.test\npilot@gmail.com",
+ "fibe_agent_server_pool": `[{"label":"Main","agentId":" agent-1 ","serverId":" server-1 "},
+ {"label":"","agent_id":"","server_id":""}]`,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if values["signup_allowed_emails"] != "pilot@gmail.com\n@trusted.test" {
+ t.Fatalf("allowlist=%q, want newline-normalized emails", values["signup_allowed_emails"])
+ }
+ pool, err := fibe.ParseAssignmentPool(values["fibe_agent_server_pool"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pool) != 1 || pool[0].AgentID != "agent-1" || pool[0].MarqueeID != "server-1" {
+ t.Fatalf("pool=%+v, want normalized single pair", pool)
+ }
+}
+
+func TestNormalizeAdminConfigRejectsIncompletePoolRows(t *testing.T) {
+ _, err := normalizeAdminConfigValues(map[string]string{
+ "fibe_agent_server_pool": `[{"agent_id":"agent-only"}]`,
+ })
+ if err == nil || !strings.Contains(err.Error(), "requires both") {
+ t.Fatalf("err=%v, want incomplete pool row error", err)
+ }
+}
+
+func TestNormalizeAdminConfigRejectsInvalidPoolStatus(t *testing.T) {
+ _, err := normalizeAdminConfigValues(map[string]string{
+ "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"paused"}]`,
+ })
+ if err == nil || !strings.Contains(err.Error(), "invalid status") {
+ t.Fatalf("err=%v, want invalid status error", err)
+ }
+}
+
+func TestAdminConfigIncludesAgentPoolHealth(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method + " " + r.URL.Path {
+ case http.MethodGet + " /api/agents/agent-1":
+ writeJSONResponse(t, w, map[string]any{"id": 1, "status": "authenticated", "authenticated": true})
+ case http.MethodGet + " /api/marquees/server-1":
+ writeJSONResponse(t, w, map[string]any{"id": 1, "status": "active", "billing_runtime_active": false, "chat_launchable": false})
+ default:
+ t.Fatalf("unexpected Fibe request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer fibeServer.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: fibeServer.Client()}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-config-health-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": fibeServer.URL,
+ "fibe_api_key": "test-key",
+ "fibe_agent_server_pool": `[{"label":"Main","agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/config", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-config-health-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin config returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ AgentPoolHealth []AgentPoolHealth `json:"agentPoolHealth"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body.AgentPoolHealth) != 1 || body.AgentPoolHealth[0].OK || body.AgentPoolHealth[0].AgentID != "agent-1" || body.AgentPoolHealth[0].ServerID != "server-1" {
+ t.Fatalf("agentPoolHealth=%+v, want one unhealthy configured pair", body.AgentPoolHealth)
+ }
+ if !stringSliceContains(body.AgentPoolHealth[0].Problems, "server runtime is not funded") {
+ t.Fatalf("problems=%+v, want runtime funding problem", body.AgentPoolHealth[0].Problems)
+ }
+}
+
+func TestAdminReadinessReportsLaunchBlockers(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-readiness-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-readiness-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin readiness returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Readiness AdminReadiness `json:"readiness"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Readiness.Ready || body.Readiness.BlockerCount == 0 {
+ t.Fatalf("readiness=%+v, want launch blockers", body.Readiness)
+ }
+ for _, check := range body.Readiness.Checks {
+ if check.Key == "stripe_production_project_price" {
+ t.Fatalf("readiness checks=%+v, want no Likeable production-project price check", body.Readiness.Checks)
+ }
+ }
+ if check := readinessCheck(t, body.Readiness, "fibe_active_pool"); check.OK || check.Severity != "blocker" {
+ t.Fatalf("active pool check=%+v, want blocking failure", check)
+ } else if !strings.Contains(check.Detail, "fibe_agent_server_pool") {
+ t.Fatalf("active pool detail=%q, want config key", check.Detail)
+ }
+ if check := readinessCheck(t, body.Readiness, "fibe_active_pool_health"); check.OK || !strings.Contains(check.Detail, "no active") {
+ t.Fatalf("active pool health check=%+v, want no active detail", check)
+ }
+ if check := readinessCheck(t, body.Readiness, "google_oauth"); check.OK || !strings.Contains(check.Detail, "google_client_id") || !strings.Contains(check.Detail, "google_client_secret") {
+ t.Fatalf("google oauth check=%+v, want actionable config keys", check)
+ }
+ if check := readinessCheck(t, body.Readiness, "smtp_delivery"); check.OK || !strings.Contains(check.Detail, "smtp_host") || !strings.Contains(check.Detail, "smtp_from_email") {
+ t.Fatalf("smtp delivery check=%+v, want actionable config keys", check)
+ }
+ if check := readinessCheck(t, body.Readiness, "signup_enabled"); check.OK || check.Severity != "warning" {
+ t.Fatalf("signup check=%+v, want warning failure", check)
+ } else if !strings.Contains(check.Detail, "signup_mode") {
+ t.Fatalf("signup detail=%q, want config key", check.Detail)
+ }
+}
+
+func TestAdminReadinessPassesWithProductionConfig(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ templatePath := filepath.Join(t.TempDir(), "greenfield.yml")
+ if err := os.WriteFile(templatePath, []byte("services:\n app:\n image: test\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("LIKEABLE_GREENFIELD_TEMPLATE_BODY_PATH", templatePath)
+ fibeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method + " " + r.URL.Path {
+ case http.MethodGet + " /api/agents/agent-ready":
+ writeJSONResponse(t, w, map[string]any{"id": 1, "status": "authenticated", "authenticated": true})
+ case http.MethodGet + " /api/marquees/server-ready":
+ writeJSONResponse(t, w, map[string]any{"id": 1, "status": "active", "billing_runtime_active": true, "chat_launchable": true})
+ default:
+ t.Fatalf("unexpected Fibe request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer fibeServer.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: fibeServer.Client()}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-readiness-ready-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "stripe_secret_key": "sk_test_ready",
+ "stripe_webhook_secret": "whsec_ready",
+ "stripe_price_id_10_hours": "price_hours",
+ "stripe_project_quota_price_id": "price_project_quota",
+ "stripe_production_project_price_id": "price_production_project",
+ "fibe_base_url": fibeServer.URL,
+ "fibe_api_key": "test-key",
+ "fibe_agent_server_pool": `[{"label":"Main","agent_id":"agent-ready","server_id":"server-ready","status":"active"}]`,
+ "google_client_id": "google-client",
+ "google_client_secret": "google-secret",
+ "smtp_host": "smtp.example.test",
+ "smtp_from_email": "support@example.test",
+ "signup_mode": "allowlist",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-readiness-ready-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin readiness returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Readiness AdminReadiness `json:"readiness"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if !body.Readiness.Ready || body.Readiness.BlockerCount != 0 || body.Readiness.WarningCount != 0 {
+ t.Fatalf("readiness=%+v, want ready with no blockers or warnings", body.Readiness)
+ }
+ if check := readinessCheck(t, body.Readiness, "fibe_active_pool_health"); !check.OK || check.Detail != "Main" {
+ t.Fatalf("active pool health check=%+v, want healthy Main detail", check)
+ }
+ if check := readinessCheck(t, body.Readiness, "fibe_template_version"); !check.OK || check.Detail != "bundled template body" {
+ t.Fatalf("greenfield template check=%+v, want bundled template body", check)
+ }
+}
+
+func TestAdminRetireAgentPoolArchivesProjectsAndMarksPairRetired(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-pool-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "pilot-pool-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_agent_server_pool": `[{"label":"Old","agent_id":"old-agent","server_id":"old-server","status":"draining"},{"label":"New","agent_id":"new-agent","server_id":"new-server","status":"active"}]`,
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-retire", UserID: user.ID, Title: "Retire", ConversationID: "conv-retire", AgentID: "old-agent", MarqueeID: "old-server", Status: "ready"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/agent-pool/retire", strings.NewReader(`{"agent_id":"old-agent","server_id":"old-server"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-pool-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("retire returned %d: %s", rec.Code, rec.Body.String())
+ }
+ cfg, err := store.ConfigMap(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, err := fibe.ParseAssignmentPool(cfg["fibe_agent_server_pool"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pool) != 2 || pool[0].Status != fibe.AssignmentStatusRetired || pool[1].Status != fibe.AssignmentStatusActive {
+ t.Fatalf("pool=%+v, want old retired and new active", pool)
+ }
+ updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Status != "archived" {
+ t.Fatalf("status=%q, want archived", updated.Status)
+ }
+ archive, err := store.LatestProjectArchive(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if archive.StoragePath == "" || archive.Status != "ready" {
+ t.Fatalf("archive=%+v, want ready stored archive", archive)
+ }
+ exportReq := httptest.NewRequest(http.MethodPost, "/api/projects/project-retire/archive", strings.NewReader(`{}`))
+ exportReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "pilot-pool-token"})
+ exportRec := httptest.NewRecorder()
+ server.routes().ServeHTTP(exportRec, exportReq)
+ if exportRec.Code != http.StatusOK {
+ t.Fatalf("archived project zip export returned %d: %s", exportRec.Code, exportRec.Body.String())
+ }
+}
-func TestLoginRetiresPendingDeletionUserBeforeSignIn(t *testing.T) {
+func TestAdminUserResponsesExposeProjectAssignments(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-assignment-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_agent_server_pool": `[{"label":"Main","agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-assignment-summary", UserID: user.ID, Title: "Assigned", ConversationID: "conv-assignment-summary", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+
+ listReq := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil)
+ listReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-token"})
+ listRec := httptest.NewRecorder()
+ server.routes().ServeHTTP(listRec, listReq)
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("admin users returned %d: %s", listRec.Code, listRec.Body.String())
+ }
+ var listBody struct {
+ Users []AdminUserSummary `json:"users"`
+ AgentPool []AgentPoolOption `json:"agentPool"`
+ }
+ if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil {
+ t.Fatal(err)
+ }
+ if len(listBody.AgentPool) != 1 || listBody.AgentPool[0].AgentID != "agent-1" || listBody.AgentPool[0].Status != fibe.AssignmentStatusActive {
+ t.Fatalf("agentPool=%+v, want configured active option", listBody.AgentPool)
+ }
+ var customer AdminUserSummary
+ for _, summary := range listBody.Users {
+ if summary.User.ID == user.ID {
+ customer = summary
+ break
+ }
+ }
+ if len(customer.AgentPairs) != 1 || customer.AgentPairs[0].AgentID != "agent-1" || customer.AgentPairs[0].ServerID != "server-1" || customer.AgentPairs[0].Status != fibe.AssignmentStatusActive || customer.AgentPairs[0].ProjectCount != 1 {
+ t.Fatalf("agentPairs=%+v, want active project assignment summary", customer.AgentPairs)
+ }
+
+ detailReq := httptest.NewRequest(http.MethodGet, "/api/admin/users/"+user.ID, nil)
+ detailReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-token"})
+ detailRec := httptest.NewRecorder()
+ server.routes().ServeHTTP(detailRec, detailReq)
+ if detailRec.Code != http.StatusOK {
+ t.Fatalf("admin user detail returned %d: %s", detailRec.Code, detailRec.Body.String())
+ }
+ var detail AdminUserDetail
+ if err := json.Unmarshal(detailRec.Body.Bytes(), &detail); err != nil {
+ t.Fatal(err)
+ }
+ if len(detail.Projects) != 1 || detail.Projects[0].Assignment.AgentID != "agent-1" || detail.Projects[0].Assignment.ServerID != "server-1" || detail.Projects[0].Assignment.Status != fibe.AssignmentStatusActive {
+ t.Fatalf("project assignments=%+v, want active assignment exposed to admin", detail.Projects)
+ }
+ if strings.Contains(detailRec.Body.String(), `"agentId":"agent-1"`) && strings.Contains(detailRec.Body.String(), `"project":{"`) && strings.Contains(detailRec.Body.String(), `"AgentID"`) {
+ t.Fatalf("admin detail leaked Go internal field casing: %s", detailRec.Body.String())
+ }
+}
+
+func TestAdminUserGrantHoursAddsBalanceAndNotice(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ user, err := appStore.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-grant-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/billing/hours", strings.NewReader(`{"hours":5}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-grant-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("grant hours returned %d: %s", rec.Code, rec.Body.String())
+ }
+ balance, err := appStore.PaidHourCreditBalance(t.Context(), user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if balance != int64(5*time.Hour/time.Millisecond) {
+ t.Fatalf("balance=%d, want 5h credit", balance)
+ }
+ var body struct {
+ Detail AdminUserDetail `json:"detail"`
+ Granted bool `json:"granted"`
+ Hours int `json:"hours"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if !body.Granted || body.Hours != 5 || body.Detail.Summary.PaidHourBalanceMs != int64(5*time.Hour/time.Millisecond) {
+ t.Fatalf("grant response=%+v, want refreshed 5h balance", body)
+ }
+ foundNotice := false
+ for _, notice := range body.Detail.Notices {
+ if notice.Sender == "system" && strings.Contains(notice.Body, "5 build hours") {
+ foundNotice = true
+ break
+ }
+ }
+ if !foundNotice {
+ t.Fatalf("notices=%+v, want system grant notice", body.Detail.Notices)
+ }
+
+ badReq := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/billing/hours", strings.NewReader(`{"hours":101}`))
+ badReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-grant-token"})
+ badRec := httptest.NewRecorder()
+ server.routes().ServeHTTP(badRec, badReq)
+ if badRec.Code != http.StatusBadRequest {
+ t.Fatalf("oversized grant returned %d, want 400", badRec.Code)
+ }
+}
+
+func TestAdminRecoveryReportsDeletionBacklog(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-recovery-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ waitingUser, err := store.UpsertUser(t.Context(), "waiting@example.com", "Waiting", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.UpdateUserAccess(t.Context(), waitingUser.ID, "restricted", accountDeletionAccessNote); err != nil {
+ t.Fatal(err)
+ }
+ readyUser, err := store.UpsertUser(t.Context(), "ready@example.com", "Ready", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.UpdateUserAccess(t.Context(), readyUser.ID, "restricted", accountDeletionAccessNote); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{
+ ID: "project-recovery",
+ UserID: waitingUser.ID,
+ Title: "Recovery",
+ ConversationID: "conv-recovery",
+ PlaygroundID: "playground-recovery",
+ PlayspecID: "playspec-recovery",
+ PropID: "prop-recovery",
+ Status: "deleting",
+ }
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.UpdateProjectCleanupError(t.Context(), project.ID, waitingUser.ID, "previous timeout"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/recovery", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-recovery-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin recovery returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ CheckedAt string `json:"checkedAt"`
+ DeletingProjects []adminRecoveryProject `json:"deletingProjects"`
+ PendingAccountDeletions []adminRecoveryAccount `json:"pendingAccountDeletions"`
+ DeletingProjectCount int `json:"deletingProjectCount"`
+ PendingAccountDeletionCount int `json:"pendingAccountDeletionCount"`
+ SweepIntervalSeconds int `json:"sweepIntervalSeconds"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body.CheckedAt == "" || body.SweepIntervalSeconds != int(projectDeletionSweepInterval.Seconds()) {
+ t.Fatalf("recovery metadata=%+v, want checkedAt and sweep interval", body)
+ }
+ if body.DeletingProjectCount != 1 || len(body.DeletingProjects) != 1 {
+ t.Fatalf("deleting projects=%+v count=%d, want one", body.DeletingProjects, body.DeletingProjectCount)
+ }
+ if body.DeletingProjects[0].ID != project.ID || body.DeletingProjects[0].CleanupLastError != "previous timeout" {
+ t.Fatalf("deleting project=%+v, want cleanup failure details", body.DeletingProjects[0])
+ }
+ if body.PendingAccountDeletionCount != 2 || len(body.PendingAccountDeletions) != 2 {
+ t.Fatalf("pending accounts=%+v count=%d, want two", body.PendingAccountDeletions, body.PendingAccountDeletionCount)
+ }
+ accountsByEmail := map[string]adminRecoveryAccount{}
+ for _, account := range body.PendingAccountDeletions {
+ accountsByEmail[account.Email] = account
+ }
+ if account := accountsByEmail["waiting@example.com"]; account.Ready || account.ProjectCount != 1 {
+ t.Fatalf("waiting account=%+v, want not ready with one project", account)
+ }
+ if account := accountsByEmail["ready@example.com"]; !account.Ready || account.ProjectCount != 0 {
+ t.Fatalf("ready account=%+v, want ready with zero projects", account)
+ }
+}
+
+func TestAdminProjectAssignmentPatchValidatesTargetAndPreservesProjectState(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- if err := store.UpsertConfig(t.Context(), map[string]string{"signup_mode": "all"}, secretConfigKeys); err != nil {
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
t.Fatal(err)
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", DevAuth: true}, http: http.DefaultClient}
- oldUser, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- if _, err := store.UpdateUserAccess(t.Context(), oldUser.ID, "restricted", accountDeletionAccessNote); err != nil {
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-assignment-patch-token", time.Hour); err != nil {
t.Fatal(err)
}
-
- req := httptest.NewRequest(http.MethodGet, "/api/dev/login?email=pilot@example.com", nil)
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("dev login returned %d, want 200; body=%s", rec.Code, rec.Body.String())
- }
- retired, err := store.UserByID(t.Context(), oldUser.ID)
- if err != nil {
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_agent_server_pool": `[
+ {"label":"Old","agent_id":"old-agent","server_id":"old-server","status":"active"},
+ {"label":"New","agent_id":"new-agent","server_id":"new-server","status":"active"},
+ {"label":"Drain","agent_id":"drain-agent","server_id":"drain-server","status":"draining"},
+ {"label":"Retired","agent_id":"retired-agent","server_id":"retired-server","status":"retired"}
+ ]`,
+ }, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if retired.Email == "pilot@example.com" || retired.AccessStatus != "restricted" {
- t.Fatalf("retired user=%+v, want restricted tombstone", retired)
+ project := &Project{
+ ID: "project-assignment-patch",
+ UserID: user.ID,
+ Title: "Patch",
+ ConversationID: "conv-assignment-patch",
+ AgentID: "old-agent",
+ MarqueeID: "old-server",
+ PlaygroundID: "playground-1",
+ PlaygroundName: "playground-name",
+ PlayspecID: "playspec-1",
+ PropID: "prop-1",
+ RepoURL: "http://gitea.test/owner/repo.git",
+ PreviewURL: "http://preview.example.test",
+ SelectedService: "app",
+ Status: "ready",
}
- newUser, err := store.UserByEmail(t.Context(), "pilot@example.com")
- if err != nil {
+ if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- if newUser.ID == oldUser.ID {
- t.Fatal("login reused pending-deletion user id")
- }
- if newUser.AccessStatus != "active" {
- t.Fatalf("new user access_status=%q, want active", newUser.AccessStatus)
- }
-}
-func TestDevLoginRestrictsPublicHTTPSBaseURLToAdmin(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
- if err != nil {
- t.Fatal(err)
+ patchAssignment := func(agentID, serverID string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/assignment", strings.NewReader(`{"agent_id":"`+agentID+`","server_id":"`+serverID+`"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-patch-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ return rec
}
- defer store.Close()
- if err := store.UpsertConfig(t.Context(), map[string]string{"signup_mode": "all"}, secretConfigKeys); err != nil {
- t.Fatal(err)
+ if rec := patchAssignment("unknown-agent", "unknown-server"); rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown target returned %d, want 404: %s", rec.Code, rec.Body.String())
}
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "https://likeable.test", AdminEmail: "admin@example.com", DevAuth: true}, http: http.DefaultClient}
-
- req := httptest.NewRequest(http.MethodPost, "/api/dev/login?email=pilot@example.com", nil)
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusForbidden {
- t.Fatalf("public dev login returned %d, want 403; body=%s", rec.Code, rec.Body.String())
+ if rec := patchAssignment("drain-agent", "drain-server"); rec.Code != http.StatusBadRequest {
+ t.Fatalf("draining target returned %d, want 400: %s", rec.Code, rec.Body.String())
}
-
- req = httptest.NewRequest(http.MethodPost, "/api/dev/login?email=admin@example.com", nil)
- rec = httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
+ if rec := patchAssignment("retired-agent", "retired-server"); rec.Code != http.StatusBadRequest {
+ t.Fatalf("retired target returned %d, want 400: %s", rec.Code, rec.Body.String())
+ }
+ rec := patchAssignment("new-agent", "new-server")
if rec.Code != http.StatusOK {
- t.Fatalf("admin dev login returned %d, want 200; body=%s", rec.Code, rec.Body.String())
+ t.Fatalf("active target returned %d, want 200: %s", rec.Code, rec.Body.String())
}
-}
-
-func TestNormalizeAdminConfigValuesFormatsAllowlistAndPool(t *testing.T) {
- values, err := normalizeAdminConfigValues(map[string]string{
- "signup_allowed_emails": "Pilot@Gmail.com, @Trusted.test\npilot@gmail.com",
- "fibe_agent_server_pool": `[{"label":"Main","agentId":" agent-1 ","serverId":" server-1 "},
- {"label":"","agent_id":"","server_id":""}]`,
- })
+ updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if values["signup_allowed_emails"] != "pilot@gmail.com\n@trusted.test" {
- t.Fatalf("allowlist=%q, want newline-normalized emails", values["signup_allowed_emails"])
+ if updated.AgentID != "new-agent" || updated.MarqueeID != "new-server" {
+ t.Fatalf("assignment=%s/%s, want new active pair", updated.AgentID, updated.MarqueeID)
}
- pool, err := fibe.ParseAssignmentPool(values["fibe_agent_server_pool"])
- if err != nil {
- t.Fatal(err)
+ if updated.PlaygroundID != project.PlaygroundID || updated.PlaygroundName != project.PlaygroundName || updated.PlayspecID != project.PlayspecID || updated.PropID != project.PropID || updated.RepoURL != project.RepoURL || updated.PreviewURL != project.PreviewURL || updated.SelectedService != project.SelectedService || updated.Status != project.Status {
+ t.Fatalf("project state changed during assignment: before=%+v after=%+v", project, updated)
}
- if len(pool) != 1 || pool[0].AgentID != "agent-1" || pool[0].MarqueeID != "server-1" {
- t.Fatalf("pool=%+v, want normalized single pair", pool)
+ var body struct {
+ Detail AdminUserDetail `json:"detail"`
+ Warning string `json:"warning"`
}
-}
-
-func TestNormalizeAdminConfigRejectsIncompletePoolRows(t *testing.T) {
- _, err := normalizeAdminConfigValues(map[string]string{
- "fibe_agent_server_pool": `[{"agent_id":"agent-only"}]`,
- })
- if err == nil || !strings.Contains(err.Error(), "requires both") {
- t.Fatalf("err=%v, want incomplete pool row error", err)
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
}
-}
-
-func TestNormalizeAdminConfigRejectsInvalidPoolStatus(t *testing.T) {
- _, err := normalizeAdminConfigValues(map[string]string{
- "fibe_agent_server_pool": `[{"agent_id":"agent-1","server_id":"server-1","status":"paused"}]`,
- })
- if err == nil || !strings.Contains(err.Error(), "invalid status") {
- t.Fatalf("err=%v, want invalid status error", err)
+ if len(body.Detail.Projects) != 1 || body.Detail.Projects[0].Assignment.AgentID != "new-agent" || body.Detail.Projects[0].Assignment.Status != fibe.AssignmentStatusActive {
+ t.Fatalf("response detail=%+v, want updated active assignment", body.Detail.Projects)
}
}
-func TestAdminRetireAgentPoolArchivesProjectsAndMarksPairRetired(t *testing.T) {
+func TestAdminProjectAssignmentPatchMakesNextMessageUseNewAgentAndSameContext(t *testing.T) {
+ cliPath, logPath, stdinPath := fakeFibeCLI(t)
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
@@ -4702,906 +5750,1027 @@ func TestAdminRetireAgentPoolArchivesProjectsAndMarksPairRetired(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), admin.ID, "admin-pool-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-assignment-message-token", time.Hour); err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "pilot-pool-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), user.ID, "pilot-assignment-message-token", time.Hour); err != nil {
t.Fatal(err)
}
if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_agent_server_pool": `[{"label":"Old","agent_id":"old-agent","server_id":"old-server","status":"draining"},{"label":"New","agent_id":"new-agent","server_id":"new-server","status":"active"}]`,
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "test-key",
+ "fibe_cli_path": cliPath,
+ "fibe_agent_server_pool": `[{"label":"Old","agent_id":"old-agent","server_id":"old-server","status":"active"},{"label":"New","agent_id":"new-agent","server_id":"new-server","status":"active"}]`,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-retire", UserID: user.ID, Title: "Retire", ConversationID: "conv-retire", AgentID: "old-agent", MarqueeID: "old-server", Status: "ready"}
+ project := &Project{
+ ID: "project-assignment-message",
+ UserID: user.ID,
+ Title: "Message",
+ ConversationID: "conv-assignment-message",
+ AgentID: "old-agent",
+ MarqueeID: "old-server",
+ PlaygroundID: "123",
+ PlaygroundName: "lk-message",
+ PlayspecID: "456",
+ PropID: "789",
+ RepoURL: "http://gitea.test/owner/repo.git",
+ PreviewURL: "http://preview.example.test",
+ Status: "ready",
+ }
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- req := httptest.NewRequest(http.MethodPost, "/api/admin/agent-pool/retire", strings.NewReader(`{"agent_id":"old-agent","server_id":"old-server"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-pool-token"})
+ patchReq := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/assignment", strings.NewReader(`{"agent_id":"new-agent","server_id":"new-server"}`))
+ patchReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-message-token"})
+ patchRec := httptest.NewRecorder()
+ server.routes().ServeHTTP(patchRec, patchReq)
+ if patchRec.Code != http.StatusOK {
+ t.Fatalf("assignment patch returned %d: %s", patchRec.Code, patchRec.Body.String())
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.ID+"/messages", strings.NewReader(`{"text":"keep building"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "pilot-assignment-message-token"})
rec := httptest.NewRecorder()
server.routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("retire returned %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("message returned %d: %s", rec.Code, rec.Body.String())
}
- cfg, err := store.ConfigMap(t.Context())
+ log := readFile(t, logPath)
+ if !strings.Contains(log, "agents start-chat new-agent --marquee-id new-server") {
+ t.Fatalf("commands=%s, want reassigned agent chat warmed", log)
+ }
+ if !strings.Contains(log, "agents send-message new-agent") {
+ t.Fatalf("commands=%s, want next user message sent to reassigned agent", log)
+ }
+ var payload struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal([]byte(readFile(t, stdinPath)), &payload); err != nil {
+ t.Fatal(err)
+ }
+ prompt := payload.Text
+ for _, want := range []string{
+ "target Fibe playground_id: 123",
+ "target private source repo: http://gitea.test/owner/repo.git",
+ "User request:\nkeep building",
+ } {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("prompt missing %q:\n%s", want, prompt)
+ }
+ }
+}
+
+func TestArchivedProjectsDoNotCountTowardProjectQuota(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- pool, err := fibe.ParseAssignmentPool(cfg["fibe_agent_server_pool"])
+ defer store.Close()
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- if len(pool) != 2 || pool[0].Status != fibe.AssignmentStatusRetired || pool[1].Status != fibe.AssignmentStatusActive {
- t.Fatalf("pool=%+v, want old retired and new active", pool)
+ for _, project := range []*Project{
+ {ID: "project-active", UserID: user.ID, Title: "Active", ConversationID: "conv-active", Status: "ready"},
+ {ID: "project-archived", UserID: user.ID, Title: "Archived", ConversationID: "conv-archived", Status: "archived"},
+ } {
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
}
- updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ count, err := store.ProjectCountForUser(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
- if updated.Status != "archived" {
- t.Fatalf("status=%q, want archived", updated.Status)
+ if count != 1 {
+ t.Fatalf("project count=%d, want active project only", count)
}
- archive, err := store.LatestProjectArchive(t.Context(), user.ID, project.ID)
+ excess, err := store.ProjectsExceedingQuota(t.Context(), user.ID, 1)
if err != nil {
t.Fatal(err)
}
- if archive.StoragePath == "" || archive.Status != "ready" {
- t.Fatalf("archive=%+v, want ready stored archive", archive)
+ if len(excess) != 0 {
+ t.Fatalf("excess=%+v, want archived project excluded", excess)
}
- exportReq := httptest.NewRequest(http.MethodPost, "/api/projects/project-retire/archive", strings.NewReader(`{}`))
- exportReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "pilot-pool-token"})
- exportRec := httptest.NewRecorder()
- server.routes().ServeHTTP(exportRec, exportReq)
- if exportRec.Code != http.StatusOK {
- t.Fatalf("archived project zip export returned %d: %s", exportRec.Code, exportRec.Body.String())
+}
+
+func TestPublicAdminConfigExposesSMTPSettings(t *testing.T) {
+ cfg := publicAdminConfig(map[string]string{
+ "smtp_host": "smtp.example.com",
+ "smtp_port": "2525",
+ "smtp_from_email": "noreply@example.com",
+ "smtp_password": "secret",
+ "github_username": "fallback-owner",
+ "github_token": "ghp_secret",
+ })
+ if entry := cfg["smtp_host"].(map[string]any); entry["value"] != "smtp.example.com" || entry["secret"].(bool) {
+ t.Fatalf("smtp_host entry=%+v, want public value", entry)
+ }
+ if entry := cfg["smtp_password"].(map[string]any); !entry["secret"].(bool) || !entry["set"].(bool) || entry["value"] != "" {
+ t.Fatalf("smtp_password entry=%+v, want write-only secret", entry)
+ }
+ if entry := cfg["github_username"].(map[string]any); entry["value"] != "fallback-owner" || entry["secret"].(bool) {
+ t.Fatalf("github_username entry=%+v, want public fallback owner", entry)
+ }
+ if entry := cfg["github_token"].(map[string]any); !entry["secret"].(bool) || !entry["set"].(bool) || entry["value"] != "" {
+ t.Fatalf("github_token entry=%+v, want write-only secret", entry)
}
}
-func TestAdminUserResponsesExposeProjectAssignments(t *testing.T) {
+func TestCreateProjectRecordStoresAssignedFibePair(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
- admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
- if err != nil {
- t.Fatal(err)
- }
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), admin.ID, "admin-assignment-token", time.Hour); err != nil {
- t.Fatal(err)
- }
if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_agent_server_pool": `[{"label":"Main","agent_id":"agent-1","server_id":"server-1","status":"active"}]`,
+ "fibe_agent_server_pool": `[{"label":"A","agent_id":"agent-a","server_id":"server-a"},{"label":"B","agent_id":"agent-b","server_id":"server-b"}]`,
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-assignment-summary", UserID: user.ID, Title: "Assigned", ConversationID: "conv-assignment-summary", AgentID: "agent-1", MarqueeID: "server-1", Status: "ready"}
- if err := store.CreateProject(t.Context(), project); err != nil {
- t.Fatal(err)
- }
-
- listReq := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil)
- listReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-token"})
- listRec := httptest.NewRecorder()
- server.routes().ServeHTTP(listRec, listReq)
- if listRec.Code != http.StatusOK {
- t.Fatalf("admin users returned %d: %s", listRec.Code, listRec.Body.String())
- }
- var listBody struct {
- Users []AdminUserSummary `json:"users"`
- AgentPool []AgentPoolOption `json:"agentPool"`
- }
- if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil {
+ project, err := server.createProjectRecord(t.Context(), user, "Assigned app")
+ if err != nil {
t.Fatal(err)
}
- if len(listBody.AgentPool) != 1 || listBody.AgentPool[0].AgentID != "agent-1" || listBody.AgentPool[0].Status != fibe.AssignmentStatusActive {
- t.Fatalf("agentPool=%+v, want configured active option", listBody.AgentPool)
- }
- var customer AdminUserSummary
- for _, summary := range listBody.Users {
- if summary.User.ID == user.ID {
- customer = summary
- break
- }
- }
- if len(customer.AgentPairs) != 1 || customer.AgentPairs[0].AgentID != "agent-1" || customer.AgentPairs[0].ServerID != "server-1" || customer.AgentPairs[0].Status != fibe.AssignmentStatusActive || customer.AgentPairs[0].ProjectCount != 1 {
- t.Fatalf("agentPairs=%+v, want active project assignment summary", customer.AgentPairs)
+ if !((project.AgentID == "agent-a" && project.MarqueeID == "server-a") || (project.AgentID == "agent-b" && project.MarqueeID == "server-b")) {
+ t.Fatalf("project assignment=%s/%s, want configured pool pair", project.AgentID, project.MarqueeID)
}
-
- detailReq := httptest.NewRequest(http.MethodGet, "/api/admin/users/"+user.ID, nil)
- detailReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-token"})
- detailRec := httptest.NewRecorder()
- server.routes().ServeHTTP(detailRec, detailReq)
- if detailRec.Code != http.StatusOK {
- t.Fatalf("admin user detail returned %d: %s", detailRec.Code, detailRec.Body.String())
+ if project.PlaygroundName != projecttext.SourceNameForProject(project) {
+ t.Fatalf("playground name=%q, want deterministic project source name", project.PlaygroundName)
}
- var detail AdminUserDetail
- if err := json.Unmarshal(detailRec.Body.Bytes(), &detail); err != nil {
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
t.Fatal(err)
}
- if len(detail.Projects) != 1 || detail.Projects[0].Assignment.AgentID != "agent-1" || detail.Projects[0].Assignment.ServerID != "server-1" || detail.Projects[0].Assignment.Status != fibe.AssignmentStatusActive {
- t.Fatalf("project assignments=%+v, want active assignment exposed to admin", detail.Projects)
+ if stored.AgentID != project.AgentID || stored.MarqueeID != project.MarqueeID {
+ t.Fatalf("stored assignment=%s/%s, want %s/%s", stored.AgentID, stored.MarqueeID, project.AgentID, project.MarqueeID)
}
- if strings.Contains(detailRec.Body.String(), `"agentId":"agent-1"`) && strings.Contains(detailRec.Body.String(), `"project":{"`) && strings.Contains(detailRec.Body.String(), `"AgentID"`) {
- t.Fatalf("admin detail leaked Go internal field casing: %s", detailRec.Body.String())
+ if stored.PlaygroundName != project.PlaygroundName {
+ t.Fatalf("stored playground name=%q, want %q", stored.PlaygroundName, project.PlaygroundName)
}
}
-func TestAdminUserGrantHoursAddsBalanceAndNotice(t *testing.T) {
- appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestCreateProjectRecordUsesCapacityAwareLeastLoadedPair(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- defer appStore.Close()
- server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
- admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- user, err := appStore.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
- if err != nil {
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_agent_server_pool": `[
+ {"label":"A","agent_id":"agent-a","server_id":"server-a","capacity":1},
+ {"label":"B","agent_id":"agent-b","server_id":"server-b","capacity":2}
+ ]`,
+ }, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if err := appStore.CreateSession(t.Context(), admin.ID, "admin-grant-token", time.Hour); err != nil {
+ if err := store.CreateProject(t.Context(), &Project{
+ ID: "existing-a",
+ UserID: user.ID,
+ Title: "Existing",
+ ConversationID: "conv-existing-a",
+ AgentID: "agent-a",
+ MarqueeID: "server-a",
+ Status: "ready",
+ }); err != nil {
t.Fatal(err)
}
-
- req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/billing/hours", strings.NewReader(`{"hours":5}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-grant-token"})
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("grant hours returned %d: %s", rec.Code, rec.Body.String())
- }
- balance, err := appStore.PaidHourCreditBalance(t.Context(), user.ID)
+ project, err := server.createProjectRecord(t.Context(), user, "Assigned app")
if err != nil {
t.Fatal(err)
}
- if balance != int64(5*time.Hour/time.Millisecond) {
- t.Fatalf("balance=%d, want 5h credit", balance)
- }
- var body struct {
- Detail AdminUserDetail `json:"detail"`
- Granted bool `json:"granted"`
- Hours int `json:"hours"`
- }
- if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
- t.Fatal(err)
- }
- if !body.Granted || body.Hours != 5 || body.Detail.Summary.PaidHourBalanceMs != int64(5*time.Hour/time.Millisecond) {
- t.Fatalf("grant response=%+v, want refreshed 5h balance", body)
- }
- foundNotice := false
- for _, notice := range body.Detail.Notices {
- if notice.Sender == "system" && strings.Contains(notice.Body, "5 build hours") {
- foundNotice = true
- break
- }
- }
- if !foundNotice {
- t.Fatalf("notices=%+v, want system grant notice", body.Detail.Notices)
- }
-
- badReq := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/billing/hours", strings.NewReader(`{"hours":101}`))
- badReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-grant-token"})
- badRec := httptest.NewRecorder()
- server.routes().ServeHTTP(badRec, badReq)
- if badRec.Code != http.StatusBadRequest {
- t.Fatalf("oversized grant returned %d, want 400", badRec.Code)
+ if project.AgentID != "agent-b" || project.MarqueeID != "server-b" {
+ t.Fatalf("project assignment=%s/%s, want least-loaded pair agent-b/server-b", project.AgentID, project.MarqueeID)
}
}
-func TestAdminRecoveryReportsDeletionBacklog(t *testing.T) {
+func TestCreateProjectRecordRejectsFullAgentPool(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
- admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), admin.ID, "admin-recovery-token", time.Hour); err != nil {
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_agent_server_pool": `[{"agent_id":"agent-a","server_id":"server-a","capacity":1}]`,
+ }, secretConfigKeys); err != nil {
t.Fatal(err)
}
- waitingUser, err := store.UpsertUser(t.Context(), "waiting@example.com", "Waiting", "")
- if err != nil {
+ if err := store.CreateProject(t.Context(), &Project{
+ ID: "existing-a",
+ UserID: user.ID,
+ Title: "Existing",
+ ConversationID: "conv-existing-a",
+ AgentID: "agent-a",
+ MarqueeID: "server-a",
+ Status: "ready",
+ }); err != nil {
t.Fatal(err)
}
- if _, err := store.UpdateUserAccess(t.Context(), waitingUser.ID, "restricted", accountDeletionAccessNote); err != nil {
- t.Fatal(err)
+ _, err = server.createProjectRecord(t.Context(), user, "Overflow app")
+ if !errors.Is(err, errAgentPoolAtCapacity) {
+ t.Fatalf("err=%v, want errAgentPoolAtCapacity", err)
}
- readyUser, err := store.UpsertUser(t.Context(), "ready@example.com", "Ready", "")
+}
+
+func TestProvisionProjectLeaseSkipsDuplicateGreenfield(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- if _, err := store.UpdateUserAccess(t.Context(), readyUser.ID, "restricted", accountDeletionAccessNote); err != nil {
+ defer store.Close()
+ _, logPath, _ := fakeFibeCLI(t)
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if err != nil {
t.Fatal(err)
}
project := &Project{
- ID: "project-recovery",
- UserID: waitingUser.ID,
- Title: "Recovery",
- ConversationID: "conv-recovery",
- PlaygroundID: "playground-recovery",
- PlayspecID: "playspec-recovery",
- PropID: "prop-recovery",
- Status: "deleting",
+ ID: "project-lease",
+ UserID: user.ID,
+ Title: "Lease",
+ ConversationID: "conv-lease",
+ AgentID: "agent-1",
+ Status: "creating",
}
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- if err := store.UpdateProjectCleanupError(t.Context(), project.ID, waitingUser.ID, "previous timeout"); err != nil {
+ acquired, err := store.TryAcquireProjectProvisioning(t.Context(), project.ID, user.ID, time.Minute)
+ if err != nil {
t.Fatal(err)
}
-
- req := httptest.NewRequest(http.MethodGet, "/api/admin/recovery", nil)
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-recovery-token"})
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("admin recovery returned %d: %s", rec.Code, rec.Body.String())
- }
- var body struct {
- CheckedAt string `json:"checkedAt"`
- DeletingProjects []adminRecoveryProject `json:"deletingProjects"`
- PendingAccountDeletions []adminRecoveryAccount `json:"pendingAccountDeletions"`
- DeletingProjectCount int `json:"deletingProjectCount"`
- PendingAccountDeletionCount int `json:"pendingAccountDeletionCount"`
- SweepIntervalSeconds int `json:"sweepIntervalSeconds"`
+ if !acquired {
+ t.Fatal("setup lease was not acquired")
}
- if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+
+ if err := server.provisionProject(t.Context(), user.ID, user.Email, project, ""); err != nil {
t.Fatal(err)
}
- if body.CheckedAt == "" || body.SweepIntervalSeconds != int(projectDeletionSweepInterval.Seconds()) {
- t.Fatalf("recovery metadata=%+v, want checkedAt and sweep interval", body)
- }
- if body.DeletingProjectCount != 1 || len(body.DeletingProjects) != 1 {
- t.Fatalf("deleting projects=%+v count=%d, want one", body.DeletingProjects, body.DeletingProjectCount)
- }
- if body.DeletingProjects[0].ID != project.ID || body.DeletingProjects[0].CleanupLastError != "previous timeout" {
- t.Fatalf("deleting project=%+v, want cleanup failure details", body.DeletingProjects[0])
+ if data, err := os.ReadFile(logPath); err == nil && strings.Contains(string(data), "greenfield") {
+ t.Fatalf("duplicate provisioning issued Greenfield command: %s", string(data))
+ } else if err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
}
- if body.PendingAccountDeletionCount != 2 || len(body.PendingAccountDeletions) != 2 {
- t.Fatalf("pending accounts=%+v count=%d, want two", body.PendingAccountDeletions, body.PendingAccountDeletionCount)
+}
+
+func TestFibeClientForProjectUsesStoredAssignment(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
}
- accountsByEmail := map[string]adminRecoveryAccount{}
- for _, account := range body.PendingAccountDeletions {
- accountsByEmail[account.Email] = account
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "fibe_base_url": "server.test:3000",
+ "fibe_api_key": "secret",
+ "fibe_agent_id": "global-agent",
+ "fibe_marquee_id": "global-marquee",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
}
- if account := accountsByEmail["waiting@example.com"]; account.Ready || account.ProjectCount != 1 {
- t.Fatalf("waiting account=%+v, want not ready with one project", account)
+ client, err := server.fibeClientForProject(t.Context(), &Project{AgentID: "stored-agent", MarqueeID: "stored-marquee"}, "pilot@example.com")
+ if err != nil {
+ t.Fatal(err)
}
- if account := accountsByEmail["ready@example.com"]; !account.Ready || account.ProjectCount != 0 {
- t.Fatalf("ready account=%+v, want ready with zero projects", account)
+ if client.AgentID() != "stored-agent" || client.MarqueeID() != "stored-marquee" {
+ t.Fatalf("client pair=%s/%s, want stored pair", client.AgentID(), client.MarqueeID())
+ }
+ if client.BaseURL() != "http://server.test:3000" {
+ t.Fatalf("baseURL=%q, want normalized local URL", client.BaseURL())
}
}
-func TestAdminProjectAssignmentPatchValidatesTargetAndPreservesProjectState(t *testing.T) {
+func TestAdminUserListingAndRestrictionControls(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
- admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+
+ user, err := store.UpsertUser(t.Context(), "customer@example.com", "Customer", "")
if err != nil {
t.Fatal(err)
}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
- if err != nil {
+ project := &Project{ID: "project-customer", UserID: user.ID, Title: "Customer app", ConversationID: "conv-customer", Status: "ready"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), admin.ID, "admin-assignment-patch-token", time.Hour); err != nil {
+ if _, err := store.AddMessage(t.Context(), project.ID, "user", "build"); err != nil {
t.Fatal(err)
}
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_agent_server_pool": `[
- {"label":"Old","agent_id":"old-agent","server_id":"old-server","status":"active"},
- {"label":"New","agent_id":"new-agent","server_id":"new-server","status":"active"},
- {"label":"Drain","agent_id":"drain-agent","server_id":"drain-server","status":"draining"},
- {"label":"Retired","agent_id":"retired-agent","server_id":"retired-server","status":"retired"}
- ]`,
- }, secretConfigKeys); err != nil {
+ if err := store.UpsertSocialConnection(t.Context(), SocialConnection{UserID: user.ID, Provider: "github", ProviderUserID: "gh-customer", AccessToken: "secret"}); err != nil {
t.Fatal(err)
}
- project := &Project{
- ID: "project-assignment-patch",
- UserID: user.ID,
- Title: "Patch",
- ConversationID: "conv-assignment-patch",
- AgentID: "old-agent",
- MarqueeID: "old-server",
- PlaygroundID: "playground-1",
- PlaygroundName: "playground-name",
- PlayspecID: "playspec-1",
- PropID: "prop-1",
- RepoURL: "http://gitea.test/owner/repo.git",
- PreviewURL: "http://preview.example.test",
- SelectedService: "app",
- Status: "ready",
+ if err := store.UpsertPayment(t.Context(), Payment{UserID: user.ID, ProviderPaymentID: "cs_test", AmountCents: 2500, Currency: "usd", Status: "paid"}); err != nil {
+ t.Fatal(err)
}
- if err := store.CreateProject(t.Context(), project); err != nil {
+ if _, err := store.AddUserNotice(t.Context(), UserNotice{UserID: user.ID, Severity: "warning", Body: "Please reduce usage."}); err != nil {
t.Fatal(err)
}
- patchAssignment := func(agentID, serverID string) *httptest.ResponseRecorder {
- req := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/assignment", strings.NewReader(`{"agent_id":"`+agentID+`","server_id":"`+serverID+`"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-patch-token"})
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
- return rec
- }
- if rec := patchAssignment("unknown-agent", "unknown-server"); rec.Code != http.StatusNotFound {
- t.Fatalf("unknown target returned %d, want 404: %s", rec.Code, rec.Body.String())
+ users, total, err := store.AdminUsers(t.Context(), AdminUserFilters{Github: "connected", Billing: "paid", Page: 1, PerPage: 10})
+ if err != nil {
+ t.Fatal(err)
}
- if rec := patchAssignment("drain-agent", "drain-server"); rec.Code != http.StatusBadRequest {
- t.Fatalf("draining target returned %d, want 400: %s", rec.Code, rec.Body.String())
+ if total != 1 || len(users) != 1 {
+ t.Fatalf("users=%d total=%d, want single paid github-connected user", len(users), total)
}
- if rec := patchAssignment("retired-agent", "retired-server"); rec.Code != http.StatusBadRequest {
- t.Fatalf("retired target returned %d, want 400: %s", rec.Code, rec.Body.String())
+ got := users[0]
+ startedAt := time.Now().UTC().Add(-40 * time.Minute)
+ if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "turn-admin", startedAt); err != nil {
+ t.Fatal(err)
}
- rec := patchAssignment("new-agent", "new-server")
- if rec.Code != http.StatusOK {
- t.Fatalf("active target returned %d, want 200: %s", rec.Code, rec.Body.String())
+ if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "turn-admin", startedAt.Add(30*time.Minute), 5, int64(5*time.Hour/time.Millisecond)); err != nil {
+ t.Fatal(err)
}
- updated, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+
+ users, total, err = store.AdminUsers(t.Context(), AdminUserFilters{Github: "connected", Billing: "paid", Page: 1, PerPage: 10})
if err != nil {
t.Fatal(err)
}
- if updated.AgentID != "new-agent" || updated.MarqueeID != "new-server" {
- t.Fatalf("assignment=%s/%s, want new active pair", updated.AgentID, updated.MarqueeID)
+ got = users[0]
+ if got.LifetimeWorkMs != int64(30*time.Minute/time.Millisecond) || got.ProjectCount != 1 || !got.GithubConnected || got.PaidTotalCents != 2500 || got.LatestNotice == nil {
+ t.Fatalf("summary=%+v, want usage/github/payment/notice populated", got)
}
- if updated.PlaygroundID != project.PlaygroundID || updated.PlaygroundName != project.PlaygroundName || updated.PlayspecID != project.PlayspecID || updated.PropID != project.PropID || updated.RepoURL != project.RepoURL || updated.PreviewURL != project.PreviewURL || updated.SelectedService != project.SelectedService || updated.Status != project.Status {
- t.Fatalf("project state changed during assignment: before=%+v after=%+v", project, updated)
+
+ if _, err := store.UpdateUserAccess(t.Context(), user.ID, "restricted", "abuse review"); err != nil {
+ t.Fatal(err)
}
- var body struct {
- Detail AdminUserDetail `json:"detail"`
- Warning string `json:"warning"`
+ allowed, err := server.canSignInEmail(t.Context(), user.Email)
+ if err != nil {
+ t.Fatal(err)
}
- if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ if allowed {
+ t.Fatal("restricted user should not be allowed to sign in")
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "restricted-token", time.Hour); err != nil {
t.Fatal(err)
}
- if len(body.Detail.Projects) != 1 || body.Detail.Projects[0].Assignment.AgentID != "new-agent" || body.Detail.Projects[0].Assignment.Status != fibe.AssignmentStatusActive {
- t.Fatalf("response detail=%+v, want updated active assignment", body.Detail.Projects)
+ req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "restricted-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("restricted projects request returned %d, want 403", rec.Code)
}
}
-func TestAdminProjectAssignmentPatchMakesNextMessageUseNewAgentAndSameContext(t *testing.T) {
- cliPath, logPath, stdinPath := fakeFibeCLI(t)
+func TestAdminNoticeSendsEmailWhenSMTPConfigured(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "smtp_host": "smtp.example.test",
+ "smtp_port": "2525",
+ "smtp_from_email": "noreply@example.test",
+ "smtp_tls_mode": "none",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ ch := make(chan emailMessage, 1)
+ server := &Server{
+ store: store,
+ config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"},
+ http: http.DefaultClient,
+ email: captureEmailSender{ch: ch},
+ }
admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
if err != nil {
t.Fatal(err)
}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ target, err := store.UpsertUser(t.Context(), "customer@example.com", "Customer", "")
if err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), admin.ID, "admin-assignment-message-token", time.Hour); err != nil {
+ if err := store.CreateSession(t.Context(), admin.ID, "admin-token", time.Hour); err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), user.ID, "pilot-assignment-message-token", time.Hour); err != nil {
- t.Fatal(err)
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+target.ID+"/notices", strings.NewReader(`{"severity":"warning","body":"Please reduce usage."}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("notice returned %d, want 201; body=%s", rec.Code, rec.Body.String())
}
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "test-key",
- "fibe_cli_path": cliPath,
- "fibe_agent_server_pool": `[{"label":"Old","agent_id":"old-agent","server_id":"old-server","status":"active"},{"label":"New","agent_id":"new-agent","server_id":"new-server","status":"active"}]`,
- }, secretConfigKeys); err != nil {
+ select {
+ case message := <-ch:
+ if message.To != "customer@example.com" || message.Subject != "New Likeable message" || !strings.Contains(message.Body, "Please reduce usage.") {
+ t.Fatalf("email=%+v, want customer notice email", message)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for notice email")
+ }
+}
+
+func TestFixedUTCFreeHoursAndPaidBalance(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
t.Fatal(err)
}
- project := &Project{
- ID: "project-assignment-message",
- UserID: user.ID,
- Title: "Message",
- ConversationID: "conv-assignment-message",
- AgentID: "old-agent",
- MarqueeID: "old-server",
- PlaygroundID: "123",
- PlaygroundName: "lk-message",
- PlayspecID: "456",
- PropID: "789",
- RepoURL: "http://gitea.test/owner/repo.git",
- PreviewURL: "http://preview.example.test",
- Status: "ready",
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+ user, err := store.UpsertUser(t.Context(), "customer@example.com", "Customer", "")
+ if err != nil {
+ t.Fatal(err)
}
+ project := &Project{ID: "project-quota", UserID: user.ID, Title: "Quota app", ConversationID: "conv-quota", Status: "ready"}
if err := store.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
-
- patchReq := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/assignment", strings.NewReader(`{"agent_id":"new-agent","server_id":"new-server"}`))
- patchReq.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-assignment-message-token"})
- patchRec := httptest.NewRecorder()
- server.routes().ServeHTTP(patchRec, patchReq)
- if patchRec.Code != http.StatusOK {
- t.Fatalf("assignment patch returned %d: %s", patchRec.Code, patchRec.Body.String())
+ if err := store.UpsertConfig(t.Context(), map[string]string{"free_hours": "1", "free_hour_window_hours": "24"}, secretConfigKeys); err != nil {
+ t.Fatal(err)
}
-
- req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.ID+"/messages", strings.NewReader(`{"text":"keep building"}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "pilot-assignment-message-token"})
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusAccepted {
- t.Fatalf("message returned %d: %s", rec.Code, rec.Body.String())
+ windowStart, _ := server.freeHourWindow(time.Now().UTC(), t.Context())
+ oldStart := windowStart.Add(-90 * time.Minute)
+ if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "old-turn", oldStart); err != nil {
+ t.Fatal(err)
}
- log := readFile(t, logPath)
- if !strings.Contains(log, "agents start-chat new-agent --marquee-id new-server") {
- t.Fatalf("commands=%s, want reassigned agent chat warmed", log)
+ if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "old-turn", oldStart.Add(30*time.Minute), server.freeHourWindowHours(t.Context()), server.freeHourLimitMs(t.Context())); err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(log, "agents send-message new-agent") {
- t.Fatalf("commands=%s, want next user message sent to reassigned agent", log)
+ currentStart := windowStart.Add(time.Minute)
+ if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "current-turn", currentStart); err != nil {
+ t.Fatal(err)
}
- var payload struct {
- Text string `json:"text"`
+ if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "current-turn", currentStart.Add(10*time.Minute), server.freeHourWindowHours(t.Context()), server.freeHourLimitMs(t.Context())); err != nil {
+ t.Fatal(err)
}
- if err := json.Unmarshal([]byte(readFile(t, stdinPath)), &payload); err != nil {
+ quota := server.hourQuota(t.Context(), user)
+ if quota["usedMs"] != int64(10*time.Minute/time.Millisecond) || quota["remainingMs"] != int64(50*time.Minute/time.Millisecond) || quota["lifetimeUsedMs"] != int64(40*time.Minute/time.Millisecond) {
+ t.Fatalf("quota=%+v, want current window 10m used, 50m remaining, 40m lifetime", quota)
+ }
+ if quota["windowHours"] != 24 {
+ t.Fatalf("quota windowHours=%v, want 24", quota["windowHours"])
+ }
+ if _, err := store.GrantHourCredits(t.Context(), user.ID, "cs_pack", 10); err != nil {
t.Fatal(err)
}
- prompt := payload.Text
- for _, want := range []string{
- "target Fibe playground_id: 123",
- "target private source repo: http://gitea.test/owner/repo.git",
- "User request:\nkeep building",
- } {
- if !strings.Contains(prompt, want) {
- t.Fatalf("prompt missing %q:\n%s", want, prompt)
- }
+ if _, err := store.GrantHourCredits(t.Context(), user.ID, "cs_pack", 10); err != nil {
+ t.Fatal(err)
}
-}
-
-func TestArchivedProjectsDoNotCountTowardProjectQuota(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ balance, err := store.PaidHourCreditBalance(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ if balance != int64(10*time.Hour/time.Millisecond) {
+ t.Fatalf("balance=%d, want idempotent grant of 10h", balance)
+ }
+ allowed, err := server.hourAllowance(t.Context(), user)
if err != nil {
t.Fatal(err)
}
- for _, project := range []*Project{
- {ID: "project-active", UserID: user.ID, Title: "Active", ConversationID: "conv-active", Status: "ready"},
- {ID: "project-archived", UserID: user.ID, Title: "Archived", ConversationID: "conv-archived", Status: "archived"},
- } {
- if err := store.CreateProject(t.Context(), project); err != nil {
- t.Fatal(err)
- }
+ if !allowed {
+ t.Fatal("hour allowance should allow user with free time or paid balance")
}
- count, err := store.ProjectCountForUser(t.Context(), user.ID)
+ paidStart := windowStart.Add(20 * time.Minute)
+ if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "paid-turn", paidStart); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "paid-turn", paidStart.Add(70*time.Minute), server.freeHourWindowHours(t.Context()), server.freeHourLimitMs(t.Context())); err != nil {
+ t.Fatal(err)
+ }
+ balance, err = store.PaidHourCreditBalance(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
- if count != 1 {
- t.Fatalf("project count=%d, want active project only", count)
+ if balance != int64((10*time.Hour-20*time.Minute)/time.Millisecond) {
+ t.Fatalf("balance=%d, want 20 paid minutes consumed after free hour", balance)
}
- excess, err := store.ProjectsExceedingQuota(t.Context(), user.ID, 1)
+}
+
+func TestFreeBuildMinutesConfig(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- if len(excess) != 0 {
- t.Fatalf("excess=%+v, want archived project excluded", excess)
+ defer store.Close()
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
+
+ if got := server.freeBuildLimitMinutes(t.Context()); got != defaultFreeBuildMinutes {
+ t.Fatalf("default free minutes=%d, want %d", got, defaultFreeBuildMinutes)
+ }
+ if got := server.freeHourLimitMs(t.Context()); got != int64(30*time.Minute/time.Millisecond) {
+ t.Fatalf("default free limit ms=%d, want 30m", got)
}
-}
-func TestPublicAdminConfigExposesSMTPSettings(t *testing.T) {
- cfg := publicAdminConfig(map[string]string{
- "smtp_host": "smtp.example.com",
- "smtp_port": "2525",
- "smtp_from_email": "noreply@example.com",
- "smtp_password": "secret",
- "github_username": "fallback-owner",
- "github_token": "ghp_secret",
- })
- if entry := cfg["smtp_host"].(map[string]any); entry["value"] != "smtp.example.com" || entry["secret"].(bool) {
- t.Fatalf("smtp_host entry=%+v, want public value", entry)
+ if err := store.UpsertConfig(t.Context(), map[string]string{"free_hours": "2"}, secretConfigKeys); err != nil {
+ t.Fatal(err)
}
- if entry := cfg["smtp_password"].(map[string]any); !entry["secret"].(bool) || !entry["set"].(bool) || entry["value"] != "" {
- t.Fatalf("smtp_password entry=%+v, want write-only secret", entry)
+ if got := server.freeBuildLimitMinutes(t.Context()); got != 120 {
+ t.Fatalf("legacy free hours as minutes=%d, want 120", got)
}
- if entry := cfg["github_username"].(map[string]any); entry["value"] != "fallback-owner" || entry["secret"].(bool) {
- t.Fatalf("github_username entry=%+v, want public fallback owner", entry)
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{"free_minutes": "45"}, secretConfigKeys); err != nil {
+ t.Fatal(err)
}
- if entry := cfg["github_token"].(map[string]any); !entry["secret"].(bool) || !entry["set"].(bool) || entry["value"] != "" {
- t.Fatalf("github_token entry=%+v, want write-only secret", entry)
+ if got := server.freeBuildLimitMinutes(t.Context()); got != 45 {
+ t.Fatalf("configured free minutes=%d, want 45", got)
+ }
+ if got := server.freeHourLimitMs(t.Context()); got != int64(45*time.Minute/time.Millisecond) {
+ t.Fatalf("configured free limit ms=%d, want 45m", got)
}
}
-func TestCreateProjectRecordStoresAssignedFibePair(t *testing.T) {
+func TestProjectQuotaDaysConfig(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
- if err != nil {
- t.Fatal(err)
+
+ if got := server.projectQuotaDays(t.Context()); got != defaultProjectQuotaDays {
+ t.Fatalf("default project quota days=%d, want %d", got, defaultProjectQuotaDays)
}
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_agent_server_pool": `[{"label":"A","agent_id":"agent-a","server_id":"server-a"},{"label":"B","agent_id":"agent-b","server_id":"server-b"}]`,
- }, secretConfigKeys); err != nil {
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{"project_quota_days": "14"}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- project, err := server.createProjectRecord(t.Context(), user, "Assigned app")
- if err != nil {
- t.Fatal(err)
+ if got := server.projectQuotaDays(t.Context()); got != 14 {
+ t.Fatalf("configured project quota days=%d, want 14", got)
}
- if !((project.AgentID == "agent-a" && project.MarqueeID == "server-a") || (project.AgentID == "agent-b" && project.MarqueeID == "server-b")) {
- t.Fatalf("project assignment=%s/%s, want configured pool pair", project.AgentID, project.MarqueeID)
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{"project_quota_days": "900"}, secretConfigKeys); err != nil {
+ t.Fatal(err)
}
- if project.PlaygroundName != projecttext.SourceNameForProject(project) {
- t.Fatalf("playground name=%q, want deterministic project source name", project.PlaygroundName)
+ if got := server.projectQuotaDays(t.Context()); got != maxProjectQuotaDays {
+ t.Fatalf("clamped project quota days=%d, want %d", got, maxProjectQuotaDays)
}
- stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
- if err != nil {
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{"project_quota_days": "nope"}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if stored.AgentID != project.AgentID || stored.MarqueeID != project.MarqueeID {
- t.Fatalf("stored assignment=%s/%s, want %s/%s", stored.AgentID, stored.MarqueeID, project.AgentID, project.MarqueeID)
- }
- if stored.PlaygroundName != project.PlaygroundName {
- t.Fatalf("stored playground name=%q, want %q", stored.PlaygroundName, project.PlaygroundName)
+ if got := server.projectQuotaDays(t.Context()); got != defaultProjectQuotaDays {
+ t.Fatalf("invalid project quota days=%d, want default %d", got, defaultProjectQuotaDays)
}
}
-func TestCreateProjectRecordUsesCapacityAwareLeastLoadedPair(t *testing.T) {
+func TestPlaygroundIdleStopHoursConfig(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
- if err != nil {
- t.Fatal(err)
+
+ if got := server.playgroundIdleStopHours(t.Context()); got != defaultPlaygroundIdleStopHours {
+ t.Fatalf("default idle stop hours=%d, want %d", got, defaultPlaygroundIdleStopHours)
}
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_agent_server_pool": `[
- {"label":"A","agent_id":"agent-a","server_id":"server-a","capacity":1},
- {"label":"B","agent_id":"agent-b","server_id":"server-b","capacity":2}
- ]`,
- }, secretConfigKeys); err != nil {
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{"playground_idle_stop_hours": "12"}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if err := store.CreateProject(t.Context(), &Project{
- ID: "existing-a",
- UserID: user.ID,
- Title: "Existing",
- ConversationID: "conv-existing-a",
- AgentID: "agent-a",
- MarqueeID: "server-a",
- Status: "ready",
- }); err != nil {
- t.Fatal(err)
+ if got := server.playgroundIdleStopHours(t.Context()); got != 12 {
+ t.Fatalf("configured idle stop hours=%d, want 12", got)
}
- project, err := server.createProjectRecord(t.Context(), user, "Assigned app")
- if err != nil {
+
+ if err := store.UpsertConfig(t.Context(), map[string]string{"playground_idle_stop_hours": "900"}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if project.AgentID != "agent-b" || project.MarqueeID != "server-b" {
- t.Fatalf("project assignment=%s/%s, want least-loaded pair agent-b/server-b", project.AgentID, project.MarqueeID)
+ if got := server.playgroundIdleStopHours(t.Context()); got != maxPlaygroundIdleStopHours {
+ t.Fatalf("clamped idle stop hours=%d, want %d", got, maxPlaygroundIdleStopHours)
}
}
-func TestCreateProjectRecordRejectsFullAgentPool(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestPublicAdminConfigExposesLegacyFreeHoursAsMinutes(t *testing.T) {
+ cfg := publicAdminConfig(map[string]string{"free_hours": "2"})
+ entry, ok := cfg["free_minutes"].(map[string]any)
+ if !ok {
+ t.Fatalf("free_minutes entry=%T, want map", cfg["free_minutes"])
+ }
+ if entry["value"] != "120" || entry["set"] != true {
+ t.Fatalf("free_minutes entry=%+v, want legacy 2h exposed as 120 minutes and set", entry)
+ }
+}
+
+func TestAdminBillingHealthReportsStripeConfigAndRecentPayments(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
if err != nil {
t.Fatal(err)
}
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_agent_server_pool": `[{"agent_id":"agent-a","server_id":"server-a","capacity":1}]`,
+ customer, err := appStore.UpsertUser(t.Context(), "payer@example.com", "Payer", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-billing-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if err := appStore.UpsertConfig(t.Context(), map[string]string{
+ "stripe_publishable_key": "pk_test_health",
+ "stripe_secret_key": "sk_test_health",
+ "stripe_webhook_secret": "whsec_health",
+ "stripe_price_id_1_hour": "price_hour_1",
+ "stripe_project_quota_price_id": "price_project_quota",
+ "free_minutes": "30",
+ "free_hour_window_hours": "5",
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if err := store.CreateProject(t.Context(), &Project{
- ID: "existing-a",
- UserID: user.ID,
- Title: "Existing",
- ConversationID: "conv-existing-a",
- AgentID: "agent-a",
- MarqueeID: "server-a",
- Status: "ready",
+ if err := appStore.UpsertPayment(t.Context(), store.Payment{
+ ID: "payment-health",
+ UserID: customer.ID,
+ ProviderPaymentID: "cs_health",
+ AmountCents: 1500,
+ Currency: "usd",
+ Status: "paid",
+ CreatedAt: "2026-05-27T10:00:00Z",
}); err != nil {
t.Fatal(err)
}
- _, err = server.createProjectRecord(t.Context(), user, "Overflow app")
- if !errors.Is(err, errAgentPoolAtCapacity) {
- t.Fatalf("err=%v, want errAgentPoolAtCapacity", err)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/billing/health", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-billing-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("billing health returned %d: %s", rec.Code, rec.Body.String())
+ }
+ var resp struct {
+ Health struct {
+ Configured struct {
+ PublishableKey bool `json:"publishableKey"`
+ SecretKey bool `json:"secretKey"`
+ WebhookSecret bool `json:"webhookSecret"`
+ } `json:"configured"`
+ Products struct {
+ HourPacks []int `json:"hourPacks"`
+ ProjectQuota bool `json:"projectQuota"`
+ } `json:"products"`
+ Free struct {
+ Minutes int `json:"minutes"`
+ WindowHours int `json:"windowHours"`
+ } `json:"free"`
+ Issues []string `json:"issues"`
+ RecentPayments []struct {
+ UserEmail string `json:"userEmail"`
+ ProviderPaymentID string `json:"providerPaymentId"`
+ AmountCents int64 `json:"amountCents"`
+ Status string `json:"status"`
+ } `json:"recentPayments"`
+ } `json:"health"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if !resp.Health.Configured.PublishableKey || !resp.Health.Configured.SecretKey || !resp.Health.Configured.WebhookSecret {
+ t.Fatalf("configured=%+v, want all Stripe keys set", resp.Health.Configured)
+ }
+ if !reflect.DeepEqual(resp.Health.Products.HourPacks, []int{1}) || !resp.Health.Products.ProjectQuota {
+ t.Fatalf("products=%+v, want 1h pack and project quota", resp.Health.Products)
+ }
+ if resp.Health.Free.Minutes != 30 || resp.Health.Free.WindowHours != 5 {
+ t.Fatalf("free=%+v, want 30m/5h", resp.Health.Free)
+ }
+ if len(resp.Health.Issues) != 0 {
+ t.Fatalf("issues=%+v, want none", resp.Health.Issues)
+ }
+ if len(resp.Health.RecentPayments) != 1 || resp.Health.RecentPayments[0].UserEmail != "payer@example.com" || resp.Health.RecentPayments[0].ProviderPaymentID != "cs_health" {
+ t.Fatalf("recent payments=%+v, want payer payment", resp.Health.RecentPayments)
}
}
-func TestProvisionProjectLeaseSkipsDuplicateGreenfield(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestAdminBillingHealthDoesNotRequirePublishableKey(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- _, logPath, _ := fakeFibeCLI(t)
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "pilot@example.com", "Pilot", "")
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
if err != nil {
t.Fatal(err)
}
- project := &Project{
- ID: "project-lease",
- UserID: user.ID,
- Title: "Lease",
- ConversationID: "conv-lease",
- AgentID: "agent-1",
- Status: "creating",
- }
- if err := store.CreateProject(t.Context(), project); err != nil {
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-billing-no-pk-token", time.Hour); err != nil {
t.Fatal(err)
}
- acquired, err := store.TryAcquireProjectProvisioning(t.Context(), project.ID, user.ID, time.Minute)
- if err != nil {
+ if err := appStore.UpsertConfig(t.Context(), map[string]string{
+ "stripe_secret_key": "sk_test_health",
+ "stripe_webhook_secret": "whsec_health",
+ "stripe_price_id_1_hour": "price_hour_1",
+ "stripe_project_quota_price_id": "price_project_quota",
+ }, secretConfigKeys); err != nil {
t.Fatal(err)
}
- if !acquired {
- t.Fatal("setup lease was not acquired")
- }
- if err := server.provisionProject(t.Context(), user.ID, user.Email, project, ""); err != nil {
- t.Fatal(err)
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/billing/health", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-billing-no-pk-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("billing health returned %d: %s", rec.Code, rec.Body.String())
}
- if data, err := os.ReadFile(logPath); err == nil && strings.Contains(string(data), "greenfield") {
- t.Fatalf("duplicate provisioning issued Greenfield command: %s", string(data))
- } else if err != nil && !os.IsNotExist(err) {
+ var resp struct {
+ Health struct {
+ Configured struct {
+ PublishableKey bool `json:"publishableKey"`
+ } `json:"configured"`
+ Issues []string `json:"issues"`
+ } `json:"health"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
+ if resp.Health.Configured.PublishableKey {
+ t.Fatalf("publishableKey configured=%v, want false", resp.Health.Configured.PublishableKey)
+ }
+ if len(resp.Health.Issues) != 0 {
+ t.Fatalf("issues=%+v, want none without publishable key", resp.Health.Issues)
+ }
}
-func TestFibeClientForProjectUsesStoredAssignment(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestAdminBillingHealthReturnsEmptyRecentPayments(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "fibe_base_url": "server.test:3000",
- "fibe_api_key": "secret",
- "fibe_agent_id": "global-agent",
- "fibe_marquee_id": "global-marquee",
- }, secretConfigKeys); err != nil {
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
t.Fatal(err)
}
- client, err := server.fibeClientForProject(t.Context(), &Project{AgentID: "stored-agent", MarqueeID: "stored-marquee"}, "pilot@example.com")
- if err != nil {
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-empty-billing-token", time.Hour); err != nil {
t.Fatal(err)
}
- if client.AgentID() != "stored-agent" || client.MarqueeID() != "stored-marquee" {
- t.Fatalf("client pair=%s/%s, want stored pair", client.AgentID(), client.MarqueeID())
+
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/billing/health", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-empty-billing-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("billing health returned %d: %s", rec.Code, rec.Body.String())
}
- if client.BaseURL() != "http://server.test:3000" {
- t.Fatalf("baseURL=%q, want normalized local URL", client.BaseURL())
+ if !strings.Contains(rec.Body.String(), `"recentPayments":[]`) {
+ t.Fatalf("billing health body=%s, want empty recentPayments array", rec.Body.String())
}
}
-func TestAdminUserListingAndRestrictionControls(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestAdminProjectDiagnosticsExposeSupportContext(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
-
- user, err := store.UpsertUser(t.Context(), "customer@example.com", "Customer", "")
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-customer", UserID: user.ID, Title: "Customer app", ConversationID: "conv-customer", Status: "ready"}
- if err := store.CreateProject(t.Context(), project); err != nil {
+ user, err := appStore.UpsertUser(t.Context(), "support@example.com", "Support", "")
+ if err != nil {
t.Fatal(err)
}
- if _, err := store.AddMessage(t.Context(), project.ID, "user", "build"); err != nil {
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-diagnostics-token", time.Hour); err != nil {
t.Fatal(err)
}
- if err := store.UpsertSocialConnection(t.Context(), SocialConnection{UserID: user.ID, Provider: "github", ProviderUserID: "gh-customer", AccessToken: "secret"}); err != nil {
- t.Fatal(err)
+ project := &Project{
+ ID: "project-diagnostics",
+ UserID: user.ID,
+ Title: "Diagnostics",
+ ConversationID: "conv-diagnostics",
+ AgentID: "agent-diag",
+ MarqueeID: "server-diag",
+ PlaygroundID: "playground-diag",
+ PlaygroundName: "lk-diag",
+ PlayspecID: "playspec-diag",
+ PropID: "prop-diag",
+ RepoURL: "https://gitea.test/owner/repo.git",
+ PreviewURL: "https://preview.test",
+ Status: "ready",
}
- if err := store.UpsertPayment(t.Context(), Payment{UserID: user.ID, ProviderPaymentID: "cs_test", AmountCents: 2500, Currency: "usd", Status: "paid"}); err != nil {
+ if err := appStore.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- if _, err := store.AddUserNotice(t.Context(), UserNotice{UserID: user.ID, Severity: "warning", Body: "Please reduce usage."}); err != nil {
+ if err := appStore.ReplaceProjectResources(t.Context(), project.ID, []store.ProjectRepository{{Role: "source", SourceRepoURL: "https://github.test/source/repo", Provider: "github"}}, []store.ProjectService{{Name: "app", URL: "https://app.test", Type: "dynamic", Visibility: "external"}}); err != nil {
t.Fatal(err)
}
-
- users, total, err := store.AdminUsers(t.Context(), AdminUserFilters{Github: "connected", Billing: "paid", Page: 1, PerPage: 10})
- if err != nil {
+ if err := appStore.UpsertPayment(t.Context(), store.Payment{ID: "payment-diag", UserID: user.ID, ProviderPaymentID: "cs_diag", AmountCents: 2000, Currency: "usd", Status: "paid", CreatedAt: "2026-05-27T10:00:00Z"}); err != nil {
t.Fatal(err)
}
- if total != 1 || len(users) != 1 {
- t.Fatalf("users=%d total=%d, want single paid github-connected user", len(users), total)
- }
- got := users[0]
- startedAt := time.Now().UTC().Add(-40 * time.Minute)
- if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "turn-admin", startedAt); err != nil {
+ if _, err := appStore.GrantHourCredits(t.Context(), user.ID, "cs_diag", 1); err != nil {
t.Fatal(err)
}
- if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "turn-admin", startedAt.Add(30*time.Minute), 5, int64(5*time.Hour/time.Millisecond)); err != nil {
+ startedAt := time.Now().UTC().Add(-10 * time.Minute)
+ if err := appStore.StartProjectWorkSession(t.Context(), user.ID, project.ID, "turn-diag", startedAt); err != nil {
t.Fatal(err)
}
-
- users, total, err = store.AdminUsers(t.Context(), AdminUserFilters{Github: "connected", Billing: "paid", Page: 1, PerPage: 10})
- if err != nil {
+ if _, err := appStore.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "turn-diag", startedAt.Add(10*time.Minute), 5, int64(30*time.Minute/time.Millisecond)); err != nil {
t.Fatal(err)
}
- got = users[0]
- if got.LifetimeWorkMs != int64(30*time.Minute/time.Millisecond) || got.ProjectCount != 1 || !got.GithubConnected || got.PaidTotalCents != 2500 || got.LatestNotice == nil {
- t.Fatalf("summary=%+v, want usage/github/payment/notice populated", got)
- }
- if _, err := store.UpdateUserAccess(t.Context(), user.ID, "restricted", "abuse review"); err != nil {
- t.Fatal(err)
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/diagnostics", nil)
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-diagnostics-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("diagnostics returned %d: %s", rec.Code, rec.Body.String())
}
- allowed, err := server.canSignInEmail(t.Context(), user.Email)
- if err != nil {
- t.Fatal(err)
+ bodyBytes := rec.Body.Bytes()
+ for _, hidden := range []string{"productionExpiresAt", "productionRuntimeStatus", "customDomain", "customDomainStatus", "customDomainTarget"} {
+ if bytes.Contains(bodyBytes, []byte(hidden)) {
+ t.Fatalf("diagnostics response includes hidden legacy field %q: %s", hidden, string(bodyBytes))
+ }
}
- if allowed {
- t.Fatal("restricted user should not be allowed to sign in")
+ var resp struct {
+ Diagnostics AdminProjectDiagnostics `json:"diagnostics"`
}
- if err := store.CreateSession(t.Context(), user.ID, "restricted-token", time.Hour); err != nil {
+ if err := json.Unmarshal(bodyBytes, &resp); err != nil {
t.Fatal(err)
}
- req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "restricted-token"})
- rec := httptest.NewRecorder()
- server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusForbidden {
- t.Fatalf("restricted projects request returned %d, want 403", rec.Code)
+ if resp.Diagnostics.Internal.ConversationID != "conv-diagnostics" || resp.Diagnostics.Internal.PlaygroundID != "playground-diag" || resp.Diagnostics.Internal.RepoURL != "https://gitea.test/owner/repo.git" {
+ t.Fatalf("internal=%+v, want support ids", resp.Diagnostics.Internal)
+ }
+ if len(resp.Diagnostics.Project.Services) != 1 || resp.Diagnostics.Project.Services[0].URL != "https://app.test" {
+ t.Fatalf("services=%+v, want attached service", resp.Diagnostics.Project.Services)
+ }
+ if len(resp.Diagnostics.WorkSessions) != 1 || resp.Diagnostics.WorkSessions[0].SessionKey != "turn-diag" {
+ t.Fatalf("work sessions=%+v, want turn", resp.Diagnostics.WorkSessions)
+ }
+ if len(resp.Diagnostics.HourLedger) == 0 {
+ t.Fatalf("hour ledger empty")
+ }
+ if len(resp.Diagnostics.Payments) != 1 || resp.Diagnostics.Payments[0].ProviderPaymentID != "cs_diag" {
+ t.Fatalf("payments=%+v, want checkout payment", resp.Diagnostics.Payments)
}
}
-func TestAdminNoticeSendsEmailWhenSMTPConfigured(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+func TestAdminProductionGrantIsDisabled(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- if err := store.UpsertConfig(t.Context(), map[string]string{
- "smtp_host": "smtp.example.test",
- "smtp_port": "2525",
- "smtp_from_email": "noreply@example.test",
- "smtp_tls_mode": "none",
- }, secretConfigKeys); err != nil {
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
t.Fatal(err)
}
- ch := make(chan emailMessage, 1)
- server := &Server{
- store: store,
- config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"},
- http: http.DefaultClient,
- email: captureEmailSender{ch: ch},
- }
- admin, err := store.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ user, err := appStore.UpsertUser(t.Context(), "manual-production@example.com", "Manual Production", "")
if err != nil {
t.Fatal(err)
}
- target, err := store.UpsertUser(t.Context(), "customer@example.com", "Customer", "")
- if err != nil {
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-token", time.Hour); err != nil {
t.Fatal(err)
}
- if err := store.CreateSession(t.Context(), admin.ID, "admin-token", time.Hour); err != nil {
+ project := &Project{
+ ID: "project-admin-production",
+ UserID: user.ID,
+ Title: "Admin Production",
+ ConversationID: "conv-admin-production",
+ AgentID: "agent-1",
+ PlaygroundID: "playground-admin-production",
+ Status: "stopped",
+ PlaygroundLastUsedAt: time.Now().UTC().Add(-9 * time.Hour).Format(time.RFC3339Nano),
+ }
+ if err := appStore.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+target.ID+"/notices", strings.NewReader(`{"severity":"warning","body":"Please reduce usage."}`))
- req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-token"})
- rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production", strings.NewReader(`{}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-production-token"})
+ rec := httptest.NewRecorder()
server.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusCreated {
- t.Fatalf("notice returned %d, want 201; body=%s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusGone {
+ t.Fatalf("production grant returned %d, want 410; body=%s", rec.Code, rec.Body.String())
}
- select {
- case message := <-ch:
- if message.To != "customer@example.com" || message.Subject != "New Likeable message" || !strings.Contains(message.Body, "Please reduce usage.") {
- t.Fatalf("email=%+v, want customer notice email", message)
- }
- case <-time.After(time.Second):
- t.Fatal("timed out waiting for notice email")
+ if !strings.Contains(rec.Body.String(), "Fibe") {
+ t.Fatalf("body=%s, want Fibe handoff message", rec.Body.String())
}
-}
-
-func TestFixedUTCFreeHoursAndPaidBalance(t *testing.T) {
- store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- defer store.Close()
- server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: http.DefaultClient}
- user, err := store.UpsertUser(t.Context(), "customer@example.com", "Customer", "")
+ if stored.ProductionExpiresAt != "" || stored.Status != "stopped" {
+ t.Fatalf("stored project=%+v, want no production grant", stored)
+ }
+ notices, err := appStore.NoticesForUser(t.Context(), user.ID, 10)
if err != nil {
t.Fatal(err)
}
- project := &Project{ID: "project-quota", UserID: user.ID, Title: "Quota app", ConversationID: "conv-quota", Status: "ready"}
- if err := store.CreateProject(t.Context(), project); err != nil {
- t.Fatal(err)
+ if len(notices) != 0 {
+ t.Fatalf("notices=%+v, want none", notices)
}
- if err := store.UpsertConfig(t.Context(), map[string]string{"free_hours": "1", "free_hour_window_hours": "24"}, secretConfigKeys); err != nil {
+}
+
+func TestAdminProductionProjectStartRetryIsDisabled(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
t.Fatal(err)
}
- windowStart, _ := server.freeHourWindow(time.Now().UTC(), t.Context())
- oldStart := windowStart.Add(-90 * time.Minute)
- if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "old-turn", oldStart); err != nil {
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
+ if err != nil {
t.Fatal(err)
}
- if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "old-turn", oldStart.Add(30*time.Minute), server.freeHourWindowHours(t.Context()), server.freeHourLimitMs(t.Context())); err != nil {
+ user, err := appStore.UpsertUser(t.Context(), "admin-production-start@example.com", "Admin Production Start", "")
+ if err != nil {
t.Fatal(err)
}
- currentStart := windowStart.Add(time.Minute)
- if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "current-turn", currentStart); err != nil {
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-start-token", time.Hour); err != nil {
t.Fatal(err)
}
- if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "current-turn", currentStart.Add(10*time.Minute), server.freeHourWindowHours(t.Context()), server.freeHourLimitMs(t.Context())); err != nil {
+ project := &Project{
+ ID: "project-admin-production-start",
+ UserID: user.ID,
+ Title: "Admin Production Start",
+ ConversationID: "conv-admin-production-start",
+ AgentID: "agent-1",
+ MarqueeID: "server-1",
+ PlaygroundID: "playground-admin-production-start",
+ Status: "stopped",
+ }
+ if err := appStore.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- quota := server.hourQuota(t.Context(), user)
- if quota["usedMs"] != int64(10*time.Minute/time.Millisecond) || quota["remainingMs"] != int64(50*time.Minute/time.Millisecond) || quota["lifetimeUsedMs"] != int64(40*time.Minute/time.Millisecond) {
- t.Fatalf("quota=%+v, want current window 10m used, 50m remaining, 40m lifetime", quota)
+ if granted, err := appStore.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_admin_production_start", time.Now().UTC().Add(7*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("production grant=%v err=%v, want granted", granted, err)
}
- if quota["windowHours"] != 24 {
- t.Fatalf("quota windowHours=%v, want 24", quota["windowHours"])
+
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production/start", strings.NewReader(`{}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-production-start-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusGone {
+ t.Fatalf("production start returned %d, want 410; body=%s", rec.Code, rec.Body.String())
}
- if _, err := store.GrantHourCredits(t.Context(), user.ID, "cs_pack", 10); err != nil {
- t.Fatal(err)
+ if !strings.Contains(rec.Body.String(), "Fibe") {
+ t.Fatalf("body=%s, want Fibe handoff message", rec.Body.String())
}
- if _, err := store.GrantHourCredits(t.Context(), user.ID, "cs_pack", 10); err != nil {
+}
+
+func TestAdminProductionGrantRejectsInactiveProject(t *testing.T) {
+ appStore, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
t.Fatal(err)
}
- balance, err := store.PaidHourCreditBalance(t.Context(), user.ID)
+ defer appStore.Close()
+ server := &Server{store: appStore, config: RuntimeConfig{BaseURL: "http://example.test", AdminEmail: "admin@example.com"}, http: http.DefaultClient}
+ admin, err := appStore.UpsertUser(t.Context(), "admin@example.com", "Admin", "")
if err != nil {
t.Fatal(err)
}
- if balance != int64(10*time.Hour/time.Millisecond) {
- t.Fatalf("balance=%d, want idempotent grant of 10h", balance)
- }
- allowed, err := server.hourAllowance(t.Context(), user)
+ user, err := appStore.UpsertUser(t.Context(), "manual-inactive@example.com", "Manual Inactive", "")
if err != nil {
t.Fatal(err)
}
- if !allowed {
- t.Fatal("hour allowance should allow user with free time or paid balance")
- }
- paidStart := windowStart.Add(20 * time.Minute)
- if err := store.StartProjectWorkSession(t.Context(), user.ID, project.ID, "paid-turn", paidStart); err != nil {
+ if err := appStore.CreateSession(t.Context(), admin.ID, "admin-production-inactive-token", time.Hour); err != nil {
t.Fatal(err)
}
- if _, err := store.CompleteAndBillProjectWorkSession(t.Context(), user.ID, project.ID, "paid-turn", paidStart.Add(70*time.Minute), server.freeHourWindowHours(t.Context()), server.freeHourLimitMs(t.Context())); err != nil {
+ project := &Project{ID: "project-admin-production-archived", UserID: user.ID, Title: "Archived", ConversationID: "conv-admin-production-archived", Status: "archived"}
+ if err := appStore.CreateProject(t.Context(), project); err != nil {
t.Fatal(err)
}
- balance, err = store.PaidHourCreditBalance(t.Context(), user.ID)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+user.ID+"/projects/"+project.ID+"/production", strings.NewReader(`{"days":7}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "admin-production-inactive-token"})
+ rec := httptest.NewRecorder()
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusGone {
+ t.Fatalf("production grant returned %d, want 410; body=%s", rec.Code, rec.Body.String())
+ }
+ stored, err := appStore.ProjectForUser(t.Context(), user.ID, project.ID)
if err != nil {
t.Fatal(err)
}
- if balance != int64((10*time.Hour-20*time.Minute)/time.Millisecond) {
- t.Fatalf("balance=%d, want 20 paid minutes consumed after free hour", balance)
+ if stored.ProductionExpiresAt != "" {
+ t.Fatalf("stored project=%+v, want no production grant", stored)
}
}
@@ -5741,6 +6910,7 @@ func TestProjectQuotaCheckoutBuildsStripeMetadata(t *testing.T) {
if err := store.UpsertConfig(t.Context(), map[string]string{
"stripe_secret_key": "sk_test",
"stripe_project_quota_price_id": "price_project_slot",
+ "project_quota_days": "14",
}, secretConfigKeys); err != nil {
t.Fatal(err)
}
@@ -5781,9 +6951,124 @@ func TestProjectQuotaCheckoutBuildsStripeMetadata(t *testing.T) {
form.Get("success_url") != "http://example.test/profile?billing=success&session_id={CHECKOUT_SESSION_ID}" ||
form.Get("metadata[purchase_kind]") != "project_quota" ||
form.Get("metadata[project_slots]") != "1" ||
- form.Get("metadata[project_quota_days]") != "30" {
+ form.Get("metadata[project_quota_days]") != "14" {
t.Fatalf("stripe form=%v, want project quota metadata", form)
}
+ assertStripeCheckoutUsesDynamicPaymentMethods(t, form)
+}
+
+func TestProductionProjectCheckoutIsDisabledBeforeStripe(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "stripe_secret_key": "sk_test",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "production-buyer@example.com", "Buyer", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "production-buyer-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-production-checkout", UserID: user.ID, Title: "Production checkout", ConversationID: "conv-production-checkout", Status: "ready"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ stripeCalled := false
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ stripeCalled = true
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"url":"https://checkout.stripe.test/session"}`)),
+ }, nil
+ })}
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client}
+ req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", strings.NewReader(`{"product":"production_project","projectId":"project-production-checkout"}`))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-buyer-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusGone {
+ t.Fatalf("checkout returned %d, want 410; body=%s", rec.Code, rec.Body.String())
+ }
+ if stripeCalled {
+ t.Fatal("Stripe was called for disabled production checkout")
+ }
+}
+
+func TestProductionProjectCheckoutAlwaysReturnsGoneBeforeStripe(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "stripe_secret_key": "sk_test",
+ "stripe_production_project_price_id": "price_production_project",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "production-guard@example.com", "Buyer", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ other, err := store.UpsertUser(t.Context(), "production-other@example.com", "Other", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateSession(t.Context(), user.ID, "production-guard-token", time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ otherProject := &Project{ID: "project-production-other", UserID: other.ID, Title: "Other", ConversationID: "conv-production-other", Status: "ready"}
+ archivedProject := &Project{ID: "project-production-archived", UserID: user.ID, Title: "Archived", ConversationID: "conv-production-archived", Status: "archived"}
+ activeProject := &Project{ID: "project-production-active", UserID: user.ID, Title: "Active", ConversationID: "conv-production-active", Status: "ready"}
+ for _, project := range []*Project{otherProject, archivedProject, activeProject} {
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, activeProject.ID, "cs_active_project", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("active production grant=%v err=%v, want granted", granted, err)
+ }
+ stripeCalled := false
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ stripeCalled = true
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"url":"https://checkout.stripe.test/session"}`))}, nil
+ })}
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client}
+ for _, tc := range []struct {
+ name string
+ body string
+ code int
+ }{
+ {name: "missing project id", body: `{"product":"production_project"}`, code: http.StatusGone},
+ {name: "project owned by another user", body: `{"product":"production_project","projectId":"project-production-other"}`, code: http.StatusGone},
+ {name: "archived project", body: `{"product":"production_project","projectId":"project-production-archived"}`, code: http.StatusGone},
+ {name: "already active production", body: `{"product":"production_project","projectId":"project-production-active"}`, code: http.StatusGone},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ stripeCalled = false
+ req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", strings.NewReader(tc.body))
+ req.AddCookie(&http.Cookie{Name: "likeable_session", Value: "production-guard-token"})
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != tc.code {
+ t.Fatalf("checkout returned %d, want %d; body=%s", rec.Code, tc.code, rec.Body.String())
+ }
+ if stripeCalled {
+ t.Fatalf("Stripe was called for rejected checkout body=%s", tc.body)
+ }
+ })
+ }
}
func TestHourPackCheckoutBuildsStripeMetadata(t *testing.T) {
@@ -5837,6 +7122,16 @@ func TestHourPackCheckoutBuildsStripeMetadata(t *testing.T) {
form.Get("metadata[pack_hours]") != "10" {
t.Fatalf("stripe form=%v, want hour pack metadata", form)
}
+ assertStripeCheckoutUsesDynamicPaymentMethods(t, form)
+}
+
+func assertStripeCheckoutUsesDynamicPaymentMethods(t *testing.T, form url.Values) {
+ t.Helper()
+ for key := range form {
+ if strings.HasPrefix(key, "payment_method_types") {
+ t.Fatalf("stripe form=%v, want dynamic payment methods so Dashboard-enabled Link can appear", form)
+ }
+ }
}
func TestBillingRefreshAppliesCompletedHourPack(t *testing.T) {
@@ -5951,8 +7246,9 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) {
"payment_status": "paid",
"status": "complete",
"metadata": map[string]any{
- "purchase_kind": "project_quota",
- "project_slots": "1",
+ "purchase_kind": "project_quota",
+ "project_slots": "1",
+ "project_quota_days": "45",
},
}},
}
@@ -5973,6 +7269,13 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) {
if slots != 1 || expiresAt == "" {
t.Fatalf("slots=%d expires=%q, want active paid project slot", slots, expiresAt)
}
+ expires, err := time.Parse(time.RFC3339Nano, expiresAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if expires.Before(time.Now().UTC().Add(44*24*time.Hour)) || expires.After(time.Now().UTC().Add(46*24*time.Hour)) {
+ t.Fatalf("expiresAt=%s, want roughly 45 days from now", expiresAt)
+ }
notices, err := store.NoticesForUser(t.Context(), user.ID, 10)
if err != nil {
t.Fatal(err)
@@ -5982,6 +7285,85 @@ func TestStripeWebhookGrantsProjectQuota(t *testing.T) {
}
}
+func TestStripeWebhookIgnoresLegacyProductionProjectPurchase(t *testing.T) {
+ store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.UpsertConfig(t.Context(), map[string]string{
+ "stripe_secret_key": "sk_test",
+ "stripe_webhook_secret": "whsec_test",
+ }, secretConfigKeys); err != nil {
+ t.Fatal(err)
+ }
+ user, err := store.UpsertUser(t.Context(), "production-webhook@example.com", "Buyer", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{ID: "project-production-webhook", UserID: user.ID, Title: "Production webhook", ConversationID: "conv-production-webhook", AgentID: "agent-1", PlaygroundID: "playground-production-webhook", Status: "stopped"}
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ t.Fatalf("unexpected stripe request %s", req.URL.String())
+ return nil, nil
+ })}
+ server := &Server{store: store, config: RuntimeConfig{BaseURL: "http://example.test"}, http: client}
+ event := map[string]any{
+ "type": "checkout.session.completed",
+ "data": map[string]any{"object": map[string]any{
+ "id": "cs_production_project",
+ "client_reference_id": user.ID,
+ "customer": "cus_production_project",
+ "subscription": "sub_production_project",
+ "amount_total": 2900,
+ "currency": "usd",
+ "payment_status": "paid",
+ "status": "complete",
+ "metadata": map[string]any{
+ "purchase_kind": "production_project",
+ "project_id": project.ID,
+ "production_project_days": "45",
+ },
+ }},
+ }
+ payload, _ := json.Marshal(event)
+ req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader(string(payload)))
+ req.Header.Set("Stripe-Signature", testStripeSignature("whsec_test", payload))
+ rec := httptest.NewRecorder()
+
+ server.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("webhook returned %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.ProductionExpiresAt != "" || stored.Status != "stopped" {
+ t.Fatalf("project=%+v, want no production grant or start", stored)
+ }
+ payments, err := store.UserPayments(t.Context(), user.ID, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(payments) != 1 || payments[0].ProviderPaymentID != "cs_production_project" {
+ t.Fatalf("payments=%+v, want legacy payment recorded without grant", payments)
+ }
+ if sub, err := store.SubscriptionForUser(t.Context(), user.ID); !errors.Is(err, sql.ErrNoRows) || sub != nil {
+ t.Fatalf("subscription=%+v err=%v, want no subscription for legacy production purchase", sub, err)
+ }
+ notices, err := store.NoticesForUser(t.Context(), user.ID, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(notices) != 0 {
+ t.Fatalf("notices=%+v, want no production notice", notices)
+ }
+}
+
func TestStripeWebhookGrantsHourPack(t *testing.T) {
store, err := store.Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
diff --git a/internal/likeable/stripe.go b/internal/likeable/stripe.go
index 55fe6b7..f950465 100644
--- a/internal/likeable/stripe.go
+++ b/internal/likeable/stripe.go
@@ -45,7 +45,9 @@ func (s *Server) billingProducts(ctx context.Context) map[string]any {
if err != nil {
return emptyBillingProducts()
}
- return billingProductsFromConfig(stripeConfigFromMap(cfg))
+ products := billingProductsFromConfig(stripeConfigFromMap(cfg))
+ products["projectQuotaDays"] = s.projectQuotaDays(ctx)
+ return products
}
func billingProductsFromConfig(cfg map[string]string) map[string]any {
@@ -63,15 +65,17 @@ func billingProductsFromConfig(cfg map[string]string) map[string]any {
hourPacks = append(hourPacks, 100)
}
return map[string]any{
- "hourPacks": hourPacks,
- "projectQuota": strings.TrimSpace(cfg["project_quota_price"]) != "",
+ "hourPacks": hourPacks,
+ "projectQuota": strings.TrimSpace(cfg["project_quota_price"]) != "",
+ "projectQuotaDays": defaultProjectQuotaDays,
}
}
func emptyBillingProducts() map[string]any {
return map[string]any{
- "hourPacks": []int{},
- "projectQuota": false,
+ "hourPacks": []int{},
+ "projectQuota": false,
+ "projectQuotaDays": defaultProjectQuotaDays,
}
}
@@ -81,21 +85,26 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) {
return
}
user := userFromContext(r.Context())
- cfg, err := s.stripeConfig(r)
- if err != nil {
- writeError(w, http.StatusServiceUnavailable, err.Error())
- return
- }
var body struct {
- Pack int `json:"pack"`
- Product string `json:"product"`
- Slots int `json:"slots"`
+ Pack int `json:"pack"`
+ Product string `json:"product"`
+ Slots int `json:"slots"`
+ ProjectID string `json:"projectId"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
product := normalizeStripeProduct(body.Product)
+ if product == "production_project" {
+ writeError(w, http.StatusGone, "production hosting is handled in Fibe")
+ return
+ }
+ cfg, err := s.stripeConfig(r)
+ if err != nil {
+ writeError(w, http.StatusServiceUnavailable, err.Error())
+ return
+ }
pack := 0
slots := 0
priceID := ""
@@ -118,7 +127,7 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) {
form.Set("metadata[purchase_kind]", product)
if product == "project_quota" {
form.Set("metadata[project_slots]", strconv.Itoa(slots))
- form.Set("metadata[project_quota_days]", "30")
+ form.Set("metadata[project_quota_days]", strconv.Itoa(s.projectQuotaDays(r.Context())))
} else {
form.Set("metadata[pack_hours]", strconv.Itoa(pack))
}
@@ -190,6 +199,8 @@ func (s *Server) handleBillingRefresh(w http.ResponseWriter, r *http.Request) {
func normalizeStripeProduct(product string) string {
switch strings.ToLower(strings.TrimSpace(product)) {
+ case "production", "production_project", "production-project":
+ return "production_project"
case "project", "projects", "project_quota", "project-quota":
return "project_quota"
default:
@@ -325,6 +336,9 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string]
Status: "paid",
})
}
+ if stripeLegacyProductionProjectPurchase(session["metadata"]) {
+ return result, nil
+ }
packHours := stripePackHours(session["metadata"])
if packHours > 0 {
result.PurchaseKind = "hour_pack"
@@ -350,7 +364,8 @@ func (s *Server) applyStripeCheckoutSession(ctx context.Context, cfg map[string]
}
result.Applied = true
if sessionID != "" && sessionID != "" {
- expiresAt := time.Now().UTC().Add(30 * 24 * time.Hour)
+ quotaDays := stripeProjectQuotaDays(session["metadata"], s.projectQuotaDays(ctx))
+ expiresAt := time.Now().UTC().Add(time.Duration(quotaDays) * 24 * time.Hour)
if granted, err := s.store.GrantProjectQuota(ctx, userID, sessionID, projectSlots, expiresAt); err == nil && granted {
result.Granted = true
s.notifyProjectQuotaPurchased(ctx, userID, projectSlots, expiresAt)
@@ -441,6 +456,41 @@ func stripeProjectSlots(metadata any) int {
}
}
+func stripeProjectQuotaDays(metadata any, fallback int) int {
+ if fallback <= 0 || fallback > maxProjectQuotaDays {
+ fallback = defaultProjectQuotaDays
+ }
+ raw := ""
+ if m, ok := metadata.(map[string]any); ok {
+ raw = fmt.Sprint(m["project_quota_days"])
+ }
+ if raw == "" || raw == "" {
+ return fallback
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n <= 0 {
+ return fallback
+ }
+ if n > maxProjectQuotaDays {
+ return maxProjectQuotaDays
+ }
+ return n
+}
+
+func stripeLegacyProductionProjectPurchase(metadata any) bool {
+ if m, ok := metadata.(map[string]any); ok {
+ kind := strings.ToLower(strings.TrimSpace(fmt.Sprint(m["purchase_kind"])))
+ if kind == "production_project" || kind == "production-project" || kind == "production" {
+ return true
+ }
+ raw := strings.TrimSpace(fmt.Sprint(m["project_id"]))
+ if raw != "" && raw != "" {
+ return true
+ }
+ }
+ return false
+}
+
func expectedStripeHourPackPrice(cfg map[string]string, pack int) string {
switch pack {
case 1:
diff --git a/internal/likeable/test_helpers_test.go b/internal/likeable/test_helpers_test.go
index 1bd1fc5..d50022f 100644
--- a/internal/likeable/test_helpers_test.go
+++ b/internal/likeable/test_helpers_test.go
@@ -275,6 +275,8 @@ func (rt *fakeFibeTransport) roundTripWithBody(req *http.Request, bodyBytes []by
return rt.roundTripAlreadyStopped(req, methodPath, bodyBytes), nil
case "missing-playground":
return rt.roundTripMissingPlayground(req, methodPath, bodyBytes), nil
+ case "runtime-billing-required":
+ return rt.roundTripRuntimeBillingRequired(req, methodPath, bodyBytes), nil
case "hydration-fail":
if req.Method == http.MethodDelete && strings.Contains(req.URL.Path, "playground-bad") {
return fakeJSONResponse(req, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "BAD_REQUEST", "message": "delete failed after debug failed"}}), nil
@@ -459,6 +461,14 @@ func (rt *fakeFibeTransport) roundTripMissingPlayground(req *http.Request, metho
return rt.roundTripDefault(req, methodPath, body)
}
+func (rt *fakeFibeTransport) roundTripRuntimeBillingRequired(req *http.Request, methodPath string, body []byte) *http.Response {
+ if strings.HasSuffix(req.URL.Path, "/operations") && actionTypeFromBody(body) == "start" {
+ rt.log("playgrounds start " + resourceIDFromPath(req.URL.Path, "playgrounds"))
+ return fakeJSONResponse(req, http.StatusPaymentRequired, map[string]any{"error": map[string]any{"code": "INTERNAL_ERROR", "message": "unexpected status 402"}})
+ }
+ return rt.roundTripDefault(req, methodPath, body)
+}
+
func agentIDFromPath(path string) string {
return resourceIDFromPath(path, "agents")
}
diff --git a/internal/likeable/types.go b/internal/likeable/types.go
index 3851708..7553b33 100644
--- a/internal/likeable/types.go
+++ b/internal/likeable/types.go
@@ -16,9 +16,11 @@ type ProjectQuotaGrant = domain.ProjectQuotaGrant
type ProjectArchive = domain.ProjectArchive
type AgentAssignmentSummary = domain.AgentAssignmentSummary
type AgentPoolOption = domain.AgentPoolOption
+type AgentPoolHealth = domain.AgentPoolHealth
type UserNotice = domain.UserNotice
type AdminUserSummary = domain.AdminUserSummary
type AdminProjectSummary = domain.AdminProjectSummary
+type AdminProjectDiagnostics = domain.AdminProjectDiagnostics
type AdminUserDetail = domain.AdminUserDetail
type AdminUserFilters = domain.AdminUserFilters
diff --git a/internal/store/store.go b/internal/store/store.go
index 3bff2ab..871fd92 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -80,6 +80,7 @@ func (s *Store) migrate(ctx context.Context) error {
selected_service_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'creating',
error_message TEXT NOT NULL DEFAULT '',
+ internal_error_message TEXT NOT NULL DEFAULT '',
provisioning_lock_until TEXT NOT NULL DEFAULT '',
cleanup_last_error TEXT NOT NULL DEFAULT '',
cleanup_lock_until TEXT NOT NULL DEFAULT '',
@@ -232,6 +233,27 @@ func (s *Store) migrate(ctx context.Context) error {
UNIQUE(payment_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_project_quota_user_expires ON project_quota_ledger(user_id, expires_at DESC)`,
+ `CREATE TABLE IF NOT EXISTS project_production_grants (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ payment_id TEXT NOT NULL DEFAULT '',
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ UNIQUE(payment_id)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_project_production_project_expires ON project_production_grants(project_id, expires_at DESC)`,
+ `CREATE INDEX IF NOT EXISTS idx_project_production_user_expires ON project_production_grants(user_id, expires_at DESC)`,
+ `CREATE TABLE IF NOT EXISTS project_domains (
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ domain TEXT NOT NULL UNIQUE,
+ target TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending_dns',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_project_domains_user_updated ON project_domains(user_id, updated_at DESC)`,
`CREATE TABLE IF NOT EXISTS export_jobs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
@@ -280,6 +302,9 @@ func (s *Store) migrate(ctx context.Context) error {
if err := s.ensureColumn(ctx, "projects", "error_message", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
+ if err := s.ensureColumn(ctx, "projects", "internal_error_message", "TEXT NOT NULL DEFAULT ''"); err != nil {
+ return err
+ }
if err := s.ensureColumn(ctx, "projects", "selected_service_name", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
diff --git a/internal/store/store_admin.go b/internal/store/store_admin.go
index fe6c20f..435b66c 100644
--- a/internal/store/store_admin.go
+++ b/internal/store/store_admin.go
@@ -367,3 +367,59 @@ func (s *Store) AdminProjectsForUser(ctx context.Context, userID string) ([]Admi
}
return out, nil
}
+
+func (s *Store) AdminProjectDiagnostics(ctx context.Context, userID, projectID string) (*AdminProjectDiagnostics, error) {
+ project, err := s.ProjectForUser(ctx, userID, projectID)
+ if err != nil {
+ return nil, err
+ }
+ workSessions, err := s.ProjectWorkSessions(ctx, userID, projectID, 20)
+ if err != nil {
+ return nil, err
+ }
+ hourLedger, err := s.UserHourCreditLedger(ctx, userID, 30)
+ if err != nil {
+ return nil, err
+ }
+ payments, err := s.UserPayments(ctx, userID, 20)
+ if err != nil {
+ return nil, err
+ }
+ internalErrorMessage, err := s.projectInternalErrorMessage(ctx, userID, projectID)
+ if err != nil {
+ return nil, err
+ }
+ return &AdminProjectDiagnostics{
+ Project: *project,
+ Internal: AdminProjectInternal{
+ UserID: project.UserID,
+ ConversationID: project.ConversationID,
+ AgentID: project.AgentID,
+ ServerID: project.MarqueeID,
+ PlaygroundID: project.PlaygroundID,
+ PlaygroundName: project.PlaygroundName,
+ PlayspecID: project.PlayspecID,
+ PropID: project.PropID,
+ RepoURL: project.RepoURL,
+ ProvisioningLockUntil: project.ProvisioningLockUntil,
+ InternalErrorMessage: internalErrorMessage,
+ CleanupLastError: project.CleanupLastError,
+ },
+ WorkSessions: workSessions,
+ HourLedger: hourLedger,
+ Payments: payments,
+ }, nil
+}
+
+func (s *Store) projectInternalErrorMessage(ctx context.Context, userID, projectID string) (string, error) {
+ row := s.db.QueryRowContext(ctx, `
+ SELECT internal_error_message
+ FROM projects
+ WHERE id = ? AND user_id = ?
+ `, projectID, userID)
+ var message string
+ if err := row.Scan(&message); err != nil {
+ return "", err
+ }
+ return message, nil
+}
diff --git a/internal/store/store_billing.go b/internal/store/store_billing.go
index dd6e693..68e0a2d 100644
--- a/internal/store/store_billing.go
+++ b/internal/store/store_billing.go
@@ -531,6 +531,43 @@ func (s *Store) ActiveProjectQuota(ctx context.Context, userID string) (int, str
return slots, nextExpiresAt, nil
}
+func (s *Store) GrantProjectProduction(ctx context.Context, userID, projectID, paymentID string, expiresAt time.Time) (bool, error) {
+ if strings.TrimSpace(userID) == "" || strings.TrimSpace(projectID) == "" {
+ return false, nil
+ }
+ paymentID = strings.TrimSpace(paymentID)
+ if paymentID == "" {
+ paymentID = uuid.NewString()
+ }
+ if expiresAt.IsZero() {
+ expiresAt = time.Now().UTC().Add(30 * 24 * time.Hour)
+ }
+ result, err := s.db.ExecContext(ctx, `
+ INSERT OR IGNORE INTO project_production_grants(id, user_id, project_id, payment_id, expires_at, created_at)
+ SELECT ?, ?, projects.id, ?, ?, ?
+ FROM projects
+ WHERE projects.id = ? AND projects.user_id = ? AND projects.status NOT IN ('archived', 'deleting')
+ `, uuid.NewString(), userID, paymentID, expiresAt.UTC().Format(time.RFC3339Nano), nowString(), projectID, userID)
+ if err != nil {
+ return false, err
+ }
+ rows, _ := result.RowsAffected()
+ return rows > 0, nil
+}
+
+func (s *Store) ActiveProjectProduction(ctx context.Context, userID, projectID string) (string, error) {
+ row := s.db.QueryRowContext(ctx, `
+ SELECT COALESCE(MAX(expires_at), '')
+ FROM project_production_grants
+ WHERE user_id = ? AND project_id = ? AND expires_at > ?
+ `, userID, projectID, nowString())
+ var expiresAt string
+ if err := row.Scan(&expiresAt); err != nil {
+ return "", err
+ }
+ return expiresAt, nil
+}
+
func (s *Store) UpsertSocialConnection(ctx context.Context, conn SocialConnection) error {
if conn.ID == "" {
conn.ID = uuid.NewString()
@@ -620,3 +657,164 @@ func (s *Store) UpsertPayment(ctx context.Context, payment Payment) error {
`, payment.ID, payment.UserID, payment.ProviderPaymentID, payment.AmountCents, strings.ToLower(payment.Currency), payment.Status, payment.CreatedAt)
return err
}
+
+func (s *Store) RecentPayments(ctx context.Context, limit int) ([]AdminBillingPayment, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT payments.id, payments.user_id, COALESCE(users.email, ''), payments.provider_payment_id,
+ payments.amount_cents, payments.currency, payments.status, payments.created_at
+ FROM payments
+ LEFT JOIN users ON users.id = payments.user_id
+ ORDER BY payments.created_at DESC
+ LIMIT ?
+ `, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []AdminBillingPayment
+ for rows.Next() {
+ var payment AdminBillingPayment
+ if err := rows.Scan(
+ &payment.ID,
+ &payment.UserID,
+ &payment.UserEmail,
+ &payment.ProviderPaymentID,
+ &payment.AmountCents,
+ &payment.Currency,
+ &payment.Status,
+ &payment.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, payment)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if out == nil {
+ out = []AdminBillingPayment{}
+ }
+ return out, nil
+}
+
+func (s *Store) UserPayments(ctx context.Context, userID string, limit int) ([]AdminBillingPayment, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT payments.id, payments.user_id, COALESCE(users.email, ''), payments.provider_payment_id,
+ payments.amount_cents, payments.currency, payments.status, payments.created_at
+ FROM payments
+ LEFT JOIN users ON users.id = payments.user_id
+ WHERE payments.user_id = ?
+ ORDER BY payments.created_at DESC
+ LIMIT ?
+ `, userID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []AdminBillingPayment
+ for rows.Next() {
+ var payment AdminBillingPayment
+ if err := rows.Scan(
+ &payment.ID,
+ &payment.UserID,
+ &payment.UserEmail,
+ &payment.ProviderPaymentID,
+ &payment.AmountCents,
+ &payment.Currency,
+ &payment.Status,
+ &payment.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, payment)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if out == nil {
+ out = []AdminBillingPayment{}
+ }
+ return out, nil
+}
+
+func (s *Store) UserHourCreditLedger(ctx context.Context, userID string, limit int) ([]AdminHourCreditLedgerEntry, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT id, user_id, delta_ms, reason, payment_id, work_session_key, created_at
+ FROM hour_credit_ledger
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ LIMIT ?
+ `, userID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []AdminHourCreditLedgerEntry
+ for rows.Next() {
+ var entry AdminHourCreditLedgerEntry
+ if err := rows.Scan(&entry.ID, &entry.UserID, &entry.DeltaMs, &entry.Reason, &entry.PaymentID, &entry.WorkSessionKey, &entry.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, entry)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if out == nil {
+ out = []AdminHourCreditLedgerEntry{}
+ }
+ return out, nil
+}
+
+func (s *Store) ProjectWorkSessions(ctx context.Context, userID, projectID string, limit int) ([]ProjectWorkSession, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT project_id, user_id, session_key, started_at, completed_at, elapsed_ms, free_billed_ms, paid_billed_ms, billed_at, created_at, updated_at
+ FROM project_work_sessions
+ WHERE user_id = ? AND project_id = ?
+ ORDER BY started_at DESC
+ LIMIT ?
+ `, userID, projectID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []ProjectWorkSession
+ for rows.Next() {
+ var session ProjectWorkSession
+ if err := rows.Scan(&session.ProjectID, &session.UserID, &session.SessionKey, &session.StartedAt, &session.CompletedAt, &session.ElapsedMs, &session.FreeBilledMs, &session.PaidBilledMs, &session.BilledAt, &session.CreatedAt, &session.UpdatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, session)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if out == nil {
+ out = []ProjectWorkSession{}
+ }
+ return out, nil
+}
diff --git a/internal/store/store_projects.go b/internal/store/store_projects.go
index 19fc858..abc31f5 100644
--- a/internal/store/store_projects.go
+++ b/internal/store/store_projects.go
@@ -35,7 +35,7 @@ func (s *Store) UpdateProjectProvisioning(ctx context.Context, projectID, userID
now := nowString()
result, err := s.db.ExecContext(ctx, `
UPDATE projects
- SET playground_id = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '',
+ SET playground_id = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', internal_error_message = '',
playground_last_used_at = CASE WHEN ? = 'ready' AND playground_last_used_at = '' THEN ? ELSE playground_last_used_at END,
updated_at = ?
WHERE id = ? AND user_id = ? AND status != 'deleting'
@@ -114,19 +114,39 @@ func (s *Store) ClearProjectCleanupLease(ctx context.Context, projectID, userID
}
func (s *Store) UpdateProjectError(ctx context.Context, projectID, userID, message string) error {
- return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessage(message))
+ return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessage(message), internalProjectErrorMessage(message))
}
func (s *Store) UpdateProjectErrorFromError(ctx context.Context, projectID, userID string, err error) error {
- return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessageFromError(err))
+ return s.updateProjectError(ctx, projectID, userID, publicProjectErrorMessageFromError(err), internalProjectErrorMessageFromError(err))
}
-func (s *Store) updateProjectError(ctx context.Context, projectID, userID, message string) error {
+func (s *Store) UpdateProjectProvisioningRetryError(ctx context.Context, projectID, userID, status string, err error) error {
+ status = strings.TrimSpace(status)
+ if status == "" {
+ status = "creating"
+ }
+ result, execErr := s.db.ExecContext(ctx, `
+ UPDATE projects
+ SET status = ?, internal_error_message = ?, updated_at = ?
+ WHERE id = ? AND user_id = ? AND status != 'deleting'
+ `, status, internalProjectErrorMessageFromError(err), nowString(), projectID, userID)
+ if execErr != nil {
+ return execErr
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
+func (s *Store) updateProjectError(ctx context.Context, projectID, userID, message, internalMessage string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE projects
- SET status = 'error', error_message = ?, updated_at = ?
+ SET status = 'error', error_message = ?, internal_error_message = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND status != 'deleting'
- `, message, nowString(), projectID, userID)
+ `, message, internalMessage, nowString(), projectID, userID)
if err != nil {
return err
}
@@ -137,6 +157,22 @@ func (s *Store) updateProjectError(ctx context.Context, projectID, userID, messa
return nil
}
+func internalProjectErrorMessageFromError(err error) string {
+ if err == nil {
+ return ""
+ }
+ return internalProjectErrorMessage(err.Error())
+}
+
+func internalProjectErrorMessage(message string) string {
+ message = strings.TrimSpace(message)
+ const maxInternalProjectErrorMessage = 2000
+ if len(message) > maxInternalProjectErrorMessage {
+ return message[:maxInternalProjectErrorMessage]
+ }
+ return message
+}
+
type projectPublicErrorKind interface {
PublicProjectErrorKind() string
}
@@ -150,6 +186,8 @@ func publicProjectErrorMessageFromError(err error) string {
switch classified.PublicProjectErrorKind() {
case "configuration":
return "Workspace settings are incomplete. Ask an admin to review the configuration, then create a new project."
+ case "runtime_billing":
+ return "The workspace runtime is not funded. Ask an admin to fund the linked Fibe workspace, then retry starting the project."
case "timeout":
return "The canvas took too long to start. Try creating a new project."
}
@@ -302,7 +340,7 @@ func (s *Store) SaveProjectProvisioningSnapshot(ctx context.Context, project *Pr
result, err := tx.ExecContext(ctx, `
UPDATE projects
- SET agent_id = ?, marquee_id = ?, playground_id = ?, playground_name = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', cleanup_last_error = '',
+ SET agent_id = ?, marquee_id = ?, playground_id = ?, playground_name = ?, playspec_id = ?, prop_id = ?, repo_url = ?, preview_url = ?, selected_service_name = ?, status = ?, error_message = '', internal_error_message = '', cleanup_last_error = '',
playground_last_used_at = CASE WHEN playground_last_used_at = '' AND ? != '' THEN ? ELSE playground_last_used_at END,
updated_at = ?
WHERE id = ? AND user_id = ? AND status != 'deleting'
@@ -597,6 +635,12 @@ func (s *Store) attachProjectResources(ctx context.Context, project *Project) er
if project.PreviewURL == "" && len(services) > 0 {
project.PreviewURL = services[0].URL
}
+ expiresAt, err := s.ActiveProjectProduction(ctx, project.UserID, project.ID)
+ if err != nil {
+ return err
+ }
+ project.ProductionExpiresAt = expiresAt
+ project.RefreshComputedFields()
return nil
}
diff --git a/internal/store/store_projects_test.go b/internal/store/store_projects_test.go
index 0e0971d..be289ec 100644
--- a/internal/store/store_projects_test.go
+++ b/internal/store/store_projects_test.go
@@ -4,8 +4,11 @@ import (
"database/sql"
"errors"
"path/filepath"
+ "strings"
"testing"
"time"
+
+ "github.com/fibegg/likeable/internal/fibe"
)
func TestTryAcquireProjectProvisioningLeasesProject(t *testing.T) {
@@ -290,6 +293,84 @@ func TestIdleProjectsForPlaygroundStopUsesDedicatedUsageTimestamp(t *testing.T)
}
}
+func TestIdleProjectsForPlaygroundStopIncludesLegacyProductionProjects(t *testing.T) {
+ store, err := Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ user, err := store.UpsertUser(t.Context(), "idle-production@example.com", "Idle Production", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ old := time.Now().UTC().Add(-9 * time.Hour).Format(time.RFC3339Nano)
+ activeProject := &Project{ID: "active-production-idle", UserID: user.ID, Title: "Active", ConversationID: "conv-active", PlaygroundID: "pg-active", Status: "ready", PlaygroundLastUsedAt: old}
+ expiredProject := &Project{ID: "expired-production-idle", UserID: user.ID, Title: "Expired", ConversationID: "conv-expired", PlaygroundID: "pg-expired", Status: "ready", PlaygroundLastUsedAt: old}
+ for _, project := range []*Project{activeProject, expiredProject} {
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, activeProject.ID, "cs_active_production_idle", time.Now().UTC().Add(30*24*time.Hour)); err != nil || !granted {
+ t.Fatalf("active production grant=%v err=%v, want granted", granted, err)
+ }
+ if granted, err := store.GrantProjectProduction(t.Context(), user.ID, expiredProject.ID, "cs_expired_production_idle", time.Now().UTC().Add(-time.Hour)); err != nil || !granted {
+ t.Fatalf("expired production grant=%v err=%v, want granted", granted, err)
+ }
+
+ projects, err := store.IdleProjectsForPlaygroundStop(t.Context(), time.Now().UTC().Add(-8*time.Hour), 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := map[string]bool{}
+ for _, project := range projects {
+ got[project.ID] = true
+ }
+ if len(projects) != 2 || !got[activeProject.ID] || !got[expiredProject.ID] {
+ t.Fatalf("idle projects=%+v, want active and expired legacy production projects", projects)
+ }
+ idle, reason, err := store.ProjectIdleForPlaygroundStop(t.Context(), activeProject.ID, time.Now().UTC().Add(-8*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !idle || reason != "" {
+ t.Fatalf("active production idle=%v reason=%q, want eligible idle project", idle, reason)
+ }
+ idle, reason, err = store.ProjectIdleForPlaygroundStop(t.Context(), expiredProject.ID, time.Now().UTC().Add(-8*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !idle || reason != "" {
+ t.Fatalf("expired production idle=%v reason=%q, want eligible idle project", idle, reason)
+ }
+}
+
+func TestGrantProjectProductionIgnoresInactiveProjects(t *testing.T) {
+ store, err := Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ user, err := store.UpsertUser(t.Context(), "production-inactive@example.com", "Inactive", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ archived := &Project{ID: "archived-production-grant", UserID: user.ID, Title: "Archived", ConversationID: "conv-archived", Status: "archived"}
+ deleting := &Project{ID: "deleting-production-grant", UserID: user.ID, Title: "Deleting", ConversationID: "conv-deleting", Status: "deleting"}
+ for _, project := range []*Project{archived, deleting} {
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ granted, err := store.GrantProjectProduction(t.Context(), user.ID, project.ID, "cs_"+project.ID, time.Now().UTC().Add(30*24*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if granted {
+ t.Fatalf("production grant for %s was applied, want ignored for status %s", project.ID, project.Status)
+ }
+ }
+}
+
func TestBackfillProjectPlaygroundUsageProtectsReadyProjectsOnStartup(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "likeable.db"))
if err != nil {
@@ -369,3 +450,52 @@ func TestPublicProjectErrorMessageExplainsLinkedFibePlaygroundError(t *testing.T
t.Fatalf("message=%q, want %q", got, want)
}
}
+
+func TestPublicProjectErrorMessageExplainsRuntimeBilling(t *testing.T) {
+ got := publicProjectErrorMessageFromError(&fibe.PlatformError{Code: "INTERNAL_ERROR", Status: 402, Message: "unexpected status 402"})
+ want := "The workspace runtime is not funded. Ask an admin to fund the linked Fibe workspace, then retry starting the project."
+ if got != want {
+ t.Fatalf("message=%q, want %q", got, want)
+ }
+}
+
+func TestAdminProjectDiagnosticsExposeInternalProjectError(t *testing.T) {
+ store, err := Open(filepath.Join(t.TempDir(), "likeable.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ user, err := store.UpsertUser(t.Context(), "project-internal-error@example.com", "Internal Error", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ project := &Project{
+ ID: "project-internal-error",
+ UserID: user.ID,
+ Title: "Internal Error",
+ ConversationID: "conv-internal-error",
+ Status: "creating",
+ }
+ if err := store.CreateProject(t.Context(), project); err != nil {
+ t.Fatal(err)
+ }
+ rawErr := &fibe.PlatformError{Code: "INTERNAL_ERROR", Status: 422, Message: "unexpected status 422"}
+ if err := store.UpdateProjectErrorFromError(t.Context(), project.ID, user.ID, rawErr); err != nil {
+ t.Fatal(err)
+ }
+
+ stored, err := store.ProjectForUser(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(stored.ErrorMessage, "unexpected status 422") {
+ t.Fatalf("public error_message=%q should stay sanitized", stored.ErrorMessage)
+ }
+ diagnostics, err := store.AdminProjectDiagnostics(t.Context(), user.ID, project.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(diagnostics.Internal.InternalErrorMessage, "unexpected status 422") {
+ t.Fatalf("internal_error_message=%q, want raw platform cause", diagnostics.Internal.InternalErrorMessage)
+ }
+}
diff --git a/internal/store/types.go b/internal/store/types.go
index 698b1b5..ad72bba 100644
--- a/internal/store/types.go
+++ b/internal/store/types.go
@@ -20,5 +20,9 @@ type AgentPoolOption = domain.AgentPoolOption
type UserNotice = domain.UserNotice
type AdminUserSummary = domain.AdminUserSummary
type AdminProjectSummary = domain.AdminProjectSummary
+type AdminBillingPayment = domain.AdminBillingPayment
+type AdminHourCreditLedgerEntry = domain.AdminHourCreditLedgerEntry
+type AdminProjectInternal = domain.AdminProjectInternal
+type AdminProjectDiagnostics = domain.AdminProjectDiagnostics
type AdminUserDetail = domain.AdminUserDetail
type AdminUserFilters = domain.AdminUserFilters