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
5 changes: 5 additions & 0 deletions BackendAcademy/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
coverage
.env
.git
58 changes: 40 additions & 18 deletions BackendAcademy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ Implements the README's reward flow — AI grader scores a submission off-chain,
a tutor confirms or overrides that score, and the final score triggers rewards.
Routes are under `api/v1/grading`:

| Method | Path | Purpose |
| ------ | ---- | ------- |
| `POST` | `/submissions` | Accept a learner submission and open its audit trail |
| `POST` | `/submissions/:id/ai-pre-score` | Stage 1 — run the Claude grader (score + feedback) |
| `POST` | `/submissions/:id/review` | Stage 2 — tutor `confirm`s or `override`s the pre-score |
| `GET` | `/submissions/:id` | Submission with its AI pre-score and tutor review |
| `GET` | `/submissions/:id/history` | Append-only status-transition audit trail |
| `GET` | `/queue` | AI pre-scored submissions awaiting tutor review |
| Method | Path | Purpose |
| ------ | ------------------------------- | ------------------------------------------------------- |
| `POST` | `/submissions` | Accept a learner submission and open its audit trail |
| `POST` | `/submissions/:id/ai-pre-score` | Stage 1 — run the Claude grader (score + feedback) |
| `POST` | `/submissions/:id/review` | Stage 2 — tutor `confirm`s or `override`s the pre-score |
| `GET` | `/submissions/:id` | Submission with its AI pre-score and tutor review |
| `GET` | `/submissions/:id/history` | Append-only status-transition audit trail |
| `GET` | `/queue` | AI pre-scored submissions awaiting tutor review |

Statuses move `submitted → ai_graded → tutor_confirmed | tutor_overridden`, and
every move is appended to the submission's transition log. A `tutor_review`
Expand All @@ -64,12 +64,12 @@ meet the course's minimum (default: the grading pass threshold). The certificate
minting job consults these routes before minting; routes are under
`api/v1/certificates`:

| Method | Path | Purpose |
| ------ | ---- | ------- |
| `POST` | `/courses` | Register the task ids + minimum score a certificate requires |
| `POST` | `/results` | Record a learner's final task score from the grading pipeline |
| `GET` | `/users/:userId/courses/:courseId/eligibility` | Eligibility verdict for one learner |
| `GET` | `/courses/:courseId/eligible-learners` | Every learner the job may mint for |
| Method | Path | Purpose |
| ------ | ---------------------------------------------- | ------------------------------------------------------------- |
| `POST` | `/courses` | Register the task ids + minimum score a certificate requires |
| `POST` | `/results` | Record a learner's final task score from the grading pipeline |
| `GET` | `/users/:userId/courses/:courseId/eligibility` | Eligibility verdict for one learner |
| `GET` | `/courses/:courseId/eligible-learners` | Every learner the job may mint for |

`GET .../eligibility` returns `eligible`, the effective per-task results, and a
`finalScore` (the rounded mean of the task scores) for the mint to record. Only
Expand All @@ -88,10 +88,12 @@ bar but never lower the certificate.

## Sandbox and Stellar configuration

The Rust runner requires a reachable Docker Engine and the configured Rust image
(`RUSTACADEMY_SANDBOX_IMAGE`, default `rust:1.86-slim`). Each run has network
access disabled and is bounded by container CPU, memory, PID, wall-time, and
output limits. Keep Docker Engine access restricted to this service.
The Rust runner requires a reachable Docker Engine and the configured sandbox
image (`RUSTACADEMY_SANDBOX_IMAGE`, default
`rustacademy-wasm-sandbox:1.86-wasmtime-29.0.1`). Build instructions are in the
Rust WASM task execution section below. Each run has network access disabled and
is bounded by container CPU, memory, PID, wall-time, and output limits. Keep
Docker Engine access restricted to this service.

Set `STELLAR_NETWORK` to `testnet` (default) or `mainnet`. Configure
`REWARD_POOL_SECRET` only in a secret store; it is never returned by the API.
Expand All @@ -103,3 +105,23 @@ For cross-instance submission controls, configure `REDIS_REST_URL` and
are process-local and reset when the service restarts. Tune
`SUBMISSION_RATE_LIMIT` (default 5), `SUBMISSION_RATE_WINDOW_SECONDS` (default
60), and `SUBMISSION_DUPLICATE_WINDOW_SECONDS` (default 30) as needed.

## Rust WASM task execution

Build the sandbox runner image with Docker:

