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
1 change: 1 addition & 0 deletions src/server/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
gleam 1.16.0-rc4
3 changes: 3 additions & 0 deletions src/server/priv/static/polish-prompt.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Review the most recent commits on this branch and polish them: improve commit
messages, split or squash where it improves clarity, and ensure each commit
stands alone. Do not change behavior — refactor only the history.
17 changes: 17 additions & 0 deletions src/server/priv/static/supervisor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ _fbi_fatal() { printf '\033[31m✕\033[0m \033[31m%s\033[0m\n' "$*" >&2; }

export SSH_AUTH_SOCK=/ssh-agent

# WIP snapshotter — periodic snapshot of the working tree to safeguard.
if [ -x /usr/local/bin/fbi-wip-snapshotter.sh ]; then
/usr/local/bin/fbi-wip-snapshotter.sh >/dev/null 2>&1 &
fi

# Take ownership of the bind-mounted state and safeguard dirs. These are
# created on the host by the server process and bind-mounted in; their
# host uid won't match agent (1001) so writes from supervisor.sh and the
Expand Down Expand Up @@ -228,6 +233,18 @@ else
printf '\n\n---\n\n' >> /tmp/prompt.txt
fi
done
case "${FBI_KIND:-work}" in
polish)
if [ -f /usr/local/share/fbi/polish-prompt.txt ]; then
cat /usr/local/share/fbi/polish-prompt.txt > /fbi/prompt.txt
fi
;;
merge-conflict)
cat <<'EOF' > /fbi/prompt.txt
Resolve the merge conflicts in /workspace, then commit the resolution.
EOF
;;
esac
[ -f /fbi/prompt.txt ] || { _fbi_fatal "prompt.txt not found in /fbi"; exit 12; }
cat /fbi/prompt.txt >> /tmp/prompt.txt
touch /fbi-state/prompted
Expand Down
29 changes: 29 additions & 0 deletions src/server/priv/static/wip-snapshotter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# /usr/local/bin/fbi-wip-snapshotter.sh — runs as agent inside FBI containers.
# Every 5s, snapshot the working tree as a synthetic git commit and push it
# to refs/fbi/wip-snapshot on the safeguard remote. Never touches HEAD,
# branch refs, or the agent's index.

set +e # never exit; transient git errors during agent ops are normal

WORKTREE="${WORKTREE:-/workspace}"
INTERVAL="${WIP_SNAPSHOT_INTERVAL:-5}"

cd "$WORKTREE" 2>/dev/null || exit 0

while true; do
sleep "$INTERVAL"
# Skip if HEAD doesn't resolve yet (pre-first-commit).
HEAD_SHA=$(git rev-parse HEAD 2>/dev/null) || continue
TMP_INDEX=$(mktemp /tmp/fbi-wip-index.XXXXXX)
cp .git/index "$TMP_INDEX" 2>/dev/null || continue
GIT_INDEX_FILE="$TMP_INDEX" git add -A 2>/dev/null
TREE=$(GIT_INDEX_FILE="$TMP_INDEX" git write-tree 2>/dev/null)
rm -f "$TMP_INDEX"
[ -z "$TREE" ] && continue
COMMIT=$(git commit-tree "$TREE" -p "$HEAD_SHA" -m "wip snapshot" 2>/dev/null)
[ -z "$COMMIT" ] && continue
git update-ref refs/fbi/wip-snapshot "$COMMIT" 2>/dev/null
git push --quiet --force safeguard \
"refs/fbi/wip-snapshot:refs/fbi/wip-snapshot" 2>/dev/null
done
10 changes: 9 additions & 1 deletion src/server/src/fbi.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fbi/config
import fbi/context.{Context}
import fbi/db/connection
import fbi/db/migrations
import fbi/git/mutex as history_mutex
import fbi/handlers/shell_ws
import fbi/handlers/states_ws
import fbi/handlers/usage_ws
Expand Down Expand Up @@ -53,11 +54,18 @@ pub fn main() {

let assert Ok(registry) = run_registry.start()
let assert Ok(pubsub_subject) = pubsub.start()
let assert Ok(history_lock) = history_mutex.start()
reattach.run_all(db, cfg, registry)
let assert Ok(_gc_scheduler) = gc_scheduler.start(db, cfg)
let assert Ok(_resume_scheduler) = resume_scheduler.start(db, cfg, registry)
let ctx =
Context(db: db, config: cfg, run_registry: registry, pubsub: pubsub_subject)
Context(
db: db,
config: cfg,
run_registry: registry,
pubsub: pubsub_subject,
history_mutex: history_lock,
)

let wisp_fn =
wisp_mist.handler(fn(req) { router.handle(req, ctx) }, cfg.secret_key)
Expand Down
2 changes: 2 additions & 0 deletions src/server/src/fbi/context.gleam
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fbi/config.{type Config}
import fbi/git/mutex.{type Cmd}
import fbi/pubsub.{type PubsubMsg}
import fbi/run/registry.{type RegistryMsg}
import gleam/erlang/process.{type Subject}
Expand All @@ -10,5 +11,6 @@ pub type Context {
config: Config,
run_registry: Subject(RegistryMsg),
pubsub: Subject(PubsubMsg),
history_mutex: Subject(Cmd),
)
}
89 changes: 89 additions & 0 deletions src/server/src/fbi/db/runs.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import gleam/json
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import simplifile
import sqlight

