mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-07 17:31:04 +00:00
refactor: reduce complexity in display/selection and streaming functions (#1162)
Extract helper functions to reduce cyclomatic complexity: - shared/common.sh: Split _display_and_select() (81 lines) into: - _prepare_fzf_input(): Format items for fzf - _fzf_select(): Handle fzf interactive selection - _numbered_list_select(): Fallback numbered list mode - trigger-server.ts: Extract startStreamingRun() (133 lines) helpers: - createEnqueuer(): Manage client connection state safely - drainStreamOutput(): Generic stream draining with activity tracking - render/lib/common.sh: Extract repeated error messages from _render_wait_for_service() (51 lines) into helper functions: - _render_print_deployment_failed_help() - _render_print_timeout_help() Agent: complexity-hunter Co-authored-by: spawn-refactor-bot <refactor@openrouter.ai> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf738bee69
commit
df96db3499
3 changed files with 166 additions and 100 deletions
|
|
@ -190,6 +190,59 @@ function gracefulShutdown(signal: string) {
|
|||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
|
||||
/**
|
||||
* Create a safe enqueue function that gracefully handles client disconnect.
|
||||
*/
|
||||
function createEnqueuer(encoder: TextEncoder): {
|
||||
enqueue: (controller: ReadableStreamDefaultController, chunk: Uint8Array) => void;
|
||||
isConnected: () => boolean;
|
||||
setDisconnected: () => void;
|
||||
} {
|
||||
let clientConnected = true;
|
||||
|
||||
return {
|
||||
enqueue(controller: ReadableStreamDefaultController, chunk: Uint8Array) {
|
||||
if (!clientConnected) return;
|
||||
try {
|
||||
controller.enqueue(chunk);
|
||||
} catch {
|
||||
clientConnected = false;
|
||||
}
|
||||
},
|
||||
isConnected() {
|
||||
return clientConnected;
|
||||
},
|
||||
setDisconnected() {
|
||||
clientConnected = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain a readable stream, logging to console and enqueuing to HTTP response.
|
||||
*/
|
||||
async function drainStreamOutput(
|
||||
src: ReadableStream<Uint8Array> | null,
|
||||
enqueue: (chunk: Uint8Array) => void,
|
||||
onActivity: () => void
|
||||
): Promise<void> {
|
||||
if (!src) return;
|
||||
const reader = src.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Always log locally
|
||||
process.stdout.write(value);
|
||||
// Stream to HTTP client if still connected
|
||||
onActivity();
|
||||
enqueue(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the target script and return a streaming Response.
|
||||
*
|
||||
|
|
@ -237,49 +290,37 @@ function startStreamingRun(reason: string, issue: string): Response {
|
|||
});
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let clientConnected = true;
|
||||
const enqueuer = createEnqueuer(encoder);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// --- Header ---
|
||||
const header = `[trigger] Run #${id} started (reason=${reason}${issue ? `, issue=#${issue}` : ""}, concurrent=${runs.size}/${MAX_CONCURRENT})\n`;
|
||||
enqueue(controller, encoder.encode(header));
|
||||
enqueuer.enqueue(controller, encoder.encode(header));
|
||||
|
||||
// --- Heartbeat: emit every 15s of silence to keep connection alive ---
|
||||
// Must fire well before Bun's idleTimeout (255s) and any proxy timeouts.
|
||||
let lastActivity = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!clientConnected) return;
|
||||
if (!enqueuer.isConnected()) return;
|
||||
const silentMs = Date.now() - lastActivity;
|
||||
if (silentMs >= 14_000) {
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
const msg = `[heartbeat] Run #${id} active (${elapsed}s elapsed)\n`;
|
||||
enqueue(controller, encoder.encode(msg));
|
||||
enqueuer.enqueue(controller, encoder.encode(msg));
|
||||
lastActivity = Date.now();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
// --- Drain stdout + stderr concurrently ---
|
||||
async function drain(src: ReadableStream<Uint8Array> | null) {
|
||||
if (!src) return;
|
||||
const reader = src.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Always log locally
|
||||
process.stdout.write(value);
|
||||
// Stream to HTTP client if still connected
|
||||
lastActivity = Date.now();
|
||||
enqueue(controller, value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
|
||||
await Promise.all([
|
||||
drainStreamOutput(proc.stdout, (chunk) => enqueuer.enqueue(controller, chunk), () => {
|
||||
lastActivity = Date.now();
|
||||
}),
|
||||
drainStreamOutput(proc.stderr, (chunk) => enqueuer.enqueue(controller, chunk), () => {
|
||||
lastActivity = Date.now();
|
||||
}),
|
||||
]);
|
||||
|
||||
// --- Wait for exit ---
|
||||
const exitCode = await proc.exited;
|
||||
|
|
@ -287,12 +328,12 @@ function startStreamingRun(reason: string, issue: string): Response {
|
|||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
const footer = `\n[trigger] Run #${id} finished (exit=${exitCode}, duration=${elapsed}s, remaining=${runs.size}/${MAX_CONCURRENT})\n`;
|
||||
console.log(footer.trim());
|
||||
enqueue(controller, encoder.encode(footer));
|
||||
enqueuer.enqueue(controller, encoder.encode(footer));
|
||||
} catch (err) {
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
const errMsg = `\n[trigger] Run #${id} stream error after ${elapsed}s: ${err}\n`;
|
||||
console.error(errMsg.trim());
|
||||
enqueue(controller, encoder.encode(errMsg));
|
||||
enqueuer.enqueue(controller, encoder.encode(errMsg));
|
||||
} finally {
|
||||
runs.delete(id);
|
||||
clearInterval(heartbeat);
|
||||
|
|
@ -304,26 +345,13 @@ function startStreamingRun(reason: string, issue: string): Response {
|
|||
|
||||
cancel() {
|
||||
// Called when the HTTP client disconnects
|
||||
clientConnected = false;
|
||||
enqueuer.setDisconnected();
|
||||
console.log(
|
||||
`[trigger] Client disconnected from run #${id} stream (process continues running)`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/** Safely enqueue data — swallow errors from client disconnect */
|
||||
function enqueue(
|
||||
controller: ReadableStreamDefaultController,
|
||||
chunk: Uint8Array
|
||||
) {
|
||||
if (!clientConnected) return;
|
||||
try {
|
||||
controller.enqueue(chunk);
|
||||
} catch {
|
||||
clientConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,30 @@ print(json.dumps(body))
|
|||
|
||||
# Wait for Render service to become live
|
||||
# Usage: _render_wait_for_service SERVICE_ID [MAX_ATTEMPTS]
|
||||
_render_print_deployment_failed_help() {
|
||||
log_error "Common causes:"
|
||||
log_error " - Build failure (check Docker image or build command configuration)"
|
||||
log_error " - Insufficient resources for the selected instance type"
|
||||
log_error " - Health check failure (service crashed during startup)"
|
||||
log_error " - Application error in start command or missing runtime dependencies"
|
||||
log_error " - Network/port configuration issues"
|
||||
log_error ""
|
||||
log_error "Debugging steps:"
|
||||
log_error " 1. View deployment logs at: https://dashboard.render.com/"
|
||||
log_error " 2. Check build and runtime logs for error messages"
|
||||
log_error " 3. Verify service configuration (ports, env vars, start command)"
|
||||
log_error " 4. Try a different region or instance type"
|
||||
}
|
||||
|
||||
_render_print_timeout_help() {
|
||||
log_error "The service may still be deploying. You can:"
|
||||
log_error " 1. Check deployment status at: https://dashboard.render.com/"
|
||||
log_error " 2. View real-time deployment logs in the dashboard"
|
||||
log_error " 3. Re-run the spawn command to retry"
|
||||
log_error ""
|
||||
log_error "If the issue persists, the service may need manual intervention via the Render dashboard."
|
||||
}
|
||||
|
||||
_render_wait_for_service() {
|
||||
local service_id="$1"
|
||||
local max_attempts=${2:-60}
|
||||
|
|
@ -168,18 +192,7 @@ _render_wait_for_service() {
|
|||
if [[ "$status" == "failed" ]]; then
|
||||
log_error "Service deployment failed with status: $status"
|
||||
log_error ""
|
||||
log_error "Common causes:"
|
||||
log_error " - Build failure (check Docker image or build command configuration)"
|
||||
log_error " - Insufficient resources for the selected instance type"
|
||||
log_error " - Health check failure (service crashed during startup)"
|
||||
log_error " - Application error in start command or missing runtime dependencies"
|
||||
log_error " - Network/port configuration issues"
|
||||
log_error ""
|
||||
log_error "Debugging steps:"
|
||||
log_error " 1. View deployment logs at: https://dashboard.render.com/"
|
||||
log_error " 2. Check build and runtime logs for error messages"
|
||||
log_error " 3. Verify service configuration (ports, env vars, start command)"
|
||||
log_error " 4. Try a different region or instance type"
|
||||
_render_print_deployment_failed_help
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
|
@ -190,12 +203,7 @@ _render_wait_for_service() {
|
|||
|
||||
log_error "Service did not become live after $max_attempts attempts"
|
||||
log_error ""
|
||||
log_error "The service may still be deploying. You can:"
|
||||
log_error " 1. Check deployment status at: https://dashboard.render.com/"
|
||||
log_error " 2. View real-time deployment logs in the dashboard"
|
||||
log_error " 3. Re-run the spawn command to retry"
|
||||
log_error ""
|
||||
log_error "If the issue persists, the service may need manual intervention via the Render dashboard."
|
||||
_render_print_timeout_help
|
||||
return 1
|
||||
}
|
||||
|
||||
|
|
|
|||
114
shared/common.sh
114
shared/common.sh
|
|
@ -2596,62 +2596,64 @@ EOF
|
|||
# Display a numbered list and read user selection
|
||||
# Pipe-delimited items: "id|label". Returns selected id via stdout.
|
||||
# Usage: _display_and_select PROMPT_TEXT DEFAULT_VALUE DEFAULT_ID <<< "$items"
|
||||
_display_and_select() {
|
||||
_fzf_select() {
|
||||
local prompt_text="${1}"
|
||||
local default_value="${2}"
|
||||
local default_id="${3:-}"
|
||||
local default_id="${3}"
|
||||
local fzf_input="${4}"
|
||||
local default_line="${5}"
|
||||
|
||||
# Read all items into array
|
||||
local items_array=()
|
||||
while IFS= read -r line; do
|
||||
items_array+=("${line}")
|
||||
done
|
||||
log_step "Select ${prompt_text%s} (type to filter):"
|
||||
|
||||
if [[ "${#items_array[@]}" -eq 0 ]]; then
|
||||
log_warn "No ${prompt_text} available, using default: ${default_value}"
|
||||
# Run fzf with default selection
|
||||
local selected
|
||||
if [[ -n "${default_line}" ]]; then
|
||||
selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --query="" --select-1 --exit-0 --header="Press ESC to use default (${default_id})" --print-query --query="${default_line%%$'\t'*}" | tail -1)
|
||||
else
|
||||
selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --select-1 --exit-0)
|
||||
fi
|
||||
|
||||
# If fzf was cancelled or returned nothing, use default
|
||||
if [[ -z "${selected}" ]]; then
|
||||
log_info "Using default: ${default_value}"
|
||||
echo "${default_value}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Try to use fzf for interactive filtering if available and stdin is a TTY
|
||||
if command -v fzf >/dev/null 2>&1 && [[ -t 0 ]]; then
|
||||
log_step "Select ${prompt_text%s} (type to filter):"
|
||||
# Extract ID from selected line
|
||||
local selected_id="${selected%%$'\t'*}"
|
||||
echo "${selected_id}"
|
||||
}
|
||||
|
||||
# Prepare fzf input with formatted display
|
||||
local fzf_input=""
|
||||
local default_line=""
|
||||
for line in "${items_array[@]}"; do
|
||||
local id="${line%%|*}"
|
||||
local display
|
||||
display=$(echo "${line}" | tr '|' '\t')
|
||||
fzf_input+="${display}"$'\n'
|
||||
if [[ -n "${default_id}" && "${id}" == "${default_id}" ]]; then
|
||||
default_line="${display}"
|
||||
fi
|
||||
done
|
||||
_prepare_fzf_input() {
|
||||
local default_id="${1}"
|
||||
shift
|
||||
local items_array=("$@")
|
||||
|
||||
# Run fzf with default selection
|
||||
local selected
|
||||
if [[ -n "${default_line}" ]]; then
|
||||
selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --query="" --select-1 --exit-0 --header="Press ESC to use default (${default_id})" --print-query --query="${default_line%%$'\t'*}" | tail -1)
|
||||
else
|
||||
selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --select-1 --exit-0)
|
||||
local fzf_input=""
|
||||
local default_line=""
|
||||
for line in "${items_array[@]}"; do
|
||||
local id="${line%%|*}"
|
||||
local display
|
||||
display=$(echo "${line}" | tr '|' '\t')
|
||||
fzf_input+="${display}"$'\n'
|
||||
if [[ -n "${default_id}" && "${id}" == "${default_id}" ]]; then
|
||||
default_line="${display}"
|
||||
fi
|
||||
done
|
||||
|
||||
# If fzf was cancelled or returned nothing, use default
|
||||
if [[ -z "${selected}" ]]; then
|
||||
log_info "Using default: ${default_value}"
|
||||
echo "${default_value}"
|
||||
return
|
||||
fi
|
||||
# Return via globals (bash doesn't have good multi-value returns)
|
||||
FZF_INPUT="${fzf_input}"
|
||||
FZF_DEFAULT_LINE="${default_line}"
|
||||
}
|
||||
|
||||
# Extract ID from selected line
|
||||
local selected_id="${selected%%$'\t'*}"
|
||||
echo "${selected_id}"
|
||||
return
|
||||
fi
|
||||
_numbered_list_select() {
|
||||
local prompt_text="${1}"
|
||||
local default_value="${2}"
|
||||
local default_id="${3}"
|
||||
shift 3
|
||||
local items_array=("$@")
|
||||
|
||||
# Fallback to numbered list when fzf is not available
|
||||
log_step "Available ${prompt_text}:"
|
||||
local i=1
|
||||
local ids=()
|
||||
|
|
@ -2679,6 +2681,34 @@ _display_and_select() {
|
|||
fi
|
||||
}
|
||||
|
||||
_display_and_select() {
|
||||
local prompt_text="${1}"
|
||||
local default_value="${2}"
|
||||
local default_id="${3:-}"
|
||||
|
||||
# Read all items into array
|
||||
local items_array=()
|
||||
while IFS= read -r line; do
|
||||
items_array+=("${line}")
|
||||
done
|
||||
|
||||
if [[ "${#items_array[@]}" -eq 0 ]]; then
|
||||
log_warn "No ${prompt_text} available, using default: ${default_value}"
|
||||
echo "${default_value}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Try to use fzf for interactive filtering if available and stdin is a TTY
|
||||
if command -v fzf >/dev/null 2>&1 && [[ -t 0 ]]; then
|
||||
_prepare_fzf_input "${default_id}" "${items_array[@]}"
|
||||
_fzf_select "${prompt_text}" "${default_value}" "${default_id}" "${FZF_INPUT}" "${FZF_DEFAULT_LINE}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to numbered list when fzf is not available
|
||||
_numbered_list_select "${prompt_text}" "${default_value}" "${default_id}" "${items_array[@]}"
|
||||
}
|
||||
|
||||
# Returns: selected ID via stdout
|
||||
interactive_pick() {
|
||||
local env_var_name="${1}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue