Sprint 001 전체 구현: Foundation: - package.json (pnpm + Node 22 + TypeScript strict) - tsconfig.json (strict + noUncheckedIndexedAccess) - .env.example (DATABASE_URL, DISCORD_TOKEN, etc.) - vitest.config.ts Core: - src/env.ts — Zod 환경변수 검증 - src/logger.ts — pino 구조화 로거 - src/orchestrator/events.ts — Zod discriminated union 이벤트 스키마 - src/orchestrator/context.ts — PipelineContext 타입 + 팩토리 - src/orchestrator/machine.ts — XState v5 결정론적 FSM States: idle → planning → implementing → reviewing → deploying → done + retrying (exponential backoff 준비) + escalated + aborted - src/orchestrator/persist.ts — Prisma 기반 상태 영속화 - prisma/schema.prisma — MariaDB 스키마 (pipelines, state_transitions, actor_spawns, contracts) CLI (citty): - rails start <project> — 파이프라인 생성 - rails status [id] — 상태 조회 + 타임라인 - rails serve — 오케스트레이터 서버 (Sprint 004 에서 완성) Tests (9/9 pass): - happy path (idle → done) - REQUEST_CHANGES 재작업 루프 + max review round escalation - retryable/non-retryable 에러 분기 - RESUME / ABORT - context 추적 검증: pnpm tsc --noEmit ✓ | pnpm vitest run 9/9 ✓ | pnpm build ✓ | rails --help ✓ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { createActor } from "xstate";
|
|
import { pipelineMachine } from "../src/orchestrator/machine.js";
|
|
|
|
function runMachine(events: Array<Record<string, unknown>>) {
|
|
const actor = createActor(pipelineMachine);
|
|
actor.start();
|
|
for (const event of events) {
|
|
actor.send(event as any);
|
|
}
|
|
const snapshot = actor.getSnapshot();
|
|
actor.stop();
|
|
return snapshot;
|
|
}
|
|
|
|
describe("pipelineMachine", () => {
|
|
it("starts in idle", () => {
|
|
const actor = createActor(pipelineMachine);
|
|
actor.start();
|
|
expect(actor.getSnapshot().value).toBe("idle");
|
|
actor.stop();
|
|
});
|
|
|
|
it("happy path: idle → planning → implementing → reviewing → deploying → done", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "build something" },
|
|
{ type: "PLAN_READY", planDir: "/tmp/plans", sprintId: "SPRINT-001" },
|
|
{ type: "IMPL_DONE", branch: "feature/sprint-001", commits: ["abc1234"] },
|
|
{ type: "APPROVE", reviewArtifact: "/tmp/review.json" },
|
|
{ type: "DEPLOY_DONE", deployArtifact: "/tmp/deploy.json" },
|
|
]);
|
|
expect(snapshot.value).toBe("done");
|
|
expect(snapshot.status).toBe("done");
|
|
});
|
|
|
|
it("REQUEST_CHANGES loops back to implementing (up to maxReviewRounds)", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "" },
|
|
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
|
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
|
|
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix it" }] },
|
|
]);
|
|
expect(snapshot.value).toBe("implementing");
|
|
expect(snapshot.context.reviewRound).toBe(1);
|
|
});
|
|
|
|
it("escalates after max review rounds exceeded", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "" },
|
|
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
|
// Round 1 (reviewRound: 0 → 1)
|
|
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
|
|
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
|
// Round 2 (reviewRound: 1 → 2)
|
|
{ type: "IMPL_DONE", branch: "b", commits: ["c2"] },
|
|
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
|
// Round 3 (reviewRound: 2 → 3)
|
|
{ type: "IMPL_DONE", branch: "b", commits: ["c3"] },
|
|
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
|
// Round 4 — reviewRound=3, guard 3 < 3 = false → escalated
|
|
{ type: "IMPL_DONE", branch: "b", commits: ["c4"] },
|
|
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
|
]);
|
|
expect(snapshot.value).toBe("escalated");
|
|
expect(snapshot.context.lastError).toContain("review rounds");
|
|
});
|
|
|
|
it("retryable error goes to retrying, then back (if under limit)", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "" },
|
|
{ type: "ERROR", actor: "planner", reason: "timeout", retryable: true },
|
|
]);
|
|
// retrying has an always transition — if canRetry, goes to idle
|
|
expect(snapshot.value).toBe("idle");
|
|
expect(snapshot.context.retryCount).toBe(1);
|
|
});
|
|
|
|
it("non-retryable error goes to escalated", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "" },
|
|
{ type: "ERROR", actor: "planner", reason: "permission denied", retryable: false },
|
|
]);
|
|
expect(snapshot.value).toBe("escalated");
|
|
expect(snapshot.context.lastError).toBe("permission denied");
|
|
});
|
|
|
|
it("escalated → RESUME goes back to idle", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "" },
|
|
{ type: "ERROR", actor: "planner", reason: "fail", retryable: false },
|
|
{ type: "RESUME" },
|
|
]);
|
|
expect(snapshot.value).toBe("idle");
|
|
expect(snapshot.context.lastError).toBeNull();
|
|
});
|
|
|
|
it("ABORT from any active state goes to aborted", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "test", requirements: "" },
|
|
{ type: "ABORT", reason: "user cancelled" },
|
|
]);
|
|
expect(snapshot.value).toBe("aborted");
|
|
expect(snapshot.context.lastError).toBe("user cancelled");
|
|
});
|
|
|
|
it("context tracks projectName and requirements from REQUEST", () => {
|
|
const snapshot = runMachine([
|
|
{ type: "REQUEST", projectName: "arang", requirements: "Live2D avatar" },
|
|
]);
|
|
expect(snapshot.context.projectName).toBe("arang");
|
|
expect(snapshot.context.requirements).toBe("Live2D avatar");
|
|
});
|
|
});
|