5 Commits

Author SHA1 Message Date
1f518c0c54 feat(server): HTTP API for orchestration (/pipelines/*, /health)
rails serve 가 이제 실제 HTTP 서버를 띄움:
- GET  /health
- GET  /pipelines?limit=N
- POST /pipelines        — 파이프라인 생성만
- POST /pipelines/start  — 생성 + E2E 실행 (현재 mock 만)
- GET  /pipelines/:id    — 상태 + 타임라인
- POST /pipelines/:id/abort — 강제 종료

node built-in http 만 사용 (추가 의존성 없음).
Zod 로 요청 바디 검증. Graceful shutdown (SIGINT/SIGTERM).

하랑이(오케스트레이터) 가 이 API 를 찔러 파이프라인을 기동할 수 있음.
2026-04-10 16:10:39 +09:00
3a226ada95 merge: hotfix — FSM snapshot restore (XState v5) 2026-04-10 16:04:48 +09:00
7a8f2c1af0 fix(persist): use XState v5 getPersistedSnapshot for round-trip
기존 { value, context } 수동 스냅샷은 XState v5 의 restoreSnapshot 이
status/children 등을 기대해서 런타임 에러 발생.

actor.getPersistedSnapshot() 를 써서 완전한 snapshot JSON 을 저장/복원.
getPipelineState 는 snapshot.context 에서 PipelineContext 를 추출.

Dev 서버에서 첫 실행 중 발견.
2026-04-10 16:04:41 +09:00
f6c1768c60 docs: Sprint 007 완료 — v0.1.0 전 스프린트 완료 2026-04-10 15:54:44 +09:00
8786efc81c merge: Sprint 007 — Migration + docs + v0.1.0 (#7) 2026-04-10 15:54:14 +09:00
4 changed files with 298 additions and 25 deletions

View File

@@ -22,13 +22,17 @@
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:완료 [PR#4] | | 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:완료 [PR#4] |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:완료 [PR#5] | | 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:완료 [PR#5] |
| 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:완료 [PR#6] | | 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:완료 [PR#6] |
| 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:WIP | | 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:완료 [PR#7] |
## 현재 스프린트 ## 현재 스프린트
**Sprint 007 — 기존 프로젝트 마이그레이션 (아랑 등)** (`cc:TODO`) **전체 완료** — v0.1.0 릴리즈 준비됨. 105 테스트 통과.
다음 착수 예정. 상세는 `.plans/sprints/SPRINT-001-skeleton.md` 참조. 다음 단계 (post-v0.1.0, 운영자 작업):
1. Dev 서버에 rails 배포 (`bash install.sh`)
2. DB 마이그레이션 (`pnpm prisma migrate deploy`)
3. Discord 봇 연동 (`docs/discord-setup.md` 참조)
4. 첫 실제 프로젝트 E2E 실행
## 마커 범례 ## 마커 범례

View File

@@ -1,28 +1,72 @@
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { loadEnv } from "../env.js"; import { loadEnv } from "../env.js";
import { getLogger } from "../logger.js"; import { getLogger } from "../logger.js";
import { startHttpServer } from "../server/http.js";
import { disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({ export default defineCommand({
meta: { meta: {
name: "serve", name: "serve",
description: "Start the Rails orchestrator server (webhook + Discord bot)", description: "Start the Rails orchestrator HTTP server",
}, },
async run() { args: {
port: {
type: "string",
alias: "p",
description: "HTTP port",
default: "",
},
host: {
type: "string",
alias: "H",
description: "Bind host",
default: "0.0.0.0",
},
config: {
type: "string",
alias: "c",
description: "Path to rails.config.yaml",
default: "",
},
},
async run({ args }) {
const env = loadEnv(); const env = loadEnv();
const log = getLogger(); const log = getLogger();
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
const { url, close } = await startHttpServer({
port,
host: args.host ?? "0.0.0.0",
...(args.config && { configPath: args.config }),
});
log.info( log.info(
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV }, { url, nodeEnv: env.NODE_ENV },
"hanarang-rails starting", "hanarang-rails server ready",
); );
// TODO (Sprint 004): Discord bot initialization // Graceful shutdown
// TODO (Sprint 004): Gitea webhook HTTP server const shutdown = async (signal: string) => {
// For now, just keep the process alive log.info({ signal }, "Shutdown requested");
log.info("Orchestrator running. Press Ctrl+C to stop."); try {
await close();
await disconnectPrisma();
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Shutdown error",
);
}
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
// Keep alive
await new Promise<never>(() => { await new Promise<never>(() => {
// keep alive until signal /* block until signal */
}); });
}, },
}); });