```bash
docker build -f BackendAcademy/sandbox.Dockerfile \
-t rustacademy-wasm-sandbox:1.86-wasmtime-29.0.1 BackendAcademy
```

`POST /api/tasks/run` accepts the existing `source` field. To execute test cases,
also provide a `testCases` array of stdin strings and the shared `expectedOutput`.
The response includes compilation status and per-case pass/fail, stdout, and
elapsed duration (including compilation). Set `RUSTACADEMY_SANDBOX_IMAGE` to use
a differently tagged image.

The runner compiles to `wasm32-wasip1` and executes with Wasmtime. Each run has
no network access or WASI directory preopens, a 10-second compile timeout, a
2-second execution timeout, a 16 MiB guest-memory limit, 10 million fuel, and
the existing Docker CPU, memory, PID, and output limits.
8 changes: 8 additions & 0 deletions BackendAcademy/sandbox.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM rust:1.86-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential cmake pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*

RUN rustup target add wasm32-wasip1 \
&& cargo install wasmtime-cli --version 29.0.1 --locked --root /usr/local
26 changes: 24 additions & 2 deletions BackendAcademy/src/sandbox/dto/run-code.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from "class-validator";

export class RunCodeDto {
@IsString()
@IsNotEmpty()
@MaxLength(100_000)
source: string;
}

@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(10)
@IsString({ each: true })
@MaxLength(16_384, { each: true })
testCases?: string[];

@ValidateIf((dto: RunCodeDto) => dto.testCases !== undefined)
@IsString()
@MaxLength(64 * 1024)
expectedOutput?: string;
}
23 changes: 15 additions & 8 deletions BackendAcademy/src/sandbox/sandbox.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RunCodeDto } from './dto/run-code.dto';
import { SandboxService } from './sandbox.service';
import { Body, Controller, Post } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { RunCodeDto } from "./dto/run-code.dto";
import { SandboxService } from "./sandbox.service";

@ApiTags('sandbox')
@Controller('tasks')
@ApiTags("sandbox")
@Controller("tasks")
export class SandboxController {
constructor(private readonly sandbox: SandboxService) {}

@Post('run')
@Post("run")
run(@Body() dto: RunCodeDto) {
if (dto.testCases !== undefined) {
return this.sandbox.runRustTests(
dto.source,
dto.testCases,
dto.expectedOutput,
);
}
return this.sandbox.runRust(dto.source);
}
}
}
76 changes: 76 additions & 0 deletions BackendAcademy/src/sandbox/sandbox.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { BadRequestException } from "@nestjs/common";
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { SandboxService } from "./sandbox.service";

describe("SandboxService", () => {
let service: SandboxService;

beforeEach(() => {
service = new SandboxService();
});

it("returns pass/fail and output for each test case", async () => {
const runContainer = jest
.spyOn(service as any, "runContainer")
.mockResolvedValueOnce({
stdout: "42\r\n",
stderr: "",
exitCode: 0,
timedOut: false,
durationMs: 34,
compiled: true,
})
.mockResolvedValueOnce({
stdout: "wrong",
stderr: "",
exitCode: 0,
timedOut: false,
durationMs: 29,
compiled: true,
});

const result = await service.runRustTests(
"fn main() {}",
["1\n", "2\n"],
"42",
);

expect(result).toEqual({
compiled: true,
passed: false,
results: [
{ index: 0, passed: true, stdout: "42\r\n", durationMs: 34 },
{ index: 1, passed: false, stdout: "wrong", durationMs: 29 },
],
});
expect(runContainer).toHaveBeenCalledTimes(2);
expect(runContainer.mock.calls[0]).toEqual(["fn main() {}", "1\n"]);
expect(runContainer.mock.calls[1]).toEqual(["fn main() {}", "2\n"]);
});

it("reports compilation failure without running test cases", async () => {
jest.spyOn(service as any, "runContainer").mockResolvedValueOnce({
stdout: "",
stderr: "error: expected item",
exitCode: 1,
timedOut: false,
durationMs: 16,
compiled: false,
});

await expect(
service.runRustTests("not rust", ["input"], "output"),
).resolves.toEqual({
compiled: false,
passed: false,
results: [],
compileError: "error: expected item",
});
});

it("rejects empty test-case lists", async () => {
await expect(
service.runRustTests("fn main() {}", [], ""),
).rejects.toBeInstanceOf(BadRequestException);
});
});
Loading
Loading