@spunto/build 0.16.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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Background commands inside a box — the scripts, not the transport. Whoever can run a shell in
3
+ * the box (`docker exec`, the in-box agent's `exec`) runs these and parses what they print.
4
+ *
5
+ * The state of a command lives **in the box**, not in the control plane: it can restart (or be
6
+ * redeployed) mid-run, and the next poll picks the output back up from the files. **The layout is
7
+ * a format, not a preference** — a task started before a release must still be read after it:
8
+ *
9
+ * ~/.spunto/commands/<id>/cmd.sh the command, verbatim
10
+ * ~/.spunto/commands/<id>/run.sh the wrapper (cd + timeout + exit code)
11
+ * ~/.spunto/commands/<id>/stdin a FIFO, for an interactive harness only
12
+ * ~/.spunto/commands/<id>/stdout.log stdout of the wrapper
13
+ * ~/.spunto/commands/<id>/stderr.log stderr of the wrapper
14
+ * ~/.spunto/commands/<id>/pid pid of the wrapper, for cancellation
15
+ * ~/.spunto/commands/<id>/exit_code written last — its presence means "done"
16
+ *
17
+ * Every script prints `KEY:value` lines (`commandFields`), base64 for anything that is bytes.
18
+ */
19
+
20
+ /** Where a box keeps the state of its background commands (inside $HOME). */
21
+ export const COMMANDS_DIR = "$HOME/.spunto/commands"
22
+ /** How long finished command directories are kept in the box before pruning. */
23
+ export const COMMAND_RETENTION_DAYS = 7
24
+ /** Default tail of each stream a poll returns. */
25
+ export const DEFAULT_POLL_OUTPUT_BYTES = 64 * 1024
26
+ /** Ceiling on one read — base64 inflates it by a third on the wire. */
27
+ export const MAX_READ_BYTES = 256 * 1024
28
+
29
+ /** POSIX single-quote quoting — the only safe way to inject a value into a shell string. */
30
+ const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
31
+
32
+ function base64(s: string): string {
33
+ const bytes = new TextEncoder().encode(s)
34
+ let bin = ""
35
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
36
+ return btoa(bin)
37
+ }
38
+
39
+ /**
40
+ * Command ids end up in a shell-built path inside the box, so they are checked rather than trusted,
41
+ * whatever the caller sends. A security boundary, like `sanitizeSessionName`.
42
+ */
43
+ export const isCommandId = (id: string) => /^[a-z0-9]{1,64}$/.test(id)
44
+
45
+ export class InvalidCommandIdError extends Error {
46
+ constructor(id: string) {
47
+ super(`Invalid command id ${id}`)
48
+ this.name = "InvalidCommandIdError"
49
+ }
50
+ }
51
+
52
+ function dirOf(commandId: string): string {
53
+ if (!isCommandId(commandId)) throw new InvalidCommandIdError(commandId)
54
+ return `${COMMANDS_DIR}/${commandId}`
55
+ }
56
+
57
+ export type StartCommandScript = {
58
+ commandId: string
59
+ command: string
60
+ cwd: string
61
+ /** Exported in the wrapper, so the command itself sees it — not just the exec that launched it. */
62
+ env?: Record<string, string>
63
+ timeoutMs: number
64
+ /**
65
+ * A writable stdin — a FIFO under the command's directory, appended to with `stdinScript`. For a
66
+ * harness that reads instructions as a stream and stays alive between turns.
67
+ */
68
+ stdin?: boolean
69
+ }
70
+
71
+ /**
72
+ * Write the command and its wrapper into the box, detach it, print its pid. `setsid` puts the
73
+ * wrapper in its own process group so a cancel takes the whole tree down; an image without it gets
74
+ * `nohup`.
75
+ */
76
+ export function startScript(p: StartCommandScript): string {
77
+ const dir = dirOf(p.commandId)
78
+ const timeoutSec = Math.max(1, Math.ceil(p.timeoutMs / 1000))
79
+ const exports = Object.entries(p.env ?? {})
80
+ .map(([k, v]) => `export ${k}=${shq(v)}`)
81
+ .join("\n")
82
+ // A file rather than an inline `-c`: the detached process depends on nothing of the exec that
83
+ // launched it, and `cmd.sh` stays readable in the box for whoever debugs a run afterwards.
84
+ // `exec 9<>fifo` is the load-bearing line of interactive mode: opening a FIFO for writing blocks
85
+ // until a reader shows up (`<>` never does), and holding it open for the wrapper's whole life is
86
+ // what stops the harness from seeing EOF between two turns.
87
+ const runner = [
88
+ `#!/usr/bin/env bash`,
89
+ exports,
90
+ `cd ${shq(p.cwd)} 2>/dev/null || cd "$HOME"`,
91
+ ...(p.stdin
92
+ ? [`exec 9<>"$(dirname "$0")/stdin"`, `timeout -k 5 ${timeoutSec}s bash -l "$(dirname "$0")/cmd.sh" < "$(dirname "$0")/stdin"`]
93
+ : [`timeout -k 5 ${timeoutSec}s bash -l "$(dirname "$0")/cmd.sh"`]),
94
+ `echo $? > "$(dirname "$0")/exit_code"`,
95
+ ].join("\n")
96
+ return [
97
+ `set -e`,
98
+ `mkdir -p ${dir}`,
99
+ ...(p.stdin ? [`[ -p ${dir}/stdin ] || mkfifo ${dir}/stdin`] : []),
100
+ // A box is long-lived: its command log must not grow forever.
101
+ `find ${COMMANDS_DIR} -mindepth 1 -maxdepth 1 -type d -mtime +${COMMAND_RETENTION_DAYS} -exec rm -rf {} + 2>/dev/null || true`,
102
+ `printf %s ${shq(base64(p.command))} | base64 -d > ${dir}/cmd.sh`,
103
+ `printf %s ${shq(base64(runner))} | base64 -d > ${dir}/run.sh`,
104
+ `: > ${dir}/stdout.log`,
105
+ `: > ${dir}/stderr.log`,
106
+ `if command -v setsid >/dev/null 2>&1; then`,
107
+ ` setsid bash ${dir}/run.sh > ${dir}/stdout.log 2> ${dir}/stderr.log < /dev/null &`,
108
+ `else`,
109
+ ` nohup bash ${dir}/run.sh > ${dir}/stdout.log 2> ${dir}/stderr.log < /dev/null &`,
110
+ `fi`,
111
+ `echo $! > ${dir}/pid`,
112
+ `cat ${dir}/pid`,
113
+ ].join("\n")
114
+ }
115
+
116
+ /**
117
+ * The state of a command and the tail of both streams. `exit_code` is written last by the wrapper,
118
+ * so its presence is the completion signal — the pid alone would call a command finished the
119
+ * instant its shell is replaced by an exec'd child.
120
+ * Prints `FOUND`, `EXIT` (`-` while running), `ALIVE`, `SIZEOUT`, `SIZEERR`, `OUT`, `ERR` (base64).
121
+ */
122
+ export function pollScript(commandId: string, maxOutputBytes = DEFAULT_POLL_OUTPUT_BYTES): string {
123
+ const dir = dirOf(commandId)
124
+ return [
125
+ `if [ ! -d ${dir} ]; then echo "FOUND:0"; exit 0; fi`,
126
+ `echo "FOUND:1"`,
127
+ `if [ -f ${dir}/exit_code ]; then echo "EXIT:$(cat ${dir}/exit_code)"; else echo "EXIT:-"; fi`,
128
+ `pid=$(cat ${dir}/pid 2>/dev/null || echo "")`,
129
+ `if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "ALIVE:1"; else echo "ALIVE:0"; fi`,
130
+ `echo "SIZEOUT:$(wc -c < ${dir}/stdout.log 2>/dev/null || echo 0)"`,
131
+ `echo "SIZEERR:$(wc -c < ${dir}/stderr.log 2>/dev/null || echo 0)"`,
132
+ `echo "OUT:$(tail -c ${maxOutputBytes} ${dir}/stdout.log 2>/dev/null | base64 | tr -d '\\n')"`,
133
+ `echo "ERR:$(tail -c ${maxOutputBytes} ${dir}/stderr.log 2>/dev/null | base64 | tr -d '\\n')"`,
134
+ ].join("\n")
135
+ }
136
+
137
+ /**
138
+ * A byte range of stdout, from `offset` on — for a reader that keeps a cursor (a harness streaming
139
+ * JSONL): every line, exactly once. Prints `FOUND`, `SIZE`, `EXIT`, `ALIVE`, `DATA` (base64).
140
+ */
141
+ export function readScript(commandId: string, offset: number, maxBytes: number): string {
142
+ const dir = dirOf(commandId)
143
+ const from = Math.max(0, Math.floor(offset))
144
+ const bytes = Math.max(1, Math.floor(maxBytes))
145
+ return [
146
+ `if [ ! -d ${dir} ]; then echo "FOUND:0"; exit 0; fi`,
147
+ `echo "FOUND:1"`,
148
+ `echo "SIZE:$(wc -c < ${dir}/stdout.log 2>/dev/null || echo 0)"`,
149
+ `if [ -f ${dir}/exit_code ]; then echo "EXIT:$(cat ${dir}/exit_code)"; else echo "EXIT:-"; fi`,
150
+ `pid=$(cat ${dir}/pid 2>/dev/null || echo "")`,
151
+ `if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "ALIVE:1"; else echo "ALIVE:0"; fi`,
152
+ // `tail -c +N` counts bytes from 1, so an offset of 0 is `+1`.
153
+ `echo "DATA:$(tail -c +${from + 1} ${dir}/stdout.log 2>/dev/null | head -c ${bytes} | base64 | tr -d '\\n')"`,
154
+ ].join("\n")
155
+ }
156
+
157
+ /**
158
+ * Append to a command's stdin FIFO (`dataB64`: the exact bytes, newline included). The wrapper holds
159
+ * the FIFO open, so there is always a reader; a write under PIPE_BUF (4 KiB) is atomic. Prints `WRITTEN`.
160
+ */
161
+ export function stdinScript(commandId: string, dataB64: string): string {
162
+ const dir = dirOf(commandId)
163
+ return [`if [ ! -p ${dir}/stdin ]; then echo "WRITTEN:0"; exit 0; fi`, `printf %s ${shq(dataB64)} | base64 -d > ${dir}/stdin`, `echo "WRITTEN:1"`].join("\n")
164
+ }
165
+
166
+ /**
167
+ * TERM to the whole process group, then a KILL sweep for anything that ignored it. The wrapper is
168
+ * gone after that, so the cancellation is recorded as exit 143 for a later poll. Prints `SIGNALLED`.
169
+ */
170
+ export function cancelScript(commandId: string): string {
171
+ const dir = dirOf(commandId)
172
+ return [
173
+ `pid=$(cat ${dir}/pid 2>/dev/null || echo "")`,
174
+ `[ -z "$pid" ] && { echo "SIGNALLED:0"; exit 0; }`,
175
+ `pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')`,
176
+ `if [ -n "$pgid" ]; then kill -TERM -"$pgid" 2>/dev/null || true; fi`,
177
+ `kill -TERM "$pid" 2>/dev/null || true`,
178
+ `sleep 2`,
179
+ `if [ -n "$pgid" ]; then kill -KILL -"$pgid" 2>/dev/null || true; fi`,
180
+ `kill -KILL "$pid" 2>/dev/null || true`,
181
+ `[ -f ${dir}/exit_code ] || echo 143 > ${dir}/exit_code`,
182
+ `echo "SIGNALLED:1"`,
183
+ ].join("\n")
184
+ }
185
+
186
+ /** The `KEY:value` lines the scripts above print. */
187
+ export function commandFields(stdout: string): Map<string, string> {
188
+ const fields = new Map<string, string>()
189
+ for (const line of stdout.split("\n")) {
190
+ const idx = line.indexOf(":")
191
+ if (idx > 0) fields.set(line.slice(0, idx), line.slice(idx + 1).trim())
192
+ }
193
+ return fields
194
+ }
195
+
196
+ /** `EXIT` of a poll or a read: null while the command runs (or its wrapper died without one). */
197
+ export function commandExitCode(fields: Map<string, string>): number | null {
198
+ const raw = fields.get("EXIT")
199
+ if (!raw || raw === "-") return null
200
+ const n = parseInt(raw, 10)
201
+ return Number.isFinite(n) ? n : null
202
+ }
203
+
204
+ /** A wrapper still alive with an exit code already written is finishing up: done, not running. */
205
+ export const commandRunning = (fields: Map<string, string>) => commandExitCode(fields) === null && fields.get("ALIVE") === "1"
package/src/index.ts CHANGED
@@ -14,3 +14,6 @@ export * from "./catalogs/index"
14
14
  export * from "./script/index"
15
15
  export * from "./terminal/index"
16
16
  export * from "./agent-stream/index"
17
+ export * from "./agent-protocol/index"
18
+ export * from "./agent-connection/index"
19
+ export * from "./commands/index"