diff --git a/web/e2e/authoring-flow.spec.ts b/web/e2e/authoring-flow.spec.ts new file mode 100644 index 000000000..86ce2c662 --- /dev/null +++ b/web/e2e/authoring-flow.spec.ts @@ -0,0 +1,370 @@ +import crypto from 'node:crypto' +import { expect, test, type APIResponse, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { csrfHeaders } from './helpers/csrf' + +/** + * End-to-end coverage for the skill authoring workbench against the real API: + * draft creation, file editing (text + binary assets), runtime binding, + * validation runs with the fix-preview loop, and the submit gate. Assertions + * ride on durable UI state (button enablement, badges, response bodies) rather + * than toasts so they do not race the toast auto-dismiss. + */ + +interface ApiEnvelope { + code: number + msg?: string + data: T +} + +interface DraftSummary { + id: number + name: string +} + +interface DraftFileSummaryPayload { + path: string + sha256: string + size: number + contentType?: string +} + +// Minimal 1×1 transparent PNG used for the binary-asset upload test. +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +) + +function uniqueSkillName(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` +} + +function validSkillMd(name: string): string { + return `--- +name: ${name} +description: greets the caller with a fixed message +--- + +# ${name} + +## Overview + +greets the caller with a fixed message +` +} + +function brokenSkillMd(name: string): string { + return `--- +name: ${name} +--- + +# ${name} +` +} + +const GREET_SCRIPT = 'echo hello from authoring e2e\n' + +const VALIDATION_YAML = `version: 1 +tasks: + - name: greet + description: script prints the greeting + type: script + script: scripts/greet.sh + args: [] + timeoutMs: 30000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: hello from authoring e2e +` + +async function createDraftViaApi(page: Page, namespaceSlug: string, name: string): Promise { + const response = await page.context().request.post('/api/web/authoring/drafts', { + data: { namespaceSlug, name }, + headers: await csrfHeaders(page), + }) + const body = await response.json() as ApiEnvelope + expect(body.code, `draft creation failed: ${body.msg ?? 'unknown error'}`).toBe(0) + return body.data.id +} + +async function deleteDraftViaApi(page: Page, draftId: number): Promise { + try { + await page.context().request.delete(`/api/web/authoring/drafts/${draftId}`, { + headers: await csrfHeaders(page), + }) + } catch { + // Best-effort cleanup for E2E environments. + } +} + +function waitForDraftFileRead(page: Page, path: string, draftId?: number): Promise { + // draftId is optional so the waiter can be registered BEFORE navigating to + // the draft page — the editor fetches the selected file on mount, and a + // waiter registered after navigation can miss the response entirely. + return page.waitForResponse( + (response) => { + const url = response.url() + return response.request().method() === 'GET' + && url.includes('/api/web/authoring/drafts/') + && url.includes('/files/content') + && url.includes(`path=${encodeURIComponent(path)}`) + && (draftId === undefined + || url.includes(`/api/web/authoring/drafts/${draftId}/files/content`)) + }, + ) +} + +function waitForDraftFileSave(page: Page, draftId: number): Promise { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/files`) + && !response.url().includes('/runtime'), + ) +} + +/** Creates a text file through the workbench UI and fills it with `content`. */ +async function createFileViaUi(page: Page, draftId: number, path: string, content: string): Promise { + await page.locator('input[placeholder="scripts/run.py"]').fill(path) + // register before the click: the editor swap triggers a content read of the + // new (empty) file once the create PUT resolves, and waiting for that read + // keeps the fill below from being overwritten by the fetch resolving late. + const newFileRead = waitForDraftFileRead(page, path, draftId) + await page.getByRole('button', { name: 'Create file' }).click() + await newFileRead + await expect(page.getByTestId('draft-file-list')).toContainText(path) + const editor = page.getByTestId('file-editor') + await editor.fill(content) + const saveResponse = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await saveResponse).status()).toBe(200) +} + +test.describe('Authoring Flow UI (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('create → edit → bind → validate → submit through the workbench UI', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-ui') + + // --- create the draft from the workbench list + await page.goto('/dashboard/authoring') + await expect(page.getByRole('heading', { name: 'Skill authoring' })).toBeVisible() + await page.getByTestId('create-draft').click() + const createDialog = page.getByRole('dialog') + await expect(createDialog).toBeVisible() + await createDialog.getByTestId('draft-namespace').click() + const namespaceOption = page.getByRole('option', { name: namespace.displayName }).first() + await namespaceOption.evaluate((element) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(createDialog.getByTestId('draft-namespace')).toContainText(namespace.displayName) + await createDialog.getByTestId('draft-name').fill(skillName) + await createDialog.getByTestId('submit-create-draft').click() + + const draftLink = page.getByRole('link', { name: skillName, exact: true }) + await expect(draftLink).toBeVisible({ timeout: 15_000 }) + const skillMdRead = waitForDraftFileRead(page, 'SKILL.md') + await draftLink.click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + draftId = Number(page.url().split('/').pop()) + expect(Number.isFinite(draftId)).toBeTruthy() + + // --- files tab: replace the scaffold, add the script and validation spec + const editor = page.getByTestId('file-editor') + await expect(editor).toBeVisible() + // the freshly created draft serves its scaffold immediately (regression + // guard: creation must persist content, not just the metadata row) + expect((await skillMdRead).status()).toBe(200) + await expect(editor).toHaveValue(/name: /) + await editor.fill(validSkillMd(skillName)) + const skillMdSave = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await skillMdSave).status()).toBe(200) + + await createFileViaUi(page, draftId, 'scripts/greet.sh', GREET_SCRIPT) + await createFileViaUi(page, draftId, 'validation.yaml', VALIDATION_YAML) + await expect(page.getByTestId('draft-file-list')).toContainText('SKILL.md') + + // --- runtime binding tab: keep the default local-script/sh binding + await page.getByRole('tab', { name: 'Runtime binding' }).click() + await expect(page.getByTestId('agent-type')).toBeVisible() + await expect(page.locator('#interpreter')).toHaveValue('sh') + const bindingSave = page.waitForResponse( + (response) => + response.request().method() === 'PUT' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/runtime`), + ) + await page.getByTestId('save-binding').click() + expect((await bindingSave).status()).toBe(200) + + // --- validate: the run should pass all three layers + await page.getByRole('tab', { name: 'Files' }).click() + const runStart = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/runs`), + ) + await page.getByTestId('start-run').click() + expect((await runStart).status()).toBe(200) + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Succeeded', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).toHaveText('0') + await expect(page.getByTestId('task-count')).toHaveText('1') + // the behavior task's stdout lands in the event console + await expect(page.getByText('hello from authoring e2e')).toBeVisible() + + // --- back to the draft and submit through the gated dialog + await page.getByRole('link', { name: 'Back to draft' }).click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + await expect(page.getByText('Validated at revision')).toBeVisible({ timeout: 15_000 }) + const submitButton = page.getByTestId('submit-draft') + await expect(submitButton).toBeEnabled() + await submitButton.click() + const submitDialog = page.getByTestId('submit-dialog') + await expect(submitDialog).toBeVisible() + const submitResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/submit`), + { timeout: 120_000 }, + ) + await submitDialog.getByRole('button', { name: 'Submit', exact: true }).click() + const submitResponse = await submitResponsePromise + expect(submitResponse.status()).toBe(200) + const submitBody = await submitResponse.json() as ApiEnvelope<{ skillId: number }> + expect(submitBody.code, `submit failed: ${submitBody.msg ?? 'unknown error'}`).toBe(0) + expect(submitBody.data.skillId).toBeTruthy() + await expect(page.getByTestId('submit-draft')).toBeDisabled() + await expect(page.getByTestId('submit-draft')).toContainText('Submitted') + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) + + test('failing validation shows a previewable fix; apply → re-validate succeeds', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-fix') + draftId = await createDraftViaApi(page, namespace.slug, skillName) + + // break the scaffold: drop the required description field + const skillMdRead = waitForDraftFileRead(page, 'SKILL.md', draftId) + await page.goto(`/dashboard/authoring/${draftId}`) + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + const editor = page.getByTestId('file-editor') + await expect(editor).toBeVisible() + await skillMdRead + await editor.fill(brokenSkillMd(skillName)) + const skillMdSave = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await skillMdSave).status()).toBe(200) + + // validate → fails with a finding that carries a suggested fix + await page.getByTestId('start-run').click() + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Failed', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).not.toHaveText('0') + const finding = page.locator('[data-testid^="finding-"]', { + hasText: 'FRONTMATTER_FIELD_MISSING', + }).first() + await expect(finding).toBeVisible({ timeout: 15_000 }) + + // preview the suggested patch before applying it + await finding.getByTestId('suggestion-toggle').click() + const diffPreview = finding.locator('pre') + await expect(diffPreview).toBeVisible() + await expect(diffPreview).toContainText('description:') + + // two-step confirm: apply, then watch the finding flip to Applied + await finding.getByTestId('apply-start').click() + await finding.getByTestId('apply-confirm').click() + await expect(finding.getByTestId('finding-status')).toContainText('Applied', { timeout: 15_000 }) + + // one click back to the workbench, re-validate, and the run passes + await page.getByRole('button', { name: 'Fix and re-validate' }).click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + await page.getByTestId('start-run').click() + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Succeeded', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).toHaveText('0') + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) + + test('uploads a binary asset that shows read-only with byte-accurate metadata', async ({ page }, testInfo) => { + test.setTimeout(120_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-binary') + draftId = await createDraftViaApi(page, namespace.slug, skillName) + + await page.goto(`/dashboard/authoring/${draftId}`) + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + + await page.getByTestId('upload-file-button').click() + const uploadDialog = page.getByRole('dialog') + await expect(uploadDialog).toBeVisible() + await uploadDialog.getByTestId('upload-file-input').setInputFiles({ + name: 'logo.png', + mimeType: 'image/png', + buffer: PNG_BUFFER, + }) + // choosing a file prefills the target draft path + await expect(uploadDialog.getByTestId('upload-path-input')).toHaveValue('assets/logo.png') + const uploadSave = waitForDraftFileSave(page, draftId) + await uploadDialog.getByTestId('confirm-upload').click() + expect((await uploadSave).status()).toBe(200) + + // the asset appears in the file list and opens the read-only binary panel + await page.getByTestId('draft-file-list').getByRole('button', { name: 'assets/logo.png', exact: true }).click() + const panel = page.getByTestId('binary-file-panel') + await expect(panel).toBeVisible() + await expect(panel).toContainText('assets/logo.png') + await expect(panel).toContainText('image/png') + await expect(panel).toContainText(`${PNG_BUFFER.length} B`) + await expect(panel.getByTestId('replace-file')).toBeVisible() + await expect(panel.getByTestId('delete-file')).toBeVisible() + + // the stored bytes round-trip byte-accurately (sha256 over the API-reported content) + const filesResponse = await page.context().request.get(`/api/web/authoring/drafts/${draftId}/files`) + const files = await filesResponse.json() as ApiEnvelope + const uploaded = files.data.find((file) => file.path === 'assets/logo.png') + expect(uploaded?.size).toBe(PNG_BUFFER.length) + expect(uploaded?.contentType).toBe('image/png') + expect(uploaded?.sha256).toBe(crypto.createHash('sha256').update(PNG_BUFFER).digest('hex')) + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f92af8763..da2c755d5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -62,6 +62,15 @@ import type { BatchMemberResponse, SkillSuite, SkillSuiteDraftInput, + AuthoringDraft, + DraftFileSummary, + DraftFileContent, + SaveDraftFileOutcome, + RuntimeBindingInfo, + ValidationRunInfo, + ValidationEventInfo, + ValidationFindingInfo, + SubmitDraftOutcome, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -2281,3 +2290,162 @@ export const notificationApi = { }) }, } + +export const authoringApi = { + async listDrafts(): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`) + }, + + async createDraft(request: { namespaceSlug: string, name: string, requirement?: string }): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async getDraft(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`) + }, + + async deleteDraft(draftId: number): Promise { + await fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async listFiles(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`) + }, + + async readFile(draftId: number, path: string): Promise { + const query = new URLSearchParams({ path }) + return fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`, + ) + }, + + async saveFile( + draftId: number, + request: { path: string, content: string, contentType?: string, expectedRevision?: number, base64?: boolean }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + path: request.path, + content: request.content, + encoding: request.base64 ? 'base64' : undefined, + contentType: request.contentType, + expectedRevision: request.expectedRevision, + }), + }) + }, + + async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise { + const query = new URLSearchParams({ path }) + if (expectedRevision !== undefined) { + query.set('expectedRevision', String(expectedRevision)) + } + await fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`, + { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async getRuntimeBinding(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`) + }, + + async saveRuntimeBinding( + draftId: number, + request: { agentType: string, config?: Record, toolAllowlist?: string[], mcpServers?: Record[] }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + agentType: request.agentType, + config: request.config ?? {}, + toolAllowlist: request.toolAllowlist ?? [], + mcpServers: request.mcpServers ?? [], + }), + }) + }, + + async startRun(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listRuns(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`) + }, + + async getRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}`) + }, + + async cancelRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listEvents(runId: number, afterSeq?: number): Promise { + const query = afterSeq === undefined ? '' : `?afterSeq=${afterSeq}` + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/events${query}`) + }, + + async listFindings(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`) + }, + + async applyFix(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/apply`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async dismissFinding(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/dismiss`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async submitDraft(draftId: number, visibility: 'PRIVATE' | 'PUBLIC' = 'PRIVATE'): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/submit`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ visibility }), + }) + }, + + /** Absolute URL for the SSE stream; EventSource resumes from the browser-managed Last-Event-ID. */ + validationStreamUrl(runId: number): string { + return buildApiUrl(`${WEB_API_PREFIX}/authoring/runs/${runId}/events/stream`) + }, +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5058bb530..cee4f4acc 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -884,3 +884,123 @@ export type NamespaceInvitationHistory = Required, 'items'> & { items: NamespaceInvitationHistory[] } + +// Authoring workbench types (draft create/edit/validate/submit flow) +export interface AuthoringDraft { + id: number + namespaceId: number + name: string + requirement?: string + revision: number + contentDigest: string + validated: boolean + validatedRevision?: number + validatedRunId?: number + submittedSkillId?: number + submittedVersionId?: number + submittedAt?: string + createdAt: string + updatedAt: string +} + +export interface DraftFileSummary { + id?: number + path: string + sha256: string + size: number + contentType?: string + updatedAt?: string +} + +export interface DraftFileContent { + path: string + sha256: string + size: number + contentType?: string + content: string +} + +export interface SaveDraftFileOutcome { + draft: AuthoringDraft + file: DraftFileSummary + created: boolean + revisionAdvanced: boolean +} + +export interface RuntimeBindingInfo { + // null while the draft has no saved binding yet (the form falls back to + // its local-script default) + agentType: string | null + config: Record + toolAllowlist: string[] + mcpServers: Record[] + updatedAt?: string +} + +export type ValidationRunStatus = + | 'QUEUED' + | 'PREPARING' + | 'RUNNING' + | 'SUCCEEDED' + | 'FAILED' + | 'CANCELLED' + | 'TIMED_OUT' + +export interface ValidationRunInfo { + id: number + draftId: number + draftRevision: number + status: ValidationRunStatus + cancelRequested: boolean + active: boolean + terminal: boolean + errorCount: number + warningCount: number + triggeredBy: string + startedAt?: string + finishedAt?: string + createdAt: string + summary: Record +} + +export interface ValidationEventInfo { + seq: number + type: string + phase?: string + payload: Record + createdAt?: string +} + +export interface FilePatch { + filePath: string + oldSha256?: string + oldValue?: string + newValue?: string +} + +export interface FixSuggestionInfo { + description?: string + patches?: FilePatch[] +} + +export interface ValidationFindingInfo { + id: number + runId: number + layer: 'STRUCTURE' | 'CONFIG' | 'BEHAVIOR' + ruleCode: string + severity: 'ERROR' | 'WARNING' + filePath?: string + location?: string + message: string + suggestion?: FixSuggestionInfo | null + status: 'OPEN' | 'APPLIED' | 'DISMISSED' + appliedRevision?: number + createdAt: string +} + +export interface SubmitDraftOutcome { + skillId: number + versionId: number + slug: string + version: string +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 9219ef9d0..d317f3825 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -195,6 +195,17 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions) const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions) +const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions) +const AuthoringDraftDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/draft-detail'), + 'DraftDetailPage', + dashboardRouteOptions, +) +const AuthoringRunDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/run-detail'), + 'RunDetailPage', + dashboardRouteOptions, +) const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), @@ -688,6 +699,27 @@ const dashboardTokensRoute = createRoute({ component: TokensPage, }) +const authoringDraftsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring', + beforeLoad: requireAuth, + component: AuthoringDraftsPage, +}) + +const authoringDraftDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId', + beforeLoad: requireAuth, + component: AuthoringDraftDetailPage, +}) + +const authoringRunDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId/runs/$runId', + beforeLoad: requireAuth, + component: AuthoringRunDetailPage, +}) + const cliAuthRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cli/auth', @@ -818,6 +850,9 @@ const routeTree = rootRoute.addChildren([ dashboardSubscriptionsRoute, dashboardNotificationsRoute, dashboardTokensRoute, + authoringDraftsRoute, + authoringDraftDetailRoute, + authoringRunDetailRoute, cliAuthRoute, deviceAuthRoute, settingsSecurityRoute, diff --git a/web/src/features/authoring/binary-file.test.ts b/web/src/features/authoring/binary-file.test.ts new file mode 100644 index 000000000..9e076707d --- /dev/null +++ b/web/src/features/authoring/binary-file.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, isBinaryDraftPath } from './binary-file' + +describe('isBinaryDraftPath', () => { + it('marks image and office extensions as binary', () => { + expect(isBinaryDraftPath('assets/logo.png')).toBe(true) + expect(isBinaryDraftPath('assets/logo.PNG')).toBe(true) + expect(isBinaryDraftPath('docs/report.pdf')).toBe(true) + expect(isBinaryDraftPath('assets/sheet.xlsx')).toBe(true) + }) + + it('keeps text formats editable', () => { + expect(isBinaryDraftPath('SKILL.md')).toBe(false) + expect(isBinaryDraftPath('references/metrics.md')).toBe(false) + expect(isBinaryDraftPath('assets/diagram.svg')).toBe(false) + expect(isBinaryDraftPath('scripts/greet.sh')).toBe(false) + expect(isBinaryDraftPath('validation.yaml')).toBe(false) + }) +}) + +describe('formatBytes', () => { + it('formats byte sizes for humans', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB') + }) +}) diff --git a/web/src/features/authoring/binary-file.ts b/web/src/features/authoring/binary-file.ts new file mode 100644 index 000000000..5cb9b65b0 --- /dev/null +++ b/web/src/features/authoring/binary-file.ts @@ -0,0 +1,56 @@ +/** + * Binary draft files (images, office documents, PDFs) round-trip through the + * API as base64 and cannot be edited in the plain-text editor; the workbench + * shows them read-only with an upload-to-replace action instead. + */ + +const BINARY_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', + '.pdf', '.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', +]) + +export function isBinaryDraftPath(path: string): boolean { + const lower = path.toLowerCase() + for (const extension of BINARY_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true + } + } + return false +} + +export function formatBytes(size: number): string { + if (size < 1024) { + return `${size} B` + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) + reader.onload = () => { + const buffer = reader.result + if (!(buffer instanceof ArrayBuffer)) { + reject(new Error('failed to read file')) + return + } + resolve(bytesToBase64(new Uint8Array(buffer))) + } + reader.readAsArrayBuffer(file) + }) +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} diff --git a/web/src/features/authoring/draft-file-editor.tsx b/web/src/features/authoring/draft-file-editor.tsx new file mode 100644 index 000000000..d54cd1b3f --- /dev/null +++ b/web/src/features/authoring/draft-file-editor.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FilePlus2, Save, Trash2, Upload } from 'lucide-react' +import { authoringApi } from '@/api/client' +import type { DraftFileSummary } from '@/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Textarea } from '@/shared/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { toast } from '@/shared/lib/toast' +import { fileToBase64, formatBytes, isBinaryDraftPath } from './binary-file' + +/** + * Draft file editor: a file picker plus a plain-text editor for text formats + * and an upload-to-replace view for binary assets (images, documents). Saves + * go through the draft's optimistic-concurrency revision so concurrent edits + * surface as errors instead of silently overwriting each other. + */ +export function DraftFileEditor({ + draftId, + files, + revision, + onSaved, + onDeleted, +}: { + draftId: number + files: DraftFileSummary[] + revision: number + onSaved: (path: string) => void + onDeleted: (path: string) => void +}) { + const { t } = useTranslation() + const sortedFiles = useMemo( + () => [...files].sort((a, b) => a.path.localeCompare(b.path)), + [files], + ) + const [selectedPath, setSelectedPath] = useState(null) + const [content, setContent] = useState('') + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [newPath, setNewPath] = useState('') + const [creating, setCreating] = useState(false) + + const [uploadOpen, setUploadOpen] = useState(false) + const [uploadFile, setUploadFile] = useState(null) + const [uploadPath, setUploadPath] = useState('') + const [uploading, setUploading] = useState(false) + + const selected = sortedFiles.find((file) => file.path === selectedPath) ?? null + const binarySelected = selectedPath != null && isBinaryDraftPath(selectedPath) + + useEffect(() => { + if (sortedFiles.length === 0) { + setSelectedPath(null) + return + } + if (selectedPath == null) { + setSelectedPath(sortedFiles[0].path) + } + }, [sortedFiles, selectedPath]) + + useEffect(() => { + if (selectedPath == null || isBinaryDraftPath(selectedPath)) { + return + } + let cancelled = false + authoringApi + .readFile(draftId, selectedPath) + .then((file) => { + if (!cancelled) { + setContent(file.content) + setDirty(false) + } + }) + .catch((error) => { + if (!cancelled) { + toast.error(error instanceof Error ? error.message : String(error)) + } + }) + return () => { + cancelled = true + } + }, [draftId, selectedPath]) + + const save = async () => { + if (selectedPath == null) { + return + } + setSaving(true) + try { + await authoringApi.saveFile(draftId, { + path: selectedPath, + content, + expectedRevision: revision, + }) + setDirty(false) + onSaved(selectedPath) + toast.success(t('authoring.files.saved')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setSaving(false) + } + } + + const createFile = async () => { + const path = newPath.trim() + if (!path) { + return + } + setCreating(true) + try { + const outcome = await authoringApi.saveFile(draftId, { + path, + content: '', + expectedRevision: revision, + }) + setNewPath('') + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.created') : t('authoring.files.exists'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setCreating(false) + } + } + + const remove = async () => { + if (selectedPath == null) { + return + } + const removedPath = selectedPath + try { + await authoringApi.deleteFile(draftId, removedPath, revision) + // pick the next selection from the files we know remain — the list prop + // is still stale at this point, so the removed path itself must be + // excluded explicitly + const next = sortedFiles.find((file) => file.path !== removedPath)?.path ?? null + onDeleted(removedPath) + setSelectedPath(next) + toast.success(t('authoring.files.deleted')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + + const openUpload = (prefillPath?: string) => { + setUploadFile(null) + setUploadPath(prefillPath ?? '') + setUploadOpen(true) + } + + const chooseUploadFile = (file: File | null) => { + setUploadFile(file) + if (file && !uploadPath.trim()) { + setUploadPath(`assets/${file.name}`) + } + } + + const upload = async () => { + if (!uploadFile) { + return + } + const path = uploadPath.trim() + if (!path) { + return + } + setUploading(true) + try { + const base64 = await fileToBase64(uploadFile) + const outcome = await authoringApi.saveFile(draftId, { + path, + content: base64, + base64: true, + contentType: uploadFile.type || undefined, + expectedRevision: revision, + }) + setUploadOpen(false) + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.uploaded') : t('authoring.files.replaced'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setUploading(false) + } + } + + return ( +
+
+
    + {sortedFiles.map((file) => ( +
  • + +
  • + ))} +
+
+ setNewPath(event.target.value)} + placeholder="scripts/run.py" + className="h-8 font-mono text-xs" + onKeyDown={(event) => { + if (event.key === 'Enter') { + void createFile() + } + }} + /> + +
+ +
+ +
+ {selectedPath == null ? ( +

{t('authoring.files.empty')}

+ ) : binarySelected && selected ? ( +
+

{selected.path}

+
+
{t('authoring.files.size')}
+
{formatBytes(selected.size)}
+
sha256
+
+ {selected.sha256.slice(0, 16)}… +
+
{t('authoring.files.contentType')}
+
{selected.contentType || '—'}
+
+

{t('authoring.files.binaryHint')}

+
+ + +
+
+ ) : ( + <> +