Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/server/priv/static/finalizeBranch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ git fetch --quiet origin "$DEFAULT_BRANCH" 2>/dev/null || true
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo '')"

TITLE=""
if [ -f /fbi-state/title ]; then
TITLE="$(tr -d '\n\r' < /fbi-state/title | head -c 200)"
fi

# Push exit is sourced from the last origin-push log (written by the post-
# commit hook). No-remote projects write no log; treat absent log as success.
PUSH_EXIT=0
Expand All @@ -35,5 +40,5 @@ if [ -f /tmp/last-origin-push.log ]; then
fi
fi

printf '{"exit_code":%d,"push_exit":%d,"head_sha":"%s","branch":"%s","session_id":"%s"}\n' \
"$CLAUDE_EXIT" "$PUSH_EXIT" "$HEAD_SHA" "$CURRENT_BRANCH" "${SESSION_ID:-}" > "$RESULT_PATH"
printf '{"exit_code":%d,"push_exit":%d,"head_sha":"%s","branch":"%s","session_id":"%s","title":"%s"}\n' \
"$CLAUDE_EXIT" "$PUSH_EXIT" "$HEAD_SHA" "$CURRENT_BRANCH" "${SESSION_ID:-}" "$TITLE" > "$RESULT_PATH"
42 changes: 37 additions & 5 deletions src/server/src/fbi/db/runs.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -362,24 +362,23 @@ pub fn insert_run(
db: sqlight.Connection,
project_id: Int,
prompt: String,
branch: option.Option(String),
model: Option(String),
effort: Option(String),
subagent_model: Option(String),
mock: Bool,
mock_scenario: Option(String),
now: Int,
) -> Result(Run, DbError) {
let branch_name = "claude/run-" <> int.to_string(now)
let log_path = "/var/log/fbi/runs/" <> int.to_string(now) <> ".log"
connection.query_one(
use run <- result.try(connection.query_one(
"INSERT INTO runs (project_id, prompt, branch_name, state, log_path, created_at, state_entered_at, model, effort, subagent_model, mock, mock_scenario)
VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING "
VALUES (?, ?, '', 'queued', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING "
<> columns(),
db,
[
sqlight.int(project_id),
sqlight.text(prompt),
sqlight.text(branch_name),
sqlight.text(log_path),
sqlight.int(now),
sqlight.int(now),
Expand All @@ -393,9 +392,32 @@ pub fn insert_run(
nullable_opt(mock_scenario),
],
decoder(),
))
let branch_name = case branch {
option.Some(b) -> b
option.None -> "claude/run-" <> int.to_string(run.id)
}
connection.query_one(
"UPDATE runs SET branch_name = ? WHERE id = ? RETURNING " <> columns(),
db,
[sqlight.text(branch_name), sqlight.int(run.id)],
decoder(),
)
}

pub fn branch_in_use(
db: sqlight.Connection,
branch: String,
) -> Result(Bool, DbError) {
connection.query_one(
"SELECT COUNT(*) FROM runs WHERE branch_name = ? AND state IN ('queued', 'running', 'waiting', 'awaiting_resume')",
db,
[sqlight.text(branch)],
decode.at([0], decode.int),
)
|> result.map(fn(n) { n > 0 })
}

pub fn insert_continue_run(
db: sqlight.Connection,
parent: Run,
Expand Down Expand Up @@ -523,14 +545,20 @@ pub fn mark_finished(
_ -> "failed"
}
connection.query_one(
"UPDATE runs SET state = ?, exit_code = ?, head_commit = ?, finished_at = ?, error = ?,
"UPDATE runs SET state = ?, exit_code = ?, branch_name = COALESCE(?, branch_name),
head_commit = ?, finished_at = ?, error = ?,
title = COALESCE(?, title),
claude_session_id = COALESCE(?, claude_session_id)
WHERE id = ? RETURNING "
<> columns(),
db,
[
sqlight.text(state),
sqlight.int(outcome.exit_code),
case outcome.branch_pushed {
option.None -> sqlight.null()
option.Some(b) -> sqlight.text(b)
},
case outcome.head_commit {
option.None -> sqlight.null()
option.Some(c) -> sqlight.text(c)
Expand All @@ -540,6 +568,10 @@ pub fn mark_finished(
option.None -> sqlight.null()
option.Some(e) -> sqlight.text(e)
},
case outcome.title {
option.None -> sqlight.null()
option.Some(t) -> sqlight.text(t)
},
case outcome.claude_session_id {
option.None -> sqlight.null()
option.Some(s) -> sqlight.text(s)
Expand Down
199 changes: 143 additions & 56 deletions src/server/src/fbi/handlers/runs.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fbi/context.{type Context}
import fbi/db/connection
import fbi/db/projects
import fbi/db/runs
import fbi/db/settings
import fbi/json/run as run_json
import fbi/run/reattach as run_reattach
import fbi/run/registry as run_registry
Expand All @@ -14,7 +15,7 @@ import gleam/http
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{None, Some}
import gleam/option.{type Option, None, Some}
import wisp.{type Request, type Response}

pub fn handle_list(req: Request, ctx: Context) -> Response {
Expand Down Expand Up @@ -181,6 +182,10 @@ fn do_continue(req: Request, ctx: Context, run_id: Int) -> Response {
wisp.internal_server_error()
}
Ok(#(actor_subject, bc)) -> {
let global_prompt = case settings.get(ctx.db) {
Ok(s) -> s.global_prompt
Error(_) -> ""
}
run_worker.launch(
run_worker.LaunchInput(
run: new_run,
Expand All @@ -189,6 +194,7 @@ fn do_continue(req: Request, ctx: Context, run_id: Int) -> Response {
cols: 80,
rows: 24,
broadcaster: bc,
global_prompt: global_prompt,
),
actor_subject,
)
Expand Down Expand Up @@ -247,6 +253,11 @@ fn create(req: Request, ctx: Context, project_id: Int) -> Response {
use body <- wisp.require_json(req)
let decoder = {
use prompt <- decode.field("prompt", decode.string)
use branch <- decode.optional_field(
"branch",
None,
decode.optional(decode.string),
)
use model <- decode.optional_field(
"model",
None,
Expand All @@ -268,70 +279,146 @@ fn create(req: Request, ctx: Context, project_id: Int) -> Response {
None,
decode.optional(decode.string),
)
decode.success(#(prompt, model, effort, subagent_model, mock, mock_scenario))
use force <- decode.optional_field("force", False, decode.bool)
decode.success(#(
prompt,
branch,
model,
effort,
subagent_model,
mock,
mock_scenario,
force,
))
}
case decode.run(body, decoder) {
Error(_) -> wisp.bad_request("Invalid request body")
Ok(#(prompt, model, effort, subagent_model, mock, mock_scenario)) ->
Ok(#(
prompt,
branch,
model,
effort,
subagent_model,
mock,
mock_scenario,
force,
)) ->
case projects.get(ctx.db, project_id) {
Error(_) -> wisp.not_found()
Ok(project) -> {
let now = now_ms()
case
runs.insert_run(
ctx.db,
project_id,
prompt,
model,
effort,
subagent_model,
mock,
mock_scenario,
now,
)
{
Error(e) -> {
wisp.log_error(
"insert run for project "
<> int.to_string(project_id)
<> ": "
<> connection.describe_error(e),
)
wisp.internal_server_error()
}
Ok(run) ->
case
run_supervisor.start_run(
ctx.run_registry,
ctx.db,
ctx.config,
run.id,
)
{
Error(reason) -> {
wisp.log_error(
"start run " <> int.to_string(run.id) <> ": " <> reason,
)
wisp.internal_server_error()
}
Ok(#(actor_subject, bc)) -> {
run_worker.launch(
run_worker.LaunchInput(
run: run,
project: project,
config: ctx.config,
cols: 80,
rows: 24,
broadcaster: bc,
Ok(project) ->
case branch, force {
Some(b), False ->
case runs.branch_in_use(ctx.db, b) {
Error(_) -> wisp.internal_server_error()
Ok(True) ->
json.object([
#("error", json.string("branch_in_use")),
#(
"message",
json.string(
"Branch '"
<> b
<> "' is already in use by an active run.",
),
),
actor_subject,
)
run_json.encode(run)
])
|> json.to_string()
|> wisp.json_response(201)
}
|> wisp.json_response(409)
Ok(False) ->
do_create(
ctx,
project,
prompt,
branch,
model,
effort,
subagent_model,
mock,
mock_scenario,
)
}
_, _ ->
do_create(
ctx,
project,
prompt,
branch,
model,
effort,
subagent_model,
mock,
mock_scenario,
)
}
}
}
}

fn do_create(
ctx: Context,
project: projects.Project,
prompt: String,
branch: option.Option(String),
model: option.Option(String),
effort: option.Option(String),
subagent_model: option.Option(String),
mock: Bool,
mock_scenario: option.Option(String),
) -> Response {
let now = now_ms()
let global_prompt = case settings.get(ctx.db) {
Ok(s) -> s.global_prompt
Error(_) -> ""
}
case
runs.insert_run(
ctx.db,
project.id,
prompt,
branch,
model,
effort,
subagent_model,
mock,
mock_scenario,
now,
)
{
Error(e) -> {
wisp.log_error(
"insert run for project "
<> int.to_string(project.id)
<> ": "
<> connection.describe_error(e),
)
wisp.internal_server_error()
}
Ok(run) ->
case
run_supervisor.start_run(ctx.run_registry, ctx.db, ctx.config, run.id)
{
Error(reason) -> {
wisp.log_error(
"start run " <> int.to_string(run.id) <> ": " <> reason,
)
wisp.internal_server_error()
}
Ok(#(actor_subject, bc)) -> {
run_worker.launch(
run_worker.LaunchInput(
run: run,
project: project,
config: ctx.config,
cols: 80,
rows: 24,
broadcaster: bc,
global_prompt: global_prompt,
),
actor_subject,
)
run_json.encode(run)
|> json.to_string()
|> wisp.json_response(201)
}
}
}
Expand Down
Loading
Loading