From e67167648b25bd46c22c6400a251015e6c8883ce Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Thu, 3 Sep 2026 20:40:40 +0500 Subject: [PATCH] feat(alpha): complete closed-alpha readiness stage Co-authored-by: multica-agent --- README.md | 8 ++ apps/web/components/AlphaOnboarding.vue | 78 +++++++++++++++ apps/web/pages/campaign/[id].vue | 36 ++++++- apps/web/pages/dashboard.vue | 67 +++++++++++-- apps/web/server/api/v1/usage.get.ts | 55 ++++++----- .../server/utils/alpha-observability.test.ts | 82 ++++++++++++++++ apps/web/server/utils/alpha-observability.ts | 97 +++++++++++++++++++ package.json | 1 + 8 files changed, 384 insertions(+), 40 deletions(-) create mode 100644 apps/web/components/AlphaOnboarding.vue create mode 100644 apps/web/server/utils/alpha-observability.test.ts create mode 100644 apps/web/server/utils/alpha-observability.ts diff --git a/README.md b/README.md index 32bd375..184b0fc 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,11 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile 3. The campaign screen exposes the current summary, important memories, and active goals. The dashboard shows 30-day tokens, cost, latency, and the current daily request allowance. 4. Campaign owners may correct HP, inventory, or statuses through the audited character-state API; the reason and before/after values are retained in `audit_entries`. 5. `pnpm test` includes twenty long-memory regression scenarios. `pnpm e2e:full-party` verifies the hosted two-player/AI-companion flow end to end. + +## Weeks 9–10 closed-alpha flow + +1. A dismissible field guide on the dashboard takes a new tester from world creation through confirmation, invitations, and their first ready action. Its progress follows real drafts and campaigns and is stored per account on the device. +2. Round controls remain available in a compact panel on tablet and phone layouts. Owners can create and copy invitations, configure AI takeover, continue without a missing player, and retry a failed round without switching to desktop. +3. The dashboard reports 30-day round failures, mean and p95 queue-to-resolution latency, true cost per AI job (including paid retries), and the mean planning-context size. Recent failures link directly back to their private campaign; only campaign owners receive round operations data. +4. Before inviting testers, run `pnpm alpha:verify`. It executes all unit and regression tests, strict workspace typechecking, and the production build. On a configured alpha environment, also run `pnpm e2e:full-party` while `pnpm dev:all` is active to verify the real two-account invitation and round path. +5. Team and invited-user checks must cover narrow phone, tablet, and desktop widths; a normal all-ready round; owner continuation with an absent player; failed-round retry; and a return after at least three resolved rounds to confirm durable memory. Treat inaccessible round controls, private-data exposure, duplicate narration, partial state after failure, or an incorrect server-owned roll as release-blocking. diff --git a/apps/web/components/AlphaOnboarding.vue b/apps/web/components/AlphaOnboarding.vue new file mode 100644 index 0000000..b6fd491 --- /dev/null +++ b/apps/web/components/AlphaOnboarding.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/apps/web/pages/campaign/[id].vue b/apps/web/pages/campaign/[id].vue index 5c82148..9c6f129 100644 --- a/apps/web/pages/campaign/[id].vue +++ b/apps/web/pages/campaign/[id].vue @@ -208,9 +208,26 @@ async function createInvite() { async function copyInvite() { if (!inviteLink.value) return - await navigator.clipboard.writeText(inviteLink.value) - inviteCopied.value = true - setTimeout(() => { inviteCopied.value = false }, 1_500) + mutationError.value = '' + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(inviteLink.value) + } else { + const fallback = document.createElement('textarea') + fallback.value = inviteLink.value + fallback.style.position = 'fixed' + fallback.style.opacity = '0' + document.body.appendChild(fallback) + fallback.select() + const copied = document.execCommand('copy') + fallback.remove() + if (!copied) throw new Error('Copy is unavailable in this browser.') + } + inviteCopied.value = true + setTimeout(() => { inviteCopied.value = false }, 1_500) + } catch (error) { + mutationError.value = `${messageFrom(error)} Select and copy the invite link manually.` + } } async function toggleTakeover(member: Record) { @@ -335,6 +352,16 @@ onBeforeUnmount(() => {

{{ payload.world?.genre ?? 'PRIVATE WORLD' }}