pub type Run {
Expand Down Expand Up @@ -571,3 +572,91 @@ fn nullable_opt(opt: Option(String)) -> sqlight.Value {
option.Some(v) -> sqlight.text(v)
}
}

pub type ChildSummary {
ChildSummary(id: Int, kind: String, state: String, created_at: Int)
}

pub fn children_of(
db: sqlight.Connection,
run_id: Int,
) -> Result(List(ChildSummary), DbError) {
let dec = {
use id <- decode.field(0, decode.int)
use kind <- decode.field(1, decode.string)
use state <- decode.field(2, decode.string)
use created_at <- decode.field(3, decode.int)
decode.success(ChildSummary(id, kind, state, created_at))
}
connection.query_all(
"SELECT id, kind, state, created_at FROM runs WHERE parent_run_id = ? ORDER BY created_at",
db,
[sqlight.int(run_id)],
dec,
)
}

pub fn insert_polish_run(
db: sqlight.Connection,
parent: Run,
now: Int,
) -> Result(Run, DbError) {
insert_child_run(db, parent, "polish", read_polish_prompt(), now)
}

pub fn insert_merge_conflict_run(
db: sqlight.Connection,
parent: Run,
now: Int,
) -> Result(Run, DbError) {
insert_child_run(db, parent, "merge-conflict", merge_conflict_prompt(), now)
}

fn insert_child_run(
db: sqlight.Connection,
parent: Run,
kind: String,
prompt: String,
now: Int,
) -> Result(Run, DbError) {
let log_path = "/var/log/fbi/runs/" <> int.to_string(now) <> ".log"
connection.query_one("INSERT INTO runs
(project_id, prompt, branch_name, state, log_path, created_at,
state_entered_at, parent_run_id, kind)
VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?)
RETURNING " <> columns(), db, [
sqlight.int(parent.project_id),
sqlight.text(prompt),
sqlight.text(parent.branch_name),
sqlight.text(log_path),
sqlight.int(now),
sqlight.int(now),
sqlight.int(parent.id),
sqlight.text(kind),
], decoder())
}

pub fn count_active_children(
db: sqlight.Connection,
parent_id: Int,
) -> Result(Int, DbError) {
connection.query_one(
"SELECT COUNT(*) FROM runs
WHERE parent_run_id = ?
AND state IN ('queued', 'running', 'waiting', 'awaiting_resume')",
db,
[sqlight.int(parent_id)],
decode.at([0], decode.int),
)
}

fn read_polish_prompt() -> String {
case simplifile.read("priv/static/polish-prompt.txt") {
Ok(s) -> s
Error(_) -> "Polish the most recent commits on this branch."
}
}

