Compare commits
5 Commits
feature/sp
...
feature/ht
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f518c0c54 | |||
| 3a226ada95 | |||
| 7a8f2c1af0 | |||
| f6c1768c60 | |||
| 8786efc81c |
10
Plans.md
10
Plans.md
@@ -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 실행
|
||||||
|
|
||||||
## 마커 범례
|
## 마커 범례
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
191
src/server/http.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user