@spunto/build 0.15.0 → 0.17.0
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/README.md +61 -4
- package/package.json +22 -2
- package/src/agent-connection/index.ts +347 -0
- package/src/agent-protocol/index.ts +190 -0
- package/src/commands/index.ts +205 -0
- package/src/index.ts +3 -0
- package/src/sandbox-agent/agent.mjs +1174 -0
- package/src/sandbox-agent/bootstrap.sh +64 -0
- package/src/sandbox-agent/index.ts +31 -0
- package/src/sandbox-agent/recipe.ts +65 -0
- package/src/sandbox-agent/runner.sh +38 -0
- package/src/script/setup-script.ts +15 -97
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Spunto sandbox bootstrap (RFC 0035) — the main process of every sandbox, whatever runs it.
|
|
3
|
+
#
|
|
4
|
+
# Finds (or fetches) a Node >= 22, fetches the agent from the platform, and runs it forever. The
|
|
5
|
+
# provider only has to run `curl -fsSL $SPUNTO_API_URL/api/sandboxes/bootstrap.sh | sh` with
|
|
6
|
+
# SPUNTO_API_URL and SPUNTO_SANDBOX_TOKEN in the environment. Needs curl or wget, and glibc if the
|
|
7
|
+
# image has no suitable node (the official Node binaries are not built for musl).
|
|
8
|
+
#
|
|
9
|
+
# Shipped in @spunto/build (`./sandbox-agent`), so any control plane can serve it (RFC 0047):
|
|
10
|
+
# SPUNTO_AGENT_URL where the agent is fetched (default $SPUNTO_API_URL/api/sandboxes/agent.mjs)
|
|
11
|
+
# SPUNTO_NODE a Node >= 22 the control plane mounted in the box: nothing is downloaded
|
|
12
|
+
set -eu
|
|
13
|
+
|
|
14
|
+
API="${SPUNTO_API_URL:?SPUNTO_API_URL is required}"
|
|
15
|
+
: "${SPUNTO_SANDBOX_TOKEN:?SPUNTO_SANDBOX_TOKEN is required}"
|
|
16
|
+
NODE_VERSION="${SPUNTO_NODE_VERSION:-v24.11.1}"
|
|
17
|
+
AGENT_URL="${SPUNTO_AGENT_URL:-$API/api/sandboxes/agent.mjs}"
|
|
18
|
+
|
|
19
|
+
DIR=/opt/spunto-sandbox
|
|
20
|
+
if ! mkdir -p "$DIR" 2>/dev/null || [ ! -w "$DIR" ]; then
|
|
21
|
+
DIR="${HOME:-/tmp}/.spunto-sandbox"
|
|
22
|
+
mkdir -p "$DIR"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
fetch() {
|
|
26
|
+
if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 5 --retry-delay 2 "$1" -o "$2"
|
|
27
|
+
elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
|
|
28
|
+
else echo "[bootstrap] neither curl nor wget in this image" >&2; exit 1
|
|
29
|
+
fi
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
node_ok() {
|
|
33
|
+
[ -n "$1" ] && "$1" -e 'process.exit(+process.versions.node.split(".")[0] >= 22 ? 0 : 1)' 2>/dev/null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
NODE="${SPUNTO_NODE:-}"
|
|
37
|
+
node_ok "$NODE" || NODE="$(command -v node 2>/dev/null || true)"
|
|
38
|
+
if ! node_ok "$NODE"; then
|
|
39
|
+
NODE="$DIR/node/bin/node"
|
|
40
|
+
if ! node_ok "$NODE"; then
|
|
41
|
+
case "$(uname -m)" in
|
|
42
|
+
x86_64|amd64) ARCH=x64 ;;
|
|
43
|
+
aarch64|arm64) ARCH=arm64 ;;
|
|
44
|
+
*) echo "[bootstrap] unsupported arch $(uname -m)" >&2; exit 1 ;;
|
|
45
|
+
esac
|
|
46
|
+
echo "[bootstrap] fetching node $NODE_VERSION ($ARCH)"
|
|
47
|
+
fetch "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-$ARCH.tar.gz" "$DIR/node.tgz"
|
|
48
|
+
rm -rf "$DIR/node" && mkdir -p "$DIR/node"
|
|
49
|
+
tar -xzf "$DIR/node.tgz" -C "$DIR/node" --strip-components=1
|
|
50
|
+
rm -f "$DIR/node.tgz"
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Re-fetched on every boot, so a restart picks up a new agent without rebuilding anything.
|
|
55
|
+
while true; do
|
|
56
|
+
if fetch "$AGENT_URL" "$DIR/agent.mjs.new"; then
|
|
57
|
+
mv "$DIR/agent.mjs.new" "$DIR/agent.mjs"
|
|
58
|
+
fi
|
|
59
|
+
if [ -f "$DIR/agent.mjs" ]; then
|
|
60
|
+
"$NODE" "$DIR/agent.mjs" || true
|
|
61
|
+
fi
|
|
62
|
+
echo "[bootstrap] agent exited, restarting in 3s" >&2
|
|
63
|
+
sleep 3
|
|
64
|
+
done
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-box agent (RFC 0035, RFC 0047): the files a control plane serves to a box so that the box
|
|
3
|
+
* dials it — never imported, never read by this package.
|
|
4
|
+
*
|
|
5
|
+
* - `bootstrap.sh` — the box's main process: finds (or fetches) a Node >= 22, fetches the agent,
|
|
6
|
+
* runs it forever. `SPUNTO_AGENT_URL` says where from (default
|
|
7
|
+
* `$SPUNTO_API_URL/api/sandboxes/agent.mjs`), `SPUNTO_NODE` hands it a Node so it fetches none.
|
|
8
|
+
* - `agent.mjs` — one file, no dependency, served as is. It dials `SPUNTO_AGENT_CONNECT_URL`
|
|
9
|
+
* (default `$SPUNTO_API_URL/api/sandbox-agent/connect`) and speaks `./agent-protocol`.
|
|
10
|
+
* - `runner.sh` — the project runner: the recipe once per bake key, the features' environment, then
|
|
11
|
+
* the worker script. Handed to the agent in the plan, and run by a build in `bake-only` mode.
|
|
12
|
+
*
|
|
13
|
+
* What a consumer gets here is *where* the files are; reading and serving them is its own I/O. The
|
|
14
|
+
* same files are reachable as subpaths of the package (`@spunto/build/sandbox-agent/agent.mjs`…).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const SANDBOX_AGENT_FILES = {
|
|
18
|
+
agent: new URL("./agent.mjs", import.meta.url),
|
|
19
|
+
bootstrap: new URL("./bootstrap.sh", import.meta.url),
|
|
20
|
+
runner: new URL("./runner.sh", import.meta.url),
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type SandboxAgentFile = keyof typeof SANDBOX_AGENT_FILES
|
|
24
|
+
|
|
25
|
+
/** What each file is served as. */
|
|
26
|
+
export const SANDBOX_AGENT_CONTENT_TYPES: Record<SandboxAgentFile, string> = {
|
|
27
|
+
agent: "text/javascript; charset=utf-8",
|
|
28
|
+
bootstrap: "text/x-shellscript; charset=utf-8",
|
|
29
|
+
runner: "text/x-shellscript; charset=utf-8",
|
|
30
|
+
}
|
|
31
|
+
export * from "./recipe"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's recipe as a box that carries the agent runs it (RFC 0042, RFC 0047): through the
|
|
3
|
+
* agent's own runner, in `bake-only` mode, so that what a build freezes into an image is exactly
|
|
4
|
+
* what a box would have baked in place — the `.baked-<key>` marker and the features' captured
|
|
5
|
+
* environment included. A box started from the result skips straight to the worker script.
|
|
6
|
+
*
|
|
7
|
+
* The runner's text is the caller's to read (`SANDBOX_AGENT_FILES.runner`): this package does no I/O.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { IMAGE_RECIPE_VERSION } from "../script/index"
|
|
11
|
+
|
|
12
|
+
/** Where the agent keeps a project (`<dir of agent.mjs>/project`, the bootstrap putting it in /opt/spunto-sandbox). */
|
|
13
|
+
export const SANDBOX_PROJECT_DIR = "/opt/spunto-sandbox/project"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The key a recipe is baked under: changes whenever the recipe would bake something different
|
|
17
|
+
* (the generator's version, the base image, the script). **A format**: a builder and the agent of
|
|
18
|
+
* a box started from its result must compute the same one, or the box bakes again.
|
|
19
|
+
*/
|
|
20
|
+
export async function recipeBakeKey(p: { image: string; script: string; recipeVersion?: number | string }): Promise<string> {
|
|
21
|
+
const text = `${p.recipeVersion ?? IMAGE_RECIPE_VERSION}\n${p.image}\n${p.script}`
|
|
22
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))
|
|
23
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const shq = (v: string) => `'${v.replace(/'/g, `'\\''`)}'`
|
|
27
|
+
|
|
28
|
+
/** An image's `ENV` as shell: its `PATH` in front of the one there is, the rest only where unset. */
|
|
29
|
+
export function imageEnvExports(env: Record<string, string> | null | undefined): string[] {
|
|
30
|
+
return Object.entries(env ?? {})
|
|
31
|
+
.filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
|
|
32
|
+
.map(([k, v]) => (k === "PATH" ? `export PATH=${shq(v)}"\${PATH:+:$PATH}"` : `[ -n "\${${k}+x}" ] || export ${k}=${shq(v)}`))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** FNV-1a, for heredoc tags: a tag only has to be absent from the body it fences. */
|
|
36
|
+
function tagHash(s: string): string {
|
|
37
|
+
let h = 0x811c9dc5
|
|
38
|
+
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0
|
|
39
|
+
return h.toString(16).padStart(8, "0")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a builder runs, as root, in a container of the project's image: the recipe through the
|
|
44
|
+
* runner in `bake-only` mode. Self-contained: nothing to fetch, no token, nothing personal.
|
|
45
|
+
* `imageEnv`: the image's `ENV`, for a builder booted from its files without it (a microVM).
|
|
46
|
+
*/
|
|
47
|
+
export function buildRecipeScript(recipe: { bakeKey: string; bake: string; runner: string }, imageEnv: Record<string, string> | null = null): string {
|
|
48
|
+
if (!/^[A-Za-z0-9_-]+$/.test(recipe.bakeKey)) throw new Error(`Invalid bake key ${recipe.bakeKey}`)
|
|
49
|
+
const fence = (name: string, body: string) => {
|
|
50
|
+
const tag = `__SPUNTO_${name}_${tagHash(body)}__`
|
|
51
|
+
return `cat > "$DIR/${name.toLowerCase()}.sh" <<'${tag}'\n${body.replace(/\n?$/, "\n")}${tag}\n`
|
|
52
|
+
}
|
|
53
|
+
return [
|
|
54
|
+
"#!/bin/bash",
|
|
55
|
+
"set -eu",
|
|
56
|
+
`DIR=${SANDBOX_PROJECT_DIR}`,
|
|
57
|
+
...imageEnvExports(imageEnv),
|
|
58
|
+
'mkdir -p "$DIR" && chmod 700 "$DIR"',
|
|
59
|
+
fence("BAKE", recipe.bake),
|
|
60
|
+
fence("RUNNER", recipe.runner),
|
|
61
|
+
'chmod 700 "$DIR/bake.sh" "$DIR/runner.sh"',
|
|
62
|
+
`exec bash "$DIR/runner.sh" "$DIR" ${recipe.bakeKey} bake-only`,
|
|
63
|
+
"",
|
|
64
|
+
].join("\n")
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# spunto-project-runner — the recipe once per bake key, then the worker script (setup once, start always)
|
|
3
|
+
#
|
|
4
|
+
# One copy, two users: the in-sandbox agent (handed it in the project plan, `plan.runner`) and a
|
|
5
|
+
# provider's build (`buildRecipeScript` in sandbox-project.ts, mode `bake-only`) — so what a build
|
|
6
|
+
# freezes is exactly what a sandbox would have baked in place, marker and features' env included.
|
|
7
|
+
DIR="$1"; KEY="$2"; MODE="$3"
|
|
8
|
+
if [ ! -f "$DIR/.baked-$KEY" ]; then
|
|
9
|
+
echo "[spunto] baking the project recipe ($KEY)"
|
|
10
|
+
bash "$DIR/bake.sh" || { echo "[spunto] recipe failed"; exit 90; }
|
|
11
|
+
touch "$DIR/.baked-$KEY"
|
|
12
|
+
fi
|
|
13
|
+
# Features declare their environment (containerEnv: nvm's PATH…) for the image; a recipe run in
|
|
14
|
+
# place leaves it only in /etc/bash.bashrc, which non-interactive shells skip. Keep what an
|
|
15
|
+
# interactive shell adds over a login one, for the lifecycle commands below and for the agent.
|
|
16
|
+
_skip='^(USER|HOME|LOGNAME|MAIL|PS1|PS2|PS4|SHLVL|_|PWD|OLDPWD|TERM|COLUMNS|LINES|HOSTNAME|BASH_.*|HIST.*|SHELLOPTS|BASHOPTS|LS_COLORS)$'
|
|
17
|
+
# Our own profile.d file must be out of the way, or from the second boot on the login shell
|
|
18
|
+
# already has what we are measuring and the difference comes out empty (seen for real).
|
|
19
|
+
rm -f /etc/profile.d/spunto-features.sh
|
|
20
|
+
env -i HOME=/root PATH="$PATH" bash -lc 'env -0' > "$DIR/.env.login" 2>/dev/null || true
|
|
21
|
+
env -i HOME=/root PATH="$PATH" PS1='$ ' bash -ic 'env -0' > "$DIR/.env.interactive" 2>/dev/null || true
|
|
22
|
+
: > "$DIR/env.list.new"; : > "$DIR/env.sh.new"
|
|
23
|
+
while IFS= read -r -d '' _kv; do
|
|
24
|
+
_k="${_kv%%=*}"; [[ "$_k" =~ $_skip ]] && continue
|
|
25
|
+
grep -qzxF -- "$_kv" "$DIR/.env.login" && continue
|
|
26
|
+
printf '%s\0' "$_kv" >> "$DIR/env.list.new"
|
|
27
|
+
printf 'export %s=%q\n' "$_k" "${_kv#*=}" >> "$DIR/env.sh.new"
|
|
28
|
+
done < "$DIR/.env.interactive"
|
|
29
|
+
mv -f "$DIR/env.list.new" "$DIR/env.list"; mv -f "$DIR/env.sh.new" "$DIR/env.sh"
|
|
30
|
+
# World-readable (no secret in it): login shells read it from profile.d, and BASH_ENV — which su
|
|
31
|
+
# keeps, unlike PATH — makes the non-interactive bash of postCreate/postStart read it too.
|
|
32
|
+
install -m 644 "$DIR/env.sh" /etc/profile.d/spunto-features.sh
|
|
33
|
+
export BASH_ENV=/etc/profile.d/spunto-features.sh
|
|
34
|
+
. "$DIR/env.sh"
|
|
35
|
+
# A build stops here: what is on disk now is what the provider freezes.
|
|
36
|
+
if [ "$MODE" = "bake-only" ]; then echo "[spunto] recipe baked"; exit 0; fi
|
|
37
|
+
echo "[spunto] running the worker script"
|
|
38
|
+
exec bash "$DIR/worker.sh"
|
|
@@ -103,16 +103,6 @@ type CloneRepo = {
|
|
|
103
103
|
branch?: string
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
* How a feature's entrypoint is started: without the OpenTelemetry variables the platform gives the
|
|
108
|
-
* workload. An entrypoint is infrastructure — dockerd, containerd — and Docker instruments itself as
|
|
109
|
-
* soon as `OTEL_EXPORTER_OTLP_ENDPOINT` exists: every internal gRPC call of a docker-in-docker
|
|
110
|
-
* daemon became a span (~60 000 per quarter hour for three sandboxes, 2026-10-18), drowning the
|
|
111
|
-
* project's own services in its organization's telemetry. The project's processes keep them.
|
|
112
|
-
*/
|
|
113
|
-
export const ENTRYPOINT_WITHOUT_OTEL =
|
|
114
|
-
"env -u OTEL_EXPORTER_OTLP_ENDPOINT -u OTEL_EXPORTER_OTLP_TRACES_ENDPOINT -u OTEL_EXPORTER_OTLP_METRICS_ENDPOINT -u OTEL_EXPORTER_OTLP_LOGS_ENDPOINT -u OTEL_EXPORTER_OTLP_PROTOCOL -u OTEL_EXPORTER_OTLP_HEADERS -u OTEL_RESOURCE_ATTRIBUTES -u OTEL_SERVICE_NAME"
|
|
115
|
-
|
|
116
106
|
/**
|
|
117
107
|
* Branch actually checked out for a repo: the worker-level choice (made at creation, applies to
|
|
118
108
|
* every repo of the project) wins over the project-level default carried by the repository itself.
|
|
@@ -1150,7 +1140,7 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
1150
1140
|
" while IFS= read -r _ep; do",
|
|
1151
1141
|
' if [ -f "$_ep" ]; then',
|
|
1152
1142
|
' echo "Starting entrypoint: $_ep"',
|
|
1153
|
-
'
|
|
1143
|
+
' "$_ep" &',
|
|
1154
1144
|
" fi",
|
|
1155
1145
|
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1156
1146
|
"fi",
|
|
@@ -1316,7 +1306,7 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
|
|
|
1316
1306
|
" while IFS= read -r _ep; do",
|
|
1317
1307
|
' if [ -f "$_ep" ]; then',
|
|
1318
1308
|
' echo "Starting entrypoint: $_ep"',
|
|
1319
|
-
'
|
|
1309
|
+
' "$_ep" &',
|
|
1320
1310
|
" fi",
|
|
1321
1311
|
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1322
1312
|
"fi",
|
|
@@ -1490,12 +1480,12 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
|
|
|
1490
1480
|
// The functions below power the *new* model where the agent (not the container) drives setup:
|
|
1491
1481
|
// `buildContainerScript` is the passive CMD (serve code-server + stay alive, no setup, no status
|
|
1492
1482
|
// machinery), and `buildSetupPlan` returns the ordered phases the agent execs one by one, each
|
|
1493
|
-
// wrapped in
|
|
1483
|
+
// wrapped in its own try/catch. The legacy `buildWorkerScript`/`buildSetupScript`/`buildStartScript`
|
|
1494
1484
|
// above are left untouched — the runner picks the path by flag, defaulting to legacy. See RFC 0017.
|
|
1495
1485
|
|
|
1496
|
-
/** One setup phase the agent execs via `docker exec
|
|
1486
|
+
/** One setup phase the agent execs via `docker exec`. */
|
|
1497
1487
|
export type SetupPhase = {
|
|
1498
|
-
/** Timing
|
|
1488
|
+
/** Timing key and the phase's short identity. */
|
|
1499
1489
|
id: "credentials" | "shell" | "dotfiles" | "skills" | "clone" | "postCreate" | "postStart"
|
|
1500
1490
|
/** Human label for logs. */
|
|
1501
1491
|
label: string
|
|
@@ -1510,7 +1500,7 @@ export type SetupPhase = {
|
|
|
1510
1500
|
|
|
1511
1501
|
// The passive container CMD. Same "serve + stay alive" blocks as buildStartScript (SSH, feature
|
|
1512
1502
|
// entrypoints, env persistence, extensions, code-server settings + restart loop) — MINUS the setup
|
|
1513
|
-
// status machinery, the lifecycle commands and any
|
|
1503
|
+
// status machinery, the lifecycle commands and any timing emission (the agent owns all of that
|
|
1514
1504
|
// now). Ends by signalling readiness (`.mp-container-up`) then `wait`. Blocks are duplicated from
|
|
1515
1505
|
// buildStartScript on purpose: the legacy path stays byte-identical (prod default) until it's
|
|
1516
1506
|
// deleted once WORKER_SETUP_ORCHESTRATOR=agent becomes the default. See RFC 0017 §2.1.
|
|
@@ -1569,7 +1559,7 @@ export function buildContainerScript(params: StartScriptParams): { script: strin
|
|
|
1569
1559
|
" while IFS= read -r _ep; do",
|
|
1570
1560
|
' if [ -f "$_ep" ]; then',
|
|
1571
1561
|
' echo "Starting entrypoint: $_ep"',
|
|
1572
|
-
'
|
|
1562
|
+
' "$_ep" &',
|
|
1573
1563
|
" fi",
|
|
1574
1564
|
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1575
1565
|
"fi",
|
|
@@ -1724,9 +1714,6 @@ export function buildWorkerScript(params: {
|
|
|
1724
1714
|
" local now; now=\"$(_mp_now)\"",
|
|
1725
1715
|
" if [ -s \"$_MP_TFILE\" ]; then printf ',' >> \"$_MP_TFILE\"; fi",
|
|
1726
1716
|
" printf '\"%s\":%s' \"$1\" \"$now\" >> \"$_MP_TFILE\" || true",
|
|
1727
|
-
// Native OTel: emit this phase transition as a real OTLP span through the node
|
|
1728
|
-
// collector (|| true + suspends set -e inside the fn — telemetry never breaks setup).
|
|
1729
|
-
" _mp_span_on_stamp \"$1\" \"$now\" || true",
|
|
1730
1717
|
"}",
|
|
1731
1718
|
// mp-status.json lives in the home dir (persistent volume), not /tmp — when a
|
|
1732
1719
|
// DinD feature starts dockerd in the background, /tmp's mount visibility can
|
|
@@ -1743,70 +1730,14 @@ export function buildWorkerScript(params: {
|
|
|
1743
1730
|
" printf '%s' \"$j\" > " + workerStatusPath("/home/vscode") + " || true",
|
|
1744
1731
|
"}",
|
|
1745
1732
|
"",
|
|
1746
|
-
// ── Native OTel setup spans (emitted by this script, not backdated in the API) ──
|
|
1747
|
-
// Each phase is emitted as a real OTLP span to the node agent's collector (the
|
|
1748
|
-
// auto-injected OTEL_EXPORTER_OTLP_ENDPOINT); the agent forwards to /otlp where org.id
|
|
1749
|
-
// /node.id are stamped server-side. MP_SETUP_TRACEPARENT (the job.worker.spawn span,
|
|
1750
|
-
// injected by the runner) gives the traceId + parent span, so the whole `worker.setup`
|
|
1751
|
-
// subtree nests under the spawn trace. No trace context (or no curl) → silently skipped.
|
|
1752
|
-
"MP_TRACE_ID=''; MP_JOB_SPAN=''",
|
|
1753
|
-
"if [ -n \"${MP_SETUP_TRACEPARENT:-}\" ]; then",
|
|
1754
|
-
" MP_TRACE_ID=$(printf '%s' \"$MP_SETUP_TRACEPARENT\" | cut -d- -f2)",
|
|
1755
|
-
" MP_JOB_SPAN=$(printf '%s' \"$MP_SETUP_TRACEPARENT\" | cut -d- -f3)",
|
|
1756
|
-
"fi",
|
|
1757
|
-
"MP_SETUP_SPAN=''; MP_SETUP_START=''; MP_PREV_PHASE=''; MP_PREV_TS=''",
|
|
1758
1733
|
// Failure-trap state. _MP_DONE flips to 1 once setup+start reach `ready` (see buildStartScript),
|
|
1759
1734
|
// disarming _mp_fail so a normal container stop / code-server hiccup after ready never looks like
|
|
1760
1735
|
// a setup failure. _MP_LAST holds the most recent status JSON (set by _mp) for the error re-emit.
|
|
1761
1736
|
"_MP_DONE=0; _MP_LAST=''",
|
|
1762
|
-
|
|
1763
|
-
"_mp_phase_span_name() {",
|
|
1764
|
-
" case \"$1\" in",
|
|
1765
|
-
" initializing) echo 'setup.initialize' ;;",
|
|
1766
|
-
" credentials) echo 'setup.credentials' ;;",
|
|
1767
|
-
" shell) echo 'setup.shell' ;;",
|
|
1768
|
-
" cloning) echo 'setup.clone' ;;",
|
|
1769
|
-
" postCreate) echo 'setup.postCreate' ;;",
|
|
1770
|
-
" postStart) echo 'setup.postStart' ;;",
|
|
1771
|
-
" *) echo \"setup.$1\" ;;",
|
|
1772
|
-
" esac",
|
|
1773
|
-
"}",
|
|
1774
|
-
// Emit one OTLP/HTTP JSON span, backgrounded + short timeout so it never adds latency
|
|
1775
|
-
// to (or blocks) the setup path. Args: name start_ms end_ms span_id parent_span_id phase.
|
|
1776
|
-
"_mp_span_emit() {",
|
|
1777
|
-
" if [ -z \"$MP_TRACE_ID\" ] || [ -z \"${OTEL_EXPORTER_OTLP_ENDPOINT:-}\" ]; then return 0; fi",
|
|
1778
|
-
" if ! command -v curl >/dev/null 2>&1; then return 0; fi",
|
|
1779
|
-
// Optional $7 = error message and $8 = exit code turn this into a native ERROR span
|
|
1780
|
-
// (OTel status code 2 + a setup.exit_code attribute) — that's what makes a failed
|
|
1781
|
-
// clone/postCreate/postStart show up red in the waterfall instead of as a hole.
|
|
1782
|
-
" local name=\"$1\" s_ns=$(( $2 * 1000000 )) e_ns=$(( $3 * 1000000 )) sid=\"$4\" pid=\"$5\" phase=\"$6\" errmsg=\"${7:-}\" ecode=\"${8:-}\" dur=$(( $3 - $2 )) attrs='' status=''",
|
|
1783
|
-
" if [ -n \"$phase\" ]; then attrs=\"{\\\"key\\\":\\\"setup.phase\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"$phase\\\"}},{\\\"key\\\":\\\"setup.duration_ms\\\",\\\"value\\\":{\\\"intValue\\\":\\\"$dur\\\"}}\"; fi",
|
|
1784
|
-
" if [ -n \"$ecode\" ]; then [ -n \"$attrs\" ] && attrs=\"$attrs,\"; attrs=\"${attrs}{\\\"key\\\":\\\"setup.exit_code\\\",\\\"value\\\":{\\\"intValue\\\":\\\"$ecode\\\"}}\"; fi",
|
|
1785
|
-
" if [ -n \"$errmsg\" ]; then status=\",\\\"status\\\":{\\\"code\\\":2,\\\"message\\\":\\\"$errmsg\\\"}\"; fi",
|
|
1786
|
-
" local body=\"{\\\"resourceSpans\\\":[{\\\"resource\\\":{\\\"attributes\\\":[{\\\"key\\\":\\\"service.name\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"spunto-worker\\\"}},{\\\"key\\\":\\\"spunto.worker.slug\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"${WORKER_SLUG:-}\\\"}}]},\\\"scopeSpans\\\":[{\\\"scope\\\":{\\\"name\\\":\\\"spunto.setup\\\"},\\\"spans\\\":[{\\\"traceId\\\":\\\"$MP_TRACE_ID\\\",\\\"spanId\\\":\\\"$sid\\\",\\\"parentSpanId\\\":\\\"$pid\\\",\\\"name\\\":\\\"$name\\\",\\\"kind\\\":1,\\\"startTimeUnixNano\\\":\\\"$s_ns\\\",\\\"endTimeUnixNano\\\":\\\"$e_ns\\\",\\\"attributes\\\":[$attrs]$status}]}]}]}\"",
|
|
1787
|
-
" ( curl -s -m 3 -o /dev/null -X POST \"$OTEL_EXPORTER_OTLP_ENDPOINT/v1/traces\" -H 'Content-Type: application/json' -d \"$body\" || true ) &",
|
|
1788
|
-
"}",
|
|
1789
|
-
// Called on each new phase stamp. The first stamp opens the `worker.setup` window; every
|
|
1790
|
-
// later stamp closes the previous phase as a child span; `ready` also emits the parent.
|
|
1791
|
-
"_mp_span_on_stamp() {",
|
|
1792
|
-
" if [ -z \"$MP_TRACE_ID\" ]; then return 0; fi",
|
|
1793
|
-
" local key=\"$1\" now=\"$2\"",
|
|
1794
|
-
" if [ -z \"$MP_SETUP_SPAN\" ]; then",
|
|
1795
|
-
" MP_SETUP_SPAN=$(_mp_rand_id); MP_SETUP_START=\"$now\"; MP_PREV_PHASE=\"$key\"; MP_PREV_TS=\"$now\"; return 0",
|
|
1796
|
-
" fi",
|
|
1797
|
-
" _mp_span_emit \"$(_mp_phase_span_name \"$MP_PREV_PHASE\")\" \"$MP_PREV_TS\" \"$now\" \"$(_mp_rand_id)\" \"$MP_SETUP_SPAN\" \"$MP_PREV_PHASE\"",
|
|
1798
|
-
" if [ \"$key\" = ready ]; then",
|
|
1799
|
-
" _mp_span_emit 'worker.setup' \"$MP_SETUP_START\" \"$now\" \"$MP_SETUP_SPAN\" \"$MP_JOB_SPAN\" ''",
|
|
1800
|
-
" else",
|
|
1801
|
-
" MP_PREV_PHASE=\"$key\"; MP_PREV_TS=\"$now\"",
|
|
1802
|
-
" fi",
|
|
1803
|
-
"}",
|
|
1804
|
-
// ── Native error spans on setup failure (the direct goal of this refactor) ──
|
|
1737
|
+
// ── Setup failure trap ──
|
|
1805
1738
|
// set -e makes any uncaught failure abort the CMD; before PR #115 that just left the worker
|
|
1806
|
-
// stuck at its last status
|
|
1807
|
-
//
|
|
1808
|
-
// failing clone/postCreate/postStart shows up red under worker.setup, and (2) a phase=error
|
|
1809
|
-
// status write so the API poller flips the worker to `error` with a message. It fires only on a
|
|
1739
|
+
// stuck at its last status. This EXIT trap turns that same failure into a phase=error status
|
|
1740
|
+
// write so the API poller flips the worker to `error` with a message. It fires only on a
|
|
1810
1741
|
// non-zero exit and only before `ready` (_MP_DONE guards the normal path); the short sleep lets
|
|
1811
1742
|
// the 1.5s poll read the error status before the container exits.
|
|
1812
1743
|
"_mp_fail() {",
|
|
@@ -1816,26 +1747,13 @@ export function buildWorkerScript(params: {
|
|
|
1816
1747
|
" [ \"$_MP_DONE\" = 1 ] && return 0",
|
|
1817
1748
|
" [ \"$code\" = 0 ] && return 0",
|
|
1818
1749
|
" _MP_DONE=1",
|
|
1819
|
-
" local phase
|
|
1820
|
-
" phase
|
|
1821
|
-
" [ -z \"$phase\" ] && phase=$(printf '%s' \"$_MP_LAST\" | sed -n 's/.*\"phase\":\"\\([^\"]*\\)\".*/\\1/p')",
|
|
1750
|
+
" local phase msg j",
|
|
1751
|
+
" phase=$(printf '%s' \"$_MP_LAST\" | sed -n 's/.*\"phase\":\"\\([^\"]*\\)\".*/\\1/p')",
|
|
1822
1752
|
" [ -z \"$phase\" ] && phase=initializing",
|
|
1823
|
-
" now=\"$(_mp_now)\"",
|
|
1824
1753
|
// A phase that knows *why* it failed (e.g. a clone on a branch that doesn't exist) sets
|
|
1825
1754
|
// MP_FAIL_MSG before exiting, so the UI shows that instead of the generic phase message.
|
|
1826
1755
|
" msg=\"${MP_FAIL_MSG:-setup phase $phase failed (exit $code)}\"",
|
|
1827
1756
|
" echo \"[setup] ✗ $msg\" >&2",
|
|
1828
|
-
// Best-effort error spans: close the failing phase (child of worker.setup) then worker.setup
|
|
1829
|
-
// itself in ERROR. On a restart (postStart failure) the setup window was never opened, so emit
|
|
1830
|
-
// a single worker.setup error span under the job span instead.
|
|
1831
|
-
" if [ -n \"$MP_TRACE_ID\" ]; then",
|
|
1832
|
-
" if [ -n \"$MP_SETUP_SPAN\" ]; then",
|
|
1833
|
-
" _mp_span_emit \"$(_mp_phase_span_name \"$phase\")\" \"${MP_PREV_TS:-$now}\" \"$now\" \"$(_mp_rand_id)\" \"$MP_SETUP_SPAN\" \"$phase\" \"$msg\" \"$code\"",
|
|
1834
|
-
" _mp_span_emit 'worker.setup' \"${MP_SETUP_START:-$now}\" \"$now\" \"$MP_SETUP_SPAN\" \"$MP_JOB_SPAN\" '' \"$msg\" \"$code\"",
|
|
1835
|
-
" else",
|
|
1836
|
-
" _mp_span_emit 'worker.setup' \"${MP_PREV_TS:-$now}\" \"$now\" \"$(_mp_rand_id)\" \"$MP_JOB_SPAN\" \"$phase\" \"$msg\" \"$code\"",
|
|
1837
|
-
" fi",
|
|
1838
|
-
" fi",
|
|
1839
1757
|
// Flip .mp-status.json to phase=error (preserving the rest) so monitorSetup surfaces it.
|
|
1840
1758
|
" j=\"$_MP_LAST\"",
|
|
1841
1759
|
" if [ -n \"$j\" ]; then",
|
|
@@ -1865,10 +1783,10 @@ export function buildWorkerScript(params: {
|
|
|
1865
1783
|
|
|
1866
1784
|
// ─── buildSetupPlan (RFC 0017) ─────────────────────────────────────────────────
|
|
1867
1785
|
//
|
|
1868
|
-
// The ordered phases the agent execs one by one (each in its own `docker exec`
|
|
1786
|
+
// The ordered phases the agent execs one by one (each in its own `docker exec`) when
|
|
1869
1787
|
// WORKER_SETUP_ORCHESTRATOR=agent. Reuses the exact bash blocks from buildSetupScript/buildStartScript
|
|
1870
|
-
// (clone strategies, oh-my-zsh, dotfiles, lifecycle) MINUS the `_mp`/`_mp_stamp
|
|
1871
|
-
// agent owns status
|
|
1788
|
+
// (clone strategies, oh-my-zsh, dotfiles, lifecycle) MINUS the `_mp`/`_mp_stamp` machinery — the
|
|
1789
|
+
// agent owns status now. Each phase carries the SetupStatus to persist at its start/end
|
|
1872
1790
|
// (mirrors the legacy `mkStatus(...)` writes) so the DB/UI progression is identical. See RFC 0017 §2.2.
|
|
1873
1791
|
|
|
1874
1792
|
export function buildSetupPlan(params: SetupScriptParams): {
|