worker: make/jq/gopls in base image + per-repo .lvmh/setup.sh hook (agent-driven persistent setup)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# docker/ — worker image & bridge
|
||||
|
||||
## lvmh-worker image (`worker.Dockerfile`)
|
||||
|
||||
Headless pi worker: node 24, pi (global npm), golang, git, ripgrep, make, jq,
|
||||
gopls, plus the user's pi config baked from dotfiles (`deploy/rsync-pi-agent.sh`:
|
||||
settings/skills/agents/extensions; no auth.json/sessions/cache; `git:` packages
|
||||
dropped — postinstalls crash headless installs).
|
||||
|
||||
## Per-repo setup hook (agent-driven image setup)
|
||||
|
||||
The agent works as root with network access, so it can `apt-get install` /
|
||||
`go install` anything **in-session**. Containers are ephemeral, so system
|
||||
packages vanish on container stop. For durability, a repo can ship:
|
||||
|
||||
/workspace/.lvmh/setup.sh
|
||||
|
||||
The bridge (`bridge/index.mjs`) runs it with bash on **every spawn** before the
|
||||
pi session starts (10-min timeout, failure is non-fatal — logged, session
|
||||
continues). Since `/workspace` is a persistent per-repo volume, the hook
|
||||
survives container death. Agents that need extra tooling should write that
|
||||
script into the repo (e.g. `apt-get update && apt-get install -y sqlite3`).
|
||||
|
||||
## bridge/index.mjs
|
||||
|
||||
Hosts the SDK session (`createAgentSession` + `bindExtensions({mode:"rpc"})` —
|
||||
the SDK never fires `session_start` without the explicit bind, and the lvmh
|
||||
plugin dials home off that event).
|
||||
@@ -2,13 +2,51 @@
|
||||
// at /root/.pi/agent/extensions/lvmh-agent.ts) dials the daemon and delivers
|
||||
// web prompts through pi.sendUserMessage. This process just hosts the session.
|
||||
// Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
||||
|
||||
const SETUP_PATH = "/workspace/.lvmh/setup.sh";
|
||||
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// Repo-level setup hook: if the repo ships .lvmh/setup.sh, run it before the
|
||||
// session starts (apt-get/go install/pip — anything the agent needs).
|
||||
// /workspace is a persistent per-repo volume, so the hook survives container
|
||||
// restarts and re-runs on every spawn. Failure is non-fatal: log and continue.
|
||||
async function runRepoSetup() {
|
||||
if (!existsSync(SETUP_PATH)) return;
|
||||
console.error(`[lvmh-bridge] running repo setup: ${SETUP_PATH}`);
|
||||
const code = await new Promise((resolve) => {
|
||||
const child = spawn("bash", [SETUP_PATH], {
|
||||
cwd: "/workspace",
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
console.error(`[lvmh-bridge] setup timed out after ${SETUP_TIMEOUT_MS}ms, killing`);
|
||||
child.kill("SIGKILL");
|
||||
resolve(124);
|
||||
}, SETUP_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
console.error("[lvmh-bridge] setup spawn error:", err);
|
||||
resolve(1);
|
||||
});
|
||||
child.on("exit", (c) => {
|
||||
clearTimeout(timer);
|
||||
resolve(c ?? 1);
|
||||
});
|
||||
});
|
||||
if (code === 0) console.error("[lvmh-bridge] setup completed");
|
||||
else console.error(`[lvmh-bridge] setup exited ${code} — continuing anyway`);
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (err) => {
|
||||
console.error("[lvmh-bridge] unhandledRejection:", err);
|
||||
});
|
||||
|
||||
try {
|
||||
await runRepoSetup();
|
||||
const { session } = await createAgentSession();
|
||||
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
||||
// bindExtensions the session_start event never fires, so the lvmh plugin
|
||||
|
||||
@@ -5,10 +5,12 @@ FROM node:24-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash ca-certificates git ripgrep curl xz-utils \
|
||||
bash ca-certificates git ripgrep curl xz-utils make jq \
|
||||
golang-go \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest || true
|
||||
|
||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
|
||||
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
||||
|
||||
+45
-12
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { GitlabStatus, Repo, SessionListItem, SpawnJob, SpawnResponse } from "./protocol";
|
||||
import type {
|
||||
GitlabStatus,
|
||||
Repo,
|
||||
SessionListItem,
|
||||
SpawnJob,
|
||||
SpawnResponse,
|
||||
} from "./protocol";
|
||||
import { Route } from "./protocol";
|
||||
import { errMessage, fetchJson } from "./api";
|
||||
import type { SessionsStore } from "./store";
|
||||
@@ -66,7 +72,10 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) });
|
||||
await fetchJson(Route.GitlabConnect, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token: pat.trim() }),
|
||||
});
|
||||
setPat("");
|
||||
await loadStatus();
|
||||
await loadRepos();
|
||||
@@ -82,7 +91,9 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
setBranch(repo.defaultBranch);
|
||||
};
|
||||
|
||||
const filtered: Repo[] = (repos ?? []).filter((r) => r.path.toLowerCase().includes(query.toLowerCase()));
|
||||
const filtered: Repo[] = (repos ?? []).filter((r) =>
|
||||
r.path.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
const spawn = async (): Promise<void> => {
|
||||
if (selected === null) {
|
||||
@@ -92,8 +103,14 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) };
|
||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, { method: "POST", body: JSON.stringify(body) });
|
||||
const body = {
|
||||
repo: selected.path,
|
||||
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||
};
|
||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setSpawning(res);
|
||||
startPolling(res.sessionId);
|
||||
} catch (err) {
|
||||
@@ -119,7 +136,8 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
const s = list.find((x) => x.id === sessionId);
|
||||
await store.refresh();
|
||||
if (s !== undefined && s.online) {
|
||||
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
||||
if (timerRef.current !== null)
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
navigate(`/s/${sessionId}`);
|
||||
}
|
||||
@@ -132,7 +150,9 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
|
||||
const jobLine = (): string => {
|
||||
if (spawning === null) return "";
|
||||
const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId);
|
||||
const job: SpawnJob | undefined = store.spawnJobs.find(
|
||||
(j) => j.sessionId === spawning.sessionId,
|
||||
);
|
||||
if (job !== undefined) return `${job.repo}: ${job.state}`;
|
||||
return "waiting for session to come online…";
|
||||
};
|
||||
@@ -141,7 +161,11 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Spawn</h1>
|
||||
<p className="empty">{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}</p>
|
||||
<p className="empty">
|
||||
{error.length > 0
|
||||
? `gitlab status failed: ${error}`
|
||||
: "checking gitlab…"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,7 +211,9 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
<div className="spawn-card">
|
||||
<h2>Spawning…</h2>
|
||||
<p className="spawn-job-line">{jobLine()}</p>
|
||||
<p className="repo-meta">container {spawning.containerId.slice(0, 12)}</p>
|
||||
<p className="repo-meta">
|
||||
container {spawning.containerId.slice(0, 12)}
|
||||
</p>
|
||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
@@ -204,7 +230,9 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{repos === null && <p className="repo-meta">loading repos…</p>}
|
||||
{repos !== null && filtered.length === 0 && <p className="repo-meta">no matching repos</p>}
|
||||
{repos !== null && filtered.length === 0 && (
|
||||
<p className="repo-meta">no matching repos</p>
|
||||
)}
|
||||
<div className="repo-list" style={{ marginTop: 10 }}>
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
@@ -221,7 +249,8 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="repo-path">{r.path}</div>
|
||||
<div className="repo-meta">
|
||||
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
|
||||
default {r.defaultBranch} ·{" "}
|
||||
{r.lastActivityAt.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,7 +279,11 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
{busy ? "Spawning…" : "Spawn"}
|
||||
</button>
|
||||
</div>
|
||||
{selected === null && <p className="repo-meta" style={{ marginTop: 8 }}>select a repo above</p>}
|
||||
{selected === null && (
|
||||
<p className="repo-meta" style={{ marginTop: 8 }}>
|
||||
select a repo above
|
||||
</p>
|
||||
)}
|
||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user