merge: Sprint 004 — Handoff engine + Transport abstraction (#4)
This commit is contained in:
2
Plans.md
2
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 |
|
||||
|
||||
44
rails.config.example.yaml
Normal file
44
rails.config.example.yaml
Normal file
@@ -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}
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
85
src/cli/run.ts
Normal file
85
src/cli/run.ts
Normal file
@@ -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<string, SisterTransport>();
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
});
|
||||
57
src/config/loader.ts
Normal file
57
src/config/loader.ts
Normal file
@@ -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<string, string>): 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<string, unknown> = {};
|
||||
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<string, string> = process.env as Record<string, string>,
|
||||
): Promise<Config> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
46
src/config/schema.ts
Normal file
46
src/config/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TransportMode = z.enum(["discord", "mock", "local"]);
|
||||
export type TransportMode = z.infer<typeof TransportMode>;
|
||||
|
||||
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<typeof AgentConfig>;
|
||||
|
||||
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<typeof DiscordConfig>;
|
||||
|
||||
export const PipelineConfig = z.object({
|
||||
stages: z
|
||||
.array(z.enum(["plan", "implement", "review", "deploy"]))
|
||||
.default(["plan", "implement", "review", "deploy"]),
|
||||
});
|
||||
export type PipelineConfig = z.infer<typeof PipelineConfig>;
|
||||
|
||||
export const RailsConfig = z.object({
|
||||
pipeline: PipelineConfig.default({}),
|
||||
agents: z.record(z.string(), AgentConfig).default({}),
|
||||
discord: DiscordConfig.default({}),
|
||||
});
|
||||
export type RailsConfig = z.infer<typeof RailsConfig>;
|
||||
|
||||
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 },
|
||||
});
|
||||
120
src/handoff/discord-transport.ts
Normal file
120
src/handoff/discord-transport.ts
Normal file
@@ -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<string>;
|
||||
waitForResult(opts: {
|
||||
channelId: string;
|
||||
pipelineId: string;
|
||||
stage: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
"<!-- rails:invoke v1 -->\n" +
|
||||
"```json\n" +
|
||||
json +
|
||||
"\n```\n" +
|
||||
"<!-- /rails:invoke -->"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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*rails:result\s+v1\s*-->\s*```json\s*([\s\S]*?)```\s*<!--\s*\/rails:result\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<Omit<DiscordTransportOptions, "poster">> & {
|
||||
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<HandoffMessage> {
|
||||
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<HealthStatus> {
|
||||
return { alive: true, latencyMs: 0 };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
95
src/handoff/message.ts
Normal file
95
src/handoff/message.ts
Normal file
@@ -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<typeof HandoffMessage>;
|
||||
|
||||
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<typeof InvokeRequest>;
|
||||
100
src/handoff/mock-transport.ts
Normal file
100
src/handoff/mock-transport.ts
Normal file
@@ -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<string, HandoffMessage>;
|
||||
|
||||
constructor(overrides: Record<string, HandoffMessage> = {}) {
|
||||
this.scenarios = new Map(Object.entries(overrides));
|
||||
}
|
||||
|
||||
setScenario(key: string, message: HandoffMessage): void {
|
||||
this.scenarios.set(key, message);
|
||||
}
|
||||
|
||||
async invoke(req: InvokeRequest): Promise<HandoffMessage> {
|
||||
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<HealthStatus> {
|
||||
return { alive: true, latencyMs: 1 };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
/* 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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/handoff/transport.ts
Normal file
14
src/handoff/transport.ts
Normal file
@@ -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<HandoffMessage>;
|
||||
health(role: string): Promise<HealthStatus>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
200
src/orchestrator/runner.ts
Normal file
200
src/orchestrator/runner.ts
Normal file
@@ -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<string, SisterTransport>;
|
||||
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<RunResult> {
|
||||
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
|
||||
79
tests/config.test.ts
Normal file
79
tests/config.test.ts
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
209
tests/handoff.test.ts
Normal file
209
tests/handoff.test.ts
Normal file
@@ -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" +
|
||||
"<!-- rails:result v1 -->\n" +
|
||||
"```json\n" +
|
||||
JSON.stringify(original) +
|
||||
"\n```\n" +
|
||||
"<!-- /rails:result -->\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 (
|
||||
"<!-- rails:result v1 -->\n" +
|
||||
"```json\n" +
|
||||
JSON.stringify(result) +
|
||||
"\n```\n" +
|
||||
"<!-- /rails:result -->"
|
||||
);
|
||||
},
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user