fn merge_conflict_prompt() -> String {
"Resolve the merge conflicts in /workspace, then commit the resolution. The conflicts were left in place by an automated merge or rebase."
}
69 changes: 69 additions & 0 deletions src/server/src/fbi/docker.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,75 @@ fn handle_build_line(
}
}

pub type ExecResult {
ExecResult(exit_code: Int, output: String)
}

pub fn exec_container(
sock: Socket,
container_id: String,
cmd: List(String),
user: String,
) -> Result(ExecResult, DockerError) {
// 1. Create the exec instance.
let create_body =
json.object([
#("AttachStdout", json.bool(True)),
#("AttachStderr", json.bool(True)),
#("Tty", json.bool(False)),
#("User", json.string(user)),
#("Cmd", json.array(cmd, json.string)),
])
use #(status, resp) <- result.try(request(
sock,
"POST",
"/containers/" <> container_id <> "/exec",
bit_array.from_string(json.to_string(create_body)),
"application/json",
))
use exec_id <- result.try(case status {
201 -> {
use s <- result.try(to_string(resp))
let dec = {
use id <- decode.field("Id", decode.string)
decode.success(id)
}
json.parse(s, dec) |> result.map_error(fn(_) { DecodeError("exec id") })
}
code -> Error(HttpError(code, result.unwrap(to_string(resp), "")))
})
// 2. Start the exec; capture combined stdout+stderr.
use #(_, start_body) <- result.try(request(
sock,
"POST",
"/exec/" <> exec_id <> "/start",
bit_array.from_string(
json.to_string(
json.object([#("Detach", json.bool(False)), #("Tty", json.bool(False))]),
),
),
"application/json",
))
let output = result.unwrap(to_string(start_body), "")
// 3. Inspect to read exit code.
use #(_, ins_body) <- result.try(request(
sock,
"GET",
"/exec/" <> exec_id <> "/json",
<<>>,
"application/json",
))
use ins_str <- result.try(to_string(ins_body))
let exit_dec = {
use code <- decode.field("ExitCode", decode.int)
decode.success(code)
}
case json.parse(ins_str, exit_dec) {
Ok(code) -> Ok(ExecResult(exit_code: code, output: output))
Error(_) -> Error(DecodeError("exec exit code"))
}
}

// Suppress unused import warning for framing module
pub fn unframe_output(b: BitArray) -> Result(BitArray, String) {
framing.unframe(b)
Expand Down
53 changes: 53 additions & 0 deletions src/server/src/fbi/git.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import gleam/list

pub type GitError {
ExitNonZero(exit_code: Int, output: String)
GitUnavailable
}

/// Shell out to `git -C repo_path <args...>`. Returns combined stdout+stderr
/// on exit 0, ExitNonZero with the same on any other exit, or GitUnavailable
/// if the git binary can't be found in PATH.
pub fn run(repo_path: String, args: List(String)) -> Result(String, GitError) {
case resolved_git() {
Error(_) -> Error(GitUnavailable)
Ok(git_path) -> {
let full_args = list.append(["-C", repo_path], args)
let #(code, output) = fbi_cmd_run(git_path, full_args, [])
case code {
0 -> Ok(output)
_ -> Error(ExitNonZero(code, output))
}
}
}
}

pub fn describe_error(e: GitError) -> String {
case e {
ExitNonZero(code, output) ->
"git exit " <> int_to_string(code) <> ": " <> output
GitUnavailable -> "git not available on PATH"
}
}

fn resolved_git() -> Result(String, Nil) {
let resolved = fbi_cmd_find_executable("git")
// fbi_cmd:find_executable returns the input unchanged when not found.
case resolved {
"git" -> Error(Nil)
p -> Ok(p)
}
}

@external(erlang, "fbi_cmd", "run")
fn fbi_cmd_run(
cmd: String,
args: List(String),
env: List(#(String, String)),
) -> #(Int, String)

@external(erlang, "fbi_cmd", "find_executable")
fn fbi_cmd_find_executable(name: String) -> String

@external(erlang, "erlang", "integer_to_binary")
fn int_to_string(i: Int) -> String
Loading
Loading