View File

@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { createActor, type Snapshot } from "xstate"; import { createActor } from "xstate";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { pipelineMachine } from "./machine.js"; import { pipelineMachine } from "./machine.js";
import { createInitialContext, type PipelineContext } from "./context.js"; import { createInitialContext, type PipelineContext } from "./context.js";
@@ -17,6 +17,13 @@ export function getPrisma(): PrismaClient {
return _prisma; return _prisma;
} }
/**
* Pipeline persistence layout:
* - pipelines.currentState — XState state value (for quick queries)
* - pipelines.contextJson — FULL persisted snapshot JSON from XState v5
* (includes value, context, status, children, etc.)
*/
export async function createPipeline( export async function createPipeline(
projectName: string, projectName: string,
requirements: string, requirements: string,
@@ -25,20 +32,27 @@ export async function createPipeline(
const pipelineId = ulid(); const pipelineId = ulid();
const ctx = createInitialContext(pipelineId, projectName, requirements); const ctx = createInitialContext(pipelineId, projectName, requirements);
const actor = createActor(pipelineMachine, { const actor = createActor(pipelineMachine, { input: ctx });
input: ctx,
});
actor.start(); actor.start();
// Manually set pipelineId into context via assign on fresh start is awkward;
// just store the ctx alongside the persisted snapshot for later restoration.
const persistedSnapshot = actor.getPersistedSnapshot();
const snapshot = actor.getSnapshot(); const snapshot = actor.getSnapshot();
actor.stop(); actor.stop();
// Merge our pipelineId into the persisted context for recovery
const persistedWithId = mergeContextIntoSnapshot(
persistedSnapshot,
ctx,
);
await prisma.pipeline.create({ await prisma.pipeline.create({
data: { data: {
id: pipelineId, id: pipelineId,
projectName, projectName,
requirements, requirements,
currentState: String(snapshot.value), currentState: String(snapshot.value),
contextJson: JSON.stringify(ctx), contextJson: JSON.stringify(persistedWithId),
}, },
}); });
@@ -56,21 +70,22 @@ export async function sendEvent(
where: { id: pipelineId }, where: { id: pipelineId },
}); });
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
const fromState = pipeline.currentState; const fromState = pipeline.currentState;
const persistedSnapshot = JSON.parse(pipeline.contextJson) as unknown;
// XState v5 accepts a persisted snapshot via the options object.
// We bypass the strict generic typing because the snapshot is produced
// by the same machine and serialised through JSON.
const actor = createActor(pipelineMachine, { const actor = createActor(pipelineMachine, {
snapshot: { snapshot: persistedSnapshot,
value: fromState, } as Parameters<typeof createActor>[1]);
context: ctx,
} as unknown as Snapshot<unknown>,
});
actor.start(); actor.start();
actor.send(event); actor.send(event);
const snapshot = actor.getSnapshot(); const snapshot = actor.getSnapshot();
const toState = String(snapshot.value); const toState = String(snapshot.value);
const newContext = snapshot.context as PipelineContext; const newContext = snapshot.context as PipelineContext;
const newPersistedSnapshot = actor.getPersistedSnapshot();
actor.stop(); actor.stop();
await prisma.$transaction([ await prisma.$transaction([
@@ -78,7 +93,7 @@ export async function sendEvent(
where: { id: pipelineId }, where: { id: pipelineId },
data: { data: {
currentState: toState, currentState: toState,
contextJson: JSON.stringify(newContext), contextJson: JSON.stringify(newPersistedSnapshot),
}, },
}), }),
prisma.stateTransition.create({ prisma.stateTransition.create({
@@ -131,13 +146,32 @@ export async function getPipelineState(
if (!pipeline) return null; if (!pipeline) return null;
const snap = JSON.parse(pipeline.contextJson) as { context?: PipelineContext };
const context =
(snap.context as PipelineContext | undefined) ??
(snap as unknown as PipelineContext);
return { return {
state: pipeline.currentState as PipelineState, state: pipeline.currentState as PipelineState,
context: JSON.parse(pipeline.contextJson) as PipelineContext, context,
transitions: pipeline.transitions, transitions: pipeline.transitions,
}; };
} }
/**
* Merge our canonical PipelineContext into the XState persisted snapshot.
* XState v5 snapshots include `.context`, so we overlay our values.
*/
function mergeContextIntoSnapshot(
snapshot: unknown,
ctx: PipelineContext,
): unknown {
if (snapshot && typeof snapshot === "object") {
return { ...(snapshot as object), context: ctx };
}
return snapshot;
}
export async function listPipelines(opts?: { export async function listPipelines(opts?: {
state?: PipelineState; state?: PipelineState;
limit?: number; limit?: number;

191
src/server/http.ts Normal file
View File

@@ -0,0 +1,191 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import {
createPipeline,
getPipelineState,
listPipelines,
sendEvent,
} from "../orchestrator/persist.js";
import { runPipeline } from "../orchestrator/runner.js";
import { loadConfig } from "../config/loader.js";
import { MockTransport } from "../handoff/mock-transport.js";
import type { SisterTransport } from "../handoff/transport.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "http-server" });
const StartRequest = z.object({
project: z.string().min(1),
requirements: z.string().default(""),
mock: z.boolean().default(true),
});
const AbortRequest = z.object({
reason: z.string().default("aborted via api"),
});
interface ServerOpts {
port: number;
host?: string;
configPath?: string;
}
export async function startHttpServer(opts: ServerOpts): Promise<{
close: () => Promise<void>;
url: string;
}> {
const host = opts.host ?? "0.0.0.0";
const config = await loadConfig(opts.configPath);
// Build transport map (mock for now; real transports wired in follow-up)
const transports = new Map<string, SisterTransport>();
const mock = new MockTransport();
for (const stage of config.pipeline.stages) {
transports.set(stage, mock);
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${host}`);
const path = url.pathname;
const method = req.method ?? "GET";
log.debug({ method, path }, "Incoming request");
try {
// ── Health ──
if (method === "GET" && path === "/health") {
return sendJson(res, 200, { ok: true, service: "hanarang-rails" });
}
// ── List pipelines ──
if (method === "GET" && path === "/pipelines") {
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
const list = await listPipelines({ limit });
return sendJson(res, 200, { pipelines: list });
}
// ── Start new pipeline ──
if (method === "POST" && path === "/pipelines/start") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements, mock: useMock } = parsed.data;
// Only mock mode is wired right now — real transports come in a follow-up.
if (!useMock) {
return sendJson(res, 501, {
error: "not_implemented",
message: "Non-mock transport wiring deferred to next iteration.",
});
}
// Run pipeline (async, but we await for this simple demo)
const result = await runPipeline({
projectName: project,
requirements,
config,
transports,
});
return sendJson(res, 201, {
pipelineId: result.pipelineId,
finalState: result.finalState,
transitions: result.transitions,
});
}
// ── Get pipeline status ──
const statusMatch = path.match(/^\/pipelines\/([^/]+)$/);
if (method === "GET" && statusMatch) {
const id = statusMatch[1]!;
const state = await getPipelineState(id);
if (!state) return sendJson(res, 404, { error: "not_found" });
return sendJson(res, 200, state);
}
// ── Abort pipeline ──
const abortMatch = path.match(/^\/pipelines\/([^/]+)\/abort$/);
if (method === "POST" && abortMatch) {
const id = abortMatch[1]!;
const body = await readJson(req);
const parsed = AbortRequest.safeParse(body || {});
const reason = parsed.success
? parsed.data.reason
: "aborted via api";
const result = await sendEvent(id, { type: "ABORT", reason });
return sendJson(res, 200, { id, state: result.state });
}
// ── Create pipeline without running ──
if (method === "POST" && path === "/pipelines") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements } = parsed.data;
const { pipelineId, state } = await createPipeline(project, requirements);
return sendJson(res, 201, { pipelineId, state });
}
return sendJson(res, 404, { error: "not_found", path });
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Request handler error",
);
return sendJson(res, 500, {
error: "internal_error",
message: err instanceof Error ? err.message : String(err),
});
}
});
await new Promise<void>((resolveFn) => {
server.listen(opts.port, host, () => resolveFn());
});
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${opts.port}`;
log.info({ url }, "HTTP server listening");
return {
url,
async close() {
await new Promise<void>((resolveFn, rejectFn) => {
server.close((err) => (err ? rejectFn(err) : resolveFn()));
});
},
};
}
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolveFn, rejectFn) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
req.on("end", () => {
if (!body) return resolveFn({});
try {
resolveFn(JSON.parse(body));
} catch (err) {
rejectFn(err);
}
});
req.on("error", rejectFn);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(JSON.stringify(body));
}