Compare commits
11 Commits
e58945dc2c
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
| fcd2e56129 | |||
| eb63428174 | |||
| 8f0691aafb | |||
| 58d6c262d5 | |||
| 205084cc60 | |||
| 53d91c08d6 | |||
| ae86d95155 | |||
| 813c65077a | |||
| ac47b91bb5 | |||
| 0af4bbc685 | |||
| c32caf6034 |
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# hanarang-rails environment variables
|
||||
# Copy to .env and fill in values.
|
||||
|
||||
# ── Database (MariaDB / MySQL) ──
|
||||
DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails"
|
||||
|
||||
# ── Discord ──
|
||||
DISCORD_TOKEN=""
|
||||
DISCORD_GUILD_ID=""
|
||||
|
||||
# ── Gitea Webhook ──
|
||||
GITEA_WEBHOOK_SECRET=""
|
||||
|
||||
# ── Rails ──
|
||||
RAILS_PORT=18800
|
||||
RAILS_LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,3 +40,4 @@ logs/
|
||||
.claude/projects/
|
||||
.claude/todos/
|
||||
.claude/tool-results/
|
||||
dist/
|
||||
|
||||
10
Plans.md
10
Plans.md
@@ -16,17 +16,17 @@
|
||||
| # | Sprint | 상세 | Status |
|
||||
|---|---|---|---|
|
||||
| 0 | 세이프티 네트 + 실패 감사 + 프로젝트 세팅 | [SPRINT-000](.plans/sprints/SPRINT-000-safety-and-audit.md) | cc:완료 [bac114d] |
|
||||
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:TODO |
|
||||
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:TODO |
|
||||
| 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:TODO |
|
||||
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:TODO |
|
||||
| 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: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 |
|
||||
|
||||
## 현재 스프린트
|
||||
|
||||
**Sprint 001 — 스켈레톤: XState FSM + orchestrator + CLI** (`cc:TODO`)
|
||||
**Sprint 004 — 4자매 핸드오프 엔진 + 디스코드 알림** (`cc:TODO`)
|
||||
|
||||
다음 착수 예정. 상세는 `.plans/sprints/SPRINT-001-skeleton.md` 참조.
|
||||
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# hanarang-rails post-tool hook (thin shim)
|
||||
# 현재 no-op — Sprint 002 에서 skill bypass 감지 + revert 로직 주입 예정.
|
||||
# 입력: stdin 으로 tool use result JSON
|
||||
# 출력: exit 0 = proceed
|
||||
|
||||
# hanarang-rails post-tool hook
|
||||
# Appends tool usage to skill trace for audit.
|
||||
# Input: stdin JSON event from Claude Code
|
||||
# Exit: always 0 (post-hook should not block)
|
||||
set -euo pipefail
|
||||
|
||||
EVENT=$(cat)
|
||||
CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
TRACE_FILE="$CWD/.rails/skill-trace.jsonl"
|
||||
|
||||
# Ensure directory
|
||||
mkdir -p "$(dirname "$TRACE_FILE")"
|
||||
|
||||
# Extract fields
|
||||
TOOL=$(echo "$EVENT" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo "unknown")
|
||||
SESSION_ID="${CLAUDE_SESSION_ID:-}"
|
||||
|
||||
# Read pipeline ID from context if available
|
||||
PIPELINE_ID=""
|
||||
CONTEXT_FILE="$CWD/.rails/skill-context.json"
|
||||
if [[ -f "$CONTEXT_FILE" ]]; then
|
||||
PIPELINE_ID=$(jq -r '.pipelineId // ""' "$CONTEXT_FILE" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Append trace entry
|
||||
ENTRY=$(jq -n \
|
||||
--argjson ts "$(date +%s)000" \
|
||||
--arg tool "$TOOL" \
|
||||
--arg cwd "$CWD" \
|
||||
--arg sessionId "$SESSION_ID" \
|
||||
--arg pipelineId "$PIPELINE_ID" \
|
||||
'{ts: $ts, tool: $tool, cwd: $cwd, sessionId: $sessionId, pipelineId: $pipelineId, blocked: false, reason: "post-trace"}' \
|
||||
2>/dev/null || true)
|
||||
|
||||
if [[ -n "$ENTRY" ]]; then
|
||||
echo "$ENTRY" >> "$TRACE_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# hanarang-rails pre-tool hook (thin shim)
|
||||
# 현재 no-op — Sprint 002 에서 skill-enforcement 로직 주입 예정.
|
||||
# 입력: stdin 으로 tool use event JSON
|
||||
# 출력: exit 0 = proceed, exit 2 = block
|
||||
|
||||
# hanarang-rails pre-tool hook
|
||||
# Blocks Write/Edit/Bash if no valid skill context exists.
|
||||
# Input: stdin JSON event from Claude Code
|
||||
# Exit: 0 = allow, 2 = block
|
||||
set -euo pipefail
|
||||
|
||||
# Escape hatch
|
||||
if [[ "${RAILS_ENFORCE:-on}" == "off" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read tool event from stdin
|
||||
EVENT=$(cat)
|
||||
TOOL=$(echo "$EVENT" | jq -r '.tool_name // empty' 2>/dev/null || true)
|
||||
|
||||
# Only gate Write, Edit, Bash
|
||||
case "$TOOL" in
|
||||
Write|Edit|Bash) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Find project root
|
||||
CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
CONTEXT_FILE="$CWD/.rails/skill-context.json"
|
||||
|
||||
# Check context exists
|
||||
if [[ ! -f "$CONTEXT_FILE" ]]; then
|
||||
echo "[rails-enforce] No skill context. Enter the pipeline via /rails first." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Check context not expired (TTL check)
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
CREATED=$(jq -r '.createdAt // empty' "$CONTEXT_FILE" 2>/dev/null || true)
|
||||
TTL=$(jq -r '.ttlSeconds // 300' "$CONTEXT_FILE" 2>/dev/null || echo 300)
|
||||
|
||||
if [[ -n "$CREATED" ]]; then
|
||||
CREATED_EPOCH=$(date -d "$CREATED" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${CREATED%%.*}" +%s 2>/dev/null || echo 0)
|
||||
NOW_EPOCH=$(date +%s)
|
||||
AGE=$(( NOW_EPOCH - CREATED_EPOCH ))
|
||||
|
||||
if [[ "$AGE" -gt "$TTL" ]]; then
|
||||
echo "[rails-enforce] Skill context expired (age: ${AGE}s > ttl: ${TTL}s). Re-enter the skill." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "hanarang-rails",
|
||||
"version": "0.1.0",
|
||||
"description": "Deterministic multi-agent pipeline orchestrator",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"bin": {
|
||||
"rails": "dist/cli/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/cli/index.js serve",
|
||||
"rails": "node dist/cli/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"citty": "^0.1.6",
|
||||
"neverthrow": "^8.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"ulid": "^2.3.0",
|
||||
"xstate": "^5.19.0",
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^6.6.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
1536
pnpm-lock.yaml
generated
Normal file
1536
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
prisma/schema.prisma
Normal file
74
prisma/schema.prisma
Normal file
@@ -0,0 +1,74 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Pipeline {
|
||||
id String @id @db.VarChar(26) // ULID
|
||||
projectName String @db.VarChar(255)
|
||||
requirements String @db.Text
|
||||
currentState String @db.VarChar(50) @default("idle")
|
||||
contextJson String @db.LongText
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
transitions StateTransition[]
|
||||
actorSpawns ActorSpawn[]
|
||||
contracts Contract[]
|
||||
|
||||
@@index([currentState])
|
||||
@@index([createdAt])
|
||||
@@map("pipelines")
|
||||
}
|
||||
|
||||
model StateTransition {
|
||||
id Int @id @default(autoincrement())
|
||||
pipelineId String @db.VarChar(26)
|
||||
fromState String @db.VarChar(50)
|
||||
toState String @db.VarChar(50)
|
||||
eventType String @db.VarChar(50)
|
||||
eventPayload String @db.LongText
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pipelineId, timestamp])
|
||||
@@index([eventType])
|
||||
@@map("state_transitions")
|
||||
}
|
||||
|
||||
model ActorSpawn {
|
||||
id Int @id @default(autoincrement())
|
||||
pipelineId String @db.VarChar(26)
|
||||
actorName String @db.VarChar(100)
|
||||
stage String @db.VarChar(50)
|
||||
spawnedAt DateTime @default(now())
|
||||
exitCode Int?
|
||||
exitedAt DateTime?
|
||||
resultJson String? @db.LongText
|
||||
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pipelineId, spawnedAt])
|
||||
@@map("actor_spawns")
|
||||
}
|
||||
|
||||
model Contract {
|
||||
id String @id @db.VarChar(26) // ULID
|
||||
pipelineId String @db.VarChar(26)
|
||||
sprintId String @db.VarChar(100)
|
||||
version String @db.VarChar(20) @default("v1")
|
||||
bodyJson String @db.LongText
|
||||
frozenAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pipelineId])
|
||||
@@index([sprintId])
|
||||
@@map("contracts")
|
||||
}
|
||||
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}
|
||||
153
src/cli/contract.ts
Normal file
153
src/cli/contract.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { generateDraftContract } from "../contract/generator.js";
|
||||
import {
|
||||
saveDraftContract,
|
||||
loadContract,
|
||||
freezeContract,
|
||||
contractFilePath,
|
||||
} from "../contract/store.js";
|
||||
import { validateContract } from "../contract/validator.js";
|
||||
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||
|
||||
const generateCmd = defineCommand({
|
||||
meta: { name: "generate", description: "Generate draft contract from sprint markdown" },
|
||||
args: {
|
||||
sprintMd: {
|
||||
type: "positional",
|
||||
description: "Path to sprint markdown file",
|
||||
required: true,
|
||||
},
|
||||
sprintId: {
|
||||
type: "string",
|
||||
alias: "s",
|
||||
description: "Sprint ID",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
try {
|
||||
const draft = await generateDraftContract(args.sprintMd, args.sprintId);
|
||||
const filePath = await saveDraftContract(process.cwd(), draft);
|
||||
console.log(`Draft contract created:`);
|
||||
console.log(` id: ${draft.id}`);
|
||||
console.log(` sprintId: ${draft.sprintId}`);
|
||||
console.log(` type: ${draft.type}`);
|
||||
console.log(` checks: ${draft.dod.checks.length}`);
|
||||
console.log(` path: ${filePath}`);
|
||||
console.log(
|
||||
`\nEdit the file to tune checks, then run: rails contract freeze ${draft.id}`,
|
||||
);
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const freezeCmd = defineCommand({
|
||||
meta: { name: "freeze", description: "Freeze a contract (make immutable)" },
|
||||
args: {
|
||||
contractId: {
|
||||
type: "positional",
|
||||
description: "Contract ID",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
try {
|
||||
await freezeContract(process.cwd(), args.contractId);
|
||||
console.log(`Contract ${args.contractId} frozen.`);
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const validateCmd = defineCommand({
|
||||
meta: { name: "validate", description: "Validate a contract against current state" },
|
||||
args: {
|
||||
contractId: {
|
||||
type: "positional",
|
||||
description: "Contract ID",
|
||||
required: true,
|
||||
},
|
||||
workdir: {
|
||||
type: "string",
|
||||
alias: "w",
|
||||
description: "Working directory",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
try {
|
||||
const contract = await loadContract(process.cwd(), args.contractId);
|
||||
const result = await validateContract(contract, {
|
||||
workdir: args.workdir || process.cwd(),
|
||||
});
|
||||
|
||||
console.log(`Contract: ${contract.id} (${contract.sprintId})`);
|
||||
console.log(`Verdict: ${result.verdict}`);
|
||||
console.log(
|
||||
`Summary: ${result.summary.passed}/${result.summary.total} passed, ${result.summary.blockingFailed} blocking failures`,
|
||||
);
|
||||
console.log("");
|
||||
|
||||
if (result.verdict === "ABORT_PRECHECK") {
|
||||
console.log("Environment prerequisites:");
|
||||
for (const p of result.prerequisiteResults) {
|
||||
console.log(` ${p.passed ? "✓" : "✗"} ${p.name}: ${p.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.checkResults.length > 0) {
|
||||
console.log("DoD checks:");
|
||||
for (const c of result.checkResults) {
|
||||
const mark = c.passed ? "✓" : "✗";
|
||||
const line = c.passed ? c.evidence : c.errorMessage;
|
||||
console.log(` ${mark} [${c.severity}] ${c.id}: ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.runtimeCommandResults.length > 0) {
|
||||
console.log("Runtime commands:");
|
||||
for (const r of result.runtimeCommandResults) {
|
||||
const mark = r.passed ? "✓" : "✗";
|
||||
console.log(` ${mark} ${r.name} (exit ${r.exitCode}, ${r.durationMs}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = result.verdict === "PASS" ? 0 : 1;
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const showCmd = defineCommand({
|
||||
meta: { name: "show", description: "Pretty-print a contract" },
|
||||
args: {
|
||||
contractId: {
|
||||
type: "positional",
|
||||
description: "Contract ID",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const filePath = contractFilePath(process.cwd(), args.contractId);
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
console.log(raw);
|
||||
},
|
||||
});
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "contract",
|
||||
description: "Manage sprint contracts",
|
||||
},
|
||||
subCommands: {
|
||||
generate: generateCmd,
|
||||
freeze: freezeCmd,
|
||||
validate: validateCmd,
|
||||
show: showCmd,
|
||||
},
|
||||
});
|
||||
23
src/cli/index.ts
Normal file
23
src/cli/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import { defineCommand, runMain } from "citty";
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "rails",
|
||||
version: "0.1.0",
|
||||
description: "Deterministic multi-agent pipeline orchestrator",
|
||||
},
|
||||
subCommands: {
|
||||
start: () => import("./start.js").then((m) => m.default),
|
||||
status: () => import("./status.js").then((m) => m.default),
|
||||
serve: () => import("./serve.js").then((m) => m.default),
|
||||
"skill-context": () =>
|
||||
import("./skill-context.js").then((m) => m.default),
|
||||
"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),
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
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();
|
||||
}
|
||||
},
|
||||
});
|
||||
28
src/cli/serve.ts
Normal file
28
src/cli/serve.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadEnv } from "../env.js";
|
||||
import { getLogger } from "../logger.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "serve",
|
||||
description: "Start the Rails orchestrator server (webhook + Discord bot)",
|
||||
},
|
||||
async run() {
|
||||
const env = loadEnv();
|
||||
const log = getLogger();
|
||||
|
||||
log.info(
|
||||
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV },
|
||||
"hanarang-rails starting",
|
||||
);
|
||||
|
||||
// TODO (Sprint 004): Discord bot initialization
|
||||
// TODO (Sprint 004): Gitea webhook HTTP server
|
||||
// For now, just keep the process alive
|
||||
log.info("Orchestrator running. Press Ctrl+C to stop.");
|
||||
|
||||
await new Promise<never>(() => {
|
||||
// keep alive until signal
|
||||
});
|
||||
},
|
||||
});
|
||||
83
src/cli/skill-context.ts
Normal file
83
src/cli/skill-context.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { defineCommand } from "citty";
|
||||
import {
|
||||
createSkillContext,
|
||||
readSkillContext,
|
||||
clearSkillContext,
|
||||
contextAgeSeconds,
|
||||
isContextExpired,
|
||||
} from "../enforcement/skill-context.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "skill-context",
|
||||
description: "Manage skill enforcement context",
|
||||
},
|
||||
args: {
|
||||
action: {
|
||||
type: "positional",
|
||||
description: "Action: create | show | clear",
|
||||
required: true,
|
||||
},
|
||||
skillName: {
|
||||
type: "string",
|
||||
alias: "s",
|
||||
description: "Skill name (for create)",
|
||||
default: "rails",
|
||||
},
|
||||
pipelineId: {
|
||||
type: "string",
|
||||
alias: "p",
|
||||
description: "Pipeline ID (for create)",
|
||||
default: "",
|
||||
},
|
||||
ttl: {
|
||||
type: "string",
|
||||
description: "TTL in seconds (for create)",
|
||||
default: "300",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const cwd = process.cwd();
|
||||
|
||||
switch (args.action) {
|
||||
case "create": {
|
||||
const ctx = await createSkillContext(cwd, {
|
||||
skillName: args.skillName,
|
||||
pipelineId: args.pipelineId,
|
||||
ttlSeconds: parseInt(args.ttl, 10) || 300,
|
||||
});
|
||||
console.log(`Skill context created:`);
|
||||
console.log(` skill: ${ctx.skillName}`);
|
||||
console.log(` pipeline: ${ctx.pipelineId || "(none)"}`);
|
||||
console.log(` ttl: ${ctx.ttlSeconds}s`);
|
||||
console.log(` created: ${ctx.createdAt}`);
|
||||
break;
|
||||
}
|
||||
case "show": {
|
||||
const ctx = await readSkillContext(cwd);
|
||||
if (!ctx) {
|
||||
console.log("No skill context found.");
|
||||
return;
|
||||
}
|
||||
const age = contextAgeSeconds(ctx);
|
||||
const expired = isContextExpired(ctx);
|
||||
console.log(`Skill context:`);
|
||||
console.log(` skill: ${ctx.skillName}`);
|
||||
console.log(` pipeline: ${ctx.pipelineId || "(none)"}`);
|
||||
console.log(` session: ${ctx.sessionId || "(none)"}`);
|
||||
console.log(` created: ${ctx.createdAt}`);
|
||||
console.log(` age: ${age}s / ${ctx.ttlSeconds}s`);
|
||||
console.log(` expired: ${expired}`);
|
||||
break;
|
||||
}
|
||||
case "clear": {
|
||||
const cleared = await clearSkillContext(cwd);
|
||||
console.log(cleared ? "Skill context cleared." : "No context to clear.");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown action: ${args.action}. Use create | show | clear.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
68
src/cli/skill-trace.ts
Normal file
68
src/cli/skill-trace.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readTrace, countBlocked } from "../enforcement/skill-trace.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "skill-trace",
|
||||
description: "View skill enforcement trace log",
|
||||
},
|
||||
args: {
|
||||
action: {
|
||||
type: "positional",
|
||||
description: "Action: show | blocked",
|
||||
required: false,
|
||||
default: "show",
|
||||
},
|
||||
pipelineId: {
|
||||
type: "string",
|
||||
alias: "p",
|
||||
description: "Filter by pipeline ID",
|
||||
default: "",
|
||||
},
|
||||
limit: {
|
||||
type: "string",
|
||||
alias: "n",
|
||||
description: "Number of entries to show",
|
||||
default: "20",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const cwd = process.cwd();
|
||||
const action = args.action || "show";
|
||||
|
||||
switch (action) {
|
||||
case "show": {
|
||||
const entries = await readTrace(cwd, {
|
||||
pipelineId: args.pipelineId || undefined,
|
||||
limit: parseInt(args.limit, 10) || 20,
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log("No trace entries found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${"TIMESTAMP".padEnd(15)} ${"TOOL".padEnd(10)} ${"BLOCKED".padEnd(8)} REASON`,
|
||||
);
|
||||
console.log("-".repeat(60));
|
||||
for (const e of entries) {
|
||||
const time = new Date(e.ts).toISOString().slice(11, 19);
|
||||
console.log(
|
||||
`${time.padEnd(15)} ${e.tool.padEnd(10)} ${String(e.blocked).padEnd(8)} ${e.reason}`,
|
||||
);
|
||||
}
|
||||
console.log(`\nTotal: ${entries.length} entries`);
|
||||
break;
|
||||
}
|
||||
case "blocked": {
|
||||
const count = await countBlocked(cwd);
|
||||
console.log(`Blocked tool calls: ${count}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown action: ${action}. Use show | blocked.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
40
src/cli/start.ts
Normal file
40
src/cli/start.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadEnv } from "../env.js";
|
||||
import { createPipeline, disconnectPrisma } from "../orchestrator/persist.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "start",
|
||||
description: "Start a new pipeline for a project",
|
||||
},
|
||||
args: {
|
||||
project: {
|
||||
type: "positional",
|
||||
description: "Project name",
|
||||
required: true,
|
||||
},
|
||||
requirements: {
|
||||
type: "string",
|
||||
alias: "r",
|
||||
description: "Requirements / task description",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
loadEnv();
|
||||
try {
|
||||
const { pipelineId, state } = await createPipeline(
|
||||
args.project,
|
||||
args.requirements ?? "",
|
||||
);
|
||||
// eslint-disable-next-line no-console -- CLI output
|
||||
console.log(`Pipeline created: ${pipelineId}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` project: ${args.project}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` state: ${state}`);
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
87
src/cli/status.ts
Normal file
87
src/cli/status.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadEnv } from "../env.js";
|
||||
import {
|
||||
getPipelineState,
|
||||
listPipelines,
|
||||
disconnectPrisma,
|
||||
} from "../orchestrator/persist.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "status",
|
||||
description: "Show pipeline status",
|
||||
},
|
||||
args: {
|
||||
id: {
|
||||
type: "positional",
|
||||
description: "Pipeline ID (omit to list all)",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
loadEnv();
|
||||
try {
|
||||
if (args.id) {
|
||||
const result = await getPipelineState(args.id);
|
||||
if (!result) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Pipeline not found: ${args.id}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Pipeline: ${args.id}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` project: ${result.context.projectName}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` state: ${result.state}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` sprint: ${result.context.currentSprintId ?? "(none)"}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` retryCount: ${result.context.retryCount}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` reviewRound: ${result.context.reviewRound}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` lastError: ${result.context.lastError ?? "(none)"}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` created: ${result.context.createdAt}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` transitions: ${result.transitions.length}`);
|
||||
|
||||
if (result.transitions.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("\n Timeline:");
|
||||
for (const t of result.transitions.slice(-10)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
` ${t.timestamp.toISOString()} ${t.fromState} → ${t.toState} [${t.eventType}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pipelines = await listPipelines({ limit: 20 });
|
||||
if (pipelines.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("No pipelines found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${"ID".padEnd(28)} ${"PROJECT".padEnd(20)} ${"STATE".padEnd(14)} CREATED`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("-".repeat(80));
|
||||
for (const p of pipelines) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${p.id.padEnd(28)} ${p.projectName.padEnd(20)} ${p.currentState.padEnd(14)} ${p.createdAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await disconnectPrisma();
|
||||
}
|
||||
},
|
||||
});
|
||||
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 },
|
||||
});
|
||||
62
src/contract/checks/artifact-schema.ts
Normal file
62
src/contract/checks/artifact-schema.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { ArtifactSchemaSpec, CheckResult } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
import { z } from "zod";
|
||||
|
||||
// Registry of known artifact schemas. Extend as needed.
|
||||
const ARTIFACT_SCHEMAS: Record<string, z.ZodTypeAny> = {
|
||||
CheckResult: CheckResult,
|
||||
// Add more schemas here
|
||||
};
|
||||
|
||||
export const artifactSchemaCheck: CheckHandler = async (check, ctx) => {
|
||||
const start = Date.now();
|
||||
const spec = ArtifactSchemaSpec.parse(check.spec);
|
||||
|
||||
const fullPath = isAbsolute(spec.artifactPath)
|
||||
? spec.artifactPath
|
||||
: resolve(ctx.workdir, spec.artifactPath);
|
||||
|
||||
const schema = ARTIFACT_SCHEMAS[spec.schemaName];
|
||||
if (!schema) {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Unknown schema: ${spec.schemaName}. Known: ${Object.keys(ARTIFACT_SCHEMAS).join(", ")}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(fullPath, "utf8");
|
||||
const data = JSON.parse(content) as unknown;
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
passed: true,
|
||||
evidence: `${spec.artifactPath} validates against ${spec.schemaName}`,
|
||||
errorMessage: "",
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
const issues = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Schema validation failed: ${issues}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Cannot parse artifact: ${err instanceof Error ? err.message : String(err)}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
};
|
||||
86
src/contract/checks/command-success.ts
Normal file
86
src/contract/checks/command-success.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { CommandSuccessSpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
interface ExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
function execCommand(
|
||||
command: string,
|
||||
opts: {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<ExecResult> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn("sh", ["-c", command], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
|
||||
const finalize = (exitCode: number) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ exitCode, stdout, stderr, timedOut });
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) child.kill("SIGKILL");
|
||||
finalize(-1);
|
||||
}, 2000);
|
||||
}, opts.timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => finalize(code ?? -1));
|
||||
child.on("error", () => finalize(-1));
|
||||
});
|
||||
}
|
||||
|
||||
export const commandSuccessCheck: CheckHandler = async (check, ctx) => {
|
||||
const start = Date.now();
|
||||
const spec = CommandSuccessSpec.parse(check.spec);
|
||||
|
||||
const result = await execCommand(spec.command, {
|
||||
cwd: spec.cwd ?? ctx.workdir,
|
||||
env: { ...ctx.env, ...(spec.env ?? {}) },
|
||||
timeoutMs: spec.timeoutMs,
|
||||
});
|
||||
|
||||
const passed = !result.timedOut && result.exitCode === spec.expectExitCode;
|
||||
|
||||
return {
|
||||
passed,
|
||||
evidence: passed
|
||||
? `Command succeeded: ${spec.command} (exit ${result.exitCode})`
|
||||
: "",
|
||||
errorMessage: passed
|
||||
? ""
|
||||
: result.timedOut
|
||||
? `Command timed out after ${spec.timeoutMs}ms: ${spec.command}`
|
||||
: `Command failed (exit ${result.exitCode}, expected ${spec.expectExitCode}): ${spec.command}\nstderr: ${result.stderr.slice(0, 500)}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
};
|
||||
|
||||
// Export helper for runtime validation commands
|
||||
export { execCommand };
|
||||
47
src/contract/checks/db-query.ts
Normal file
47
src/contract/checks/db-query.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getPrisma } from "../../orchestrator/persist.js";
|
||||
import { DbQuerySpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
/**
|
||||
* Runs a raw SQL query via Prisma and counts rows.
|
||||
* For MariaDB / MySQL via the project's default DATABASE_URL.
|
||||
* (Custom connection strings via spec.connectionString are deferred to v2.)
|
||||
*/
|
||||
export const dbQueryCheck: CheckHandler = async (check) => {
|
||||
const start = Date.now();
|
||||
const spec = DbQuerySpec.parse(check.spec);
|
||||
|
||||
if (spec.connectionString) {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage:
|
||||
"Custom connectionString not supported yet. Omit to use DATABASE_URL.",
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const prisma = getPrisma();
|
||||
const rows = (await prisma.$queryRawUnsafe(spec.query)) as unknown[];
|
||||
const count = Array.isArray(rows) ? rows.length : 0;
|
||||
const passed = count >= spec.expectMinRows;
|
||||
return {
|
||||
passed,
|
||||
evidence: passed
|
||||
? `Query returned ${count} rows (expected ≥ ${spec.expectMinRows})`
|
||||
: "",
|
||||
errorMessage: passed
|
||||
? ""
|
||||
: `Query returned ${count} rows, expected ≥ ${spec.expectMinRows}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `DB query failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
};
|
||||
29
src/contract/checks/file-exists.ts
Normal file
29
src/contract/checks/file-exists.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { FileExistsSpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
export const fileExistsCheck: CheckHandler = async (check, ctx) => {
|
||||
const start = Date.now();
|
||||
const spec = FileExistsSpec.parse(check.spec);
|
||||
const fullPath = isAbsolute(spec.path)
|
||||
? spec.path
|
||||
: resolve(ctx.workdir, spec.path);
|
||||
|
||||
try {
|
||||
const s = await stat(fullPath);
|
||||
return {
|
||||
passed: true,
|
||||
evidence: `${fullPath} exists (${s.isDirectory() ? "dir" : "file"}, ${s.size}B)`,
|
||||
errorMessage: "",
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `File not found: ${fullPath}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
};
|
||||
38
src/contract/checks/http-status.ts
Normal file
38
src/contract/checks/http-status.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { HttpStatusSpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
export const httpStatusCheck: CheckHandler = async (check) => {
|
||||
const start = Date.now();
|
||||
const spec = HttpStatusSpec.parse(check.spec);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), spec.timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(spec.url, {
|
||||
method: spec.method,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
const passed = res.status === spec.expectStatus;
|
||||
return {
|
||||
passed,
|
||||
evidence: passed
|
||||
? `GET ${spec.url} → ${res.status} (expected ${spec.expectStatus})`
|
||||
: "",
|
||||
errorMessage: passed
|
||||
? ""
|
||||
: `HTTP status mismatch: ${spec.url} returned ${res.status}, expected ${spec.expectStatus}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `HTTP request failed: ${spec.url} — ${err instanceof Error ? err.message : String(err)}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
};
|
||||
25
src/contract/checks/index.ts
Normal file
25
src/contract/checks/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { DodCheckKind } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
import { fileExistsCheck } from "./file-exists.js";
|
||||
import { commandSuccessCheck } from "./command-success.js";
|
||||
import { regexInFileCheck, regexAbsentCheck } from "./regex-in-file.js";
|
||||
import { httpStatusCheck } from "./http-status.js";
|
||||
import { processListeningCheck } from "./process-listening.js";
|
||||
import { artifactSchemaCheck } from "./artifact-schema.js";
|
||||
import { dbQueryCheck } from "./db-query.js";
|
||||
import { manualCheck } from "./manual.js";
|
||||
|
||||
export const CHECK_HANDLERS: Record<DodCheckKind, CheckHandler> = {
|
||||
file_exists: fileExistsCheck,
|
||||
command_success: commandSuccessCheck,
|
||||
regex_in_file: regexInFileCheck,
|
||||
regex_absent: regexAbsentCheck,
|
||||
http_status: httpStatusCheck,
|
||||
db_query: dbQueryCheck,
|
||||
process_listening: processListeningCheck,
|
||||
artifact_schema: artifactSchemaCheck,
|
||||
manual: manualCheck,
|
||||
};
|
||||
|
||||
export { execCommand } from "./command-success.js";
|
||||
export type { CheckHandler, CheckContext, CheckOutcome } from "./types.js";
|
||||
19
src/contract/checks/manual.ts
Normal file
19
src/contract/checks/manual.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ManualCheckSpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
/**
|
||||
* Manual check placeholder (Sprint 003).
|
||||
* Will be activated in Sprint 006 (QA runtime) where darang LLM actually
|
||||
* inspects code and fills in results. For now, returns SKIP (passed=true
|
||||
* with a note) so validator can proceed.
|
||||
*/
|
||||
export const manualCheck: CheckHandler = async (check) => {
|
||||
const start = Date.now();
|
||||
const spec = ManualCheckSpec.parse(check.spec);
|
||||
return {
|
||||
passed: true,
|
||||
evidence: `[SKIPPED — manual] ${spec.question} (Sprint 006 에서 활성화)`,
|
||||
errorMessage: "",
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
};
|
||||
45
src/contract/checks/process-listening.ts
Normal file
45
src/contract/checks/process-listening.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createConnection } from "node:net";
|
||||
import { ProcessListeningSpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
function probePort(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const socket = createConnection({ host, port });
|
||||
let settled = false;
|
||||
|
||||
const finalize = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolvePromise(ok);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finalize(false), timeoutMs);
|
||||
|
||||
socket.on("connect", () => {
|
||||
clearTimeout(timer);
|
||||
finalize(true);
|
||||
});
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
finalize(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const processListeningCheck: CheckHandler = async (check) => {
|
||||
const start = Date.now();
|
||||
const spec = ProcessListeningSpec.parse(check.spec);
|
||||
|
||||
const ok = await probePort(spec.host, spec.port, 3000);
|
||||
return {
|
||||
passed: ok,
|
||||
evidence: ok ? `${spec.host}:${spec.port} is listening` : "",
|
||||
errorMessage: ok ? "" : `${spec.host}:${spec.port} is not listening`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
};
|
||||
76
src/contract/checks/regex-in-file.ts
Normal file
76
src/contract/checks/regex-in-file.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { RegexInFileSpec } from "../schema.js";
|
||||
import type { CheckHandler } from "./types.js";
|
||||
|
||||
export const regexInFileCheck: CheckHandler = async (check, ctx) => {
|
||||
const start = Date.now();
|
||||
const spec = RegexInFileSpec.parse(check.spec);
|
||||
const fullPath = isAbsolute(spec.path)
|
||||
? spec.path
|
||||
: resolve(ctx.workdir, spec.path);
|
||||
|
||||
try {
|
||||
const content = await readFile(fullPath, "utf8");
|
||||
const regex = new RegExp(spec.pattern, spec.flags);
|
||||
const match = content.match(regex);
|
||||
if (match) {
|
||||
const lineIdx = content.slice(0, match.index ?? 0).split("\n").length;
|
||||
return {
|
||||
passed: true,
|
||||
evidence: `${spec.path}:${lineIdx} matches /${spec.pattern}/${spec.flags}`,
|
||||
errorMessage: "",
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Pattern not found in ${spec.path}: /${spec.pattern}/${spec.flags}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Cannot read ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const regexAbsentCheck: CheckHandler = async (check, ctx) => {
|
||||
const start = Date.now();
|
||||
const spec = RegexInFileSpec.parse(check.spec);
|
||||
const fullPath = isAbsolute(spec.path)
|
||||
? spec.path
|
||||
: resolve(ctx.workdir, spec.path);
|
||||
|
||||
try {
|
||||
const content = await readFile(fullPath, "utf8");
|
||||
const regex = new RegExp(spec.pattern, spec.flags);
|
||||
const match = content.match(regex);
|
||||
if (!match) {
|
||||
return {
|
||||
passed: true,
|
||||
evidence: `${spec.path} has no match for /${spec.pattern}/${spec.flags}`,
|
||||
errorMessage: "",
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
const lineIdx = content.slice(0, match.index ?? 0).split("\n").length;
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Forbidden pattern found in ${spec.path}:${lineIdx}: /${spec.pattern}/${spec.flags}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
passed: false,
|
||||
evidence: "",
|
||||
errorMessage: `Cannot read ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
};
|
||||
18
src/contract/checks/types.ts
Normal file
18
src/contract/checks/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { DodCheck } from "../schema.js";
|
||||
|
||||
export interface CheckContext {
|
||||
workdir: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CheckOutcome {
|
||||
passed: boolean;
|
||||
evidence: string;
|
||||
errorMessage: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type CheckHandler = (
|
||||
check: DodCheck,
|
||||
ctx: CheckContext,
|
||||
) => Promise<CheckOutcome>;
|
||||
103
src/contract/generator.ts
Normal file
103
src/contract/generator.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { ulid } from "ulid";
|
||||
import { SprintContract, type DodCheck } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Parse a sprint markdown file and produce a draft Sprint Contract.
|
||||
*
|
||||
* Heuristics:
|
||||
* - Extracts `## Type` section → contract.type
|
||||
* - Extracts "Tasks" table and creates `file_exists` / `command_success`
|
||||
* stubs for each DoD entry containing keywords like "통과", "pass", "exit".
|
||||
* - Environment prerequisites are NOT inferred from markdown; the user
|
||||
* can add them manually to the draft contract.
|
||||
*
|
||||
* The result is a **draft** — the user must review and `freeze` it
|
||||
* before validation.
|
||||
*/
|
||||
export async function generateDraftContract(
|
||||
sprintMdPath: string,
|
||||
sprintId: string,
|
||||
): Promise<SprintContract> {
|
||||
const raw = await readFile(sprintMdPath, "utf8");
|
||||
|
||||
// Extract type
|
||||
const typeMatch = raw.match(/##\s*Type\s*\n\s*`([^`]+)`/);
|
||||
const rawType = typeMatch?.[1]?.trim() ?? "feature";
|
||||
const type = normalizeType(rawType);
|
||||
|
||||
// Extract non-goals
|
||||
const nonGoalsMatch = raw.match(
|
||||
/##\s*Non-Goals\s*\n([\s\S]*?)(?=\n## |\n---|\n$)/,
|
||||
);
|
||||
const nonGoals: string[] = [];
|
||||
if (nonGoalsMatch?.[1]) {
|
||||
const items = nonGoalsMatch[1].match(/^\s*-\s+(.+)$/gm) ?? [];
|
||||
for (const item of items) {
|
||||
const clean = item.replace(/^\s*-\s+/, "").trim();
|
||||
if (clean) nonGoals.push(clean);
|
||||
}
|
||||
}
|
||||
|
||||
// Default starter checks — user will replace these
|
||||
const checks: DodCheck[] = [
|
||||
{
|
||||
id: "readme-exists",
|
||||
description: "README.md 존재",
|
||||
kind: "file_exists",
|
||||
spec: { path: "README.md" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{
|
||||
id: "typecheck",
|
||||
description: "TypeScript 타입 체크 통과",
|
||||
kind: "command_success",
|
||||
spec: { command: "pnpm tsc --noEmit", timeoutMs: 60_000, expectExitCode: 0 },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{
|
||||
id: "tests-pass",
|
||||
description: "Vitest 전부 통과",
|
||||
kind: "command_success",
|
||||
spec: { command: "pnpm vitest run", timeoutMs: 120_000, expectExitCode: 0 },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
];
|
||||
|
||||
const draft = SprintContract.parse({
|
||||
version: "v1",
|
||||
id: ulid(),
|
||||
sprintId,
|
||||
createdAt: new Date().toISOString(),
|
||||
type,
|
||||
dod: { checks },
|
||||
environmentPrerequisites: [],
|
||||
nonGoals,
|
||||
runtimeValidation: { commands: [] },
|
||||
riskFlags: [],
|
||||
reviewerProfile: "static",
|
||||
approvalGates: { impl: true, review: true, deploy: true },
|
||||
});
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
function normalizeType(raw: string): SprintContract["type"] {
|
||||
const lower = raw.toLowerCase();
|
||||
const allowed = [
|
||||
"scaffold",
|
||||
"feature",
|
||||
"refactor",
|
||||
"bugfix",
|
||||
"migration",
|
||||
"infra",
|
||||
"deploy-only",
|
||||
] as const;
|
||||
for (const t of allowed) {
|
||||
if (lower === t) return t;
|
||||
}
|
||||
return "feature";
|
||||
}
|
||||
157
src/contract/prerequisite.ts
Normal file
157
src/contract/prerequisite.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createConnection } from "node:net";
|
||||
import { z } from "zod";
|
||||
import type { EnvPrereq } from "./schema.js";
|
||||
|
||||
export interface PrereqResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const CommandExistsSpec = z.object({ command: z.string() });
|
||||
const PortOpenSpec = z.object({
|
||||
port: z.number().int().positive(),
|
||||
host: z.string().default("127.0.0.1"),
|
||||
});
|
||||
const EnvVarSpec = z.object({
|
||||
name: z.string(),
|
||||
required: z.boolean().default(true),
|
||||
});
|
||||
const FileExistsPrereqSpec = z.object({ path: z.string() });
|
||||
const HttpReachableSpec = z.object({
|
||||
url: z.string().url(),
|
||||
timeoutMs: z.number().int().positive().default(5000),
|
||||
});
|
||||
|
||||
async function commandExists(cmd: string): Promise<boolean> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn("sh", ["-c", `command -v ${cmd}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", (code) => resolvePromise(code === 0));
|
||||
child.on("error", () => resolvePromise(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function portOpen(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const socket = createConnection({ host, port });
|
||||
let settled = false;
|
||||
const finalize = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolvePromise(ok);
|
||||
};
|
||||
const timer = setTimeout(() => finalize(false), 3000);
|
||||
socket.on("connect", () => {
|
||||
clearTimeout(timer);
|
||||
finalize(true);
|
||||
});
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
finalize(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function httpReachable(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const res = await fetch(url, { method: "HEAD", signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
return res.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPrerequisite(
|
||||
prereq: EnvPrereq,
|
||||
workdir: string,
|
||||
): Promise<PrereqResult> {
|
||||
try {
|
||||
switch (prereq.check) {
|
||||
case "command_exists": {
|
||||
const spec = CommandExistsSpec.parse(prereq.spec);
|
||||
const ok = await commandExists(spec.command);
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: ok,
|
||||
message: ok
|
||||
? `${spec.command} found`
|
||||
: `${spec.command} not found — ${prereq.reason}`,
|
||||
};
|
||||
}
|
||||
case "port_open": {
|
||||
const spec = PortOpenSpec.parse(prereq.spec);
|
||||
const ok = await portOpen(spec.host, spec.port);
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: ok,
|
||||
message: ok
|
||||
? `${spec.host}:${spec.port} reachable`
|
||||
: `${spec.host}:${spec.port} not listening — ${prereq.reason}`,
|
||||
};
|
||||
}
|
||||
case "env_var": {
|
||||
const spec = EnvVarSpec.parse(prereq.spec);
|
||||
const val = process.env[spec.name];
|
||||
const ok = !spec.required || (val !== undefined && val !== "");
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: ok,
|
||||
message: ok
|
||||
? `${spec.name} set`
|
||||
: `${spec.name} missing — ${prereq.reason}`,
|
||||
};
|
||||
}
|
||||
case "file_exists": {
|
||||
const spec = FileExistsPrereqSpec.parse(prereq.spec);
|
||||
const fullPath = isAbsolute(spec.path)
|
||||
? spec.path
|
||||
: resolve(workdir, spec.path);
|
||||
try {
|
||||
await stat(fullPath);
|
||||
return { name: prereq.name, passed: true, message: `${fullPath} exists` };
|
||||
} catch {
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: false,
|
||||
message: `${fullPath} not found — ${prereq.reason}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
case "http_reachable": {
|
||||
const spec = HttpReachableSpec.parse(prereq.spec);
|
||||
const ok = await httpReachable(spec.url, spec.timeoutMs);
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: ok,
|
||||
message: ok
|
||||
? `${spec.url} reachable`
|
||||
: `${spec.url} not reachable — ${prereq.reason}`,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: false,
|
||||
message: `Unknown prereq check: ${prereq.check as string}`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
name: prereq.name,
|
||||
passed: false,
|
||||
message: `Prereq check errored: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
229
src/contract/schema.ts
Normal file
229
src/contract/schema.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Check kind-specific spec schemas
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const FileExistsSpec = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export const CommandSuccessSpec = z.object({
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
timeoutMs: z.number().int().positive().default(60_000),
|
||||
expectExitCode: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export const RegexInFileSpec = z.object({
|
||||
path: z.string(),
|
||||
pattern: z.string(),
|
||||
flags: z.string().default(""),
|
||||
});
|
||||
|
||||
export const RegexAbsentSpec = RegexInFileSpec;
|
||||
|
||||
export const HttpStatusSpec = z.object({
|
||||
url: z.string().url(),
|
||||
expectStatus: z.number().int().positive().default(200),
|
||||
timeoutMs: z.number().int().positive().default(10_000),
|
||||
method: z.enum(["GET", "HEAD", "POST"]).default("GET"),
|
||||
});
|
||||
|
||||
export const DbQuerySpec = z.object({
|
||||
query: z.string(),
|
||||
connectionString: z.string().optional(), // falls back to DATABASE_URL
|
||||
expectMinRows: z.number().int().min(0).default(1),
|
||||
});
|
||||
|
||||
export const ProcessListeningSpec = z.object({
|
||||
port: z.number().int().positive(),
|
||||
host: z.string().default("127.0.0.1"),
|
||||
});
|
||||
|
||||
export const ArtifactSchemaSpec = z.object({
|
||||
artifactPath: z.string(),
|
||||
schemaName: z.string(), // Registered schema name
|
||||
});
|
||||
|
||||
export const ManualCheckSpec = z.object({
|
||||
question: z.string(),
|
||||
guidance: z.string().optional(),
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// DoD check (one item)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const DodCheckKind = z.enum([
|
||||
"file_exists",
|
||||
"command_success",
|
||||
"regex_in_file",
|
||||
"regex_absent",
|
||||
"http_status",
|
||||
"db_query",
|
||||
"process_listening",
|
||||
"artifact_schema",
|
||||
"manual",
|
||||
]);
|
||||
|
||||
export type DodCheckKind = z.infer<typeof DodCheckKind>;
|
||||
|
||||
export const DodCheck = z.object({
|
||||
id: z.string().min(1),
|
||||
description: z.string(),
|
||||
kind: DodCheckKind,
|
||||
spec: z.unknown(),
|
||||
blocking: z.boolean().default(true),
|
||||
severity: z.enum(["critical", "major", "minor"]).default("major"),
|
||||
});
|
||||
|
||||
export type DodCheck = z.infer<typeof DodCheck>;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Environment prerequisite
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const PrereqKind = z.enum([
|
||||
"command_exists",
|
||||
"port_open",
|
||||
"env_var",
|
||||
"file_exists",
|
||||
"http_reachable",
|
||||
]);
|
||||
|
||||
export type PrereqKind = z.infer<typeof PrereqKind>;
|
||||
|
||||
export const EnvPrereq = z.object({
|
||||
name: z.string(),
|
||||
check: PrereqKind,
|
||||
spec: z.unknown(),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export type EnvPrereq = z.infer<typeof EnvPrereq>;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Runtime validation command
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const RuntimeValidationCommand = z.object({
|
||||
name: z.string(),
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
timeoutMs: z.number().int().positive().default(60_000),
|
||||
expectExitCode: z.number().int().default(0),
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Sprint Contract (top level)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const SprintContract = z.object({
|
||||
version: z.literal("v1"),
|
||||
id: z.string().min(1),
|
||||
sprintId: z.string().min(1),
|
||||
createdAt: z.string().datetime(),
|
||||
type: z.enum([
|
||||
"scaffold",
|
||||
"feature",
|
||||
"refactor",
|
||||
"bugfix",
|
||||
"migration",
|
||||
"infra",
|
||||
"deploy-only",
|
||||
]),
|
||||
|
||||
dod: z.object({
|
||||
checks: z.array(DodCheck),
|
||||
}),
|
||||
|
||||
environmentPrerequisites: z.array(EnvPrereq).default([]),
|
||||
|
||||
nonGoals: z.array(z.string()).default([]),
|
||||
|
||||
runtimeValidation: z
|
||||
.object({
|
||||
commands: z.array(RuntimeValidationCommand),
|
||||
})
|
||||
.default({ commands: [] }),
|
||||
|
||||
riskFlags: z
|
||||
.array(
|
||||
z.enum([
|
||||
"security-sensitive",
|
||||
"data-migration",
|
||||
"breaking-change",
|
||||
"ux-regression",
|
||||
"performance-critical",
|
||||
"needs-spike",
|
||||
]),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
reviewerProfile: z
|
||||
.enum(["static", "runtime", "browser"])
|
||||
.default("static"),
|
||||
|
||||
approvalGates: z
|
||||
.object({
|
||||
impl: z.boolean().default(true),
|
||||
review: z.boolean().default(true),
|
||||
deploy: z.boolean().default(true),
|
||||
})
|
||||
.default({ impl: true, review: true, deploy: true }),
|
||||
});
|
||||
|
||||
export type SprintContract = z.infer<typeof SprintContract>;
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Validation result
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const CheckResult = z.object({
|
||||
id: z.string(),
|
||||
kind: DodCheckKind,
|
||||
passed: z.boolean(),
|
||||
blocking: z.boolean(),
|
||||
severity: z.enum(["critical", "major", "minor"]),
|
||||
evidence: z.string().default(""),
|
||||
errorMessage: z.string().default(""),
|
||||
durationMs: z.number().default(0),
|
||||
});
|
||||
|
||||
export type CheckResult = z.infer<typeof CheckResult>;
|
||||
|
||||
export const ValidationResult = z.object({
|
||||
contractId: z.string(),
|
||||
verdict: z.enum(["PASS", "FAIL", "ABORT_PRECHECK"]),
|
||||
startedAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime(),
|
||||
prerequisiteResults: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
passed: z.boolean(),
|
||||
message: z.string().default(""),
|
||||
}),
|
||||
),
|
||||
checkResults: z.array(CheckResult),
|
||||
runtimeCommandResults: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
passed: z.boolean(),
|
||||
exitCode: z.number(),
|
||||
stdout: z.string().default(""),
|
||||
stderr: z.string().default(""),
|
||||
durationMs: z.number(),
|
||||
}),
|
||||
),
|
||||
summary: z.object({
|
||||
total: z.number(),
|
||||
passed: z.number(),
|
||||
failed: z.number(),
|
||||
blockingFailed: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ValidationResult = z.infer<typeof ValidationResult>;
|
||||
114
src/contract/store.ts
Normal file
114
src/contract/store.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { writeFile, readFile, mkdir, chmod } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { SprintContract } from "./schema.js";
|
||||
import { getPrisma } from "../orchestrator/persist.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "contract-store" });
|
||||
|
||||
const CONTRACTS_DIR = ".rails/contracts";
|
||||
|
||||
export function contractFilePath(railsDir: string, contractId: string): string {
|
||||
return join(railsDir, CONTRACTS_DIR, `${contractId}.sprint-contract.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a draft contract to file + DB.
|
||||
* The contract is mutable until `freezeContract()` is called.
|
||||
*/
|
||||
export async function saveDraftContract(
|
||||
railsDir: string,
|
||||
contract: SprintContract,
|
||||
pipelineId?: string,
|
||||
): Promise<string> {
|
||||
const filePath = contractFilePath(railsDir, contract.id);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, JSON.stringify(contract, null, 2), "utf8");
|
||||
|
||||
if (pipelineId) {
|
||||
const prisma = getPrisma();
|
||||
await prisma.contract.create({
|
||||
data: {
|
||||
id: contract.id,
|
||||
pipelineId,
|
||||
sprintId: contract.sprintId,
|
||||
version: contract.version,
|
||||
bodyJson: JSON.stringify(contract),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
log.info({ contractId: contract.id, sprintId: contract.sprintId }, "Draft contract saved");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a contract from file (file is source of truth for validation).
|
||||
*/
|
||||
export async function loadContract(
|
||||
railsDir: string,
|
||||
contractId: string,
|
||||
): Promise<SprintContract> {
|
||||
const filePath = contractFilePath(railsDir, contractId);
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
return SprintContract.parse(JSON.parse(raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a contract: mark as immutable in DB and make file read-only.
|
||||
*/
|
||||
export async function freezeContract(
|
||||
railsDir: string,
|
||||
contractId: string,
|
||||
): Promise<void> {
|
||||
const filePath = contractFilePath(railsDir, contractId);
|
||||
|
||||
// Make file read-only
|
||||
await chmod(filePath, 0o444);
|
||||
|
||||
// Update DB if contract exists there
|
||||
try {
|
||||
const prisma = getPrisma();
|
||||
await prisma.contract.update({
|
||||
where: { id: contractId },
|
||||
data: { frozenAt: new Date() },
|
||||
});
|
||||
} catch {
|
||||
// DB entry may not exist for local-only contracts
|
||||
}
|
||||
|
||||
log.info({ contractId }, "Contract frozen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a draft contract (allowed only if not frozen).
|
||||
*/
|
||||
export async function updateDraftContract(
|
||||
railsDir: string,
|
||||
contract: SprintContract,
|
||||
): Promise<void> {
|
||||
const filePath = contractFilePath(railsDir, contract.id);
|
||||
|
||||
// Check frozen status in DB
|
||||
try {
|
||||
const prisma = getPrisma();
|
||||
const existing = await prisma.contract.findUnique({
|
||||
where: { id: contract.id },
|
||||
});
|
||||
if (existing?.frozenAt) {
|
||||
throw new Error(
|
||||
`Contract ${contract.id} is frozen since ${existing.frozenAt.toISOString()} and cannot be modified.`,
|
||||
);
|
||||
}
|
||||
await prisma.contract.update({
|
||||
where: { id: contract.id },
|
||||
data: { bodyJson: JSON.stringify(contract) },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes("frozen")) throw err;
|
||||
// Ignore other DB errors for local-only contracts
|
||||
}
|
||||
|
||||
// Write file (will fail if file was chmod 0444, which means already frozen)
|
||||
await writeFile(filePath, JSON.stringify(contract, null, 2), "utf8");
|
||||
}
|
||||
157
src/contract/validator.ts
Normal file
157
src/contract/validator.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
SprintContract,
|
||||
type ValidationResult,
|
||||
type CheckResult,
|
||||
} from "./schema.js";
|
||||
import { CHECK_HANDLERS, execCommand } from "./checks/index.js";
|
||||
import { checkPrerequisite } from "./prerequisite.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "contract-validator" });
|
||||
|
||||
export interface ValidateOptions {
|
||||
workdir: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full validation pipeline for a sprint contract:
|
||||
* 1. Environment prerequisites (any failure → ABORT_PRECHECK)
|
||||
* 2. Runtime validation commands
|
||||
* 3. DoD checks
|
||||
*
|
||||
* Returns a ValidationResult with verdict PASS / FAIL / ABORT_PRECHECK.
|
||||
*/
|
||||
export async function validateContract(
|
||||
contractJson: unknown,
|
||||
opts: ValidateOptions,
|
||||
): Promise<ValidationResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const contract = SprintContract.parse(contractJson);
|
||||
const env = opts.env ?? (process.env as Record<string, string>);
|
||||
|
||||
log.info({ contractId: contract.id, sprintId: contract.sprintId }, "Validation started");
|
||||
|
||||
// ── Step 1. Environment prerequisites ──
|
||||
const prerequisiteResults = [];
|
||||
for (const prereq of contract.environmentPrerequisites) {
|
||||
const r = await checkPrerequisite(prereq, opts.workdir);
|
||||
prerequisiteResults.push(r);
|
||||
if (!r.passed) {
|
||||
log.warn({ prereq: prereq.name, message: r.message }, "Prereq failed");
|
||||
return {
|
||||
contractId: contract.id,
|
||||
verdict: "ABORT_PRECHECK",
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
prerequisiteResults,
|
||||
checkResults: [],
|
||||
runtimeCommandResults: [],
|
||||
summary: { total: 0, passed: 0, failed: 0, blockingFailed: 1 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2. Runtime validation commands ──
|
||||
const runtimeCommandResults: ValidationResult["runtimeCommandResults"] = [];
|
||||
for (const cmd of contract.runtimeValidation.commands) {
|
||||
const cmdStart = Date.now();
|
||||
const result = await execCommand(cmd.command, {
|
||||
cwd: cmd.cwd ?? opts.workdir,
|
||||
env: { ...env, ...(cmd.env ?? {}) },
|
||||
timeoutMs: cmd.timeoutMs,
|
||||
});
|
||||
runtimeCommandResults.push({
|
||||
name: cmd.name,
|
||||
passed: !result.timedOut && result.exitCode === cmd.expectExitCode,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout.slice(0, 2000),
|
||||
stderr: result.stderr.slice(0, 2000),
|
||||
durationMs: Date.now() - cmdStart,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 3. DoD checks ──
|
||||
const checkResults: CheckResult[] = [];
|
||||
for (const check of contract.dod.checks) {
|
||||
const handler = CHECK_HANDLERS[check.kind];
|
||||
if (!handler) {
|
||||
checkResults.push({
|
||||
id: check.id,
|
||||
kind: check.kind,
|
||||
passed: false,
|
||||
blocking: check.blocking,
|
||||
severity: check.severity,
|
||||
evidence: "",
|
||||
errorMessage: `No handler registered for kind: ${check.kind}`,
|
||||
durationMs: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await handler(check, { workdir: opts.workdir, env });
|
||||
checkResults.push({
|
||||
id: check.id,
|
||||
kind: check.kind,
|
||||
passed: outcome.passed,
|
||||
blocking: check.blocking,
|
||||
severity: check.severity,
|
||||
evidence: outcome.evidence,
|
||||
errorMessage: outcome.errorMessage,
|
||||
durationMs: outcome.durationMs,
|
||||
});
|
||||
} catch (err) {
|
||||
checkResults.push({
|
||||
id: check.id,
|
||||
kind: check.kind,
|
||||
passed: false,
|
||||
blocking: check.blocking,
|
||||
severity: check.severity,
|
||||
evidence: "",
|
||||
errorMessage: `Handler threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||
durationMs: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aggregate ──
|
||||
const allResults = [
|
||||
...checkResults,
|
||||
...runtimeCommandResults.map((r) => ({
|
||||
id: `runtime:${r.name}`,
|
||||
kind: "command_success" as const,
|
||||
passed: r.passed,
|
||||
blocking: true,
|
||||
severity: "major" as const,
|
||||
evidence: r.passed ? `${r.name} exit ${r.exitCode}` : "",
|
||||
errorMessage: r.passed ? "" : `${r.name} failed: ${r.stderr.slice(0, 200)}`,
|
||||
durationMs: r.durationMs,
|
||||
})),
|
||||
];
|
||||
|
||||
const total = allResults.length;
|
||||
const passed = allResults.filter((r) => r.passed).length;
|
||||
const failed = total - passed;
|
||||
const blockingFailed = allResults.filter(
|
||||
(r) => !r.passed && r.blocking,
|
||||
).length;
|
||||
|
||||
const verdict = blockingFailed === 0 ? "PASS" : "FAIL";
|
||||
|
||||
log.info(
|
||||
{ contractId: contract.id, verdict, total, passed, failed, blockingFailed },
|
||||
"Validation complete",
|
||||
);
|
||||
|
||||
return {
|
||||
contractId: contract.id,
|
||||
verdict,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
prerequisiteResults,
|
||||
checkResults,
|
||||
runtimeCommandResults,
|
||||
summary: { total, passed, failed, blockingFailed },
|
||||
};
|
||||
}
|
||||
81
src/enforcement/guard.ts
Normal file
81
src/enforcement/guard.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
readSkillContext,
|
||||
isContextExpired,
|
||||
contextAgeSeconds,
|
||||
type SkillContext,
|
||||
} from "./skill-context.js";
|
||||
import { appendTrace } from "./skill-trace.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "guard" });
|
||||
|
||||
export interface GuardResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
context: SkillContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current operation is allowed based on skill context.
|
||||
* Used by pre-tool hook to gate Write/Edit/Bash calls.
|
||||
*/
|
||||
export async function checkGuard(
|
||||
railsDir: string,
|
||||
toolName: string,
|
||||
opts?: { sessionId?: string },
|
||||
): Promise<GuardResult> {
|
||||
// Escape hatch
|
||||
if (process.env["RAILS_ENFORCE"] === "off") {
|
||||
log.warn({ toolName }, "Enforcement disabled via RAILS_ENFORCE=off");
|
||||
await appendTrace(railsDir, {
|
||||
ts: Date.now(),
|
||||
tool: toolName,
|
||||
sessionId: opts?.sessionId ?? "",
|
||||
blocked: false,
|
||||
reason: "enforcement-off",
|
||||
});
|
||||
return { allowed: true, reason: "enforcement-off", context: null };
|
||||
}
|
||||
|
||||
const ctx = await readSkillContext(railsDir);
|
||||
|
||||
if (!ctx) {
|
||||
const reason = "No skill context found. Run /rails or rails skill-context create first.";
|
||||
log.warn({ toolName }, reason);
|
||||
await appendTrace(railsDir, {
|
||||
ts: Date.now(),
|
||||
tool: toolName,
|
||||
sessionId: opts?.sessionId ?? "",
|
||||
blocked: true,
|
||||
reason: "no-context",
|
||||
});
|
||||
return { allowed: false, reason, context: null };
|
||||
}
|
||||
|
||||
if (isContextExpired(ctx)) {
|
||||
const age = contextAgeSeconds(ctx);
|
||||
const reason = `Skill context expired (age: ${age}s, ttl: ${ctx.ttlSeconds}s). Re-enter the skill.`;
|
||||
log.warn({ toolName, age, ttl: ctx.ttlSeconds }, reason);
|
||||
await appendTrace(railsDir, {
|
||||
ts: Date.now(),
|
||||
tool: toolName,
|
||||
sessionId: opts?.sessionId ?? "",
|
||||
pipelineId: ctx.pipelineId,
|
||||
blocked: true,
|
||||
reason: "context-expired",
|
||||
});
|
||||
return { allowed: false, reason, context: ctx };
|
||||
}
|
||||
|
||||
// Valid context
|
||||
await appendTrace(railsDir, {
|
||||
ts: Date.now(),
|
||||
tool: toolName,
|
||||
sessionId: opts?.sessionId ?? "",
|
||||
pipelineId: ctx.pipelineId,
|
||||
blocked: false,
|
||||
reason: "ok",
|
||||
});
|
||||
|
||||
return { allowed: true, reason: "ok", context: ctx };
|
||||
}
|
||||
81
src/enforcement/skill-context.ts
Normal file
81
src/enforcement/skill-context.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "skill-context" });
|
||||
|
||||
export const SkillContext = z.object({
|
||||
skillName: z.string(),
|
||||
subcommand: z.string().default(""),
|
||||
pipelineId: z.string().default(""),
|
||||
contractId: z.string().default(""),
|
||||
sessionId: z.string().default(""),
|
||||
createdAt: z.string().datetime(),
|
||||
ttlSeconds: z.number().int().positive().default(300),
|
||||
});
|
||||
|
||||
export type SkillContext = z.infer<typeof SkillContext>;
|
||||
|
||||
const CONTEXT_FILENAME = "skill-context.json";
|
||||
|
||||
function contextPath(railsDir: string): string {
|
||||
return join(railsDir, ".rails", CONTEXT_FILENAME);
|
||||
}
|
||||
|
||||
export async function createSkillContext(
|
||||
railsDir: string,
|
||||
data: {
|
||||
skillName: string;
|
||||
subcommand?: string;
|
||||
pipelineId?: string;
|
||||
contractId?: string;
|
||||
sessionId?: string;
|
||||
ttlSeconds?: number;
|
||||
},
|
||||
): Promise<SkillContext> {
|
||||
const parsed = SkillContext.parse({
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const filePath = contextPath(railsDir);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, JSON.stringify(parsed, null, 2), "utf8");
|
||||
|
||||
log.info({ skillName: parsed.skillName, pipelineId: parsed.pipelineId }, "Skill context created");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function readSkillContext(
|
||||
railsDir: string,
|
||||
): Promise<SkillContext | null> {
|
||||
try {
|
||||
const raw = await readFile(contextPath(railsDir), "utf8");
|
||||
return SkillContext.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSkillContext(railsDir: string): Promise<boolean> {
|
||||
try {
|
||||
await unlink(contextPath(railsDir));
|
||||
log.info("Skill context cleared");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextExpired(ctx: SkillContext): boolean {
|
||||
const createdMs = new Date(ctx.createdAt).getTime();
|
||||
const nowMs = Date.now();
|
||||
const elapsedSeconds = (nowMs - createdMs) / 1000;
|
||||
return elapsedSeconds > ctx.ttlSeconds;
|
||||
}
|
||||
|
||||
export function contextAgeSeconds(ctx: SkillContext): number {
|
||||
const createdMs = new Date(ctx.createdAt).getTime();
|
||||
return Math.floor((Date.now() - createdMs) / 1000);
|
||||
}
|
||||
75
src/enforcement/skill-trace.ts
Normal file
75
src/enforcement/skill-trace.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
import { appendFile, readFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "skill-trace" });
|
||||
|
||||
export const TraceEntry = z.object({
|
||||
ts: z.number(),
|
||||
tool: z.string(),
|
||||
cwd: z.string().default(""),
|
||||
sessionId: z.string().default(""),
|
||||
pipelineId: z.string().default(""),
|
||||
blocked: z.boolean().default(false),
|
||||
reason: z.string().default(""),
|
||||
});
|
||||
|
||||
export type TraceEntry = z.infer<typeof TraceEntry>;
|
||||
|
||||
const TRACE_FILENAME = "skill-trace.jsonl";
|
||||
|
||||
function tracePath(railsDir: string): string {
|
||||
return join(railsDir, ".rails", TRACE_FILENAME);
|
||||
}
|
||||
|
||||
export async function appendTrace(
|
||||
railsDir: string,
|
||||
entry: {
|
||||
ts: number;
|
||||
tool: string;
|
||||
cwd?: string;
|
||||
sessionId?: string;
|
||||
pipelineId?: string;
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const filePath = tracePath(railsDir);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
|
||||
const parsed = TraceEntry.parse(entry);
|
||||
await appendFile(filePath, JSON.stringify(parsed) + "\n", "utf8");
|
||||
|
||||
if (parsed.blocked) {
|
||||
log.warn({ tool: parsed.tool, reason: parsed.reason }, "Tool call blocked");
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTrace(
|
||||
railsDir: string,
|
||||
opts?: { pipelineId?: string; limit?: number },
|
||||
): Promise<TraceEntry[]> {
|
||||
try {
|
||||
const raw = await readFile(tracePath(railsDir), "utf8");
|
||||
const lines = raw.trim().split("\n").filter(Boolean);
|
||||
let entries = lines.map((line) => TraceEntry.parse(JSON.parse(line)));
|
||||
|
||||
if (opts?.pipelineId) {
|
||||
entries = entries.filter((e) => e.pipelineId === opts.pipelineId);
|
||||
}
|
||||
|
||||
if (opts?.limit) {
|
||||
entries = entries.slice(-opts.limit);
|
||||
}
|
||||
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function countBlocked(railsDir: string): Promise<number> {
|
||||
const entries = await readTrace(railsDir);
|
||||
return entries.filter((e) => e.blocked).length;
|
||||
}
|
||||
37
src/env.ts
Normal file
37
src/env.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
||||
DISCORD_TOKEN: z.string().default(""),
|
||||
DISCORD_GUILD_ID: z.string().default(""),
|
||||
GITEA_WEBHOOK_SECRET: z.string().default(""),
|
||||
RAILS_PORT: z.coerce.number().int().positive().default(18800),
|
||||
RAILS_LOG_LEVEL: z
|
||||
.enum(["silent", "fatal", "error", "warn", "info", "debug", "trace"])
|
||||
.default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
let _env: Env | undefined;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
if (_env) return _env;
|
||||
const result = EnvSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
const formatted = result.error.issues
|
||||
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Environment validation failed:\n${formatted}`);
|
||||
}
|
||||
_env = result.data;
|
||||
return _env;
|
||||
}
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (!_env) return loadEnv();
|
||||
return _env;
|
||||
}
|
||||
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>;
|
||||
}
|
||||
37
src/logger.ts
Normal file
37
src/logger.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import pino from "pino";
|
||||
|
||||
let _logger: pino.Logger | undefined;
|
||||
|
||||
export function createLogger(opts?: {
|
||||
level?: string;
|
||||
pipelineId?: string;
|
||||
}): pino.Logger {
|
||||
const level = opts?.level ?? process.env["RAILS_LOG_LEVEL"] ?? "info";
|
||||
const isDev = process.env["NODE_ENV"] !== "production";
|
||||
|
||||
const logger = pino({
|
||||
level,
|
||||
...(isDev && {
|
||||
transport: { target: "pino-pretty", options: { colorize: true } },
|
||||
}),
|
||||
base: {
|
||||
service: "hanarang-rails",
|
||||
...(opts?.pipelineId && { pipelineId: opts.pipelineId }),
|
||||
},
|
||||
});
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
export function getLogger(): pino.Logger {
|
||||
if (!_logger) {
|
||||
_logger = createLogger();
|
||||
}
|
||||
return _logger;
|
||||
}
|
||||
|
||||
export function childLogger(
|
||||
bindings: Record<string, unknown>,
|
||||
): pino.Logger {
|
||||
return getLogger().child(bindings);
|
||||
}
|
||||
37
src/orchestrator/context.ts
Normal file
37
src/orchestrator/context.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const PipelineContext = z.object({
|
||||
pipelineId: z.string().min(1),
|
||||
projectName: z.string(),
|
||||
requirements: z.string().default(""),
|
||||
currentSprintId: z.string().nullable().default(null),
|
||||
reviewRound: z.number().int().min(0).default(0),
|
||||
retryCount: z.number().int().min(0).default(0),
|
||||
maxRetries: z.number().int().positive().default(3),
|
||||
maxReviewRounds: z.number().int().positive().default(3),
|
||||
lastError: z.string().nullable().default(null),
|
||||
contractPath: z.string().nullable().default(null),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type PipelineContext = z.infer<typeof PipelineContext>;
|
||||
|
||||
export function createInitialContext(
|
||||
pipelineId: string,
|
||||
projectName: string,
|
||||
requirements: string,
|
||||
): PipelineContext {
|
||||
return {
|
||||
pipelineId,
|
||||
projectName,
|
||||
requirements,
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
76
src/orchestrator/events.ts
Normal file
76
src/orchestrator/events.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AgentName = z.string().min(1);
|
||||
export type AgentName = z.infer<typeof AgentName>;
|
||||
|
||||
export const ReviewIssue = z.object({
|
||||
severity: z.enum(["critical", "major", "minor", "recommendation"]),
|
||||
message: z.string(),
|
||||
file: z.string().optional(),
|
||||
line: z.number().optional(),
|
||||
});
|
||||
export type ReviewIssue = z.infer<typeof ReviewIssue>;
|
||||
|
||||
export const PipelineEvent = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("REQUEST"),
|
||||
projectName: z.string(),
|
||||
requirements: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("PLAN_READY"),
|
||||
planDir: z.string(),
|
||||
sprintId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("IMPL_DONE"),
|
||||
branch: z.string(),
|
||||
commits: z.array(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("APPROVE"),
|
||||
reviewArtifact: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("REQUEST_CHANGES"),
|
||||
issues: z.array(ReviewIssue),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("DEPLOY_DONE"),
|
||||
deployArtifact: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("ERROR"),
|
||||
actor: AgentName,
|
||||
reason: z.string(),
|
||||
retryable: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("TIMEOUT"),
|
||||
actor: AgentName,
|
||||
elapsedMs: z.number(),
|
||||
}),
|
||||
z.object({ type: z.literal("RETRY") }),
|
||||
z.object({ type: z.literal("RESUME") }),
|
||||
z.object({
|
||||
type: z.literal("ABORT"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type PipelineEvent = z.infer<typeof PipelineEvent>;
|
||||
export type PipelineEventType = PipelineEvent["type"];
|
||||
|
||||
export const PIPELINE_STATES = [
|
||||
"idle",
|
||||
"planning",
|
||||
"implementing",
|
||||
"reviewing",
|
||||
"deploying",
|
||||
"retrying",
|
||||
"escalated",
|
||||
"done",
|
||||
"aborted",
|
||||
] as const;
|
||||
|
||||
export type PipelineState = (typeof PIPELINE_STATES)[number];
|
||||
248
src/orchestrator/machine.ts
Normal file
248
src/orchestrator/machine.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { setup, assign } from "xstate";
|
||||
import type { PipelineContext } from "./context.js";
|
||||
import type { PipelineEvent } from "./events.js";
|
||||
|
||||
/**
|
||||
* Deterministic pipeline state machine.
|
||||
*
|
||||
* States: idle → planning → implementing → reviewing → deploying → done
|
||||
* Guards: retryCount < maxRetries, reviewRound <= maxReviewRounds
|
||||
* Errors: retryable → retrying → prev state | non-retryable → escalated
|
||||
*/
|
||||
export const pipelineMachine = setup({
|
||||
types: {
|
||||
context: {} as PipelineContext,
|
||||
events: {} as PipelineEvent,
|
||||
},
|
||||
guards: {
|
||||
canRetry: ({ context }: { context: PipelineContext }) =>
|
||||
context.retryCount < context.maxRetries,
|
||||
canReviewAgain: ({ context }: { context: PipelineContext }) =>
|
||||
context.reviewRound < context.maxReviewRounds,
|
||||
isRetryable: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" && event.retryable === true,
|
||||
},
|
||||
actions: {
|
||||
incrementRetry: assign({
|
||||
retryCount: ({ context }: { context: PipelineContext }) =>
|
||||
context.retryCount + 1,
|
||||
}),
|
||||
resetRetry: assign({ retryCount: 0 }),
|
||||
incrementReviewRound: assign({
|
||||
reviewRound: ({ context }: { context: PipelineContext }) =>
|
||||
context.reviewRound + 1,
|
||||
}),
|
||||
resetReviewRound: assign({ reviewRound: 0 }),
|
||||
setError: assign({
|
||||
lastError: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ERROR" ? event.reason : null,
|
||||
}),
|
||||
clearError: assign({ lastError: null }),
|
||||
setSprintId: assign({
|
||||
currentSprintId: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "PLAN_READY" ? event.sprintId : null,
|
||||
}),
|
||||
setAbortReason: assign({
|
||||
lastError: ({ event }: { event: PipelineEvent }) =>
|
||||
event.type === "ABORT" ? event.reason : null,
|
||||
}),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "pipeline",
|
||||
initial: "idle",
|
||||
context: ({}) => ({
|
||||
pipelineId: "",
|
||||
projectName: "",
|
||||
requirements: "",
|
||||
currentSprintId: null,
|
||||
reviewRound: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
maxReviewRounds: 3,
|
||||
lastError: null,
|
||||
contractPath: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
REQUEST: {
|
||||
target: "planning",
|
||||
actions: [
|
||||
"clearError",
|
||||
"resetRetry",
|
||||
assign({
|
||||
projectName: ({ event }) => event.projectName,
|
||||
requirements: ({ event }) => event.requirements,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
planning: {
|
||||
on: {
|
||||
PLAN_READY: {
|
||||
target: "implementing",
|
||||
actions: ["setSprintId", "resetRetry", "resetReviewRound"],
|
||||
},
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
implementing: {
|
||||
on: {
|
||||
IMPL_DONE: {
|
||||
target: "reviewing",
|
||||
actions: ["resetRetry"],
|
||||
},
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
reviewing: {
|
||||
on: {
|
||||
APPROVE: {
|
||||
target: "deploying",
|
||||
actions: ["resetRetry"],
|
||||
},
|
||||
REQUEST_CHANGES: [
|
||||
{
|
||||
guard: "canReviewAgain",
|
||||
target: "implementing",
|
||||
actions: ["incrementReviewRound"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: [
|
||||
assign({
|
||||
lastError: "Max review rounds exceeded",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
deploying: {
|
||||
on: {
|
||||
DEPLOY_DONE: {
|
||||
target: "done",
|
||||
},
|
||||
ERROR: [
|
||||
{
|
||||
guard: "isRetryable",
|
||||
target: "retrying",
|
||||
actions: ["setError", "incrementRetry"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: ["setError"],
|
||||
},
|
||||
],
|
||||
TIMEOUT: {
|
||||
target: "retrying",
|
||||
actions: ["incrementRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
retrying: {
|
||||
always: [
|
||||
{
|
||||
guard: "canRetry",
|
||||
// For now, go back to idle; in Sprint 005 this will return
|
||||
// to the previous state via history node.
|
||||
target: "idle",
|
||||
actions: ["clearError"],
|
||||
},
|
||||
{
|
||||
target: "escalated",
|
||||
actions: [
|
||||
assign({ lastError: "Max retries exceeded" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
escalated: {
|
||||
on: {
|
||||
RESUME: {
|
||||
target: "idle",
|
||||
actions: ["clearError", "resetRetry"],
|
||||
},
|
||||
ABORT: {
|
||||
target: "aborted",
|
||||
actions: ["setAbortReason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
done: {
|
||||
type: "final",
|
||||
},
|
||||
|
||||
aborted: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
});
|
||||
174
src/orchestrator/persist.ts
Normal file
174
src/orchestrator/persist.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { createActor, type Snapshot } from "xstate";
|
||||
import { ulid } from "ulid";
|
||||
import { pipelineMachine } from "./machine.js";
|
||||
import { createInitialContext, type PipelineContext } from "./context.js";
|
||||
import type { PipelineEvent, PipelineState } from "./events.js";
|
||||
import { childLogger } from "../logger.js";
|
||||
|
||||
const log = childLogger({ module: "persist" });
|
||||
|
||||
let _prisma: PrismaClient | undefined;
|
||||
|
||||
export function getPrisma(): PrismaClient {
|
||||
if (!_prisma) {
|
||||
_prisma = new PrismaClient();
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
export async function createPipeline(
|
||||
projectName: string,
|
||||
requirements: string,
|
||||
): Promise<{ pipelineId: string; state: PipelineState }> {
|
||||
const prisma = getPrisma();
|
||||
const pipelineId = ulid();
|
||||
const ctx = createInitialContext(pipelineId, projectName, requirements);
|
||||
|
||||
const actor = createActor(pipelineMachine, {
|
||||
input: ctx,
|
||||
});
|
||||
actor.start();
|
||||
const snapshot = actor.getSnapshot();
|
||||
actor.stop();
|
||||
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
id: pipelineId,
|
||||
projectName,
|
||||
requirements,
|
||||
currentState: String(snapshot.value),
|
||||
contextJson: JSON.stringify(ctx),
|
||||
},
|
||||
});
|
||||
|
||||
log.info({ pipelineId, projectName }, "Pipeline created");
|
||||
return { pipelineId, state: String(snapshot.value) as PipelineState };
|
||||
}
|
||||
|
||||
export async function sendEvent(
|
||||
pipelineId: string,
|
||||
event: PipelineEvent,
|
||||
): Promise<{ state: PipelineState; context: PipelineContext }> {
|
||||
const prisma = getPrisma();
|
||||
|
||||
const pipeline = await prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: pipelineId },
|
||||
});
|
||||
|
||||
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
|
||||
const fromState = pipeline.currentState;
|
||||
|
||||
const actor = createActor(pipelineMachine, {
|
||||
snapshot: {
|
||||
value: fromState,
|
||||
context: ctx,
|
||||
} as unknown as Snapshot<unknown>,
|
||||
});
|
||||
actor.start();
|
||||
actor.send(event);
|
||||
|
||||
const snapshot = actor.getSnapshot();
|
||||
const toState = String(snapshot.value);
|
||||
const newContext = snapshot.context as PipelineContext;
|
||||
actor.stop();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.pipeline.update({
|
||||
where: { id: pipelineId },
|
||||
data: {
|
||||
currentState: toState,
|
||||
contextJson: JSON.stringify(newContext),
|
||||
},
|
||||
}),
|
||||
prisma.stateTransition.create({
|
||||
data: {
|
||||
pipelineId,
|
||||
fromState,
|
||||
toState,
|
||||
eventType: event.type,
|
||||
eventPayload: JSON.stringify(event),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
log.info(
|
||||
{ pipelineId, fromState, toState, event: event.type },
|
||||
"State transition",
|
||||
);
|
||||
|
||||
return { state: toState as PipelineState, context: newContext };
|
||||
}
|
||||
|
||||
export async function getPipelineState(
|
||||
pipelineId: string,
|
||||
): Promise<{
|
||||
state: PipelineState;
|
||||
context: PipelineContext;
|
||||
transitions: Array<{
|
||||
fromState: string;
|
||||
toState: string;
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
} | null> {
|
||||
const prisma = getPrisma();
|
||||
|
||||
const pipeline = await prisma.pipeline.findUnique({
|
||||
where: { id: pipelineId },
|
||||
include: {
|
||||
transitions: {
|
||||
orderBy: { timestamp: "asc" },
|
||||
select: {
|
||||
fromState: true,
|
||||
toState: true,
|
||||
eventType: true,
|
||||
timestamp: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!pipeline) return null;
|
||||
|
||||
return {
|
||||
state: pipeline.currentState as PipelineState,
|
||||
context: JSON.parse(pipeline.contextJson) as PipelineContext,
|
||||
transitions: pipeline.transitions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPipelines(opts?: {
|
||||
state?: PipelineState;
|
||||
limit?: number;
|
||||
}): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
projectName: string;
|
||||
currentState: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>
|
||||
> {
|
||||
const prisma = getPrisma();
|
||||
|
||||
return prisma.pipeline.findMany({
|
||||
where: opts?.state ? { currentState: opts.state } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: opts?.limit ?? 20,
|
||||
select: {
|
||||
id: true,
|
||||
projectName: true,
|
||||
currentState: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function disconnectPrisma(): Promise<void> {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = undefined;
|
||||
}
|
||||
}
|
||||
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("");
|
||||
});
|
||||
});
|
||||
447
tests/contract.test.ts
Normal file
447
tests/contract.test.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { validateContract } from "../src/contract/validator.js";
|
||||
import { fileExistsCheck } from "../src/contract/checks/file-exists.js";
|
||||
import { commandSuccessCheck } from "../src/contract/checks/command-success.js";
|
||||
import {
|
||||
regexInFileCheck,
|
||||
regexAbsentCheck,
|
||||
} from "../src/contract/checks/regex-in-file.js";
|
||||
import { httpStatusCheck } from "../src/contract/checks/http-status.js";
|
||||
import { artifactSchemaCheck } from "../src/contract/checks/artifact-schema.js";
|
||||
import { manualCheck } from "../src/contract/checks/manual.js";
|
||||
import { generateDraftContract } from "../src/contract/generator.js";
|
||||
import { saveDraftContract, loadContract } from "../src/contract/store.js";
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "rails-contract-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("file_exists check", () => {
|
||||
it("passes when file exists", async () => {
|
||||
await writeFile(join(testDir, "README.md"), "# test");
|
||||
const result = await fileExistsCheck(
|
||||
{
|
||||
id: "readme",
|
||||
description: "",
|
||||
kind: "file_exists",
|
||||
spec: { path: "README.md" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.evidence).toContain("exists");
|
||||
});
|
||||
|
||||
it("fails when file missing", async () => {
|
||||
const result = await fileExistsCheck(
|
||||
{
|
||||
id: "nope",
|
||||
description: "",
|
||||
kind: "file_exists",
|
||||
spec: { path: "missing.txt" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.errorMessage).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("command_success check", () => {
|
||||
it("passes on exit 0", async () => {
|
||||
const result = await commandSuccessCheck(
|
||||
{
|
||||
id: "true",
|
||||
description: "",
|
||||
kind: "command_success",
|
||||
spec: { command: "true", timeoutMs: 5000, expectExitCode: 0 },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: process.env as Record<string, string> },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails on non-zero exit", async () => {
|
||||
const result = await commandSuccessCheck(
|
||||
{
|
||||
id: "false",
|
||||
description: "",
|
||||
kind: "command_success",
|
||||
spec: { command: "false", timeoutMs: 5000, expectExitCode: 0 },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: process.env as Record<string, string> },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
|
||||
it("fails on timeout", async () => {
|
||||
const result = await commandSuccessCheck(
|
||||
{
|
||||
id: "sleep",
|
||||
description: "",
|
||||
kind: "command_success",
|
||||
spec: { command: "sleep 5", timeoutMs: 300, expectExitCode: 0 },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: process.env as Record<string, string> },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.errorMessage).toContain("timed out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("regex_in_file check", () => {
|
||||
it("matches pattern", async () => {
|
||||
await writeFile(join(testDir, "config.json"), '{"strict": true}');
|
||||
const result = await regexInFileCheck(
|
||||
{
|
||||
id: "strict",
|
||||
description: "",
|
||||
kind: "regex_in_file",
|
||||
spec: { path: "config.json", pattern: '"strict"\\s*:\\s*true' },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when pattern absent", async () => {
|
||||
await writeFile(join(testDir, "config.json"), "{}");
|
||||
const result = await regexInFileCheck(
|
||||
{
|
||||
id: "strict",
|
||||
description: "",
|
||||
kind: "regex_in_file",
|
||||
spec: { path: "config.json", pattern: "strict" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regex_absent check", () => {
|
||||
it("passes when pattern absent", async () => {
|
||||
await writeFile(join(testDir, "code.ts"), "const x = 1");
|
||||
const result = await regexAbsentCheck(
|
||||
{
|
||||
id: "no-console",
|
||||
description: "",
|
||||
kind: "regex_absent",
|
||||
spec: { path: "code.ts", pattern: "console\\." },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when forbidden pattern found", async () => {
|
||||
await writeFile(join(testDir, "code.ts"), "console.log(42)");
|
||||
const result = await regexAbsentCheck(
|
||||
{
|
||||
id: "no-console",
|
||||
description: "",
|
||||
kind: "regex_absent",
|
||||
spec: { path: "code.ts", pattern: "console\\." },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("http_status check", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.url === "/ok") {
|
||||
res.writeHead(200);
|
||||
res.end("ok");
|
||||
} else if (req.url === "/notfound") {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
const addr = server.address();
|
||||
if (typeof addr === "object" && addr) {
|
||||
port = addr.port;
|
||||
} else {
|
||||
throw new Error("Cannot get server port");
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
it("passes on matching status", async () => {
|
||||
const result = await httpStatusCheck(
|
||||
{
|
||||
id: "health",
|
||||
description: "",
|
||||
kind: "http_status",
|
||||
spec: {
|
||||
url: `http://127.0.0.1:${port}/ok`,
|
||||
expectStatus: 200,
|
||||
},
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails on status mismatch", async () => {
|
||||
const result = await httpStatusCheck(
|
||||
{
|
||||
id: "health",
|
||||
description: "",
|
||||
kind: "http_status",
|
||||
spec: {
|
||||
url: `http://127.0.0.1:${port}/notfound`,
|
||||
expectStatus: 200,
|
||||
},
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact_schema check", () => {
|
||||
it("passes when JSON matches registered schema", async () => {
|
||||
const valid = {
|
||||
id: "c1",
|
||||
kind: "file_exists",
|
||||
passed: true,
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
evidence: "found",
|
||||
errorMessage: "",
|
||||
durationMs: 5,
|
||||
};
|
||||
await writeFile(join(testDir, "result.json"), JSON.stringify(valid));
|
||||
const result = await artifactSchemaCheck(
|
||||
{
|
||||
id: "schema",
|
||||
description: "",
|
||||
kind: "artifact_schema",
|
||||
spec: { artifactPath: "result.json", schemaName: "CheckResult" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when schema name is unknown", async () => {
|
||||
const result = await artifactSchemaCheck(
|
||||
{
|
||||
id: "schema",
|
||||
description: "",
|
||||
kind: "artifact_schema",
|
||||
spec: { artifactPath: "nope.json", schemaName: "NonExistent" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.errorMessage).toContain("Unknown schema");
|
||||
});
|
||||
});
|
||||
|
||||
describe("manual check (stub)", () => {
|
||||
it("is always SKIP (passed=true) in Sprint 003", async () => {
|
||||
const result = await manualCheck(
|
||||
{
|
||||
id: "review",
|
||||
description: "",
|
||||
kind: "manual",
|
||||
spec: { question: "Is the code clean?" },
|
||||
blocking: true,
|
||||
severity: "major",
|
||||
},
|
||||
{ workdir: testDir, env: {} },
|
||||
);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.evidence).toContain("SKIPPED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validator integration", () => {
|
||||
it("PASS when all checks pass", async () => {
|
||||
await writeFile(join(testDir, "README.md"), "# ok");
|
||||
const contract = {
|
||||
version: "v1" as const,
|
||||
id: "c1",
|
||||
sprintId: "S1",
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "feature" as const,
|
||||
dod: {
|
||||
checks: [
|
||||
{
|
||||
id: "readme",
|
||||
description: "",
|
||||
kind: "file_exists" as const,
|
||||
spec: { path: "README.md" },
|
||||
blocking: true,
|
||||
severity: "major" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
environmentPrerequisites: [],
|
||||
nonGoals: [],
|
||||
runtimeValidation: { commands: [] },
|
||||
riskFlags: [],
|
||||
reviewerProfile: "static" as const,
|
||||
approvalGates: { impl: true, review: true, deploy: true },
|
||||
};
|
||||
const result = await validateContract(contract, { workdir: testDir });
|
||||
expect(result.verdict).toBe("PASS");
|
||||
expect(result.summary.passed).toBe(1);
|
||||
});
|
||||
|
||||
it("FAIL when a blocking check fails", async () => {
|
||||
const contract = {
|
||||
version: "v1" as const,
|
||||
id: "c2",
|
||||
sprintId: "S1",
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "feature" as const,
|
||||
dod: {
|
||||
checks: [
|
||||
{
|
||||
id: "missing",
|
||||
description: "",
|
||||
kind: "file_exists" as const,
|
||||
spec: { path: "does-not-exist.txt" },
|
||||
blocking: true,
|
||||
severity: "major" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
environmentPrerequisites: [],
|
||||
nonGoals: [],
|
||||
runtimeValidation: { commands: [] },
|
||||
riskFlags: [],
|
||||
reviewerProfile: "static" as const,
|
||||
approvalGates: { impl: true, review: true, deploy: true },
|
||||
};
|
||||
const result = await validateContract(contract, { workdir: testDir });
|
||||
expect(result.verdict).toBe("FAIL");
|
||||
expect(result.summary.blockingFailed).toBe(1);
|
||||
});
|
||||
|
||||
it("ABORT_PRECHECK when prerequisite missing", async () => {
|
||||
const contract = {
|
||||
version: "v1" as const,
|
||||
id: "c3",
|
||||
sprintId: "S1",
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "feature" as const,
|
||||
dod: { checks: [] },
|
||||
environmentPrerequisites: [
|
||||
{
|
||||
name: "nonexistent-cmd",
|
||||
check: "command_exists" as const,
|
||||
spec: { command: "definitely-not-a-real-command-xyz-42" },
|
||||
reason: "need it",
|
||||
},
|
||||
],
|
||||
nonGoals: [],
|
||||
runtimeValidation: { commands: [] },
|
||||
riskFlags: [],
|
||||
reviewerProfile: "static" as const,
|
||||
approvalGates: { impl: true, review: true, deploy: true },
|
||||
};
|
||||
const result = await validateContract(contract, { workdir: testDir });
|
||||
expect(result.verdict).toBe("ABORT_PRECHECK");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generator + store", () => {
|
||||
it("generates draft contract from sprint markdown", async () => {
|
||||
const mdPath = join(testDir, "SPRINT-001.md");
|
||||
await writeFile(
|
||||
mdPath,
|
||||
`# SPRINT-001 — Test Sprint\n\n## Type\n\`scaffold\`\n\n## Non-Goals\n\n- Skip XYZ\n- Do not do ABC\n`,
|
||||
);
|
||||
const draft = await generateDraftContract(mdPath, "SPRINT-001");
|
||||
expect(draft.version).toBe("v1");
|
||||
expect(draft.type).toBe("scaffold");
|
||||
expect(draft.sprintId).toBe("SPRINT-001");
|
||||
expect(draft.nonGoals).toEqual(["Skip XYZ", "Do not do ABC"]);
|
||||
expect(draft.dod.checks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("saves and loads a contract round-trip", async () => {
|
||||
await mkdir(join(testDir, ".rails", "contracts"), { recursive: true });
|
||||
const draft = {
|
||||
version: "v1" as const,
|
||||
id: "test-01",
|
||||
sprintId: "S1",
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "scaffold" as const,
|
||||
dod: {
|
||||
checks: [
|
||||
{
|
||||
id: "c1",
|
||||
description: "",
|
||||
kind: "file_exists" as const,
|
||||
spec: { path: "README.md" },
|
||||
blocking: true,
|
||||
severity: "major" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
environmentPrerequisites: [],
|
||||
nonGoals: [],
|
||||
runtimeValidation: { commands: [] },
|
||||
riskFlags: [],
|
||||
reviewerProfile: "static" as const,
|
||||
approvalGates: { impl: true, review: true, deploy: true },
|
||||
};
|
||||
await saveDraftContract(testDir, draft);
|
||||
const loaded = await loadContract(testDir, "test-01");
|
||||
expect(loaded.id).toBe("test-01");
|
||||
expect(loaded.dod.checks[0]?.kind).toBe("file_exists");
|
||||
});
|
||||
});
|
||||
157
tests/enforcement.test.ts
Normal file
157
tests/enforcement.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
createSkillContext,
|
||||
readSkillContext,
|
||||
clearSkillContext,
|
||||
isContextExpired,
|
||||
} from "../src/enforcement/skill-context.js";
|
||||
import { appendTrace, readTrace, countBlocked } from "../src/enforcement/skill-trace.js";
|
||||
import { checkGuard } from "../src/enforcement/guard.js";
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "rails-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("skill-context", () => {
|
||||
it("creates and reads context", async () => {
|
||||
const ctx = await createSkillContext(testDir, {
|
||||
skillName: "rails",
|
||||
pipelineId: "01TEST",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
expect(ctx.skillName).toBe("rails");
|
||||
expect(ctx.pipelineId).toBe("01TEST");
|
||||
|
||||
const read = await readSkillContext(testDir);
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.skillName).toBe("rails");
|
||||
});
|
||||
|
||||
it("returns null when no context exists", async () => {
|
||||
const read = await readSkillContext(testDir);
|
||||
expect(read).toBeNull();
|
||||
});
|
||||
|
||||
it("clears context", async () => {
|
||||
await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 300 });
|
||||
const cleared = await clearSkillContext(testDir);
|
||||
expect(cleared).toBe(true);
|
||||
const read = await readSkillContext(testDir);
|
||||
expect(read).toBeNull();
|
||||
});
|
||||
|
||||
it("detects expired context", () => {
|
||||
const ctx = {
|
||||
skillName: "rails",
|
||||
subcommand: "",
|
||||
pipelineId: "",
|
||||
contractId: "",
|
||||
sessionId: "",
|
||||
createdAt: new Date(Date.now() - 400_000).toISOString(), // 400s ago
|
||||
ttlSeconds: 300,
|
||||
};
|
||||
expect(isContextExpired(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects valid context", () => {
|
||||
const ctx = {
|
||||
skillName: "rails",
|
||||
subcommand: "",
|
||||
pipelineId: "",
|
||||
contractId: "",
|
||||
sessionId: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
ttlSeconds: 300,
|
||||
};
|
||||
expect(isContextExpired(ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill-trace", () => {
|
||||
it("appends and reads trace entries", async () => {
|
||||
await appendTrace(testDir, {
|
||||
ts: Date.now(),
|
||||
tool: "Write",
|
||||
blocked: false,
|
||||
reason: "ok",
|
||||
});
|
||||
await appendTrace(testDir, {
|
||||
ts: Date.now(),
|
||||
tool: "Bash",
|
||||
blocked: true,
|
||||
reason: "no-context",
|
||||
});
|
||||
|
||||
const entries = await readTrace(testDir);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[1]!.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("counts blocked entries", async () => {
|
||||
await appendTrace(testDir, { ts: Date.now(), tool: "Write", blocked: false, reason: "ok" });
|
||||
await appendTrace(testDir, { ts: Date.now(), tool: "Edit", blocked: true, reason: "no-ctx" });
|
||||
await appendTrace(testDir, { ts: Date.now(), tool: "Bash", blocked: true, reason: "expired" });
|
||||
|
||||
expect(await countBlocked(testDir)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no trace file", async () => {
|
||||
expect(await readTrace(testDir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guard", () => {
|
||||
it("blocks when no context exists", async () => {
|
||||
const result = await checkGuard(testDir, "Write");
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("No skill context");
|
||||
});
|
||||
|
||||
it("allows when valid context exists", async () => {
|
||||
await createSkillContext(testDir, {
|
||||
skillName: "rails",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
const result = await checkGuard(testDir, "Write");
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBe("ok");
|
||||
});
|
||||
|
||||
it("blocks when context is expired", async () => {
|
||||
await createSkillContext(testDir, {
|
||||
skillName: "rails",
|
||||
ttlSeconds: 1, // 1 second TTL
|
||||
});
|
||||
// Wait just over 1 second
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const result = await checkGuard(testDir, "Edit");
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("expired");
|
||||
});
|
||||
|
||||
it("allows when RAILS_ENFORCE=off", async () => {
|
||||
process.env["RAILS_ENFORCE"] = "off";
|
||||
try {
|
||||
const result = await checkGuard(testDir, "Bash");
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBe("enforcement-off");
|
||||
} finally {
|
||||
delete process.env["RAILS_ENFORCE"];
|
||||
}
|
||||
});
|
||||
|
||||
it("records blocked calls in trace", async () => {
|
||||
await checkGuard(testDir, "Write");
|
||||
const entries = await readTrace(testDir);
|
||||
expect(entries.some((e) => e.blocked && e.tool === "Write")).toBe(true);
|
||||
});
|
||||
});
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
113
tests/machine.test.ts
Normal file
113
tests/machine.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createActor } from "xstate";
|
||||
import { pipelineMachine } from "../src/orchestrator/machine.js";
|
||||
|
||||
function runMachine(events: Array<Record<string, unknown>>) {
|
||||
const actor = createActor(pipelineMachine);
|
||||
actor.start();
|
||||
for (const event of events) {
|
||||
actor.send(event as any);
|
||||
}
|
||||
const snapshot = actor.getSnapshot();
|
||||
actor.stop();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe("pipelineMachine", () => {
|
||||
it("starts in idle", () => {
|
||||
const actor = createActor(pipelineMachine);
|
||||
actor.start();
|
||||
expect(actor.getSnapshot().value).toBe("idle");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("happy path: idle → planning → implementing → reviewing → deploying → done", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "build something" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp/plans", sprintId: "SPRINT-001" },
|
||||
{ type: "IMPL_DONE", branch: "feature/sprint-001", commits: ["abc1234"] },
|
||||
{ type: "APPROVE", reviewArtifact: "/tmp/review.json" },
|
||||
{ type: "DEPLOY_DONE", deployArtifact: "/tmp/deploy.json" },
|
||||
]);
|
||||
expect(snapshot.value).toBe("done");
|
||||
expect(snapshot.status).toBe("done");
|
||||
});
|
||||
|
||||
it("REQUEST_CHANGES loops back to implementing (up to maxReviewRounds)", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix it" }] },
|
||||
]);
|
||||
expect(snapshot.value).toBe("implementing");
|
||||
expect(snapshot.context.reviewRound).toBe(1);
|
||||
});
|
||||
|
||||
it("escalates after max review rounds exceeded", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
|
||||
// Round 1 (reviewRound: 0 → 1)
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
// Round 2 (reviewRound: 1 → 2)
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c2"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
// Round 3 (reviewRound: 2 → 3)
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c3"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
// Round 4 — reviewRound=3, guard 3 < 3 = false → escalated
|
||||
{ type: "IMPL_DONE", branch: "b", commits: ["c4"] },
|
||||
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
|
||||
]);
|
||||
expect(snapshot.value).toBe("escalated");
|
||||
expect(snapshot.context.lastError).toContain("review rounds");
|
||||
});
|
||||
|
||||
it("retryable error goes to retrying, then back (if under limit)", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "ERROR", actor: "planner", reason: "timeout", retryable: true },
|
||||
]);
|
||||
// retrying has an always transition — if canRetry, goes to idle
|
||||
expect(snapshot.value).toBe("idle");
|
||||
expect(snapshot.context.retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it("non-retryable error goes to escalated", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "ERROR", actor: "planner", reason: "permission denied", retryable: false },
|
||||
]);
|
||||
expect(snapshot.value).toBe("escalated");
|
||||
expect(snapshot.context.lastError).toBe("permission denied");
|
||||
});
|
||||
|
||||
it("escalated → RESUME goes back to idle", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "ERROR", actor: "planner", reason: "fail", retryable: false },
|
||||
{ type: "RESUME" },
|
||||
]);
|
||||
expect(snapshot.value).toBe("idle");
|
||||
expect(snapshot.context.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("ABORT from any active state goes to aborted", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "test", requirements: "" },
|
||||
{ type: "ABORT", reason: "user cancelled" },
|
||||
]);
|
||||
expect(snapshot.value).toBe("aborted");
|
||||
expect(snapshot.context.lastError).toBe("user cancelled");
|
||||
});
|
||||
|
||||
it("context tracks projectName and requirements from REQUEST", () => {
|
||||
const snapshot = runMachine([
|
||||
{ type: "REQUEST", projectName: "arang", requirements: "Live2D avatar" },
|
||||
]);
|
||||
expect(snapshot.context.projectName).toBe("arang");
|
||||
expect(snapshot.context.requirements).toBe("Live2D avatar");
|
||||
});
|
||||
});
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user