Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@ name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run checks
run: pnpm check

build-and-push:
needs: check
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ An open-source, self-hostable web UI for building and deploying websites with AI
* **API calls** should be abstracted into service functions rather than scattered directly in components.
* **State management** should use `zustand` to create domain-specific stores for complex state on components, or custom hooks if a store is not needed.

## Verification Commands

Run these commands to verify your changes before pushing:

| Command | Purpose |
|---|---|
| `pnpm dev` | Start the dev server |
| `pnpm test` | Run vitest test suites (`src/**/*.test.ts`) |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm check` | Full verification (biome lint + typecheck + tests + build) |
| `pnpm format` | Auto-format and fix lint issues with Biome |
| `pnpm build` | Production build |

## Tech Stack

### Package Manager
Expand All @@ -51,7 +64,7 @@ An open-source, self-hostable web UI for building and deploying websites with AI
**TypeScript** - Used throughout for type safety. Strict mode enabled.

### Framework
**Astro v5** - Full-stack framework providing:
**Astro v6** - Full-stack framework providing:
- Astro Pages for file-based routing
- Astro Actions for type-safe server-side operations like CRUD
- React integration for interactive components
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"shadcn:update": "bash scripts/shadcn-update.sh",
"icons:generate": "node scripts/generate-icons.js",
"format": "pnpm exec biome check --write src/",
"typecheck": "astro sync && tsc --noEmit",
"test": "vitest run",
"check": "pnpm exec biome check src/ && pnpm typecheck && pnpm test && pnpm build",
"drizzle:push": "drizzle-kit push",
"drizzle:generate": "drizzle-kit generate",
"drizzle:migrate": "drizzle-kit migrate",
Expand Down
27 changes: 21 additions & 6 deletions src/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ async function authorizeSession(projectId: string, userId: string) {
});
}
const project = await getProjectById(projectId);
if (!project?.bootstrapSessionId) {
if (!project) {
throw new ActionError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (!project.bootstrapSessionId) {
throw new ActionError({
code: "NOT_FOUND",
message: "No active session for this project",
Expand All @@ -28,6 +34,13 @@ async function authorizeSession(projectId: string, userId: string) {

async function assertRestoreAllowed(projectId: string, userId: string) {
const project = await authorizeSession(projectId, userId);
const sessionId = project.bootstrapSessionId;
if (!sessionId) {
throw new ActionError({
code: "NOT_FOUND",
message: "No active session for this project",
});
}
const restoreSafety = await getRestoreSafetyStatus(project);
if (!restoreSafety.canRestore) {
throw new ActionError({
Expand All @@ -39,7 +52,7 @@ async function assertRestoreAllowed(projectId: string, userId: string) {
}
return {
project,
sessionId: project.bootstrapSessionId,
sessionId,
restoreSafety,
};
}
Expand Down Expand Up @@ -98,24 +111,26 @@ export const chat = {
messageID: messageId,
});

let serverRevertMessageId: string | null = messageId;
let revertMessageId = messageId;
try {
const info = await client.session.get({ sessionID: sessionId });
const data = (info as { data?: { revert?: { messageID?: string } } })
.data;
serverRevertMessageId = data?.revert?.messageID ?? messageId;
if (data?.revert?.messageID) {
revertMessageId = data.revert.messageID;
}
} catch (error) {
logger.debug({ error }, "Failed to refetch session after revert");
}

logger.debug(
{ projectId, sessionId, messageId, serverRevertMessageId },
{ projectId, sessionId, messageId, revertMessageId },
"Reverted session to message",
);

return {
success: true as const,
revertMessageId: serverRevertMessageId,
revertMessageId,
};
},
}),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/chat/attachmentPromptText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { PromptAttachmentPart } from "@/types/message";

interface TextAttachmentInput {
filename: string;
textContent?: string;
textContent?: string | undefined;
}

export function formatTextAttachmentForPrompt({
Expand Down
2 changes: 2 additions & 0 deletions src/pages/api/queue/jobs-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const PAGE_SIZE = 25;
function validateJobType(typeParam: string): QueueJobType | undefined {
const allowedTypes = [
"project.create",
"project.identityGenerate",
"project.descriptionSync",
"project.delete",
"projects.deleteAllForUser",
"docker.composeUp",
Expand Down
82 changes: 24 additions & 58 deletions src/server/docker/composeFailure.test.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,25 @@
import {
type ComposeFailureKind,
classifyComposeFailure,
} from "./composeFailure";

interface TestResult {
test: string;
passed: boolean;
error?: string;
}

const results: TestResult[] = [];

function test(name: string, fn: () => void): void {
try {
fn();
results.push({ test: name, passed: true });
console.log(`PASS ${name}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
results.push({ test: name, passed: false, error: message });
console.log(`FAIL ${name}: ${message}`);
}
}

function assertEqual(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
}
}

function assertKind(rawOutput: string, expected: ComposeFailureKind): void {
const diagnostic = classifyComposeFailure(rawOutput);
assertEqual(diagnostic.kind, expected);
}

