Add MarkdownView, task retry endpoint, and chat/model store improvements
Some checks failed
CI / lint-and-test (push) Successful in 39s
CI / build (push) Has been cancelled
Deploy Production / deploy (push) Has been cancelled

Adds MarkdownView component to design system, task retry API route,
markdown rendering in dashboard and chat, and model store enhancements.
Includes DB migration for schema updates.
This commit is contained in:
Julia McGhee
2026-03-22 13:04:41 +00:00
parent a96ca9c13e
commit d0925f6555
14 changed files with 1175 additions and 75 deletions

View File

@@ -10,6 +10,7 @@ import {
type ProviderMessages,
} from "@/lib/chat-providers";
import { setupChatWorkspace, chatWorkspacePath } from "@/lib/chat-workspace";
import { recordUsage } from "@/lib/model-store";
import { db } from "@/lib/db";
import { projects } from "@homelab/db";
import { eq } from "drizzle-orm";
@@ -156,6 +157,10 @@ export async function POST(request: NextRequest) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
const streamStart = Date.now();
let totalInputTokens = 0;
let totalOutputTokens = 0;
try {
let currentMessages = [...providerMessages];
let rounds = 0;
@@ -182,6 +187,10 @@ export async function POST(request: NextRequest) {
send(event);
} else if (event.type === "message_end") {
finalUsage = event.usage;
if (event.usage) {
totalInputTokens += event.usage.input;
totalOutputTokens += event.usage.output;
}
} else if (event.type === "error") {
send(event);
controller.close();
@@ -228,6 +237,19 @@ export async function POST(request: NextRequest) {
const msg = err instanceof Error ? err.message : String(err);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "error", message: msg })}\n\n`));
} finally {
// Persist chat usage to DB
if (totalInputTokens > 0 || totalOutputTokens > 0) {
recordUsage({
source: "chat",
modelId: model,
provider,
conversationId: conversationId ?? undefined,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
durationMs: Date.now() - streamStart,
timestamp: Date.now(),
}).catch(err => console.error("[chat] Failed to record usage:", err));
}
controller.close();
}
},

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getTask, updateTask } from "@/lib/store";
import { startOrchestrator } from "@/lib/orchestrator";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const task = await getTask(id);
if (!task) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
if (task.status !== "failed" && task.status !== "completed") {
return NextResponse.json(
{ error: `Task is ${task.status}, can only retry failed or completed tasks` },
{ status: 400 },
);
}
// Reset task to pending so the orchestrator picks it up again
await updateTask(id, {
status: "pending",
iteration: 0,
startedAt: null,
evals: {},
} as any);
await startOrchestrator();
return NextResponse.json({ ok: true, message: "Task reset to pending and orchestrator started" });
}

View File

@@ -7,6 +7,7 @@ import {
Mono,
Btn,
SearchableDropdown,
MarkdownView,
type DropdownOption,
} from "./harness-design-system";
import type { ChatMessage, ToolCallRecord, StreamEvent, Conversation } from "@/lib/chat-types";
@@ -556,40 +557,35 @@ export default function ChatTab({ mobile, projects }: { mobile: boolean; project
))}
{/* Streaming message */}
{streaming && (streamText || streamToolCalls.length > 0) && (
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}>
<div
style={{
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border0}`,
padding: `${tokens.space[3]}px ${tokens.space[4]}px`,
maxWidth: "85%",
}}
>
{streamToolCalls.length > 0 && (
<div style={{ marginBottom: streamText ? tokens.space[3] : 0 }}>
{streamToolCalls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} pending={pendingToolIds.has(tc.id)} />
))}
</div>
)}
{streamText && (
<div style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.sm, color: tokens.color.text1, whiteSpace: "pre-wrap", wordBreak: "break-word", lineHeight: 1.6 }}>
{streamText}
<span style={{ display: "inline-block", width: 6, height: 14, background: tokens.color.accent, marginLeft: 2, animation: "hpulse 1s infinite" }} />
</div>
)}
</div>
</div>
)}
{/* Thinking indicator */}
{thinking && streaming && (
<div style={{ display: "flex", alignItems: "center", gap: tokens.space[2], padding: `${tokens.space[2]}px 0` }}>
<span style={{ display: "inline-block", width: 6, height: 6, borderRadius: "50%", background: tokens.color.accent, animation: "hpulse 1.5s infinite", boxShadow: tokens.color.accentGlow }} />
<Mono size={tokens.size.sm} color={tokens.color.text2} style={{ animation: "hpulse 1.5s infinite" }}>
Thinking...
</Mono>
{streaming && (streamText || streamToolCalls.length > 0 || thinking) && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: tokens.space[2] }}>
{/* Tool use ticker — compact inline summary */}
{streamToolCalls.length > 0 && (
<ToolTicker toolCalls={streamToolCalls} pendingIds={pendingToolIds} />
)}
{/* Streamed text with markdown */}
{streamText && (
<div
style={{
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border0}`,
padding: `${tokens.space[3]}px ${tokens.space[4]}px`,
maxWidth: "85%",
}}
>
<MarkdownView content={streamText} />
<span style={{ display: "inline-block", width: 6, height: 14, background: tokens.color.accent, marginLeft: 2, animation: "hpulse 1s infinite", verticalAlign: "text-bottom" }} />
</div>
)}
{/* Thinking indicator */}
{thinking && !streamText && (
<div style={{ display: "flex", alignItems: "center", gap: tokens.space[2], padding: `${tokens.space[2]}px 0` }}>
<span style={{ display: "inline-block", width: 6, height: 6, borderRadius: "50%", background: tokens.color.accent, animation: "hpulse 1.5s infinite", boxShadow: tokens.color.accentGlow }} />
<Mono size={tokens.size.sm} color={tokens.color.text2} style={{ animation: "hpulse 1.5s infinite" }}>
Thinking...
</Mono>
</div>
)}
</div>
)}
@@ -665,6 +661,10 @@ function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === "user";
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: isUser ? "flex-end" : "flex-start", gap: tokens.space[1] }}>
{/* Compact tool summary for saved messages */}
{!isUser && message.toolCalls && message.toolCalls.length > 0 && (
<ToolSummary toolCalls={message.toolCalls} />
)}
<div
style={{
background: isUser ? tokens.color.bg2 : tokens.color.bg1,
@@ -673,16 +673,13 @@ function MessageBubble({ message }: { message: ChatMessage }) {
maxWidth: "85%",
}}
>
{message.toolCalls && message.toolCalls.length > 0 && (
<div style={{ marginBottom: message.content ? tokens.space[3] : 0 }}>
{message.toolCalls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} pending={false} />
))}
{isUser ? (
<div style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.sm, color: tokens.color.text1, whiteSpace: "pre-wrap", wordBreak: "break-word", lineHeight: 1.6 }}>
{message.content}
</div>
) : (
<MarkdownView content={message.content} />
)}
<div style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.sm, color: tokens.color.text1, whiteSpace: "pre-wrap", wordBreak: "break-word", lineHeight: 1.6 }}>
{message.content}
</div>
</div>
{message.model && (
<Label color={tokens.color.text3} style={{ fontSize: 11 }}>{message.model}</Label>
@@ -691,30 +688,104 @@ function MessageBubble({ message }: { message: ChatMessage }) {
);
}
// ── Tool Call Block ──────────────────────────────────────────
// ── Tool Ticker (live streaming) ─────────────────────────────
function ToolTicker({ toolCalls, pendingIds }: { toolCalls: ToolCallRecord[]; pendingIds: Set<string> }) {
const completed = toolCalls.filter(tc => !pendingIds.has(tc.id));
const running = toolCalls.filter(tc => pendingIds.has(tc.id));
const totalMs = completed.reduce((sum, tc) => sum + tc.durationMs, 0);
function ToolCallBlock({ toolCall, pending }: { toolCall: ToolCallRecord; pending: boolean }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{ border: `1px solid ${pending ? tokens.color.warnDim : tokens.color.border0}`, background: tokens.color.bg0, marginBottom: tokens.space[2] }}>
<div style={{
display: "flex", alignItems: "center", gap: tokens.space[2], flexWrap: "wrap",
padding: `${tokens.space[1]}px ${tokens.space[2]}px`,
background: tokens.color.bg1, border: `1px solid ${tokens.color.border0}`,
maxWidth: "85%",
}}>
{running.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: tokens.space[1] }}>
<span style={{ display: "inline-block", width: 5, height: 5, borderRadius: "50%", background: tokens.color.warn, animation: "hpulse 1s infinite" }} />
<Label color={tokens.color.warn} style={{ fontSize: 11 }}>
{running[running.length - 1].name}
</Label>
</div>
)}
{completed.length > 0 && (
<Label color={tokens.color.text3} style={{ fontSize: 11 }}>
{completed.length} tool{completed.length !== 1 ? "s" : ""} · {totalMs > 1000 ? `${(totalMs / 1000).toFixed(1)}s` : `${totalMs}ms`}
</Label>
)}
</div>
);
}
// ── Tool Summary (saved messages — expandable) ───────────────
function ToolSummary({ toolCalls }: { toolCalls: ToolCallRecord[] }) {
const [expanded, setExpanded] = useState(false);
const totalMs = toolCalls.reduce((sum, tc) => sum + tc.durationMs, 0);
return (
<div style={{ maxWidth: "85%" }}>
<div
onClick={() => setExpanded(!expanded)}
style={{ display: "flex", alignItems: "center", gap: tokens.space[2], padding: `${tokens.space[1]}px ${tokens.space[3]}px`, cursor: "pointer", minHeight: 32 }}
style={{
display: "flex", alignItems: "center", gap: tokens.space[2],
padding: `${tokens.space[1]}px ${tokens.space[2]}px`,
cursor: "pointer", userSelect: "none",
}}
>
<span style={{ color: tokens.color.text3, fontSize: tokens.size.xs, fontFamily: tokens.font.mono }}>
{expanded ? "▾" : "▸"}
</span>
<Label color={pending ? tokens.color.warn : tokens.color.purple}>{toolCall.name}</Label>
{pending && <Label color={tokens.color.warn} style={{ fontSize: 11 }}>running...</Label>}
{!pending && toolCall.durationMs > 0 && (
<Label color={tokens.color.text3} style={{ fontSize: 11 }}>{toolCall.durationMs}ms</Label>
<Label color={tokens.color.purple} style={{ fontSize: 11 }}>
{toolCalls.length} tool{toolCalls.length !== 1 ? "s" : ""} used
</Label>
<Label color={tokens.color.text3} style={{ fontSize: 11 }}>
{totalMs > 1000 ? `${(totalMs / 1000).toFixed(1)}s` : `${totalMs}ms`}
</Label>
</div>
{expanded && (
<div style={{
background: tokens.color.bg0, border: `1px solid ${tokens.color.border0}`,
marginTop: tokens.space[1],
}}>
{toolCalls.map((tc) => (
<ToolCallDetail key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
// ── Tool Call Detail (individual expandable row) ─────────────
function ToolCallDetail({ toolCall }: { toolCall: ToolCallRecord }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{ borderBottom: `1px solid ${tokens.color.border0}` }}>
<div
onClick={() => setExpanded(!expanded)}
style={{
display: "flex", alignItems: "center", gap: tokens.space[2],
padding: `${tokens.space[1]}px ${tokens.space[3]}px`,
cursor: "pointer", minHeight: 28,
}}
>
<span style={{ color: tokens.color.text3, fontSize: 10, fontFamily: tokens.font.mono }}>
{expanded ? "▾" : "▸"}
</span>
<Label color={tokens.color.purple} style={{ fontSize: 11 }}>{toolCall.name}</Label>
{toolCall.durationMs > 0 && (
<Label color={tokens.color.text3} style={{ fontSize: 11, marginLeft: "auto" }}>{toolCall.durationMs}ms</Label>
)}
</div>
{expanded && (
<div style={{ borderTop: `1px solid ${tokens.color.border0}`, padding: `${tokens.space[2]}px ${tokens.space[3]}px` }}>
{Object.keys(toolCall.input).length > 0 && (
<div style={{ marginBottom: tokens.space[2] }}>
<Label color={tokens.color.text3}>Input</Label>
<Label color={tokens.color.text3} style={{ fontSize: 10 }}>INPUT</Label>
<pre style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.xs, color: tokens.color.text2, margin: `${tokens.space[1]}px 0`, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{JSON.stringify(toolCall.input, null, 2)}
</pre>
@@ -722,7 +793,7 @@ function ToolCallBlock({ toolCall, pending }: { toolCall: ToolCallRecord; pendin
)}
{toolCall.output && (
<div>
<Label color={tokens.color.text3}>Output</Label>
<Label color={tokens.color.text3} style={{ fontSize: 10 }}>OUTPUT</Label>
<pre style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.xs, color: tokens.color.text2, margin: `${tokens.space[1]}px 0`, whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 200, overflowY: "auto" }}>
{toolCall.output}
</pre>

View File

@@ -318,10 +318,14 @@ function LoopsTab({ tasks, setTasks, mobile }: { tasks: Task[]; setTasks: React.
const handleStart = async (id: string) => {
await fetch(`/api/tasks/${encodeURIComponent(id)}/start`, { method: "POST" }).catch(() => {});
// Optimistic update — the orchestrator will pick it up
setTasks(prev => prev.map(t => t.id === id ? { ...t, status: "running", startedAt: Date.now() } : t));
};
const handleRetry = async (id: string) => {
await fetch(`/api/tasks/${encodeURIComponent(id)}/retry`, { method: "POST" }).catch(() => {});
setTasks(prev => prev.map(t => t.id === id ? { ...t, status: "pending", iteration: 0, startedAt: null, evals: {}, iterations: [] } : t));
};
const TaskRow = ({ task }: { task: Task }) => {
const s = STATUS[task.status] || STATUS.pending;
return (
@@ -343,6 +347,9 @@ function LoopsTab({ tasks, setTasks, mobile }: { tasks: Task[]; setTasks: React.
{task.status === "pending" && (
<Btn variant="primary" onClick={(e) => { e.stopPropagation(); handleStart(task.id); }} style={{ fontSize: tokens.size.xs, padding: "0 8px", minHeight: 28 }}>START</Btn>
)}
{task.status === "failed" && (
<Btn variant="primary" onClick={(e) => { e.stopPropagation(); handleRetry(task.id); }} style={{ fontSize: tokens.size.xs, padding: "0 8px", minHeight: 28 }}>RETRY</Btn>
)}
{mobile && <span style={{ color: tokens.color.text3, fontSize: 16 }}></span>}
</div>
</div>
@@ -2003,14 +2010,34 @@ export default function HarnessDashboard() {
if (activeTab === "KNOWLEDGE") loadKnowledge();
}, [activeTab]);
const handleNewTask = (form: TaskForm) => {
const handleNewTask = async (form: TaskForm) => {
const proj = projects.find(p => p.id === form.projectId);
setTasks(prev => [...prev, {
id: `task-${Date.now()}`, slug: form.slug, goal: form.goal,
project: proj?.name || "—", status: "pending",
iteration: 0, maxIterations: parseInt(form.maxIterations) || 6,
startedAt: null, evals: {}, iterations: [],
}]);
// Use the first workspace repo (owner/repo format) as the project for git operations
const projectRepo = proj?.workspaces?.[0]?.repo || proj?.name || "—";
const spec = {
slug: form.slug,
goal: form.goal,
project: projectRepo,
agentId: form.agentId,
maxIterations: parseInt(form.maxIterations) || 6,
criteria: form.criteria.filter(c => c.label && c.target),
constraints: form.constraints.filter(Boolean),
knowledgeRefs: form.knowledgeRefs,
gitProvider: "gitea" as const,
};
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(spec),
});
if (res.ok) {
const task = await res.json();
setTasks(prev => [...prev, task]);
}
} catch { /* ignore */ }
setActiveTab("LOOPS");
};

View File

@@ -539,3 +539,71 @@ export function BackBtn({ onBack, label }: { onBack: () => void; label: string }
</button>
);
}
// ── Markdown ─────────────────────────────────────────────────
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function renderMarkdown(md: string): string {
let html = escapeHtml(md);
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
`<pre style="background:${tokens.color.bg0};border:1px solid ${tokens.color.border0};padding:${tokens.space[3]}px;overflow-x:auto;margin:${tokens.space[3]}px 0"><code>${code}</code></pre>`
);
// Inline code
html = html.replace(/`([^`]+)`/g, (_m, code) =>
`<code style="background:${tokens.color.bg0};border:1px solid ${tokens.color.border0};padding:1px 4px">${code}</code>`
);
// Headings
html = html.replace(/^#### (.+)$/gm, `<h4 style="color:${tokens.color.text0};font-size:${tokens.size.base}px;margin:${tokens.space[4]}px 0 ${tokens.space[2]}px;font-weight:500">$1</h4>`);
html = html.replace(/^### (.+)$/gm, `<h3 style="color:${tokens.color.text0};font-size:${tokens.size.md}px;margin:${tokens.space[5]}px 0 ${tokens.space[2]}px;font-weight:500">$1</h3>`);
html = html.replace(/^## (.+)$/gm, `<h2 style="color:${tokens.color.text0};font-size:${tokens.size.lg}px;margin:${tokens.space[6]}px 0 ${tokens.space[3]}px;font-weight:500">$1</h2>`);
html = html.replace(/^# (.+)$/gm, `<h1 style="color:${tokens.color.text0};font-size:${tokens.size.xl}px;margin:${tokens.space[6]}px 0 ${tokens.space[3]}px;font-weight:400">$1</h1>`);
// Bold / italic
html = html.replace(/\*\*(.+?)\*\*/g, `<strong style="color:${tokens.color.text0}">$1</strong>`);
html = html.replace(/\*(.+?)\*/g, `<em>$1</em>`);
// Ordered lists
html = html.replace(/^(\d+)\. (.+)$/gm, `<li style="margin-left:${tokens.space[4]}px;list-style:decimal">$2</li>`);
// Unordered lists
html = html.replace(/^- (.+)$/gm, `<li style="margin-left:${tokens.space[4]}px;list-style:disc">$1</li>`);
// Horizontal rules
html = html.replace(/^---$/gm, `<hr style="border:none;border-top:1px solid ${tokens.color.border0};margin:${tokens.space[4]}px 0" />`);
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" style="color:${tokens.color.accent};text-decoration:none" target="_blank" rel="noopener">$1</a>`);
// Paragraphs (double newline)
html = html.replace(/\n\n/g, `</p><p style="margin:${tokens.space[3]}px 0">`);
html = `<p style="margin:${tokens.space[3]}px 0">${html}</p>`;
// Single newlines as <br> (within paragraphs)
html = html.replace(/\n/g, "<br />");
return html;
}
export function MarkdownView({ content, style }: { content: string; style?: React.CSSProperties }) {
const html = renderMarkdown(content);
return (
<div
dangerouslySetInnerHTML={{ __html: html }}
style={{
fontFamily: tokens.font.mono,
fontSize: tokens.size.sm,
color: tokens.color.text1,
lineHeight: 1.8,
...style,
}}
className="md-content"
/>
);
}

View File

@@ -13,6 +13,8 @@ function rowToConversation(row: ConvRow): Conversation {
provider: row.provider,
workspace: row.workspace ?? undefined,
messages: (row.messages ?? []) as ChatMessage[],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}

View File

@@ -29,6 +29,8 @@ export interface Conversation {
model: string;
provider: string;
workspace?: string;
createdAt?: string;
updatedAt?: string;
}
/** Wire format for messages sent to/from the API route */

View File

@@ -3,11 +3,13 @@ import { modelUsage as usageTable } from "@homelab/db";
import { KNOWN_MODELS } from "./model-providers";
export interface ModelUsageEntry {
source?: "task" | "chat";
modelId: string;
provider: string;
taskId: string;
taskSlug: string;
iteration: number;
taskId?: string;
taskSlug?: string;
iteration?: number;
conversationId?: string;
inputTokens: number;
outputTokens: number;
durationMs: number;
@@ -28,11 +30,13 @@ export interface ModelUsageSummary {
export async function recordUsage(entry: ModelUsageEntry): Promise<void> {
await db.insert(usageTable).values({
source: entry.source ?? "task",
modelId: entry.modelId,
provider: entry.provider,
taskId: entry.taskId,
taskSlug: entry.taskSlug,
iteration: entry.iteration,
taskId: entry.taskId ?? null,
taskSlug: entry.taskSlug ?? null,
iteration: entry.iteration ?? null,
conversationId: entry.conversationId ?? null,
inputTokens: entry.inputTokens,
outputTokens: entry.outputTokens,
durationMs: entry.durationMs,
@@ -40,14 +44,30 @@ export async function recordUsage(entry: ModelUsageEntry): Promise<void> {
});
}
export async function getUsageLog(): Promise<ModelUsageEntry[]> {
export interface UsageLogEntry {
source: string;
modelId: string;
provider: string;
taskId?: string;
taskSlug?: string;
iteration?: number;
conversationId?: string;
inputTokens: number;
outputTokens: number;
durationMs: number;
timestamp: number;
}
export async function getUsageLog(): Promise<UsageLogEntry[]> {
const rows = await db.select().from(usageTable);
return rows.map(r => ({
source: r.source,
modelId: r.modelId,
provider: r.provider,
taskId: r.taskId,
taskSlug: r.taskSlug,
iteration: r.iteration,
taskId: r.taskId ?? undefined,
taskSlug: r.taskSlug ?? undefined,
iteration: r.iteration ?? undefined,
conversationId: r.conversationId ?? undefined,
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
durationMs: r.durationMs,

View File

@@ -159,6 +159,9 @@ export async function updateTask(id: string, updates: Partial<Task>): Promise<Ta
if (updates.evals !== undefined) dbUpdates.evals = updates.evals;
if (updates.pr !== undefined) dbUpdates.pr = updates.pr;
// Reset cancel flag when status changes to pending (retry)
if (updates.status === "pending") dbUpdates.cancelRequested = false;
await db.update(tasksTable).set(dbUpdates).where(eq(tasksTable.id, id));
return getTask(id);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
ALTER TABLE "harness_model_usage" ALTER COLUMN "task_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "harness_model_usage" ALTER COLUMN "task_slug" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "harness_model_usage" ALTER COLUMN "iteration" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "harness_model_usage" ADD COLUMN "source" text DEFAULT 'task' NOT NULL;--> statement-breakpoint
ALTER TABLE "harness_model_usage" ADD COLUMN "conversation_id" text;

View File

@@ -0,0 +1,837 @@
{
"id": "640ba87d-2722-431b-a2da-2400c5bbd77c",
"prevId": "2ecc8bb5-604b-4c54-89a6-d9e0ff7e88a6",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.harness_agent_configs": {
"name": "harness_agent_configs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"runtime": {
"name": "runtime",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_tokens": {
"name": "max_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"env": {
"name": "env",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_chat_conversations": {
"name": "harness_chat_conversations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"workspace": {
"name": "workspace",
"type": "text",
"primaryKey": false,
"notNull": false
},
"messages": {
"name": "messages",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_credentials": {
"name": "harness_credentials",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"usage": {
"name": "usage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'any'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_event_log": {
"name": "harness_event_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"trigger_id": {
"name": "trigger_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delivery_id": {
"name": "delivery_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo": {
"name": "repo",
"type": "text",
"primaryKey": false,
"notNull": true
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": false
},
"branch": {
"name": "branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'received'"
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"skip_reason": {
"name": "skip_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"harness_event_log_delivery_id_unique": {
"name": "harness_event_log_delivery_id_unique",
"nullsNotDistinct": false,
"columns": [
"delivery_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_event_triggers": {
"name": "harness_event_triggers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo_filter": {
"name": "repo_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state_filter": {
"name": "state_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"context_filter": {
"name": "context_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"task_template": {
"name": "task_template",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"consecutive_failures": {
"name": "consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_consecutive_failures": {
"name": "max_consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
},
"disabled_reason": {
"name": "disabled_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"webhook_secret": {
"name": "webhook_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_iterations": {
"name": "harness_iterations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"n": {
"name": "n",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"diagnosis": {
"name": "diagnosis",
"type": "text",
"primaryKey": false,
"notNull": false
},
"agent_output": {
"name": "agent_output",
"type": "text",
"primaryKey": false,
"notNull": false
},
"evals": {
"name": "evals",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_stats": {
"name": "diff_stats",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_model_usage": {
"name": "harness_model_usage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'task'"
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"task_slug": {
"name": "task_slug",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"input_tokens": {
"name": "input_tokens",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"output_tokens": {
"name": "output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"timestamp": {
"name": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_orchestrator": {
"name": "harness_orchestrator",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "'singleton'"
},
"running": {
"name": "running",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"current_task_id": {
"name": "current_task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"heartbeat": {
"name": "heartbeat",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_projects": {
"name": "harness_projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspaces": {
"name": "workspaces",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_tasks": {
"name": "harness_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"goal": {
"name": "goal",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_iterations": {
"name": "max_iterations",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 6
},
"started_at": {
"name": "started_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"project": {
"name": "project",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'—'"
},
"evals": {
"name": "evals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pr": {
"name": "pr",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"cancel_requested": {
"name": "cancel_requested",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"spec": {
"name": "spec",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -43,6 +43,13 @@
"when": 1774183202832,
"tag": "0005_outgoing_lake",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1774183919969,
"tag": "0006_tiny_lockjaw",
"breakpoints": true
}
]
}

View File

@@ -37,11 +37,13 @@ export const credentials = pgTable("harness_credentials", {
export const modelUsage = pgTable("harness_model_usage", {
id: serial("id").primaryKey(),
source: text("source").notNull().default("task"), // "task" | "chat"
modelId: text("model_id").notNull(),
provider: text("provider").notNull(),
taskId: text("task_id").notNull(),
taskSlug: text("task_slug").notNull(),
iteration: integer("iteration").notNull(),
taskId: text("task_id"),
taskSlug: text("task_slug"),
iteration: integer("iteration"),
conversationId: text("conversation_id"),
inputTokens: integer("input_tokens").notNull(),
outputTokens: integer("output_tokens").notNull(),
durationMs: integer("duration_ms").notNull(),