Add guest sessions and agent VM defaults

Add daemon-backed workspace and guest-session primitives so host
orchestrators can prepare /root/repo, launch long-lived guest commands,
and attach to pipe-mode sessions over the local stdio mux bridge.

Persist richer session metadata and launch diagnostics, preflight guest
cwd/command requirements, make pipe-mode attach rehydratable from guest
state after daemon restart, and allow submodules when workspace prepare
runs in full_copy mode.

At the same time, stop vm run from auto-attaching opencode, make it
print next-step commands instead, and make glibc guest images more
agent-ready by installing node, opencode, claude, and pi while syncing
opencode/claude/pi auth files into work disks on VM start.

Validation:
- GOCACHE=/tmp/banger-gocache go test ./...
- make build
- banger vm workspace prepare --help
- banger vm session --help
- banger vm session start --help
- banger vm session attach --help
This commit is contained in:
Thales Maciel 2026-04-12 23:48:42 -03:00
parent 497e6dca3d
commit 37c4c091ec
No known key found for this signature in database
GPG key ID: 33112E6833C34679
18 changed files with 3212 additions and 405 deletions

View file

@ -99,6 +99,32 @@ func (s *Store) migrate() error {
stats_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
);`,
`CREATE TABLE IF NOT EXISTS guest_sessions (
id TEXT PRIMARY KEY,
vm_id TEXT NOT NULL,
name TEXT NOT NULL,
backend TEXT NOT NULL,
command TEXT NOT NULL,
args_json TEXT NOT NULL DEFAULT '[]',
cwd TEXT,
env_json TEXT NOT NULL DEFAULT '{}',
stdin_mode TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER,
guest_pid INTEGER NOT NULL DEFAULT 0,
guest_state_dir TEXT,
stdout_log_path TEXT,
stderr_log_path TEXT,
tags_json TEXT NOT NULL DEFAULT '{}',
last_error TEXT,
attachable INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT,
updated_at TEXT NOT NULL,
ended_at TEXT,
UNIQUE(vm_id, name),
FOREIGN KEY(vm_id) REFERENCES vms(id) ON DELETE CASCADE
);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
@ -111,6 +137,18 @@ func (s *Store) migrate() error {
if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil {
return err
}
for _, spec := range []struct{ table, column, typ string }{
{"guest_sessions", "attach_backend", "TEXT"},
{"guest_sessions", "attach_mode", "TEXT"},
{"guest_sessions", "reattachable", "INTEGER NOT NULL DEFAULT 0"},
{"guest_sessions", "launch_stage", "TEXT"},
{"guest_sessions", "launch_message", "TEXT"},
{"guest_sessions", "launch_raw_log", "TEXT"},
} {
if err := ensureColumnExists(s.db, spec.table, spec.column, spec.typ); err != nil {
return err
}
}
return nil
}
@ -298,6 +336,122 @@ func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.
return vms, rows.Err()
}
func (s *Store) UpsertGuestSession(ctx context.Context, session model.GuestSession) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
argsJSON, err := json.Marshal(session.Args)
if err != nil {
return err
}
envJSON, err := json.Marshal(session.Env)
if err != nil {
return err
}
tagsJSON, err := json.Marshal(session.Tags)
if err != nil {
return err
}
const query = `
INSERT INTO guest_sessions (
id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
created_at, started_at, updated_at, ended_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
vm_id=excluded.vm_id,
name=excluded.name,
backend=excluded.backend,
attach_backend=excluded.attach_backend,
attach_mode=excluded.attach_mode,
command=excluded.command,
args_json=excluded.args_json,
cwd=excluded.cwd,
env_json=excluded.env_json,
stdin_mode=excluded.stdin_mode,
status=excluded.status,
exit_code=excluded.exit_code,
guest_pid=excluded.guest_pid,
guest_state_dir=excluded.guest_state_dir,
stdout_log_path=excluded.stdout_log_path,
stderr_log_path=excluded.stderr_log_path,
tags_json=excluded.tags_json,
last_error=excluded.last_error,
attachable=excluded.attachable,
reattachable=excluded.reattachable,
launch_stage=excluded.launch_stage,
launch_message=excluded.launch_message,
launch_raw_log=excluded.launch_raw_log,
started_at=excluded.started_at,
updated_at=excluded.updated_at,
ended_at=excluded.ended_at`
_, err = s.db.ExecContext(ctx, query,
session.ID,
session.VMID,
session.Name,
session.Backend,
session.AttachBackend,
session.AttachMode,
session.Command,
string(argsJSON),
session.CWD,
string(envJSON),
string(session.StdinMode),
string(session.Status),
nullableInt(session.ExitCode),
session.GuestPID,
session.GuestStateDir,
session.StdoutLogPath,
session.StderrLogPath,
string(tagsJSON),
session.LastError,
boolToInt(session.Attachable),
boolToInt(session.Reattachable),
session.LaunchStage,
session.LaunchMessage,
session.LaunchRawLog,
session.CreatedAt.Format(time.RFC3339),
nullableTimeString(session.StartedAt),
session.UpdatedAt.Format(time.RFC3339),
nullableTimeString(session.EndedAt),
)
return err
}
func (s *Store) GetGuestSessionByID(ctx context.Context, id string) (model.GuestSession, error) {
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE id = ?", id)
return scanGuestSessionRow(row)
}
func (s *Store) GetGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) {
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? AND (id = ? OR name = ?)", vmID, idOrName, idOrName)
return scanGuestSessionRow(row)
}
func (s *Store) ListGuestSessionsByVM(ctx context.Context, vmID string) ([]model.GuestSession, error) {
rows, err := s.db.QueryContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? ORDER BY created_at ASC", vmID)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []model.GuestSession
for rows.Next() {
session, err := scanGuestSession(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, session)
}
return sessions, rows.Err()
}
func (s *Store) DeleteGuestSession(ctx context.Context, id string) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := s.db.ExecContext(ctx, "DELETE FROM guest_sessions WHERE id = ?", id)
return err
}
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
used := map[string]struct{}{}
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
@ -467,3 +621,124 @@ func boolToInt(value bool) int {
}
return 0
}
const guestSessionSelectSQL = `
SELECT id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
created_at, started_at, updated_at, ended_at
FROM guest_sessions`
func scanGuestSession(rows scanner) (model.GuestSession, error) {
return scanGuestSessionRow(rows)
}
func scanGuestSessionRow(row scanner) (model.GuestSession, error) {
var session model.GuestSession
var (
argsJSON string
envJSON string
tagsJSON string
stdinMode string
status string
exitCode sql.NullInt64
startedAt sql.NullString
endedAt sql.NullString
attachable int
reattachable int
createdRaw string
updatedRaw string
)
err := row.Scan(
&session.ID,
&session.VMID,
&session.Name,
&session.Backend,
&session.AttachBackend,
&session.AttachMode,
&session.Command,
&argsJSON,
&session.CWD,
&envJSON,
&stdinMode,
&status,
&exitCode,
&session.GuestPID,
&session.GuestStateDir,
&session.StdoutLogPath,
&session.StderrLogPath,
&tagsJSON,
&session.LastError,
&attachable,
&reattachable,
&session.LaunchStage,
&session.LaunchMessage,
&session.LaunchRawLog,
&createdRaw,
&startedAt,
&updatedRaw,
&endedAt,
)
if err != nil {
return session, err
}
session.StdinMode = model.GuestSessionStdinMode(stdinMode)
session.Status = model.GuestSessionStatus(status)
session.Attachable = attachable == 1
session.Reattachable = reattachable == 1
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &session.Args); err != nil {
return session, err
}
}
if envJSON != "" {
if err := json.Unmarshal([]byte(envJSON), &session.Env); err != nil {
return session, err
}
}
if tagsJSON != "" {
if err := json.Unmarshal([]byte(tagsJSON), &session.Tags); err != nil {
return session, err
}
}
if exitCode.Valid {
value := int(exitCode.Int64)
session.ExitCode = &value
}
var parseErr error
session.CreatedAt, parseErr = time.Parse(time.RFC3339, createdRaw)
if parseErr != nil {
return session, parseErr
}
session.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedRaw)
if parseErr != nil {
return session, parseErr
}
if startedAt.Valid && startedAt.String != "" {
session.StartedAt, parseErr = time.Parse(time.RFC3339, startedAt.String)
if parseErr != nil {
return session, parseErr
}
}
if endedAt.Valid && endedAt.String != "" {
session.EndedAt, parseErr = time.Parse(time.RFC3339, endedAt.String)
if parseErr != nil {
return session, parseErr
}
}
return session, nil
}
func nullableTimeString(value time.Time) any {
if value.IsZero() {
return nil
}
return value.Format(time.RFC3339)
}
func nullableInt(value *int) any {
if value == nil {
return nil
}
return *value
}