test("classifies cloudflare docker blob timeout as registry timeout", () => {
assertKind(
"failed to do request: Get https://docker-images-prod.abc.r2.cloudflarestorage.com/blob: dial tcp 172.64.66.1:443: i/o timeout",
"registry_timeout",
);
import { describe, expect, it } from "vitest";
import { classifyComposeFailure } from "./composeFailure";

describe("classifyComposeFailure", () => {
it("classifies cloudflare docker blob timeout as registry_timeout", () => {
const diagnostic = classifyComposeFailure(
"failed to do request: Get https://docker-images-prod.abc.r2.cloudflarestorage.com/blob: dial tcp 172.64.66.1:443: i/o timeout",
);
expect(diagnostic.kind).toBe("registry_timeout");
});

it("classifies missing container errors as missing_container", () => {
const diagnostic = classifyComposeFailure(
"Error: No such container: doce_preview_1",
);
expect(diagnostic.kind).toBe("missing_container");
});

it("falls back to generic for unknown failures", () => {
const diagnostic = classifyComposeFailure(
"Service 'preview' failed to build: Build failed",
);
expect(diagnostic.kind).toBe("generic");
});
});

test("classifies missing container errors", () => {
assertKind("Error: No such container: doce_preview_1", "missing_container");
});

test("falls back to generic for unknown failures", () => {
assertKind("Service 'preview' failed to build: Build failed", "generic");
});

const passed = results.filter((result) => result.passed).length;
const failed = results.length - passed;

console.log(`\n${passed}/${results.length} tests passed`);

if (failed > 0) {
process.exitCode = 1;
}
2 changes: 1 addition & 1 deletion src/server/opencode/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export type NormalizedEventType =
export interface NormalizedEventEnvelope {
type: NormalizedEventType;
projectId: string;
sessionId?: string;
sessionId?: string | undefined;
time: string;
payload: unknown;
}
Expand Down
112 changes: 44 additions & 68 deletions src/server/queue/job-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,11 @@
import { describe, expect, it } from "vitest";
import type { QueueJob } from "@/server/db/schema";
import {
getQueueJobDerivedError,
getQueueJobDerivedState,
isQueueJobExhausted,
} from "./job-state";

interface TestResult {
test: string;
passed: boolean;
error?: string;
}

const results: TestResult[] = [];

function test(name: string, fn: () => void): void {
try {
fn();
results.push({ test: name, passed: true });
console.log(`PASS ${name}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
results.push({ test: name, passed: false, error: message });
console.log(`FAIL ${name}: ${message}`);
}
}

function assertEqual(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
}
}

function buildJob(overrides: Partial<QueueJob>): QueueJob {
const now = new Date();

Expand Down Expand Up @@ -60,51 +35,52 @@ function buildJob(overrides: Partial<QueueJob>): QueueJob {
};
}

test("queued jobs at max attempts are exhausted", () => {
const job = buildJob({ attempts: 3, maxAttempts: 3, state: "queued" });
assertEqual(isQueueJobExhausted(job), true);
assertEqual(getQueueJobDerivedState(job), "exhausted");
});
describe("job-state", () => {
describe("isQueueJobExhausted", () => {
it("returns true for queued jobs at max attempts", () => {
const job = buildJob({ attempts: 3, maxAttempts: 3, state: "queued" });
expect(isQueueJobExhausted(job)).toBe(true);
expect(getQueueJobDerivedState(job)).toBe("exhausted");
});

test("running jobs are not exhausted", () => {
const job = buildJob({ attempts: 3, maxAttempts: 3, state: "running" });
assertEqual(isQueueJobExhausted(job), false);
assertEqual(getQueueJobDerivedState(job), "running");
});
it("returns false for running jobs even at max attempts", () => {
const job = buildJob({ attempts: 3, maxAttempts: 3, state: "running" });
expect(isQueueJobExhausted(job)).toBe(false);
expect(getQueueJobDerivedState(job)).toBe("running");
});

test("queued jobs with lock owner are not exhausted", () => {
const job = buildJob({
attempts: 3,
maxAttempts: 3,
state: "queued",
lockedBy: "worker_1",
it("returns false for queued jobs with a lock owner", () => {
const job = buildJob({
attempts: 3,
maxAttempts: 3,
state: "queued",
lockedBy: "worker_1",
});
expect(isQueueJobExhausted(job)).toBe(false);
expect(getQueueJobDerivedState(job)).toBe("queued");
});
});
assertEqual(isQueueJobExhausted(job), false);
assertEqual(getQueueJobDerivedState(job), "queued");
});

test("derived error uses lastError when present", () => {
const job = buildJob({
attempts: 3,
maxAttempts: 3,
lastError: "compose failed",
});
assertEqual(getQueueJobDerivedError(job), "compose failed");
});

test("derived error provides fallback for exhausted jobs", () => {
const job = buildJob({ attempts: 3, maxAttempts: 3, state: "queued" });
assertEqual(
getQueueJobDerivedError(job),
"Job exhausted all retry attempts before it could be marked failed.",
);
});

const passed = results.filter((result) => result.passed).length;
const failed = results.length - passed;
describe("getQueueJobDerivedError", () => {
it("uses lastError when present", () => {
const job = buildJob({
attempts: 3,
maxAttempts: 3,
lastError: "compose failed",
});
expect(getQueueJobDerivedError(job)).toBe("compose failed");
});

console.log(`\n${passed}/${results.length} tests passed`);
it("provides fallback for exhausted jobs without lastError", () => {
const job = buildJob({ attempts: 3, maxAttempts: 3, state: "queued" });
expect(getQueueJobDerivedError(job)).toBe(
"Job exhausted all retry attempts before it could be marked failed.",
);
});

if (failed > 0) {
process.exitCode = 1;
}
it("returns null for non-exhausted jobs without lastError", () => {
const job = buildJob({ attempts: 1, maxAttempts: 3, state: "queued" });
expect(getQueueJobDerivedError(job)).toBeNull();
});
});
});
1 change: 1 addition & 0 deletions src/server/settings/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function validateJobType(typeParam: string): QueueJobType | undefined {
const allowedTypes = [
"project.create",
"project.identityGenerate",
"project.descriptionSync",
"project.delete",
"projects.deleteAllForUser",
"docker.composeUp",
Expand Down
Loading
Loading