@john-ezra/openralph 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +25 -0
- package/AGENTS.md +148 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/PROMPT_build.md +48 -0
- package/PROMPT_plan.md +29 -0
- package/README.md +247 -0
- package/bin/openralph +5 -0
- package/bun.lock +85 -0
- package/container/Dockerfile +58 -0
- package/container/bin/chrome-devtools-mcp-wrapper +57 -0
- package/package.json +50 -0
- package/src/args.ts +236 -0
- package/src/artifacts.ts +183 -0
- package/src/cli.ts +125 -0
- package/src/design.ts +24 -0
- package/src/docker.ts +403 -0
- package/src/exec.ts +103 -0
- package/src/git.ts +62 -0
- package/src/launcher.ts +235 -0
- package/src/loop.ts +339 -0
- package/src/plugin.ts +56 -0
- package/src/release-check.ts +143 -0
- package/src/sentinels.ts +23 -0
- package/src/tags.ts +22 -0
- package/src/trust.ts +116 -0
- package/src/tui.ts +436 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
FROM oven/bun:1.2.22-debian
|
|
2
|
+
|
|
3
|
+
ARG OPENCODE_VERSION=1.15.13
|
|
4
|
+
ARG CHROME_DEVTOOLS_MCP_VERSION=1.1.1
|
|
5
|
+
ARG NODE_MAJOR=22
|
|
6
|
+
|
|
7
|
+
ENV BUN_INSTALL=/usr/local
|
|
8
|
+
ENV PATH=/opt/openralph/bin:/usr/local/bin:${PATH}
|
|
9
|
+
|
|
10
|
+
RUN apt-get update \
|
|
11
|
+
&& apt-get install -y --no-install-recommends bash ca-certificates curl git gnupg \
|
|
12
|
+
&& mkdir -p /etc/apt/keyrings \
|
|
13
|
+
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
|
14
|
+
&& printf 'deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_%s.x nodistro main\n' "${NODE_MAJOR}" > /etc/apt/sources.list.d/nodesource.list \
|
|
15
|
+
&& curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /etc/apt/keyrings/google-linux.gpg \
|
|
16
|
+
&& printf 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-linux.gpg] https://dl.google.com/linux/chrome/deb/ stable main\n' > /etc/apt/sources.list.d/google-chrome.list \
|
|
17
|
+
&& apt-get update \
|
|
18
|
+
&& apt-get install -y --no-install-recommends \
|
|
19
|
+
build-essential \
|
|
20
|
+
fonts-dejavu \
|
|
21
|
+
fonts-liberation \
|
|
22
|
+
fonts-noto-color-emoji \
|
|
23
|
+
fonts-noto-core \
|
|
24
|
+
google-chrome-stable \
|
|
25
|
+
nodejs \
|
|
26
|
+
pkg-config \
|
|
27
|
+
python3 \
|
|
28
|
+
python3-pip \
|
|
29
|
+
python3-venv \
|
|
30
|
+
ripgrep \
|
|
31
|
+
&& corepack enable \
|
|
32
|
+
&& npm install --global chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION} \
|
|
33
|
+
&& npm cache clean --force \
|
|
34
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
35
|
+
|
|
36
|
+
RUN bun install --global opencode-ai@${OPENCODE_VERSION}
|
|
37
|
+
|
|
38
|
+
WORKDIR /opt/openralph
|
|
39
|
+
|
|
40
|
+
COPY package.json bun.lock tsconfig.json ./
|
|
41
|
+
COPY src ./src
|
|
42
|
+
COPY container/bin ./bin
|
|
43
|
+
COPY bin/openralph ./bin/openralph
|
|
44
|
+
COPY AGENTS.md README.md PROMPT_build.md PROMPT_plan.md ./
|
|
45
|
+
|
|
46
|
+
RUN bun install --production --frozen-lockfile
|
|
47
|
+
|
|
48
|
+
RUN chmod +x /opt/openralph/bin/chrome-devtools-mcp-wrapper /opt/openralph/bin/openralph
|
|
49
|
+
|
|
50
|
+
RUN useradd --create-home --shell /bin/bash opencode \
|
|
51
|
+
&& mkdir -p /home/opencode/.local/share/opencode /workspace \
|
|
52
|
+
&& chmod -R 0777 /home/opencode /workspace
|
|
53
|
+
|
|
54
|
+
ENV HOME=/home/opencode
|
|
55
|
+
|
|
56
|
+
USER opencode
|
|
57
|
+
|
|
58
|
+
WORKDIR /workspace
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
profile="$(mktemp -d "${TMPDIR:-/tmp}/openralph-chrome-profile.XXXXXX")"
|
|
5
|
+
log_file="${TMPDIR:-/tmp}/openralph-chrome-$$.log"
|
|
6
|
+
chrome_pid=""
|
|
7
|
+
port=""
|
|
8
|
+
ready="false"
|
|
9
|
+
|
|
10
|
+
cleanup() {
|
|
11
|
+
if [ -n "${chrome_pid}" ] && kill -0 "${chrome_pid}" 2>/dev/null; then
|
|
12
|
+
kill "${chrome_pid}" 2>/dev/null || true
|
|
13
|
+
wait "${chrome_pid}" 2>/dev/null || true
|
|
14
|
+
fi
|
|
15
|
+
rm -rf "${profile}" 2>/dev/null || true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
trap cleanup EXIT INT TERM
|
|
19
|
+
|
|
20
|
+
google-chrome \
|
|
21
|
+
--headless \
|
|
22
|
+
--disable-gpu \
|
|
23
|
+
--no-sandbox \
|
|
24
|
+
--remote-debugging-address=127.0.0.1 \
|
|
25
|
+
--remote-debugging-port=0 \
|
|
26
|
+
--user-data-dir="${profile}" \
|
|
27
|
+
--window-size=1440,1000 \
|
|
28
|
+
about:blank >"${log_file}" 2>&1 &
|
|
29
|
+
chrome_pid=$!
|
|
30
|
+
|
|
31
|
+
port_file="${profile}/DevToolsActivePort"
|
|
32
|
+
attempts=0
|
|
33
|
+
while [ "${attempts}" -lt 100 ]; do
|
|
34
|
+
attempts=$((attempts + 1))
|
|
35
|
+
if [ -s "${port_file}" ]; then
|
|
36
|
+
IFS= read -r port <"${port_file}"
|
|
37
|
+
if [ -n "${port}" ] && curl -fsS "http://127.0.0.1:${port}/json/version" >/dev/null 2>&1; then
|
|
38
|
+
ready="true"
|
|
39
|
+
break
|
|
40
|
+
fi
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if ! kill -0 "${chrome_pid}" 2>/dev/null; then
|
|
44
|
+
sed -n '1,120p' "${log_file}" >&2 || true
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
sleep 0.1
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
if [ "${ready}" != "true" ]; then
|
|
52
|
+
sed -n '1,120p' "${log_file}" >&2 || true
|
|
53
|
+
printf 'Chrome did not start remote debugging for Chrome DevTools MCP.\n' >&2
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
chrome-devtools-mcp --browser-url="http://127.0.0.1:${port}" "$@"
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@john-ezra/openralph",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Light opencode plugin for the Ralph workflow.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/plugin.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/plugin.ts",
|
|
10
|
+
"./server": "./src/plugin.ts",
|
|
11
|
+
"./tui": "./src/tui.ts",
|
|
12
|
+
"./cli": "./src/cli.ts",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"openralph": "./bin/openralph"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.2.0"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
".dockerignore",
|
|
23
|
+
"AGENTS.md",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"README.md",
|
|
27
|
+
"PROMPT_build.md",
|
|
28
|
+
"PROMPT_plan.md",
|
|
29
|
+
"bin/openralph",
|
|
30
|
+
"bun.lock",
|
|
31
|
+
"container/",
|
|
32
|
+
"src/",
|
|
33
|
+
"tsconfig.json"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"release:check": "bun src/release-check.ts",
|
|
42
|
+
"validate": "bun run typecheck && bun test && bun run release:check",
|
|
43
|
+
"prepublishOnly": "bun run validate"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@opencode-ai/plugin": "1.15.13",
|
|
47
|
+
"@types/bun": "latest",
|
|
48
|
+
"typescript": "latest"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export type LoopPhase = "plan" | "build"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_DOCKER_IMAGE = "openralph:local"
|
|
4
|
+
|
|
5
|
+
const DOCKER_DOMAIN_COMPONENT = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
|
|
6
|
+
const DOCKER_NAME_COMPONENT = "[a-z0-9]+(?:(?:[._-]+|__)[a-z0-9]+)*"
|
|
7
|
+
const DOCKER_IMAGE_PATTERN = new RegExp(
|
|
8
|
+
`^(?=.{1,255}$)(?:${DOCKER_DOMAIN_COMPONENT}(?:\\.${DOCKER_DOMAIN_COMPONENT})*(?::[0-9]+)?/)?${DOCKER_NAME_COMPONENT}(?:/${DOCKER_NAME_COMPONENT})*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[A-Fa-f0-9]{64})?$`,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export interface OpenRalphOptions {
|
|
12
|
+
defineModel?: string
|
|
13
|
+
planModel?: string
|
|
14
|
+
buildModel?: string
|
|
15
|
+
docker?: DockerOptions
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DockerOptions {
|
|
19
|
+
enabled?: boolean
|
|
20
|
+
image?: string
|
|
21
|
+
maskEnv?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolvedDockerOptions {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
image: string
|
|
27
|
+
maskEnv: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ParsedLoopArgs {
|
|
31
|
+
maxIterations?: number
|
|
32
|
+
model?: string
|
|
33
|
+
push: boolean
|
|
34
|
+
noDocker: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateOptions(input: unknown): OpenRalphOptions {
|
|
38
|
+
if (input == null) return {}
|
|
39
|
+
if (typeof input !== "object" || Array.isArray(input)) {
|
|
40
|
+
throw new Error("OpenRalph plugin options must be an object")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const options = input as Record<string, unknown>
|
|
44
|
+
return {
|
|
45
|
+
defineModel: readOptionalModel(options, "defineModel"),
|
|
46
|
+
planModel: readOptionalModel(options, "planModel"),
|
|
47
|
+
buildModel: readOptionalModel(options, "buildModel"),
|
|
48
|
+
docker: readDockerOptions(options),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseLoopArgs(phase: LoopPhase, raw: string): ParsedLoopArgs {
|
|
53
|
+
const tokens = splitArgs(raw)
|
|
54
|
+
const parsed: ParsedLoopArgs = { push: false, noDocker: false }
|
|
55
|
+
let hasMax = false
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
58
|
+
const token = tokens[index]
|
|
59
|
+
|
|
60
|
+
if (token === "--model") {
|
|
61
|
+
if (parsed.model) throw new Error("--model can only be provided once")
|
|
62
|
+
const value = tokens[index + 1]
|
|
63
|
+
if (!value || !isValidModelValue(value)) {
|
|
64
|
+
throw new Error("--model requires a provider/model value")
|
|
65
|
+
}
|
|
66
|
+
parsed.model = value
|
|
67
|
+
index += 1
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (token === "--push") {
|
|
72
|
+
if (phase !== "build") throw new Error("--push is only supported for OpenRalph Build")
|
|
73
|
+
if (parsed.push) throw new Error("--push can only be provided once")
|
|
74
|
+
parsed.push = true
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (token === "--no-docker") {
|
|
79
|
+
if (parsed.noDocker) throw new Error("--no-docker can only be provided once")
|
|
80
|
+
parsed.noDocker = true
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (token.startsWith("-")) {
|
|
85
|
+
throw new Error(`Unknown flag: ${token}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!isPositiveIntegerToken(token)) {
|
|
89
|
+
throw new Error(`Unexpected argument: ${token}`)
|
|
90
|
+
}
|
|
91
|
+
if (hasMax) throw new Error("max iterations can only be provided once")
|
|
92
|
+
|
|
93
|
+
const maxIterations = Number(token)
|
|
94
|
+
if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) {
|
|
95
|
+
throw new Error("max iterations must be a positive integer")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
parsed.maxIterations = maxIterations
|
|
99
|
+
hasMax = true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parsed
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatLoopArgsForReplay(args: ParsedLoopArgs): string {
|
|
106
|
+
const tokens: string[] = []
|
|
107
|
+
if (args.maxIterations !== undefined) tokens.push(String(args.maxIterations))
|
|
108
|
+
if (args.model) tokens.push("--model", args.model)
|
|
109
|
+
if (args.push) tokens.push("--push")
|
|
110
|
+
return tokens.map(quoteArg).join(" ")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resolveDockerOptions(options: OpenRalphOptions): ResolvedDockerOptions {
|
|
114
|
+
return {
|
|
115
|
+
enabled: options.docker?.enabled ?? false,
|
|
116
|
+
image: options.docker?.image ?? DEFAULT_DOCKER_IMAGE,
|
|
117
|
+
maskEnv: options.docker?.maskEnv ?? true,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function validateDockerImageReference(value: string, label = "docker.image"): string {
|
|
122
|
+
if (!DOCKER_IMAGE_PATTERN.test(value)) {
|
|
123
|
+
throw new Error(`${label} must be a valid Docker image reference`)
|
|
124
|
+
}
|
|
125
|
+
return value
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveModel(
|
|
129
|
+
phase: LoopPhase,
|
|
130
|
+
args: ParsedLoopArgs,
|
|
131
|
+
options: OpenRalphOptions,
|
|
132
|
+
): string | undefined {
|
|
133
|
+
if (args.model) return args.model
|
|
134
|
+
return phase === "plan" ? options.planModel : options.buildModel
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readOptionalString(options: Record<string, unknown>, key: "defineModel" | "planModel" | "buildModel" | "image"): string | undefined {
|
|
138
|
+
const value = options[key]
|
|
139
|
+
if (value == null) return undefined
|
|
140
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string when provided`)
|
|
141
|
+
if (value.trim() === "") throw new Error(`${key} must not be empty when provided`)
|
|
142
|
+
return value
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readOptionalModel(options: Record<string, unknown>, key: "defineModel" | "planModel" | "buildModel"): string | undefined {
|
|
146
|
+
const value = readOptionalString(options, key)
|
|
147
|
+
if (value === undefined) return undefined
|
|
148
|
+
if (!isValidModelValue(value)) throw new Error(`${key} must be a provider/model value`)
|
|
149
|
+
return value
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readDockerOptions(options: Record<string, unknown>): DockerOptions | undefined {
|
|
153
|
+
const value = options.docker
|
|
154
|
+
if (value == null) return undefined
|
|
155
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
156
|
+
throw new Error("docker must be an object when provided")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const docker = value as Record<string, unknown>
|
|
160
|
+
const image = readOptionalString(docker, "image")
|
|
161
|
+
return {
|
|
162
|
+
enabled: readOptionalBoolean(docker, "enabled"),
|
|
163
|
+
image: image === undefined ? undefined : validateDockerImageReference(image),
|
|
164
|
+
maskEnv: readOptionalBoolean(docker, "maskEnv"),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readOptionalBoolean(options: Record<string, unknown>, key: "enabled" | "maskEnv"): boolean | undefined {
|
|
169
|
+
const value = options[key]
|
|
170
|
+
if (value == null) return undefined
|
|
171
|
+
if (typeof value !== "boolean") throw new Error(`docker.${key} must be a boolean when provided`)
|
|
172
|
+
return value
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPositiveIntegerToken(token: string): boolean {
|
|
176
|
+
return /^[0-9]+$/.test(token) && Number(token) > 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isValidModelValue(value: string): boolean {
|
|
180
|
+
const slash = value.indexOf("/")
|
|
181
|
+
if (slash <= 0 || slash === value.length - 1) return false
|
|
182
|
+
const provider = value.slice(0, slash)
|
|
183
|
+
const model = value.slice(slash + 1)
|
|
184
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(provider) && !model.startsWith("-") && !/[,\s]/.test(model)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function splitArgs(input: string): string[] {
|
|
188
|
+
const tokens: string[] = []
|
|
189
|
+
let current = ""
|
|
190
|
+
let quote: '"' | "'" | undefined
|
|
191
|
+
let escaping = false
|
|
192
|
+
|
|
193
|
+
for (const char of input.trim()) {
|
|
194
|
+
if (escaping) {
|
|
195
|
+
current += char
|
|
196
|
+
escaping = false
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (char === "\\" && quote !== "'") {
|
|
201
|
+
escaping = true
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (quote) {
|
|
206
|
+
if (char === quote) quote = undefined
|
|
207
|
+
else current += char
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (char === '"' || char === "'") {
|
|
212
|
+
quote = char
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (/\s/.test(char)) {
|
|
217
|
+
if (current) {
|
|
218
|
+
tokens.push(current)
|
|
219
|
+
current = ""
|
|
220
|
+
}
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
current += char
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (escaping) current += "\\"
|
|
228
|
+
if (quote) throw new Error("Unterminated quoted argument")
|
|
229
|
+
if (current) tokens.push(current)
|
|
230
|
+
return tokens
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function quoteArg(token: string): string {
|
|
234
|
+
if (/^[^\s"'\\]+$/.test(token)) return token
|
|
235
|
+
return `"${token.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
236
|
+
}
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { LoopPhase } from "./args.ts"
|
|
4
|
+
import type { CommandOutputEvent, CommandResult } from "./exec.ts"
|
|
5
|
+
import { createTimestampId } from "./tags.ts"
|
|
6
|
+
|
|
7
|
+
export interface CreateRunArtifactsInput {
|
|
8
|
+
projectRoot: string
|
|
9
|
+
phase: LoopPhase
|
|
10
|
+
rawArgs: string
|
|
11
|
+
date?: Date
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RunArtifacts {
|
|
15
|
+
phase: LoopPhase
|
|
16
|
+
timestampId: string
|
|
17
|
+
runName: string
|
|
18
|
+
dir: string
|
|
19
|
+
logPath: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IterationArtifacts {
|
|
23
|
+
index: number
|
|
24
|
+
jsonlPath: string
|
|
25
|
+
textPath: string
|
|
26
|
+
recordOutput: (event: CommandOutputEvent) => void
|
|
27
|
+
finish: (outcome: IterationOutcome) => Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IterationOutcome {
|
|
31
|
+
result?: Pick<CommandResult, "exitCode" | "signal">
|
|
32
|
+
status?: string
|
|
33
|
+
error?: unknown
|
|
34
|
+
sentinel?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RunArtifactSummary {
|
|
38
|
+
phase: LoopPhase
|
|
39
|
+
status: string
|
|
40
|
+
message: string
|
|
41
|
+
launched: number
|
|
42
|
+
tagged: number
|
|
43
|
+
blocked: number
|
|
44
|
+
warnings: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function createRunArtifacts(input: CreateRunArtifactsInput): Promise<RunArtifacts> {
|
|
48
|
+
const timestampId = createTimestampId(input.date)
|
|
49
|
+
const runsDir = join(input.projectRoot, "runs")
|
|
50
|
+
await mkdir(runsDir, { recursive: true })
|
|
51
|
+
await ensureRunsIgnored(runsDir)
|
|
52
|
+
|
|
53
|
+
const runName = createRunArtifactName(input.phase, timestampId)
|
|
54
|
+
const dir = join(runsDir, runName)
|
|
55
|
+
const logPath = join(dir, "ralph.log")
|
|
56
|
+
const run: RunArtifacts = { phase: input.phase, timestampId, runName, dir, logPath }
|
|
57
|
+
|
|
58
|
+
await mkdir(dir, { recursive: true })
|
|
59
|
+
await appendRunLog(run, [
|
|
60
|
+
`OpenRalph ${input.phase} run started`,
|
|
61
|
+
`time: ${(input.date ?? new Date()).toISOString()}`,
|
|
62
|
+
`project: ${input.projectRoot}`,
|
|
63
|
+
`args: ${input.rawArgs.trim() || "(default)"}`,
|
|
64
|
+
"",
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
return run
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createRunArtifactName(phase: LoopPhase, timestampId: string): string {
|
|
71
|
+
return `openralph-${phase}-${timestampId}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function startIterationArtifacts(run: RunArtifacts, index: number, childArgs: string[]): Promise<IterationArtifacts> {
|
|
75
|
+
const iteration = formatIteration(index)
|
|
76
|
+
const jsonlPath = join(run.dir, `${iteration}.jsonl`)
|
|
77
|
+
const textPath = join(run.dir, `${iteration}.txt`)
|
|
78
|
+
let queue = Promise.resolve()
|
|
79
|
+
let writeError: unknown
|
|
80
|
+
|
|
81
|
+
await writeFile(jsonlPath, "")
|
|
82
|
+
await writeFile(textPath, "")
|
|
83
|
+
await appendRunLog(run, [
|
|
84
|
+
`${iteration} started`,
|
|
85
|
+
`time: ${new Date().toISOString()}`,
|
|
86
|
+
`command: opencode ${childArgs.map(quoteArg).join(" ")}`,
|
|
87
|
+
"",
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
const enqueue = (write: () => Promise<void>) => {
|
|
91
|
+
queue = queue.then(write, write).catch((error) => {
|
|
92
|
+
writeError ??= error
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
index,
|
|
98
|
+
jsonlPath,
|
|
99
|
+
textPath,
|
|
100
|
+
recordOutput: (event) => {
|
|
101
|
+
const entry = JSON.stringify({
|
|
102
|
+
time: new Date().toISOString(),
|
|
103
|
+
iteration: index,
|
|
104
|
+
type: "output",
|
|
105
|
+
stream: event.stream,
|
|
106
|
+
chunk: event.chunk,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
enqueue(async () => {
|
|
110
|
+
await appendFile(jsonlPath, `${entry}\n`)
|
|
111
|
+
await appendFile(textPath, event.chunk)
|
|
112
|
+
})
|
|
113
|
+
},
|
|
114
|
+
finish: async (outcome) => {
|
|
115
|
+
await queue
|
|
116
|
+
if (writeError) throw writeError
|
|
117
|
+
|
|
118
|
+
await appendRunLog(run, [
|
|
119
|
+
`${iteration} finished`,
|
|
120
|
+
`time: ${new Date().toISOString()}`,
|
|
121
|
+
`status: ${formatOutcomeStatus(outcome)}`,
|
|
122
|
+
...(outcome.sentinel ? [`sentinel: ${outcome.sentinel}`] : []),
|
|
123
|
+
"",
|
|
124
|
+
])
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function finishRunArtifacts(run: RunArtifacts, summary: RunArtifactSummary): Promise<void> {
|
|
130
|
+
await appendRunLog(run, [
|
|
131
|
+
`OpenRalph ${summary.phase} run finished`,
|
|
132
|
+
`time: ${new Date().toISOString()}`,
|
|
133
|
+
`status: ${summary.status}`,
|
|
134
|
+
`message: ${summary.message}`,
|
|
135
|
+
`launched iterations: ${summary.launched}`,
|
|
136
|
+
...(summary.phase === "build" ? [`tagged iterations: ${summary.tagged}`, `blocked iterations: ${summary.blocked}`] : []),
|
|
137
|
+
...summary.warnings,
|
|
138
|
+
"",
|
|
139
|
+
])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function ensureRunsIgnored(runsDir: string): Promise<void> {
|
|
143
|
+
const ignorePath = join(runsDir, ".gitignore")
|
|
144
|
+
const ignoreAll = "*"
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const current = await readFile(ignorePath, "utf8")
|
|
148
|
+
if (current.split(/\r?\n/).some((line) => line.trim() === ignoreAll)) return
|
|
149
|
+
await appendFile(ignorePath, `${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${ignoreAll}\n`)
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (!isNotFound(error)) throw error
|
|
152
|
+
await writeFile(ignorePath, `${ignoreAll}\n`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function appendRunLog(run: RunArtifacts, lines: string[]): Promise<void> {
|
|
157
|
+
await appendFile(run.logPath, `${lines.join("\n")}\n`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatIteration(index: number): string {
|
|
161
|
+
return `iter-${String(index).padStart(3, "0")}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatOutcomeStatus(outcome: IterationOutcome): string {
|
|
165
|
+
if (outcome.error) return `error: ${formatError(outcome.error)}`
|
|
166
|
+
if (outcome.status) return outcome.status
|
|
167
|
+
if (!outcome.result) return "unknown"
|
|
168
|
+
if (outcome.result.exitCode === 0) return "exit 0"
|
|
169
|
+
return outcome.result.exitCode === null ? `signal ${outcome.result.signal ?? "unknown"}` : `exit ${outcome.result.exitCode}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function quoteArg(arg: string): string {
|
|
173
|
+
if (/^[^\s"'\\]+$/.test(arg)) return arg
|
|
174
|
+
return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isNotFound(error: unknown): boolean {
|
|
178
|
+
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatError(error: unknown): string {
|
|
182
|
+
return error instanceof Error ? error.message : String(error)
|
|
183
|
+
}
|