{{ campaign?.title }}

{{ payload.world?.title ?? 'Unknown world' }}
ROUND {{ String(currentRound.number).padStart(2,'0') }} · {{ currentRound.status.toUpperCase() }}LOBBY
+
+ ROUND CONTROL{{ readyCount }} / {{ activeHumanMembers.length }} READY +
+

{{ readyCount }} / {{ activeHumanMembers.length }} HUMANS READY

+
PLAYERS & AI TAKEOVER
{{ initials(member.profile?.display_name || 'Adventurer') }}{{ member.profile?.display_name || 'Adventurer' }}{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}
+
INVITE PLAYERS

Private link · up to 8 joins · expires in 72 hours.

+
CAMPAIGN MEMORY

THROUGH ROUND {{ payload.storySummary.through_round }}{{ payload.storySummary.summary }}

No three-round summary has been created yet.

{{ goal.summary }}ACTIVE GOAL
+
+
+
ROUND INTERRUPTEDNO STATE WAS CHANGED
@@ -376,7 +403,8 @@ onBeforeUnmount(() => { diff --git a/apps/web/server/api/v1/usage.get.ts b/apps/web/server/api/v1/usage.get.ts index ada0ceb..5a5f824 100644 --- a/apps/web/server/api/v1/usage.get.ts +++ b/apps/web/server/api/v1/usage.get.ts @@ -1,14 +1,10 @@ import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase' - -interface UsageRow { - campaign_id: string | null - request_kind: string - input_tokens: number - output_tokens: number - cost_usd: number | string - latency_ms: number | null - created_at: string -} +import { + summarizeAlphaUsage, + summarizeRoundObservability, + type AlphaRoundRow, + type AlphaUsageRow, +} from '~/server/utils/alpha-observability' export default defineEventHandler(async (event) => { try { @@ -17,30 +13,32 @@ export default defineEventHandler(async (event) => { since.setUTCDate(since.getUTCDate() - 30) const today = new Date() today.setUTCHours(0, 0, 0, 0) - const [usage, quotaEvents] = await Promise.all([ - stageTwoDatabase( - `ai_usage?select=campaign_id,request_kind,input_tokens,output_tokens,cost_usd,latency_ms,created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(since.toISOString())}&order=created_at.desc&limit=1000`, + const [usage, quotaEvents, ownedCampaigns] = await Promise.all([ + stageTwoDatabase( + `ai_usage?select=campaign_id,job_id,request_kind,input_tokens,output_tokens,cost_usd,latency_ms,created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(since.toISOString())}&order=created_at.desc&limit=1000`, ), stageTwoDatabase>( `ai_quota_events?select=created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(today.toISOString())}`, ), + stageTwoDatabase>( + `campaigns?select=id,title&owner_id=eq.${user.id}`, + ), ]) const campaignIds = [...new Set(usage.flatMap(row => row.campaign_id ? [row.campaign_id] : []))] - const campaigns = campaignIds.length - ? await stageTwoDatabase>(`campaigns?select=id,title&id=in.(${campaignIds.join(',')})`) + const knownCampaignIds = new Set(ownedCampaigns.map(campaign => campaign.id)) + const additionalCampaignIds = campaignIds.filter(id => !knownCampaignIds.has(id)) + const additionalCampaigns = additionalCampaignIds.length + ? await stageTwoDatabase>(`campaigns?select=id,title&id=in.(${additionalCampaignIds.join(',')})`) : [] + const campaigns = [...ownedCampaigns, ...additionalCampaigns] const campaignNames = new Map(campaigns.map(campaign => [campaign.id, campaign.title])) - const summarize = (rows: UsageRow[]) => { - const latencies = rows.flatMap(row => row.latency_ms === null ? [] : [Number(row.latency_ms)]) - return { - requests: rows.length, - inputTokens: rows.reduce((sum, row) => sum + Number(row.input_tokens), 0), - outputTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0), - costUsd: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0), - averageLatencyMs: latencies.length ? Math.round(latencies.reduce((sum, value) => sum + value, 0) / latencies.length) : 0, - } - } - const grouped = new Map() + const ownedCampaignIds = ownedCampaigns.map(campaign => campaign.id) + const rounds = ownedCampaignIds.length + ? await stageTwoDatabase( + `rounds?select=id,campaign_id,number,status,queued_at,resolved_at,error,created_at&campaign_id=in.(${ownedCampaignIds.join(',')})&status=in.(resolved,failed)&created_at=gte.${encodeURIComponent(since.toISOString())}&order=created_at.desc&limit=1000`, + ) + : [] + const grouped = new Map() for (const row of usage) { const key = row.campaign_id ?? 'coauthor' grouped.set(key, [...(grouped.get(key) ?? []), row]) @@ -48,11 +46,12 @@ export default defineEventHandler(async (event) => { return { periodDays: 30, quota: { usedToday: quotaEvents.length, dailyRequestLimit: 60, dailyTokenLimit: 250000 }, - totals: summarize(usage), + totals: summarizeAlphaUsage(usage), + rounds: summarizeRoundObservability(usage, rounds, campaignNames), groups: [...grouped].map(([id, rows]) => ({ id, label: id === 'coauthor' ? 'World coauthor' : campaignNames.get(id) ?? 'Private campaign', - ...summarize(rows), + ...summarizeAlphaUsage(rows), })).sort((left, right) => right.costUsd - left.costUsd), } } catch (error) { diff --git a/apps/web/server/utils/alpha-observability.test.ts b/apps/web/server/utils/alpha-observability.test.ts new file mode 100644 index 0000000..3020801 --- /dev/null +++ b/apps/web/server/utils/alpha-observability.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { summarizeAlphaUsage, summarizeRoundObservability, type AlphaRoundRow, type AlphaUsageRow } from './alpha-observability' + +const usage = (patch: Partial): AlphaUsageRow => ({ + campaign_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + job_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + request_kind: 'round_plan', + input_tokens: 1_200, + output_tokens: 300, + cost_usd: 0.01, + latency_ms: 1_000, + created_at: '2026-09-01T10:00:00.000Z', + ...patch, +}) + +const round = (patch: Partial): AlphaRoundRow => ({ + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + campaign_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + number: 4, + status: 'resolved', + queued_at: '2026-09-01T10:00:00.000Z', + resolved_at: '2026-09-01T10:00:08.000Z', + error: null, + created_at: '2026-09-01T10:00:00.000Z', + ...patch, +}) + +describe('closed-alpha observability', () => { + it('summarizes provider usage without treating absent latency as zero-latency work', () => { + expect(summarizeAlphaUsage([ + usage({ latency_ms: 1_000, cost_usd: '0.010' }), + usage({ latency_ms: null, input_tokens: 800, output_tokens: 200, cost_usd: '0.005' }), + ])).toEqual({ + requests: 2, + inputTokens: 2_000, + outputTokens: 500, + costUsd: 0.015, + averageLatencyMs: 1_000, + }) + }) + + it('reports true per-round cost, context size, latency and failures', () => { + const otherJob = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + const result = summarizeRoundObservability([ + usage({ request_kind: 'round_plan', input_tokens: 1_200, cost_usd: 0.01 }), + usage({ request_kind: 'round_plan', input_tokens: 1_200, cost_usd: 0.01 }), // paid retry + usage({ request_kind: 'round_resolution', input_tokens: 1_700, cost_usd: 0.02 }), + usage({ job_id: otherJob, request_kind: 'round_plan', input_tokens: 800, cost_usd: 0.01 }), + usage({ job_id: null, request_kind: 'character-draft', input_tokens: 9_999, cost_usd: 9 }), + ], [ + round({}), + round({ + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + number: 5, + status: 'resolved', + queued_at: '2026-09-02T10:00:00.000Z', + resolved_at: '2026-09-02T10:00:12.000Z', + created_at: '2026-09-02T10:00:00.000Z', + }), + round({ + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + number: 6, + status: 'failed', + queued_at: '2026-09-03T10:00:00.000Z', + resolved_at: null, + error: 'Provider timed out', + created_at: '2026-09-03T10:00:00.000Z', + }), + ], new Map([['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'Signal Below']])) + + expect(result).toMatchObject({ + completed: 2, + failed: 1, + failureRate: 1 / 3, + averageCostUsd: 0.025, + averageLatencyMs: 10_000, + p95LatencyMs: 12_000, + averageContextTokens: 1_000, + latestFailures: [{ campaign: 'Signal Below', round: 6, message: 'Provider timed out' }], + }) + }) +}) diff --git a/apps/web/server/utils/alpha-observability.ts b/apps/web/server/utils/alpha-observability.ts new file mode 100644 index 0000000..4fd04cf --- /dev/null +++ b/apps/web/server/utils/alpha-observability.ts @@ -0,0 +1,97 @@ +export interface AlphaUsageRow { + campaign_id: string | null + job_id: string | null + request_kind: string + input_tokens: number + output_tokens: number + cost_usd: number | string + latency_ms: number | null + created_at: string +} + +export interface AlphaRoundRow { + id: string + campaign_id: string + number: number + status: string + queued_at: string | null + resolved_at: string | null + error: string | null + created_at: string +} + +const roundRequestKinds = new Set(['round_plan', 'round_resolution', 'story_summary']) + +function average(values: number[]): number { + return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 +} + +function percentile(values: number[], percentileValue: number): number { + if (!values.length) return 0 + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.max(0, Math.ceil(sorted.length * percentileValue) - 1)] ?? 0 +} + +export function summarizeAlphaUsage(rows: AlphaUsageRow[]) { + const latencies = rows.flatMap(row => row.latency_ms === null ? [] : [Number(row.latency_ms)]) + return { + requests: rows.length, + inputTokens: rows.reduce((sum, row) => sum + Number(row.input_tokens), 0), + outputTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0), + costUsd: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0), + averageLatencyMs: Math.round(average(latencies)), + } +} + +export function summarizeRoundObservability( + usageRows: AlphaUsageRow[], + roundRows: AlphaRoundRow[], + campaignNames: ReadonlyMap, +) { + const jobs = new Map() + for (const row of usageRows) { + if (!row.job_id || !roundRequestKinds.has(row.request_kind)) continue + const current = jobs.get(row.job_id) ?? { costUsd: 0, contextTokens: 0 } + current.costUsd += Number(row.cost_usd) + // The planning request is the direct serialization of RoundContext. Keep + // the largest attempt so retries increase true cost without inflating the + // reported size of the context itself. + if (row.request_kind === 'round_plan') { + current.contextTokens = Math.max(current.contextTokens, Number(row.input_tokens)) + } + jobs.set(row.job_id, current) + } + + const completed = roundRows.filter(round => round.status === 'resolved') + const failed = roundRows.filter(round => round.status === 'failed') + const endToEndLatencies = completed.flatMap(round => { + if (!round.queued_at || !round.resolved_at) return [] + const elapsed = Date.parse(round.resolved_at) - Date.parse(round.queued_at) + return Number.isFinite(elapsed) && elapsed >= 0 ? [elapsed] : [] + }) + const costs = [...jobs.values()].map(job => job.costUsd) + const contextSizes = [...jobs.values()].flatMap(job => job.contextTokens > 0 ? [job.contextTokens] : []) + const observedOutcomes = completed.length + failed.length + + return { + completed: completed.length, + failed: failed.length, + failureRate: observedOutcomes ? failed.length / observedOutcomes : 0, + averageCostUsd: average(costs), + averageLatencyMs: Math.round(average(endToEndLatencies)), + p95LatencyMs: Math.round(percentile(endToEndLatencies, 0.95)), + averageContextTokens: Math.round(average(contextSizes)), + latestFailures: failed + .slice() + .sort((left, right) => right.created_at.localeCompare(left.created_at)) + .slice(0, 3) + .map(round => ({ + id: round.id, + campaignId: round.campaign_id, + campaign: campaignNames.get(round.campaign_id) ?? 'Private campaign', + round: Number(round.number), + message: round.error?.trim() || 'The round stopped before narration was committed.', + occurredAt: round.created_at, + })), + } +} diff --git a/package.json b/package.json index e41e8c8..917d9e6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build": "pnpm -r --if-present build", "prepare:web": "pnpm --filter @dng/web exec nuxt prepare", "test": "pnpm prepare:web && pnpm test:unit", + "alpha:verify": "pnpm test && pnpm typecheck && pnpm build", "test:unit": "vitest run", "test:watch": "vitest", "typecheck": "pnpm -r --if-present typecheck",