Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/operator-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export type ArtifactKind =
| 'doc'
| 'external_receipt'
| 'preview_file'
| 'preview_url'
| 'implementation_prompt'
| 'validation_report'
| 'other'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { useState } from 'react'
import {
FileArrowUp,
Globe,
FileText,
TestTube,
Image,
File,
ArrowSquareOut,
DownloadSimple,
Expand All @@ -19,23 +17,23 @@ import {
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'

// ── constants ─────────────────────────────────────────────────────────────────

/** Artifact kinds that are internal/infrastructure — hidden from the user. */
export const HIDDEN_ARTIFACT_KINDS = new Set<string>(['preview_file', 'implementation_prompt'])

// ── icon mapping ──────────────────────────────────────────────────────────────

function artifactIcon(kind: ArtifactKind) {
switch (kind) {
case 'changed_file':
return FileArrowUp
case 'preview_url':
return Globe
case 'doc':
case 'diff_summary':
case 'implementation_prompt':
case 'validation_report':
return FileText
case 'test_report':
return TestTube
case 'preview_file':
return Image
case 'external_receipt':
return ArrowSquareOut
default:
Expand Down Expand Up @@ -141,12 +139,13 @@ interface ArtifactListProps {
export function ArtifactList({ artifacts }: ArtifactListProps) {
const [inlineArtifact, setInlineArtifact] = useState<Artifact | null>(null)

if (artifacts.length === 0) return null
const visible = artifacts.filter((a) => !HIDDEN_ARTIFACT_KINDS.has(a.kind))
if (visible.length === 0) return null

return (
<>
<div className="space-y-1">
{artifacts.map((art) => (
{visible.map((art) => (
<ArtifactCard
key={art.id}
artifact={art}
Expand Down
24 changes: 19 additions & 5 deletions apps/operator-web/src/features/tasks/components/task-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { ArrowLeft, ChatCircle, Timer, Lightning, FileText, ArrowSquareOut, Check, X, ArrowBendUpLeft, ArrowCounterClockwise, Stop } from '@phosphor-icons/react'
import { ArrowLeft, ChatCircle, Timer, Lightning, FileText, ArrowSquareOut, Globe, Check, X, ArrowBendUpLeft, ArrowCounterClockwise, Stop } from '@phosphor-icons/react'
import { Link } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { StatusPill } from '@/components/ui/status-pill'
Expand All @@ -24,7 +24,7 @@ import { useTaskArtifacts, useApproveTask, useRejectTask, useReplyTask, useRetry
import { buildTimeline } from '../lib/build-timeline'
import { WorkflowTimeline } from './workflow-timeline'
import { RunViewerSheet } from './run-viewer-sheet'
import { ArtifactList } from './artifact-list'
import { ArtifactList, HIDDEN_ARTIFACT_KINDS } from './artifact-list'

interface TaskDetailProps {
detail: TaskWithRelations | null | undefined
Expand Down Expand Up @@ -444,6 +444,8 @@ export function TaskDetail({ detail, isLoading, onBack, onSelectTask }: TaskDeta
<div className="space-y-2">
{detail.runs.map((run) => {
const runArtifacts = (artifactsQuery.data ?? []).filter((a) => a.run_id === run.id)
const visibleArtifacts = runArtifacts.filter((a) => !HIDDEN_ARTIFACT_KINDS.has(a.kind))
const hasPreview = runArtifacts.some((a) => a.kind === 'preview_file')
return (
<div key={run.id}>
<button
Expand All @@ -454,9 +456,21 @@ export function TaskDetail({ detail, isLoading, onBack, onSelectTask }: TaskDeta
<div className="flex items-center justify-between gap-2">
<StatusPill status={taskStatusToPill(run.status)} />
<div className="flex items-center gap-2">
{runArtifacts.length > 0 && (
{hasPreview && (
<a
href={`/api/previews/${run.id}/index.html`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 font-mono text-[10px] text-primary hover:underline"
>
<Globe size={10} />
Preview
</a>
)}
{visibleArtifacts.length > 0 && (
<span className="font-mono text-[10px] text-muted-foreground">
{runArtifacts.length} artifact{runArtifacts.length !== 1 ? 's' : ''}
{visibleArtifacts.length} artifact{visibleArtifacts.length !== 1 ? 's' : ''}
</span>
)}
<span className="font-mono text-[11px] text-muted-foreground">{run.agent_id}</span>
Expand All @@ -480,7 +494,7 @@ export function TaskDetail({ detail, isLoading, onBack, onSelectTask }: TaskDeta
)}
</div>
</button>
{runArtifacts.length > 0 && (
{visibleArtifacts.length > 0 && (
<div className="mt-1 pl-3">
<ArtifactList artifacts={runArtifacts} />
</div>
Expand Down
1 change: 0 additions & 1 deletion apps/operator-web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,6 @@
"artifact_kind_validation_report": "Validation",
"artifact_kind_implementation_prompt": "Code",
"artifact_kind_preview_file": "Preview",
"artifact_kind_preview_url": "URL preview",
"artifact_kind_external_receipt": "Receipt",
"artifact_kind_other": "File",
"approve": "Approve",
Expand Down
1 change: 0 additions & 1 deletion apps/operator-web/src/locales/sk.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@
"artifact_kind_validation_report": "Validácia",
"artifact_kind_implementation_prompt": "Kód",
"artifact_kind_preview_file": "Náhľad",
"artifact_kind_preview_url": "URL náhľad",
"artifact_kind_external_receipt": "Potvrdenie",
"artifact_kind_other": "Súbor",
"approve": "Schváliť",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ Tasks created from chat automatically bind results back to the originating conve

- Unknown artifact kinds normalize to \`other\` with \`metadata.original_kind\`
- Use \`doc\` for text documents
- Use \`preview_file\` + \`preview_url\` for HTML/file previews
- Use \`preview_file\` for HTML/file previews (preview URL is derived automatically)

## Provider/workflow installation

Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,19 @@ interface Run {

interface Artifact {
kind: string
title: string
ref_value: string
}

/** Derive the preview URL from preview_file artifacts (no preview_url artifact needed). */
function derivePreviewUrl(artifacts: Artifact[], runId: string, baseUrl: string): string | null {
const previewFiles = artifacts.filter((a) => a.kind === 'preview_file')
if (previewFiles.length === 0) return null
const htmlFile = previewFiles.find((a) => /\.(html?)$/i.test(a.title))
const entry = htmlFile?.title ?? 'index.html'
return `${baseUrl}/api/previews/${runId}/${entry}`
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

function statusBadge(status: string): string {
Expand Down Expand Up @@ -95,8 +105,8 @@ async function renderInbox(): Promise<void> {
const artsRes = await client.api.runs[':id'].artifacts.$get({ param: { id: run.id } })
if (artsRes.ok) {
const arts = (await artsRes.json()) as Artifact[]
const preview = arts.find((a) => a.kind === 'preview_url')
if (preview) previewMap.set(run.id, preview.ref_value)
const url = derivePreviewUrl(arts, run.id, baseUrl)
if (url) previewMap.set(run.id, url)
}
} catch (err) { console.debug('[inbox] failed to fetch preview URLs:', err instanceof Error ? err.message : String(err)) }
}
Expand Down Expand Up @@ -316,8 +326,8 @@ async function handleWatchEvent(
const artsRes = await client.api.runs[':id'].artifacts.$get({ param: { id: runId } })
if (artsRes.ok) {
const arts = (await artsRes.json()) as Artifact[]
const preview = arts.find((a) => a.kind === 'preview_url')
if (preview) previewUrl = preview.ref_value
const derived = derivePreviewUrl(arts, runId, getBaseUrl())
if (derived) previewUrl = derived
}
} catch (err) { console.debug('[inbox:watch] failed to fetch artifacts:', err instanceof Error ? err.message : String(err)) }

Expand Down
21 changes: 11 additions & 10 deletions packages/cli/tests/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Covers:
* - Inbox renders blocked tasks
* - Inbox renders failed runs
* - Preview URL is shown when present
* - Preview URL is derived from preview_file artifacts
* - Watch mode filters actionable events only
* - Non-actionable events are filtered out
*/
Expand Down Expand Up @@ -191,8 +191,8 @@ describe('Inbox Data', () => {
expect(found!.status).toBe('failed')
})

test('preview URL is available through artifacts API', async () => {
// Create task + run + complete + add preview artifact
test('preview_file artifact is available through artifacts API', async () => {
// Create task + run + complete + add preview_file artifact (URL derived via derivePreviewUrl)
await taskService.create({
id: 'task-inbox-preview-1',
title: 'Build homepage',
Expand All @@ -215,20 +215,21 @@ describe('Inbox Data', () => {
id: 'art-preview-inbox-1',
run_id: 'run-inbox-preview-1',
task_id: 'task-inbox-preview-1',
kind: 'preview_url',
title: 'Preview',
ref_kind: 'url',
ref_value: 'http://localhost:7778/api/previews/run-inbox-preview-1/index.html',
kind: 'preview_file',
title: 'index.html',
ref_kind: 'inline',
ref_value: '<html><body>Homepage</body></html>',
mime_type: 'text/html',
})

const res = await app.request('/api/runs/run-inbox-preview-1/artifacts')
expect(res.status).toBe(200)

const arts = (await res.json()) as Array<{ kind: string; ref_value: string }>
const preview = arts.find((a) => a.kind === 'preview_url')
const arts = (await res.json()) as Array<{ kind: string; title: string; ref_value: string }>
const preview = arts.find((a) => a.kind === 'preview_file')
expect(preview).toBeDefined()
expect(preview!.ref_value).toContain('/api/previews/')
expect(preview!.title).toBe('index.html')
expect(preview!.ref_value).toContain('<html>')
})

test('completed runs are returned with status filter', async () => {
Expand Down
52 changes: 0 additions & 52 deletions packages/orchestrator/src/api/routes/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,17 +218,6 @@ const runs = new Hono<AppEnv>()
const entry = body.title.endsWith('.html') ? body.title : 'index.html'
const baseUrl = c.get('orchestratorUrl')
previewUrl = baseUrl ? `${baseUrl}/api/previews/${id}/${entry}` : `/api/previews/${id}/${entry}`
await artifactService.create({
id: `art-preview-${Date.now()}-${randomBytes(6).toString('hex')}`,
run_id: id,
task_id: run.task_id ?? undefined,
kind: 'preview_url',
title: 'Preview',
ref_kind: 'url',
ref_value: previewUrl,
mime_type: 'text/html',
metadata: JSON.stringify({ entry, run_id: id }),
})
}

eventBus.emit({
Expand Down Expand Up @@ -358,9 +347,6 @@ const runs = new Hono<AppEnv>()

// Register artifacts reported by the worker
const validArtifactKinds = new Set(ArtifactKindSchema.options)
let hasPreviewFiles = false
let previewEntry: string | null = null
const previewFileTitles: string[] = []
if (body.artifacts?.length) {
for (const art of body.artifacts) {
const normalizedKind = validArtifactKinds.has(art.kind) ? art.kind : 'other'
Expand All @@ -380,47 +366,9 @@ const runs = new Hono<AppEnv>()
mime_type: art.mime_type,
metadata: Object.keys(artMetadata).length > 0 ? JSON.stringify(artMetadata) : undefined,
})
if (normalizedKind === 'preview_file') {
hasPreviewFiles = true
previewFileTitles.push(art.title)
if (!previewEntry && art.title.endsWith('index.html')) {
previewEntry = art.title
}
}
// Explicit entry from preview_dir manifest takes priority
if (normalizedKind === 'other' && artMetadata.original_kind === 'preview_dir') {
const manifestEntry = artMetadata.preview_entry
if (typeof manifestEntry === 'string' && manifestEntry) {
previewEntry = manifestEntry
}
}
}
}

// Auto-create preview_url artifact if preview files were stored
if (hasPreviewFiles) {
const singleHtmlEntry = previewFileTitles.length === 1
&& /\.(html?)$/i.test(previewFileTitles[0] ?? '')
? previewFileTitles[0]!
: null
const entry = previewEntry ?? singleHtmlEntry ?? 'index.html'
// Canonical orchestrator URL for rendered links — not request-derived (reverse proxy / spoofing safe).
// Relative-path fallback only when orchestratorUrl is absent (tests, custom embed).
const baseUrl = c.get('orchestratorUrl')
const previewUrl = baseUrl ? `${baseUrl}/api/previews/${id}/${entry}` : `/api/previews/${id}/${entry}`
await artifactService.create({
id: `art-preview-${Date.now()}-${randomBytes(6).toString('hex')}`,
run_id: id,
task_id: run.task_id ?? undefined,
kind: 'preview_url',
title: 'Preview',
ref_kind: 'url',
ref_value: previewUrl,
mime_type: 'text/html',
metadata: JSON.stringify({ entry, run_id: id }),
})
}

// Release worker lease for this run + update worker status
if (run.worker_id) {
const lease = await workerService.getActiveLeaseForRun(run.worker_id, id)
Expand Down
2 changes: 1 addition & 1 deletion packages/orchestrator/src/db/company-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export const artifacts = sqliteTable(
id: text('id').primaryKey(),
run_id: text('run_id').notNull(),
task_id: text('task_id'),
kind: text('kind').notNull(), // changed_file | diff_summary | test_report | doc | external_receipt | preview_url | other
kind: text('kind').notNull(), // changed_file | diff_summary | test_report | doc | external_receipt | preview_file | other
title: text('title').notNull(),
ref_kind: text('ref_kind').notNull(), // file | url | inline
ref_value: text('ref_value').notNull(),
Expand Down
4 changes: 1 addition & 3 deletions packages/orchestrator/src/providers/notification-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,6 @@ export class NotificationBridge {
}

private async findPreviewUrl(runId: string): Promise<string | null> {
const artifacts = await this.artifactService.listForRun(runId)
const preview = artifacts.find((a) => a.kind === 'preview_url')
return preview?.ref_value ?? null
return this.artifactService.resolvePreviewUrl(runId, this.config.orchestratorUrl)
}
}
4 changes: 1 addition & 3 deletions packages/orchestrator/src/providers/query-response-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,7 @@ export class QueryResponseBridge {

let previewUrl: string | undefined
if (this.artifactService && run) {
const artifacts = await this.artifactService.listForRun(event.runId)
const preview = artifacts.find((a) => a.kind === 'preview_url')
if (preview) previewUrl = preview.ref_value
previewUrl = await this.artifactService.resolvePreviewUrl(event.runId, this.config.orchestratorUrl) ?? undefined
}

const MAX_SUMMARY_LENGTH = 3500
Expand Down
9 changes: 2 additions & 7 deletions packages/orchestrator/src/providers/task-progress-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,7 @@ export class TaskProgressBridge {
const baseUrl = this.config.orchestratorUrl

// Look up preview_url artifact
let previewUrl: string | undefined
const artifacts = await this.artifactService.listForRun(event.runId)
const preview = artifacts.find((a) => a.kind === 'preview_url')
if (preview) previewUrl = preview.ref_value
const previewUrl = await this.artifactService.resolvePreviewUrl(event.runId, this.config.orchestratorUrl) ?? undefined

const summary = event.status === 'failed'
? (run.error ?? run.summary ?? 'Run failed.')
Expand Down Expand Up @@ -310,9 +307,7 @@ export class TaskProgressBridge {
)[0]
if (lastRun) {
if (lastRun.summary) summary = lastRun.summary
const artifacts = await this.artifactService.listForRun(lastRun.id)
const preview = artifacts.find((a) => a.kind === 'preview_url')
if (preview) previewUrl = preview.ref_value
previewUrl = await this.artifactService.resolvePreviewUrl(lastRun.id, this.config.orchestratorUrl) ?? undefined
}
}

Expand Down
15 changes: 15 additions & 0 deletions packages/orchestrator/src/services/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ export class ArtifactService {
return await this.db.select().from(artifactBlobs).where(eq(artifactBlobs.id, blobId)).get()
}

/**
* Derive the preview URL for a run from its preview_file artifacts.
* Returns null if the run has no preview_file artifacts.
*/
async resolvePreviewUrl(runId: string, orchestratorUrl?: string): Promise<string | null> {
const arts = await this.listForRun(runId)
const previewFiles = arts.filter((a) => a.kind === 'preview_file')
if (previewFiles.length === 0) return null

const htmlFile = previewFiles.find((a) => /\.(html?)$/i.test(a.title))
const entry = htmlFile?.title ?? 'index.html'
const base = orchestratorUrl ?? ''
return `${base}/api/previews/${runId}/${entry}`
}

// ── Internal ─────────────────────────────────────────────────────────────

async #findOrCreateBlobRow(blob: {
Expand Down
Loading
Loading