From fcd2e561294ab84487b2a86659f57aa3d0c58f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=9E=91=EC=9D=B4?= Date: Fri, 10 Apr 2026 15:34:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(sprint-004):=204-agent=20handoff=20engine?= =?UTF-8?q?=20=E2=80=94=20Transport=20=EC=B6=94=EC=83=81=ED=99=94=20+=20ru?= =?UTF-8?q?nner=20+=20Discord=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 004 핵심 구현 — F3 (QA 자동 라우팅 누락) + F4 (핸드오프 불안정) 해결: Config (범용): - src/config/schema.ts — Zod RailsConfig (pipeline/agents/discord) - src/config/loader.ts — YAML + 환경변수 interpolation (${VAR}) - rails.config.example.yaml — 샘플 설정 Handoff: - src/handoff/message.ts — HandoffMessage discriminated union (plan/implement/review/deploy) - src/handoff/transport.ts — SisterTransport 인터페이스 - src/handoff/mock-transport.ts — 시나리오 override 가능한 mock - src/handoff/discord-transport.ts — encodeInvokeMarker / decodeResultMarker (HTML 주석 + json 블록 — 자매는 LLM 우회 파서로 처리) DiscordPoster 인터페이스 주입으로 discord.js 와 독립 테스트 가능 Orchestrator: - src/orchestrator/runner.ts — runPipeline E2E state → stage 매핑 → transport.invoke → HandoffMessage → FSM 이벤트 타임아웃/에러는 ERROR 이벤트로 변환해 FSM 에 위임 CLI: - rails run [-r requirements] [-c config.yaml] [--mock] Tests (16 신규, 57 total pass): - HandoffMessage discriminated union 검증 - MockTransport 기본/오버라이드 시나리오 - Discord marker encode/decode round-trip - DiscordTransport with fake poster - Config loader YAML + 환경변수 interpolation 검증: tsc --noEmit ✓ | vitest 57/57 ✓ | build ✓ | rails run --help ✓ Co-Authored-By: Claude Opus 4.6 (1M context) --- Plans.md | 2 +- rails.config.example.yaml | 44 +++++++ src/cli/index.ts | 1 + src/cli/run.ts | 85 +++++++++++++ src/config/loader.ts | 57 +++++++++ src/config/schema.ts | 46 +++++++ src/handoff/discord-transport.ts | 120 ++++++++++++++++++ src/handoff/message.ts | 95 ++++++++++++++ src/handoff/mock-transport.ts | 100 +++++++++++++++ src/handoff/transport.ts | 14 +++ src/orchestrator/runner.ts | 200 +++++++++++++++++++++++++++++ tests/config.test.ts | 79 ++++++++++++ tests/handoff.test.ts | 209 +++++++++++++++++++++++++++++++ 13 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 rails.config.example.yaml create mode 100644 src/cli/run.ts create mode 100644 src/config/loader.ts create mode 100644 src/config/schema.ts create mode 100644 src/handoff/discord-transport.ts create mode 100644 src/handoff/message.ts create mode 100644 src/handoff/mock-transport.ts create mode 100644 src/handoff/transport.ts create mode 100644 src/orchestrator/runner.ts create mode 100644 tests/config.test.ts create mode 100644 tests/handoff.test.ts diff --git a/Plans.md b/Plans.md index 4f7333a..028fda9 100644 --- a/Plans.md +++ b/Plans.md @@ -19,7 +19,7 @@ | 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] | | 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:완료 [PR#2] | | 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:완료 [PR#3] | -| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:TODO | +| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:WIP | | 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:TODO | | 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:TODO | | 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:TODO | diff --git a/rails.config.example.yaml b/rails.config.example.yaml new file mode 100644 index 0000000..68c07b9 --- /dev/null +++ b/rails.config.example.yaml @@ -0,0 +1,44 @@ +# hanarang-rails sample configuration. +# Copy to rails.config.yaml and tune for your environment. + +pipeline: + stages: + - plan + - implement + - review + - deploy + +agents: + plan: + role: plan + displayName: Planner + transport: mock # or discord, local + channelId: "" # discord channel id for this agent + timeoutMs: 30000 + + implement: + role: implement + displayName: Generator + transport: mock + channelId: "" + timeoutMs: 60000 + + review: + role: review + displayName: Evaluator + transport: mock + channelId: "" + timeoutMs: 30000 + + deploy: + role: deploy + displayName: Deploy + transport: mock + channelId: "" + timeoutMs: 30000 + +discord: + enabled: false + railsToken: ${RAILS_DISCORD_TOKEN} + guildId: ${DISCORD_GUILD_ID} + pipelineChannelId: ${DISCORD_PIPELINE_CHANNEL_ID} diff --git a/src/cli/index.ts b/src/cli/index.ts index f26407f..ba22cd5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,7 @@ const main = defineCommand({ "skill-trace": () => import("./skill-trace.js").then((m) => m.default), contract: () => import("./contract.js").then((m) => m.default), + run: () => import("./run.js").then((m) => m.default), }, }); diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..27be5df --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,85 @@ +import { defineCommand } from "citty"; +import { loadEnv } from "../env.js"; +import { loadConfig } from "../config/loader.js"; +import { runPipeline } from "../orchestrator/runner.js"; +import { MockTransport } from "../handoff/mock-transport.js"; +import type { SisterTransport } from "../handoff/transport.js"; +import { disconnectPrisma } from "../orchestrator/persist.js"; + +export default defineCommand({ + meta: { + name: "run", + description: "Run a pipeline end-to-end through configured transports", + }, + args: { + project: { + type: "positional", + description: "Project name", + required: true, + }, + requirements: { + type: "string", + alias: "r", + description: "Task description", + default: "", + }, + config: { + type: "string", + alias: "c", + description: "Path to rails.config.yaml", + default: "", + }, + mock: { + type: "boolean", + description: "Force mock transport for all stages", + default: false, + }, + }, + async run({ args }) { + loadEnv(); + try { + const config = await loadConfig(args.config || undefined); + const transports = new Map(); + + if (args.mock) { + const mock = new MockTransport(); + for (const stage of config.pipeline.stages) { + transports.set(stage, mock); + } + } else { + // For now, default to mock when no real transport wiring is provided. + // Sprint 004 ships DiscordTransport as a class; wiring a live discord + // client is an operator task (see docs/discord-setup.md). + const mock = new MockTransport(); + for (const stage of config.pipeline.stages) { + const t = config.agents[stage]?.transport; + if (t === "mock" || !t) { + transports.set(stage, mock); + } else if (t === "discord") { + console.warn( + `[rails] Discord transport for stage '${stage}' requires a bot wiring — falling back to mock.`, + ); + transports.set(stage, mock); + } else { + transports.set(stage, mock); + } + } + } + + const result = await runPipeline({ + projectName: args.project, + requirements: args.requirements ?? "", + config, + transports, + }); + + console.log(`Pipeline: ${result.pipelineId}`); + console.log(`Final state: ${result.finalState}`); + console.log(`Transitions: ${result.transitions}`); + + process.exitCode = result.finalState === "done" ? 0 : 1; + } finally { + await disconnectPrisma(); + } + }, +}); diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000..5c72564 --- /dev/null +++ b/src/config/loader.ts @@ -0,0 +1,57 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { RailsConfig, DEFAULT_CONFIG } from "./schema.js"; +import type { RailsConfig as Config } from "./schema.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "config-loader" }); + +/** + * Resolve ${VAR_NAME} patterns in string values against process.env. + * Returns the original string if no variable reference. + */ +function interpolate(value: unknown, env: Record): unknown { + if (typeof value === "string") { + return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, name: string) => { + return env[name] ?? ""; + }); + } + if (Array.isArray(value)) { + return value.map((v) => interpolate(v, env)); + } + if (value && typeof value === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = interpolate(v, env); + } + return result; + } + return value; +} + +export async function loadConfig( + configPath?: string, + env: Record = process.env as Record, +): Promise { + if (!configPath) { + log.info("No config file specified, using defaults"); + return DEFAULT_CONFIG; + } + + const fullPath = resolve(configPath); + try { + const raw = await readFile(fullPath, "utf8"); + const parsed = parseYaml(raw) as unknown; + const interpolated = interpolate(parsed, env); + const config = RailsConfig.parse(interpolated); + log.info({ path: fullPath }, "Config loaded"); + return config; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + log.warn({ path: fullPath }, "Config file not found, using defaults"); + return DEFAULT_CONFIG; + } + throw err; + } +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..881d871 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +export const TransportMode = z.enum(["discord", "mock", "local"]); +export type TransportMode = z.infer; + +export const AgentConfig = z.object({ + role: z.string().min(1), + displayName: z.string().default(""), + transport: TransportMode.default("mock"), + channelId: z.string().default(""), + timeoutMs: z.number().int().positive().default(30_000), +}); +export type AgentConfig = z.infer; + +export const DiscordConfig = z.object({ + enabled: z.boolean().default(false), + railsToken: z.string().default(""), + guildId: z.string().default(""), + pipelineChannelId: z.string().default(""), +}); +export type DiscordConfig = z.infer; + +export const PipelineConfig = z.object({ + stages: z + .array(z.enum(["plan", "implement", "review", "deploy"])) + .default(["plan", "implement", "review", "deploy"]), +}); +export type PipelineConfig = z.infer; + +export const RailsConfig = z.object({ + pipeline: PipelineConfig.default({}), + agents: z.record(z.string(), AgentConfig).default({}), + discord: DiscordConfig.default({}), +}); +export type RailsConfig = z.infer; + +export const DEFAULT_CONFIG: RailsConfig = RailsConfig.parse({ + pipeline: { stages: ["plan", "implement", "review", "deploy"] }, + agents: { + plan: { role: "plan", displayName: "Planner", transport: "mock" }, + implement: { role: "implement", displayName: "Generator", transport: "mock" }, + review: { role: "review", displayName: "Evaluator", transport: "mock" }, + deploy: { role: "deploy", displayName: "Deploy", transport: "mock" }, + }, + discord: { enabled: false }, +}); diff --git a/src/handoff/discord-transport.ts b/src/handoff/discord-transport.ts new file mode 100644 index 0000000..40dac6a --- /dev/null +++ b/src/handoff/discord-transport.ts @@ -0,0 +1,120 @@ +import type { + SisterTransport, + HealthStatus, +} from "./transport.js"; +import { + HandoffMessage, + type InvokeRequest, +} from "./message.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "discord-transport" }); + +export interface DiscordTransportOptions { + token: string; + guildId: string; + channelId: string; + timeoutMs?: number; + /** + * Dependency-injected message poster. Real usage passes a discord.js client; + * tests pass a fake. Rails invokes this to put the request marker into + * the agent channel and waits for a reply marker. + */ + poster: DiscordPoster; +} + +export interface DiscordPoster { + postMessage(channelId: string, content: string): Promise; + waitForResult(opts: { + channelId: string; + pipelineId: string; + stage: string; + timeoutMs: number; + signal?: AbortSignal; + }): Promise; + close(): Promise; +} + +/** + * Encode an invoke request as a marker block that the agent bot can parse + * without LLM interpretation. + */ +export function encodeInvokeMarker(req: InvokeRequest): string { + const json = JSON.stringify(req); + return ( + "\n" + + "```json\n" + + json + + "\n```\n" + + "" + ); +} + +/** + * Extract a result marker from a message body. + * Returns the parsed HandoffMessage or throws on invalid payload. + */ +export function decodeResultMarker(body: string): HandoffMessage { + const match = body.match( + /\s*```json\s*([\s\S]*?)```\s*/, + ); + if (!match?.[1]) { + throw new Error("No rails:result marker found in message"); + } + const json = match[1].trim(); + const data = JSON.parse(json) as unknown; + return HandoffMessage.parse(data); +} + +export class DiscordTransport implements SisterTransport { + readonly name = "discord"; + private readonly opts: Required> & { + poster: DiscordPoster; + }; + + constructor(opts: DiscordTransportOptions) { + this.opts = { + token: opts.token, + guildId: opts.guildId, + channelId: opts.channelId, + timeoutMs: opts.timeoutMs ?? 30_000, + poster: opts.poster, + }; + } + + async invoke( + req: InvokeRequest, + signal?: AbortSignal, + ): Promise { + const marker = encodeInvokeMarker(req); + const content = marker + "\n\n" + this.humanPreamble(req); + + log.info( + { stage: req.stage, pipelineId: req.pipelineId }, + "Dispatching invoke via discord", + ); + await this.opts.poster.postMessage(this.opts.channelId, content); + + const replyBody = await this.opts.poster.waitForResult({ + channelId: this.opts.channelId, + pipelineId: req.pipelineId, + stage: req.stage, + timeoutMs: req.timeoutMs || this.opts.timeoutMs, + ...(signal && { signal }), + }); + + return decodeResultMarker(replyBody); + } + + async health(): Promise { + return { alive: true, latencyMs: 0 }; + } + + async close(): Promise { + await this.opts.poster.close(); + } + + private humanPreamble(req: InvokeRequest): string { + return `📋 Task dispatched — stage: **${req.stage}** | pipeline: \`${req.pipelineId.slice(0, 8)}\` | timeout: ${req.timeoutMs}ms\n${req.task.title}`; + } +} diff --git a/src/handoff/message.ts b/src/handoff/message.ts new file mode 100644 index 0000000..1577f7b --- /dev/null +++ b/src/handoff/message.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +/** + * HandoffMessage — the structured result returned by an agent invocation. + * Each stage has its own payload shape. + * + * Rails will always parse agent responses through this discriminated union; + * any response that fails validation is treated as an ERROR event. + */ + +export const PlanHandoffPayload = z.object({ + planDir: z.string(), + sprintId: z.string(), + contractId: z.string().default(""), +}); + +export const ImplementHandoffPayload = z.object({ + branch: z.string(), + commits: z.array(z.string()), + workdir: z.string().default(""), + selfTestReport: z.record(z.unknown()).default({}), +}); + +export const ReviewIssueLite = z.object({ + severity: z.enum(["critical", "major", "minor", "recommendation"]), + message: z.string(), + file: z.string().optional(), + line: z.number().optional(), +}); + +export const ReviewHandoffPayload = z.object({ + artifactPath: z.string().default(""), + checklistResults: z + .array( + z.object({ + id: z.string(), + passed: z.boolean(), + note: z.string().default(""), + }), + ) + .default([]), + issues: z.array(ReviewIssueLite).default([]), +}); + +export const DeployHandoffPayload = z.object({ + deployArtifactPath: z.string().default(""), + projectType: z.string().default(""), + verificationResults: z.record(z.unknown()).default({}), +}); + +export const HandoffMessage = z.discriminatedUnion("stage", [ + z.object({ + stage: z.literal("plan"), + verdict: z.enum(["PLAN_READY", "ABORT"]), + payload: PlanHandoffPayload.optional(), + abortReason: z.string().default(""), + }), + z.object({ + stage: z.literal("implement"), + verdict: z.enum(["IMPL_DONE", "ERROR"]), + payload: ImplementHandoffPayload.optional(), + errorReason: z.string().default(""), + }), + z.object({ + stage: z.literal("review"), + verdict: z.enum(["APPROVE", "REQUEST_CHANGES", "ABORT"]), + payload: ReviewHandoffPayload.optional(), + abortReason: z.string().default(""), + }), + z.object({ + stage: z.literal("deploy"), + verdict: z.enum(["DEPLOY_DONE", "DEPLOY_FAILED"]), + payload: DeployHandoffPayload.optional(), + errorReason: z.string().default(""), + }), +]); + +export type HandoffMessage = z.infer; + +export const InvokeRequest = z.object({ + pipelineId: z.string(), + contractId: z.string().default(""), + stage: z.enum(["plan", "implement", "review", "deploy"]), + role: z.string(), + sprintId: z.string().default(""), + task: z.object({ + title: z.string(), + description: z.string().default(""), + workdir: z.string().default(""), + }), + timeoutMs: z.number().int().positive().default(30_000), + structuredOutput: z.literal(true).default(true), +}); + +export type InvokeRequest = z.infer; diff --git a/src/handoff/mock-transport.ts b/src/handoff/mock-transport.ts new file mode 100644 index 0000000..c055d7d --- /dev/null +++ b/src/handoff/mock-transport.ts @@ -0,0 +1,100 @@ +import type { + SisterTransport, + HealthStatus, +} from "./transport.js"; +import type { HandoffMessage, InvokeRequest } from "./message.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "mock-transport" }); + +/** + * MockTransport — deterministic fake transport for testing and local dev. + * + * By default it returns successful HandoffMessages for each stage: + * plan → PLAN_READY + * implement → IMPL_DONE + * review → APPROVE + * deploy → DEPLOY_DONE + * + * Scenarios can be overridden per pipelineId or per stage via constructor. + */ +export class MockTransport implements SisterTransport { + readonly name = "mock"; + private scenarios: Map; + + constructor(overrides: Record = {}) { + this.scenarios = new Map(Object.entries(overrides)); + } + + setScenario(key: string, message: HandoffMessage): void { + this.scenarios.set(key, message); + } + + async invoke(req: InvokeRequest): Promise { + const key = `${req.pipelineId}:${req.stage}`; + const override = this.scenarios.get(key) ?? this.scenarios.get(req.stage); + if (override) { + log.debug({ stage: req.stage, key }, "Mock scenario override"); + return override; + } + return defaultSuccess(req); + } + + async health(): Promise { + return { alive: true, latencyMs: 1 }; + } + + async close(): Promise { + /* noop */ + } +} + +function defaultSuccess(req: InvokeRequest): HandoffMessage { + switch (req.stage) { + case "plan": + return { + stage: "plan", + verdict: "PLAN_READY", + payload: { + planDir: "/tmp/mock-plans", + sprintId: req.sprintId || "SPRINT-MOCK", + contractId: req.contractId || "", + }, + abortReason: "", + }; + case "implement": + return { + stage: "implement", + verdict: "IMPL_DONE", + payload: { + branch: "feature/mock", + commits: ["mockc01"], + workdir: req.task.workdir || "/tmp", + selfTestReport: { typecheck: "pass", tests: "pass" }, + }, + errorReason: "", + }; + case "review": + return { + stage: "review", + verdict: "APPROVE", + payload: { + artifactPath: "/tmp/mock-review.json", + checklistResults: [], + issues: [], + }, + abortReason: "", + }; + case "deploy": + return { + stage: "deploy", + verdict: "DEPLOY_DONE", + payload: { + deployArtifactPath: "/tmp/mock-deploy.json", + projectType: "mock", + verificationResults: {}, + }, + errorReason: "", + }; + } +} diff --git a/src/handoff/transport.ts b/src/handoff/transport.ts new file mode 100644 index 0000000..7c2e63b --- /dev/null +++ b/src/handoff/transport.ts @@ -0,0 +1,14 @@ +import type { HandoffMessage, InvokeRequest } from "./message.js"; + +export interface HealthStatus { + alive: boolean; + latencyMs: number; + message?: string; +} + +export interface SisterTransport { + readonly name: string; + invoke(req: InvokeRequest, signal?: AbortSignal): Promise; + health(role: string): Promise; + close(): Promise; +} diff --git a/src/orchestrator/runner.ts b/src/orchestrator/runner.ts new file mode 100644 index 0000000..08886a5 --- /dev/null +++ b/src/orchestrator/runner.ts @@ -0,0 +1,200 @@ +import { ulid } from "ulid"; +import { sendEvent, createPipeline } from "./persist.js"; +import type { PipelineEvent, PipelineState } from "./events.js"; +import type { HandoffMessage, InvokeRequest } from "../handoff/message.js"; +import type { SisterTransport } from "../handoff/transport.js"; +import type { RailsConfig } from "../config/schema.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "runner" }); + +export interface RunOptions { + projectName: string; + requirements: string; + config: RailsConfig; + transports: Map; + signal?: AbortSignal; +} + +export interface RunResult { + pipelineId: string; + finalState: PipelineState; + transitions: number; +} + +/** + * Run an end-to-end pipeline using the configured transports. + * Each stage invokes the corresponding agent and feeds the result back + * into the FSM until done or escalated. + */ +export async function runPipeline(opts: RunOptions): Promise { + const { pipelineId, state: initialState } = await createPipeline( + opts.projectName, + opts.requirements, + ); + + log.info({ pipelineId, project: opts.projectName }, "Pipeline run started"); + + // REQUEST event — enters planning + let result = await sendEvent(pipelineId, { + type: "REQUEST", + projectName: opts.projectName, + requirements: opts.requirements, + }); + + let transitions = 1; + const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"]; + + while (!TERMINAL.includes(result.state)) { + if (opts.signal?.aborted) { + result = await sendEvent(pipelineId, { + type: "ABORT", + reason: "Aborted by caller", + }); + transitions += 1; + break; + } + + const stage = mapStateToStage(result.state); + if (!stage) { + log.warn({ state: result.state }, "Non-active state encountered, stopping"); + break; + } + + const transport = opts.transports.get(stage); + if (!transport) { + log.error({ stage }, "No transport configured for stage"); + result = await sendEvent(pipelineId, { + type: "ERROR", + actor: stage, + reason: `No transport configured for stage: ${stage}`, + retryable: false, + }); + transitions += 1; + continue; + } + + const invokeReq: InvokeRequest = { + pipelineId, + contractId: result.context.contractPath ?? "", + stage, + role: opts.config.agents[stage]?.role ?? stage, + sprintId: result.context.currentSprintId ?? "", + task: { + title: opts.requirements || opts.projectName, + description: opts.requirements, + workdir: process.cwd(), + }, + timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000, + structuredOutput: true, + }; + + try { + const handoff = await transport.invoke(invokeReq, opts.signal); + const event = handoffToEvent(handoff); + result = await sendEvent(pipelineId, event); + transitions += 1; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + log.error({ stage, reason }, "Transport invoke failed"); + result = await sendEvent(pipelineId, { + type: "ERROR", + actor: stage, + reason, + retryable: true, + }); + transitions += 1; + } + } + + log.info( + { pipelineId, finalState: result.state, transitions }, + "Pipeline run finished", + ); + + void initialState; // referenced only for typecheck + return { + pipelineId, + finalState: result.state, + transitions, + }; +} + +function mapStateToStage( + state: PipelineState, +): "plan" | "implement" | "review" | "deploy" | null { + switch (state) { + case "planning": + return "plan"; + case "implementing": + return "implement"; + case "reviewing": + return "review"; + case "deploying": + return "deploy"; + default: + return null; + } +} + +function handoffToEvent(h: HandoffMessage): PipelineEvent { + switch (h.stage) { + case "plan": + if (h.verdict === "PLAN_READY" && h.payload) { + return { + type: "PLAN_READY", + planDir: h.payload.planDir, + sprintId: h.payload.sprintId, + }; + } + return { + type: "ABORT", + reason: h.abortReason || "Planner aborted", + }; + case "implement": + if (h.verdict === "IMPL_DONE" && h.payload) { + return { + type: "IMPL_DONE", + branch: h.payload.branch, + commits: h.payload.commits, + }; + } + return { + type: "ERROR", + actor: "implement", + reason: h.errorReason || "Implementation failed", + retryable: true, + }; + case "review": + if (h.verdict === "APPROVE" && h.payload) { + return { type: "APPROVE", reviewArtifact: h.payload.artifactPath }; + } + if (h.verdict === "REQUEST_CHANGES" && h.payload) { + return { + type: "REQUEST_CHANGES", + issues: h.payload.issues.map((i) => ({ + severity: i.severity, + message: i.message, + ...(i.file !== undefined && { file: i.file }), + ...(i.line !== undefined && { line: i.line }), + })), + }; + } + return { type: "ABORT", reason: h.abortReason || "Review aborted" }; + case "deploy": + if (h.verdict === "DEPLOY_DONE" && h.payload) { + return { + type: "DEPLOY_DONE", + deployArtifact: h.payload.deployArtifactPath, + }; + } + return { + type: "ERROR", + actor: "deploy", + reason: h.errorReason || "Deploy failed", + retryable: false, + }; + } +} + +void ulid; // satisfy unused import check if any diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..52c04c6 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig } from "../src/config/loader.js"; +import { DEFAULT_CONFIG } from "../src/config/schema.js"; + +let testDir: string; + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "rails-config-test-")); +}); + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); +}); + +describe("loadConfig", () => { + it("returns defaults when no path provided", async () => { + const cfg = await loadConfig(); + expect(cfg).toEqual(DEFAULT_CONFIG); + }); + + it("returns defaults when file does not exist", async () => { + const cfg = await loadConfig(join(testDir, "missing.yaml")); + expect(cfg).toEqual(DEFAULT_CONFIG); + }); + + it("parses valid yaml config", async () => { + const yamlPath = join(testDir, "rails.config.yaml"); + await writeFile( + yamlPath, + ` +pipeline: + stages: [plan, implement, review] +agents: + plan: + role: plan + displayName: TestPlanner + transport: mock + timeoutMs: 15000 +`, + ); + const cfg = await loadConfig(yamlPath); + expect(cfg.pipeline.stages).toEqual(["plan", "implement", "review"]); + expect(cfg.agents["plan"]?.displayName).toBe("TestPlanner"); + expect(cfg.agents["plan"]?.timeoutMs).toBe(15_000); + }); + + it("interpolates environment variables", async () => { + const yamlPath = join(testDir, "rails.config.yaml"); + await writeFile( + yamlPath, + ` +discord: + enabled: true + railsToken: \${MY_TEST_TOKEN} + guildId: fixed-guild +`, + ); + const cfg = await loadConfig(yamlPath, { MY_TEST_TOKEN: "secret-abc" }); + expect(cfg.discord.railsToken).toBe("secret-abc"); + expect(cfg.discord.guildId).toBe("fixed-guild"); + }); + + it("defaults missing env vars to empty string", async () => { + const yamlPath = join(testDir, "rails.config.yaml"); + await writeFile( + yamlPath, + ` +discord: + enabled: false + railsToken: \${MISSING_VAR} +`, + ); + const cfg = await loadConfig(yamlPath, {}); + expect(cfg.discord.railsToken).toBe(""); + }); +}); diff --git a/tests/handoff.test.ts b/tests/handoff.test.ts new file mode 100644 index 0000000..6fb41e2 --- /dev/null +++ b/tests/handoff.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "vitest"; +import { HandoffMessage, InvokeRequest } from "../src/handoff/message.js"; +import { MockTransport } from "../src/handoff/mock-transport.js"; +import { + encodeInvokeMarker, + decodeResultMarker, + DiscordTransport, + type DiscordPoster, +} from "../src/handoff/discord-transport.js"; + +describe("HandoffMessage schema", () => { + it("parses a valid plan result", () => { + const parsed = HandoffMessage.parse({ + stage: "plan", + verdict: "PLAN_READY", + payload: { planDir: "/tmp", sprintId: "S1", contractId: "c1" }, + abortReason: "", + }); + expect(parsed.stage).toBe("plan"); + if (parsed.stage === "plan") { + expect(parsed.verdict).toBe("PLAN_READY"); + } + }); + + it("parses a review REQUEST_CHANGES with issues", () => { + const parsed = HandoffMessage.parse({ + stage: "review", + verdict: "REQUEST_CHANGES", + payload: { + artifactPath: "/tmp/r.json", + checklistResults: [], + issues: [ + { severity: "major", message: "fix this", file: "src/a.ts", line: 10 }, + ], + }, + abortReason: "", + }); + expect(parsed.stage).toBe("review"); + if (parsed.stage === "review" && parsed.payload) { + expect(parsed.payload.issues[0]!.severity).toBe("major"); + } + }); + + it("rejects invalid stage", () => { + expect(() => + HandoffMessage.parse({ stage: "bogus", verdict: "PLAN_READY" }), + ).toThrow(); + }); + + it("rejects invalid verdict for stage", () => { + expect(() => + HandoffMessage.parse({ stage: "plan", verdict: "DEPLOY_DONE" }), + ).toThrow(); + }); +}); + +describe("MockTransport", () => { + it("returns PLAN_READY for plan stage by default", async () => { + const t = new MockTransport(); + const req = InvokeRequest.parse({ + pipelineId: "01MOCK", + stage: "plan", + role: "plan", + sprintId: "S1", + task: { title: "test" }, + }); + const result = await t.invoke(req); + expect(result.stage).toBe("plan"); + if (result.stage === "plan") { + expect(result.verdict).toBe("PLAN_READY"); + } + }); + + it("applies per-pipeline scenario overrides", async () => { + const t = new MockTransport({ + "01TEST:review": { + stage: "review", + verdict: "REQUEST_CHANGES", + payload: { artifactPath: "", checklistResults: [], issues: [] }, + abortReason: "", + }, + }); + const req = InvokeRequest.parse({ + pipelineId: "01TEST", + stage: "review", + role: "review", + task: { title: "test" }, + }); + const result = await t.invoke(req); + if (result.stage === "review") { + expect(result.verdict).toBe("REQUEST_CHANGES"); + } + }); + + it("applies stage-level overrides", async () => { + const t = new MockTransport(); + t.setScenario("implement", { + stage: "implement", + verdict: "ERROR", + errorReason: "mock fail", + }); + const req = InvokeRequest.parse({ + pipelineId: "01ANY", + stage: "implement", + role: "implement", + task: { title: "test" }, + }); + const result = await t.invoke(req); + if (result.stage === "implement") { + expect(result.verdict).toBe("ERROR"); + expect(result.errorReason).toBe("mock fail"); + } + }); +}); + +describe("Discord marker encoding", () => { + it("encode → decode round-trip (result marker)", () => { + const original: HandoffMessage = { + stage: "implement", + verdict: "IMPL_DONE", + payload: { + branch: "feature/x", + commits: ["abc1234"], + workdir: "/tmp", + selfTestReport: { tests: "pass" }, + }, + errorReason: "", + }; + const body = + "some natural language before\n\n" + + "\n" + + "```json\n" + + JSON.stringify(original) + + "\n```\n" + + "\n\n" + + "constructor note"; + const parsed = decodeResultMarker(body); + expect(parsed.stage).toBe("implement"); + if (parsed.stage === "implement" && parsed.payload) { + expect(parsed.payload.branch).toBe("feature/x"); + expect(parsed.payload.commits).toEqual(["abc1234"]); + } + }); + + it("encodeInvokeMarker produces a parseable block", () => { + const req = InvokeRequest.parse({ + pipelineId: "01INV", + stage: "plan", + role: "plan", + task: { title: "go" }, + }); + const marker = encodeInvokeMarker(req); + expect(marker).toContain("rails:invoke"); + expect(marker).toContain("01INV"); + expect(marker).toContain('"stage":"plan"'); + }); + + it("decodeResultMarker throws when no marker present", () => { + expect(() => decodeResultMarker("no marker here")).toThrow(/No rails:result/); + }); +}); + +describe("DiscordTransport (fake poster)", () => { + it("posts invoke and parses result", async () => { + const fakePoster: DiscordPoster = { + async postMessage(_channelId, _content) { + return "msg-123"; + }, + async waitForResult() { + const result: HandoffMessage = { + stage: "plan", + verdict: "PLAN_READY", + payload: { + planDir: "/tmp/plans", + sprintId: "S1", + contractId: "c1", + }, + abortReason: "", + }; + return ( + "\n" + + "```json\n" + + JSON.stringify(result) + + "\n```\n" + + "" + ); + }, + async close() {}, + }; + + const t = new DiscordTransport({ + token: "fake", + guildId: "g1", + channelId: "c1", + poster: fakePoster, + }); + + const req = InvokeRequest.parse({ + pipelineId: "01DC", + stage: "plan", + role: "plan", + task: { title: "test" }, + }); + const result = await t.invoke(req); + if (result.stage === "plan") { + expect(result.verdict).toBe("PLAN_READY"); + } + }); +});