chore: setup CI scripts (#6)

* wip: workflows

* wip: fix up issues in ci scripts and fix frontend lint errors

* wip: fix backend lints

* remove unused deps

* wip: build frontend in test.yml

* wip: attempt to improve Rust caching

* wip: testing release

* wip: linear release flow

* wip: check against both package.json versions

* wip: spurious attempt to get Rust caching

* wip: more cache

* merge release and publish jobs; add more caching to release flow

* decouple github releases and npm publishing

* update pack flow

---------

Co-authored-by: couscous <couscous@runner.com>
This commit is contained in:
Gabriel Gordon-Hall
2025-06-27 13:32:32 +01:00
committed by GitHub
parent b25f81504a
commit 340b094c75
33 changed files with 620 additions and 280 deletions

View File

@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 100",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
@@ -50,4 +50,4 @@
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
@@ -36,7 +36,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const [showEditForm, setShowEditForm] = useState(false);
const [error, setError] = useState('');
const fetchProject = async () => {
const fetchProject = useCallback(async () => {
setLoading(true);
setError('');
try {
@@ -55,7 +55,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
} finally {
setLoading(false);
}
};
}, [projectId]);
const handleDelete = async () => {
if (!project) return;
@@ -86,7 +86,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
useEffect(() => {
fetchProject();
}, [projectId]);
}, [fetchProject]);
if (loading) {
return (

View File

@@ -42,7 +42,10 @@ export function ExecutionOutputViewer({
// Check if stdout looks like JSONL (for Amp, Claude, or Gemini executor)
const { isValidJsonl, jsonlFormat } = useMemo(() => {
if ((!isAmpExecutor && !isClaudeExecutor && !isGeminiExecutor) || !executionProcess.stdout) {
if (
(!isAmpExecutor && !isClaudeExecutor && !isGeminiExecutor) ||
!executionProcess.stdout
) {
return { isValidJsonl: false, jsonlFormat: null };
}
@@ -99,7 +102,12 @@ export function ExecutionOutputViewer({
} catch {
return { isValidJsonl: false, jsonlFormat: null };
}
}, [isAmpExecutor, isClaudeExecutor, isGeminiExecutor, executionProcess.stdout]);
}, [
isAmpExecutor,
isClaudeExecutor,
isGeminiExecutor,
executionProcess.stdout,
]);
// Set initial view mode based on JSONL detection
useEffect(() => {

View File

@@ -86,9 +86,7 @@ export function TaskActivityHistory({
return (
<div>
<Label className="text-sm font-medium mb-3 block">
Activity History
</Label>
<Label className="text-sm font-medium mb-3 block">Activity History</Label>
{activities.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No activities found

View File

@@ -112,7 +112,7 @@ export function TaskDetailsHeader({
<TooltipContent>
<p>Close panel</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
</div>
</div>

View File

@@ -9,11 +9,7 @@ import {
getTaskPanelClasses,
getBackdropClasses,
} from '@/lib/responsive-config';
import type {
TaskWithAttemptStatus,
EditorType,
Project,
} from 'shared/types';
import type { TaskWithAttemptStatus, EditorType, Project } from 'shared/types';
interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;

View File

@@ -105,9 +105,7 @@ export function TaskDetailsToolbar({
<div className="h-4 w-px bg-border" />
</>
) : (
<div className="text-sm text-muted-foreground">
No attempts yet
</div>
<div className="text-sm text-muted-foreground">No attempts yet</div>
)}
</div>
@@ -171,9 +169,7 @@ export function TaskDetailsToolbar({
</TooltipTrigger>
<TooltipContent>
<p>
{isStopping
? 'Stopping execution...'
: 'Stop execution'}
{isStopping ? 'Stopping execution...' : 'Stop execution'}
</p>
</TooltipContent>
</Tooltip>
@@ -230,8 +226,7 @@ export function TaskDetailsToolbar({
}
>
{executor.name}
{config?.executor.type === executor.id &&
' (Default)'}
{config?.executor.type === executor.id && ' (Default)'}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -257,16 +252,14 @@ export function TaskDetailsToolbar({
onMouseLeave={() => onSetIsHoveringDevServer(false)}
>
<Button
variant={
runningDevServer ? 'destructive' : 'outline'
}
variant={runningDevServer ? 'destructive' : 'outline'}
size="sm"
onClick={
runningDevServer ? onStopDevServer : onStartDevServer
}
disabled={
isStartingDevServer || !project?.dev_script
runningDevServer
? onStopDevServer
: onStartDevServer
}
disabled={isStartingDevServer || !project?.dev_script}
>
{runningDevServer ? (
<StopCircle className="h-4 w-4" />

View File

@@ -66,9 +66,7 @@ export function TaskFollowUpSection({
<Button
onClick={onSendFollowUp}
disabled={
!canSendFollowUp ||
!followUpMessage.trim() ||
isSendingFollowUp
!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp
}
size="sm"
>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -103,7 +103,7 @@ export function TaskFormDialog({
}
};
const handleCreateAndStart = async () => {
const handleCreateAndStart = useCallback(async () => {
if (!title.trim()) return;
setIsSubmittingAndStart(true);
@@ -121,9 +121,16 @@ export function TaskFormDialog({
} finally {
setIsSubmittingAndStart(false);
}
};
}, [
title,
description,
config?.executor,
isEditMode,
onCreateAndStartTask,
onOpenChange,
]);
const handleCancel = () => {
const handleCancel = useCallback(() => {
// Reset form state when canceling
if (task) {
setTitle(task.title);
@@ -135,7 +142,7 @@ export function TaskFormDialog({
setStatus('todo');
}
onOpenChange(false);
};
}, [task, onOpenChange]);
// Handle keyboard shortcuts
useEffect(() => {

View File

@@ -56,7 +56,10 @@ export function useTaskDetails(
return false;
}
const latestActivitiesByProcess = new Map<string, TaskAttemptActivityWithPrompt>();
const latestActivitiesByProcess = new Map<
string,
TaskAttemptActivityWithPrompt
>();
attemptData.activities.forEach((activity) => {
const existing = latestActivitiesByProcess.get(
@@ -110,70 +113,78 @@ export function useTaskDetails(
const lines = allOutput.split('\n').filter((line) => line.trim());
const lastLines = lines.slice(-10);
return lastLines.length > 0 ? lastLines.join('\n') : 'No output yet...';
}, [devServerDetails?.stdout, devServerDetails?.stderr]);
}, [devServerDetails]);
// Set default executor from config
useEffect(() => {
if (config) {
setSelectedExecutor(config.executor.type);
}
}, [config]);
// Define callbacks first
const fetchAttemptData = useCallback(
async (attemptId: string) => {
if (!task) return;
useEffect(() => {
if (task && isOpen) {
fetchTaskAttempts();
}
}, [task, isOpen]);
try {
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes`
),
]);
// Polling for updates when attempt is running
useEffect(() => {
if (!isAttemptRunning || !task) return;
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id, true);
}
}, 2000);
if (
activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
return () => clearInterval(interval);
}, [isAttemptRunning, task?.id, selectedAttempt?.id]);
const runningProcessDetails: Record<string, ExecutionProcess> = {};
for (const activity of runningActivities) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
}
// Fetch dev server details when hovering
const fetchDevServerDetails = useCallback(async () => {
if (!runningDevServer || !task || !selectedAttempt) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
}
}
} catch (err) {
console.error('Failed to fetch attempt data:', err);
}
} catch (err) {
console.error('Failed to fetch dev server details:', err);
}
}, [runningDevServer?.id, task?.id, selectedAttempt?.id, projectId]);
},
[task, projectId]
);
// Poll dev server details while hovering
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [
isHoveringDevServer,
runningDevServer?.id,
fetchDevServerDetails,
]);
const fetchTaskAttempts = async () => {
const fetchTaskAttempts = useCallback(async () => {
if (!task) return;
try {
@@ -210,75 +221,64 @@ export function useTaskDetails(
} finally {
setLoading(false);
}
};
}, [task, projectId, fetchAttemptData]);
const fetchAttemptData = async (
attemptId: string,
_isBackgroundUpdate = false
) => {
if (!task) return;
// Fetch dev server details when hovering
const fetchDevServerDetails = useCallback(async () => {
if (!runningDevServer || !task || !selectedAttempt) return;
try {
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes`
),
]);
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
if (
activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
const runningProcessDetails: Record<string, ExecutionProcess> = {};
for (const activity of runningActivities) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
}
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
}
}
} catch (err) {
console.error('Failed to fetch attempt data:', err);
console.error('Failed to fetch dev server details:', err);
}
};
}, [runningDevServer, task, selectedAttempt, projectId]);
// Set default executor from config
useEffect(() => {
if (config) {
setSelectedExecutor(config.executor.type);
}
}, [config]);
useEffect(() => {
if (task && isOpen) {
fetchTaskAttempts();
}
}, [task, isOpen, fetchTaskAttempts]);
// Polling for updates when attempt is running
useEffect(() => {
if (!isAttemptRunning || !task) return;
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id);
}
}, 2000);
return () => clearInterval(interval);
}, [isAttemptRunning, task, selectedAttempt, fetchAttemptData]);
// Poll dev server details while hovering
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
const handleAttemptChange = (attemptId: string) => {
const attempt = taskAttempts.find((a) => a.id === attemptId);
@@ -482,13 +482,13 @@ export function useTaskDetails(
isStartingDevServer,
devServerDetails,
isHoveringDevServer,
// Computed
runningDevServer,
isAttemptRunning,
canSendFollowUp,
processedDevServerLogs,
// Actions
setSelectedExecutor,
setFollowUpMessage,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -90,7 +90,7 @@ export function ProjectTasks() {
}
}, [taskId, tasks]);
const fetchProject = async () => {
const fetchProject = useCallback(async () => {
try {
const response = await makeRequest(
`/api/projects/${projectId}/with-branch`
@@ -108,53 +108,56 @@ export function ProjectTasks() {
} catch (err) {
setError('Failed to load project');
}
};
}, [projectId, navigate]);
const fetchTasks = async (skipLoading = false) => {
try {
if (!skipLoading) {
setLoading(true);
}
const response = await makeRequest(`/api/projects/${projectId}/tasks`);
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json();
if (result.success && result.data) {
// Only update if data has actually changed
setTasks((prevTasks) => {
const newTasks = result.data!;
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
return prevTasks; // Return same reference to prevent re-render
}
// Update selectedTask if it exists and has been modified
if (selectedTask) {
const updatedSelectedTask = newTasks.find(
(task) => task.id === selectedTask.id
);
if (
updatedSelectedTask &&
JSON.stringify(selectedTask) !==
JSON.stringify(updatedSelectedTask)
) {
setSelectedTask(updatedSelectedTask);
}
}
return newTasks;
});
const fetchTasks = useCallback(
async (skipLoading = false) => {
try {
if (!skipLoading) {
setLoading(true);
}
} else {
const response = await makeRequest(`/api/projects/${projectId}/tasks`);
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json();
if (result.success && result.data) {
// Only update if data has actually changed
setTasks((prevTasks) => {
const newTasks = result.data!;
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
return prevTasks; // Return same reference to prevent re-render
}
// Update selectedTask if it exists and has been modified
if (selectedTask) {
const updatedSelectedTask = newTasks.find(
(task) => task.id === selectedTask.id
);
if (
updatedSelectedTask &&
JSON.stringify(selectedTask) !==
JSON.stringify(updatedSelectedTask)
) {
setSelectedTask(updatedSelectedTask);
}
}
return newTasks;
});
}
} else {
setError('Failed to load tasks');
}
} catch (err) {
setError('Failed to load tasks');
} finally {
if (!skipLoading) {
setLoading(false);
}
}
} catch (err) {
setError('Failed to load tasks');
} finally {
if (!skipLoading) {
setLoading(false);
}
}
};
},
[projectId, selectedTask]
);
const handleCreateTask = async (title: string, description: string) => {
try {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -59,14 +59,8 @@ export function TaskAttemptComparePage() {
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
const [showUncommittedWarning, setShowUncommittedWarning] = useState(false);
useEffect(() => {
if (projectId && taskId && attemptId) {
fetchDiff();
fetchBranchStatus();
}
}, [projectId, taskId, attemptId]);
const fetchDiff = async () => {
// Define callbacks first
const fetchDiff = useCallback(async () => {
if (!projectId || !taskId || !attemptId) return;
try {
@@ -90,9 +84,9 @@ export function TaskAttemptComparePage() {
} finally {
setLoading(false);
}
};
}, [projectId, taskId, attemptId]);
const fetchBranchStatus = async () => {
const fetchBranchStatus = useCallback(async () => {
if (!projectId || !taskId || !attemptId) return;
try {
@@ -116,7 +110,14 @@ export function TaskAttemptComparePage() {
} finally {
setBranchStatusLoading(false);
}
};
}, [projectId, taskId, attemptId]);
useEffect(() => {
if (projectId && taskId && attemptId) {
fetchDiff();
fetchBranchStatus();
}
}, [projectId, taskId, attemptId, fetchDiff, fetchBranchStatus]);
const handleBackClick = () => {
navigate(`/projects/${projectId}/tasks/${taskId}`);