feat(sprint-001): XState FSM + Prisma + CLI 뼈대 — 결정론적 파이프라인 코어
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>
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# hanarang-rails environment variables
|
||||
# Copy to .env and fill in values.
|
||||
|
||||
# ── Database (MariaDB / MySQL) ──
|
||||
DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails"
|
||||
|
||||
# ── Discord ──
|
||||
DISCORD_TOKEN=""
|
||||
DISCORD_GUILD_ID=""
|
||||
|
||||
# ── Gitea Webhook ──
|
||||
GITEA_WEBHOOK_SECRET=""
|
||||
|
||||
# ── Rails ──
|
||||
RAILS_PORT=18800
|
||||
RAILS_LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,3 +40,4 @@ logs/
|
||||
.claude/projects/
|
||||
.claude/todos/
|
||||
.claude/tool-results/
|
||||
dist/
|
||||
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "hanarang-rails",
|
||||
"version": "0.1.0",
|
||||
"description": "Deterministic multi-agent pipeline orchestrator",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"bin": {
|
||||
"rails": "dist/cli/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/cli/index.js serve",
|
||||
"rails": "node dist/cli/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"citty": "^0.1.6",
|
||||
"neverthrow": "^8.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"ulid": "^2.3.0",
|
||||
"xstate": "^5.19.0",
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^6.6.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
1536
pnpm-lock.yaml
generated
Normal file
1536
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
prisma/schema.prisma
Normal file
74
prisma/schema.prisma
Normal file
@@ -0,0 +1,74 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Pipeline {
|
||||
id String @id @db.VarChar(26) // ULID
|
||||
projectName String @db.VarChar(255)
|
||||
requirements String @db.Text
|
||||
currentState String @db.VarChar(50) @default("idle")
|
||||
contextJson String @db.LongText
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
transitions StateTransition[]
|
||||
actorSpawns ActorSpawn[]
|
||||
contracts Contract[]
|
||||
|
||||
@@index([currentState])
|
||||
@@index([createdAt])
|
||||
@@map("pipelines")
|
||||
}
|
||||
|
||||
model StateTransition {
|
||||
id Int @id @default(autoincrement())
|
||||
pipelineId String @db.VarChar(26)
|
||||
fromState String @db.VarChar(50)
|
||||
toState String @db.VarChar(50)
|
||||
eventType String @db.VarChar(50)
|
||||
eventPayload String @db.LongText
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pipelineId, timestamp])
|
||||
@@index([eventType])
|
||||
@@map("state_transitions")
|
||||
}
|
||||
|
||||
model ActorSpawn {
|
||||
id Int @id @default(autoincrement())
|
||||
pipelineId String @db.VarChar(26)
|
||||
actorName String @db.VarChar(100)
|
||||
stage String @db.VarChar(50)
|
||||
spawnedAt DateTime @default(now())
|
||||
exitCode Int?
|
||||
exitedAt DateTime?
|
||||
resultJson String? @db.LongText
|
||||
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pipelineId, spawnedAt])
|
||||
@@map("actor_spawns")
|
||||
}
|
||||
|
||||
model Contract {
|
||||
id String @id @db.VarChar(26) // ULID
|
||||
pipelineId String @db.VarChar(26)
|
||||
sprintId String @db.VarChar(100)
|
||||
version String @db.VarChar(20) @default("v1")
|
||||
bodyJson String @db.LongText
|
||||
frozenAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pipelineId])
|
||||
@@index([sprintId])
|
||||
@@map("contracts")
|
||||
}
|
||||
17
src/cli/index.ts
Normal file
17
src/cli/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { defineCommand, runMain } from "citty";
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "rails",
|
||||
version: "0.1.0",
|
||||
description: "Deterministic multi-agent pipeline orchestrator",
|
||||
},
|
||||
subCommands: {
|
||||
start: () => import("./start.js").then((m) => m.default),
|
||||
status: () => import("./status.js").then((m) => m.default),
|
||||
serve: () => import("./serve.js").then((m) => m.default),
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
28
src/cli/serve.ts
Normal file
28
src/cli/serve.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadEnv } from "../env.js";
|
||||
import { getLogger } from "../logger.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "serve",
|
||||
description: "Start the Rails orchestrator server (webhook + Discord bot)",
|
||||
},
|
||||
async run() {
|
||||
const env = loadEnv();
|
||||
const log = getLogger();
|
||||
|
||||
log.info(
|
||||
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV },
|
||||
"hanarang-rails starting",
|
||||
);
|
||||
|
||||
// TODO (Sprint 004): Discord bot initialization
|
||||
// TODO (Sprint 004): Gitea webhook HTTP server
|
||||
// For now, just keep the process alive
|
||||
log.info("Orchestrator running. Press Ctrl+C to stop.");
|
||||
|
||||
await new Promise<never>(() => {
|
||||
// keep alive until signal
|
||||
});
|
||||
},
|
||||
});
|
||||
40
src/cli/start.ts
Normal file
40
src/cli/start.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadEnv } from "../env.js";
|
||||
import { createPipeline, disconnectPrisma } from "../orchestrator/persist.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "start",
|
||||
description: "Start a new pipeline for a project",
|
||||
},
|
||||
args: {
|
||||
project: {
|
||||
type: "positional",
|
||||
description: "Project name",
|
||||
required: true,
|
||||
},
|
||||
requirements: {
|
||||
type: "string",
|
||||
alias: "r",
|
||||
description: "Requirements / task description",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
loadEnv();
|
||||
try {
|
||||
const { pipelineId, state } = await createPipeline(
|
||||
args.project,
|
||||
args.requirements ?? "",
|
||||
);
|
||||
// eslint-disable-next-line no-console -- CLI output
|
||||
console.log(`Pipeline created: ${pipelineId}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` project: ${args.project}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` state: ${state}`);
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
87
src/cli/status.ts
Normal file
87
src/cli/status.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadEnv } from "../env.js";
|
||||
import {
|
||||
getPipelineState,
|
||||
listPipelines,
|
||||
disconnectPrisma,
|
||||
} from "../orchestrator/persist.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "status",
|
||||
description: "Show pipeline status",
|
||||
},
|
||||
args: {
|
||||
id: {
|
||||
type: "positional",
|
||||
description: "Pipeline ID (omit to list all)",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
loadEnv();
|
||||
try {
|
||||
if (args.id) {
|
||||
const result = await getPipelineState(args.id);
|
||||
if (!result) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Pipeline not found: ${args.id}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Pipeline: ${args.id}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` project: ${result.context.projectName}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` state: ${result.state}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` sprint: ${result.context.currentSprintId ?? "(none)"}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` retryCount: ${result.context.retryCount}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` reviewRound: ${result.context.reviewRound}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` lastError: ${result.context.lastError ?? "(none)"}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` created: ${result.context.createdAt}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` transitions: ${result.transitions.length}`);
|
||||
|
||||
if (result.transitions.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("\n Timeline:");
|
||||
for (const t of result.transitions.slice(-10)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
` ${t.timestamp.toISOString()} ${t.fromState} → ${t.toState} [${t.eventType}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pipelines = await listPipelines({ limit: 20 });
|
||||
if (pipelines.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("No pipelines found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${"ID".padEnd(28)} ${"PROJECT".padEnd(20)} ${"STATE".padEnd(14)} CREATED`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("-".repeat(80));
|
||||
for (const p of pipelines) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${p.id.padEnd(28)} ${p.projectName.padEnd(20)} ${p.currentState.padEnd(14)} ${p.createdAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
37
src/env.ts
Normal file
37
src/env.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
||||
DISCORD_TOKEN: z.string().default(""),
|
||||
DISCORD_GUILD_ID: z.string().default(""),
|
||||
GITEA_WEBHOOK_SECRET: z.string().default(""),
|
||||
RAILS_PORT: z.coerce.number().int().positive().default(18800),
|
||||
RAILS_LOG_LEVEL: z
|
||||
.enum(["silent", "fatal", "error", "warn", "info", "debug", "trace"])
|
||||
.default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
let _env: Env | undefined;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
if (_env) return _env;
|
||||
const result = EnvSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
const formatted = result.error.issues
|
||||
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Environment validation failed:\n${formatted}`);
|
||||
}
|
||||
_env = result.data;
|
||||
return _env;
|
||||
}
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (!_env) return loadEnv();
|
||||
return _env;
|
||||
}
|
||||
37
src/logger.ts
Normal file
37
src/logger.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import pino from "pino";
|
||||
|
||||
let _logger: pino.Logger | undefined;
|
||||
|
||||
export function createLogger(opts?: {
|
||||
level?: string;
|
||||
pipelineId?: string;
|
||||
}): pino.Logger {
|
||||
const level = opts?.level ?? process.env["RAILS_LOG_LEVEL"] ?? "info";
|
||||
const isDev = process.env["NODE_ENV"] !== "production";
|
||||
|
||||
const logger = pino({
|
||||
level,
|
||||
...(isDev && {
|
||||
transport: { target: "pino-pretty", options: { colorize: true } },
|
||||
}),
|
||||
base: {
|
||||
service: "hanarang-rails",
|
||||
...(opts?.pipelineId && { pipelineId: opts.pipelineId }),
|
||||
},
|
||||
});
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
export function getLogger(): pino.Logger {
|
||||
if (!_logger) {
|
||||
_logger = createLogger();
|
||||
}
|
||||
return _logger;
|
||||
}
|
||||
|
||||
export function childLogger(
|
||||
bindings: Record<string, unknown>,
|
||||
): pino.Logger {
|
||||
return getLogger().child(bindings);
|
||||
}
|
||||
37
src/orchestrator/context.ts
Normal file
37
src/orchestrator/context.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const PipelineContext = z.object({
|
||||
pipelineId: z.string().min(1),
|
||||
projectName: z.string(),
|
||||
requirements: z.string().default(""),
|
||||
currentSprintId: z.string().nullable().default(null),
|
||||
reviewRound: z.number().int().min(0).default(0),
|
||||
retryCount: z.number().int().min(0).default(0),
|
||||
maxRetries: z.number().int().positive().default(3),
|
||||
maxReviewRounds: z.number().int().positive().default(3),
|
||||
lastError: z.string().nullable().default(null),
|
||||
contractPath: z.string().nullable().default(null),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type PipelineContext = z.infer<typeof PipelineContext>;
|
||||
|
||||
export function createInitialContext(
|
||||
pipelineId: string,
|
||||
projectName: string,
|
||||
requirements: string,
|
||||
): PipelineContext {
|
||||
return {
|
||||
pipelineId,
|
||||
projectName,
|
||||
requirements,
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
76
src/orchestrator/events.ts
Normal file
76
src/orchestrator/events.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AgentName = z.string().min(1);
|
||||
export type AgentName = z.infer<typeof AgentName>;
|
||||
|
||||
export const ReviewIssue = z.object({
|
||||
severity: z.enum(["critical", "major", "minor", "recommendation"]),
|
||||
message: z.string(),
|
||||
file: z.string().optional(),
|
||||
line: z.number().optional(),
|
||||
});
|
||||
export type ReviewIssue = z.infer<typeof ReviewIssue>;
|
||||
|
||||
export const PipelineEvent = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("REQUEST"),
|
||||
projectName: z.string(),
|
||||
requirements: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("PLAN_READY"),
|
||||
planDir: z.string(),
|
||||
sprintId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("IMPL_DONE"),
|
||||
branch: z.string(),
|
||||
commits: z.array(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("APPROVE"),
|
||||
reviewArtifact: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("REQUEST_CHANGES"),
|
||||
issues: z.array(ReviewIssue),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("DEPLOY_DONE"),
|
||||
deployArtifact: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("ERROR"),
|
||||
actor: AgentName,
|
||||
reason: z.string(),
|
||||
retryable: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("TIMEOUT"),
|
||||
actor: AgentName,
|
||||
elapsedMs: z.number(),
|
||||
}),
|
||||
z.object({ type: z.literal("RETRY") }),
|
||||
z.object({ type: z.literal("RESUME") }),
|
||||
z.object({
|
||||
type: z.literal("ABORT"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type PipelineEvent = z.infer<typeof PipelineEvent>;
|
||||
export type PipelineEventType = PipelineEvent["type"];
|
||||
|
||||
export const PIPELINE_STATES = [
|
||||
"idle",
|
||||
"planning",
|
||||
"implementing",
|
||||
"reviewing",
|
||||
"deploying",
|
||||
"retrying",
|
||||
"escalated",
|
||||
"done",
|
||||
"aborted",
|
||||
] as const;
|
||||
|
||||
export type PipelineState = (typeof PIPELINE_STATES)[number];
|
||||
248
src/orchestrator/machine.ts
Normal file
248
src/orchestrator/machine.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { setup, assign } from "xstate";
|
||||
import type { PipelineContext } from "./context.js";
|
||||
import type { PipelineEvent } from "./events.js";
|
||||
|
||||
/**
|
||||
* Deterministic pipeline state machine.
|
||||
*
|
||||
* States: idle → planning → implementing → reviewing → deploying → done
|
||||
* Guards: retryCount < maxRetries, reviewRound <= maxReviewRounds
|
||||
* Errors: retryable → retrying → prev state | non-retryable → escalated
|
||||
*/
|
||||
export const pipelineMachine = setup({
|
||||
types: {
|
||||
context: {} as PipelineContext,
|
||||
events: {} as PipelineEvent,
|
||||
},
|
||||
guards: {
|
||||
canRetry: ({ context }: { context: PipelineContext }) =>
|
||||
context.retryCount < context.maxRetries,
|
||||
canReviewAgain: ({ context }: { context: PipelineContext }) =>
|
||||
context.reviewRound < context.maxReviewRounds,
|
||||
isRetryable: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" && event.retryable === true,
|
||||
},
|
||||
actions: {
|
||||
incrementRetry: assign({
|
||||
retryCount: ({ context }: { context: PipelineContext }) =>
|
||||
context.retryCount + 1,
|
||||
}),
|
||||
resetRetry: assign({ retryCount: 0 }),
|
||||
incrementReviewRound: assign({
|
||||
reviewRound: ({ context }: { context: PipelineContext }) =>
|
||||
context.reviewRound + 1,
|
||||
}),
|
||||
resetReviewRound: assign({ reviewRound: 0 }),
|
||||
setError: assign({
|
||||
lastError: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" ? event.reason : null,
|
||||
}),
|
||||
clearError: assign({ lastError: null }),
|
||||
setSprintId: assign({
|
||||
currentSprintId: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "PLAN_READY" ? event.sprintId : null,
|
||||
}),
|
||||
setAbortReason: assign({
|
||||
lastError: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ABORT" ? event.reason : null,
|
||||
}),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "pipeline",
|
||||
initial: "idle",
|
||||
context: ({}) => ({
|
||||
pipelineId: "",
|
||||
projectName: "",
|
||||
requirements: "",
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
REQUEST: {
|
||||
target: "planning",
|
||||
actions: [
|
||||
"clearError",
|
||||
"resetRetry",
|
||||
assign({
|
||||
projectName: ({ event }) => event.projectName,
|
||||
requirements: ({ event }) => event.requirements,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
planning: {
|
||||
on: {
|
||||
PLAN_READY: {
|
||||
target: "implementing",
|
||||
actions: ["setSprintId", "resetRetry", "resetReviewRound"],
|
||||
},
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
implementing: {
|
||||
on: {
|
||||
IMPL_DONE: {
|
||||
target: "reviewing",
|
||||
actions: ["resetRetry"],
|
||||
},
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
reviewing: {
|
||||
on: {
|
||||
APPROVE: {
|
||||
target: "deploying",
|
||||
actions: ["resetRetry"],
|
||||
},
|
||||
REQUEST_CHANGES: [
|
||||
{
|
||||
guard: "canReviewAgain",
|
||||
target: "implementing",
|
||||
actions: ["incrementReviewRound"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: [
|
||||
assign({
|
||||
lastError: "Max review rounds exceeded",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
deploying: {
|
||||
on: {
|
||||
DEPLOY_DONE: {
|
||||
target: "done",
|
||||
},
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
retrying: {
|
||||
always: [
|
||||
{
|
||||
guard: "canRetry",
|
||||
// For now, go back to idle; in Sprint 005 this will return
|
||||
// to the previous state via history node.
|
||||
target: "idle",
|
||||
actions: ["clearError"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: [
|
||||
assign({ lastError: "Max retries exceeded" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
escalated: {
|
||||
on: {
|
||||
RESUME: {
|
||||
target: "idle",
|
||||
actions: ["clearError", "resetRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
done: {
|
||||
type: "final",
|
||||
},
|
||||
|
||||
aborted: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
});
|
||||
174
src/orchestrator/persist.ts
Normal file
174
src/orchestrator/persist.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { createActor, type Snapshot } from "xstate";
|
||||
import { ulid } from "ulid";
|
||||
import { pipelineMachine } from "./machine.js";
|
||||
import { createInitialContext, type PipelineContext } from "./context.js";
|
||||
import type { PipelineEvent, PipelineState } from "./events.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "persist" });
|
||||
|
||||
let _prisma: PrismaClient | undefined;
|
||||
|
||||
export function getPrisma(): PrismaClient {
|
||||
if (!_prisma) {
|
||||
_prisma = new PrismaClient();
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
export async function createPipeline(
|
||||
projectName: string,
|
||||
requirements: string,
|
||||
): Promise<{ pipelineId: string; state: PipelineState }> {
|
||||
const prisma = getPrisma();
|
||||
const pipelineId = ulid();
|
||||
const ctx = createInitialContext(pipelineId, projectName, requirements);
|
||||
|
||||
const actor = createActor(pipelineMachine, {
|
||||
input: ctx,
|
||||
});
|
||||
actor.start();
|
||||
const snapshot = actor.getSnapshot();
|
||||
actor.stop();
|
||||
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
id: pipelineId,
|
||||
projectName,
|
||||
requirements,
|
||||
currentState: String(snapshot.value),
|
||||
contextJson: JSON.stringify(ctx),
|
||||
},
|
||||
});
|
||||
|
||||
log.info({ pipelineId, projectName }, "Pipeline created");
|
||||
return { pipelineId, state: String(snapshot.value) as PipelineState };
|
||||
}
|
||||
|
||||
export async function sendEvent(
|
||||
pipelineId: string,
|
||||
event: PipelineEvent,
|
||||
): Promise<{ state: PipelineState; context: PipelineContext }> {
|
||||
const prisma = getPrisma();
|
||||
|
||||
const pipeline = await prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: pipelineId },
|
||||
});
|
||||
|
||||
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
|
||||
const fromState = pipeline.currentState;
|
||||
|
||||
const actor = createActor(pipelineMachine, {
|
||||
snapshot: {
|
||||
value: fromState,
|
||||
context: ctx,
|
||||
} as unknown as Snapshot<unknown>,
|
||||
});
|
||||
actor.start();
|
||||
actor.send(event);
|
||||
|
||||
const snapshot = actor.getSnapshot();
|
||||
const toState = String(snapshot.value);
|
||||
const newContext = snapshot.context as PipelineContext;
|
||||
actor.stop();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.pipeline.update({
|
||||
where: { id: pipelineId },
|
||||
data: {
|
||||
currentState: toState,
|
||||
contextJson: JSON.stringify(newContext),
|
||||
},
|
||||
}),
|
||||
prisma.stateTransition.create({
|
||||
data: {
|
||||
pipelineId,
|
||||
fromState,
|
||||
toState,
|
||||
eventType: event.type,
|
||||
eventPayload: JSON.stringify(event),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
log.info(
|
||||
{ pipelineId, fromState, toState, event: event.type },
|
||||
"State transition",
|
||||
);
|
||||
|
||||
return { state: toState as PipelineState, context: newContext };
|
||||
}
|
||||
|
||||
export async function getPipelineState(
|
||||
pipelineId: string,
|
||||
): Promise<{
|
||||
state: PipelineState;
|
||||
context: PipelineContext;
|
||||
transitions: Array<{
|
||||
fromState: string;
|
||||
toState: string;
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
} | null> {
|
||||
const prisma = getPrisma();
|
||||
|
||||
const pipeline = await prisma.pipeline.findUnique({
|
||||
where: { id: pipelineId },
|
||||
include: {
|
||||
transitions: {
|
||||
orderBy: { timestamp: "asc" },
|
||||
select: {
|
||||
fromState: true,
|
||||
toState: true,
|
||||
eventType: true,
|
||||
timestamp: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!pipeline) return null;
|
||||
|
||||
return {
|
||||
state: pipeline.currentState as PipelineState,
|
||||
context: JSON.parse(pipeline.contextJson) as PipelineContext,
|
||||
transitions: pipeline.transitions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPipelines(opts?: {
|
||||
state?: PipelineState;
|
||||
limit?: number;
|
||||
}): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
projectName: string;
|
||||
currentState: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>
|
||||
> {
|
||||
const prisma = getPrisma();
|
||||
|
||||
return prisma.pipeline.findMany({
|
||||
where: opts?.state ? { currentState: opts.state } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: opts?.limit ?? 20,
|
||||
select: {
|
||||
id: true,
|
||||
projectName: true,
|
||||
currentState: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function disconnectPrisma(): Promise<void> {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = undefined;
|
||||
}
|
||||
}
|
||||
113
tests/machine.test.ts
Normal file
113
tests/machine.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user