From 322127190b59bca3065f0e15010d5f0dc65f012a Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Tue, 1 Sep 2026 03:32:57 +0000 Subject: [PATCH 1/4] feat: add weekly session retention --- .github/workflows/ci.yml | 8 +- .opencode_web_yolo.Dockerfile | 9 +- .opencode_web_yolo.sh | 71 ++- .opencode_web_yolo_completion.bash | 2 +- .opencode_web_yolo_completion.zsh | 1 + .opencode_web_yolo_config.sh | 13 + .opencode_web_yolo_entrypoint.sh | 18 +- .opencode_web_yolo_retention.js | 366 +++++++++++++++ .opencode_web_yolo_runtime.sh | 104 +++++ CHANGELOG.md | 8 + README.md | 31 +- TECHNICAL.md | 23 + VERSION | 2 +- docs/specs/weekly-session-retention.md | 111 +++++ install.sh | 2 + skills/opencode-web-quality-docs/SKILL.md | 2 + .../references/test-acceptance-matrix.md | 3 + skills/opencode-web-release/SKILL.md | 1 + .../references/install-layout.md | 1 + skills/opencode-web-runtime/SKILL.md | 8 + .../references/flag-contracts.md | 4 +- .../references/runtime-checklist.md | 5 + tests/run.sh | 1 + tests/test_health.sh | 7 + tests/test_help.sh | 2 + tests/test_helpers.sh | 2 + tests/test_install_bootstrap.sh | 2 + tests/test_session_retention.sh | 426 ++++++++++++++++++ tests/version_guard.sh | 3 +- 29 files changed, 1226 insertions(+), 10 deletions(-) create mode 100755 .opencode_web_yolo_retention.js create mode 100755 .opencode_web_yolo_runtime.sh create mode 100644 docs/specs/weekly-session-retention.md create mode 100755 tests/test_session_retention.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aab9a8..7b71227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: bash -n .opencode_web_yolo.sh bash -n .opencode_web_yolo_config.sh bash -n .opencode_web_yolo_entrypoint.sh + bash -n .opencode_web_yolo_runtime.sh bash -n install.sh bash -n tests/test_helpers.sh bash -n tests/test_dry_run.sh @@ -37,6 +38,7 @@ jobs: bash -n tests/test_source_of_truth_refs.sh bash -n tests/test_docs_required_sections.sh bash -n tests/test_technical_required_sections.sh + bash -n tests/test_session_retention.sh bash -n tests/run.sh bash -n tests/version_guard.sh - name: Shellcheck @@ -44,6 +46,7 @@ jobs: shellcheck -x .opencode_web_yolo.sh shellcheck .opencode_web_yolo_config.sh shellcheck .opencode_web_yolo_entrypoint.sh + shellcheck .opencode_web_yolo_runtime.sh shellcheck install.sh shellcheck -x tests/test_helpers.sh shellcheck -x tests/test_dry_run.sh @@ -66,8 +69,11 @@ jobs: shellcheck -x tests/test_source_of_truth_refs.sh shellcheck -x tests/test_docs_required_sections.sh shellcheck -x tests/test_technical_required_sections.sh + shellcheck -x tests/test_session_retention.sh shellcheck -x tests/run.sh shellcheck -x tests/version_guard.sh + - name: JavaScript syntax check + run: node --check .opencode_web_yolo_retention.js behavior-checks: runs-on: ubuntu-latest @@ -100,7 +106,7 @@ jobs: run: docker build -f .opencode_web_yolo.Dockerfile -t opencode_web_yolo:ci . - name: Validate required binaries run: | - docker run --rm --entrypoint sh opencode_web_yolo:ci -lc 'command -v gh && command -v git && command -v ssh' + docker run --rm --entrypoint sh opencode_web_yolo:ci -lc 'command -v gh && command -v git && command -v ssh && command -v tini' - name: Build Playwright-enabled runtime image run: | docker build \ diff --git a/.opencode_web_yolo.Dockerfile b/.opencode_web_yolo.Dockerfile index 33ae3ae..0239e9b 100644 --- a/.opencode_web_yolo.Dockerfile +++ b/.opencode_web_yolo.Dockerfile @@ -18,6 +18,7 @@ RUN apt-get update \ openssh-client \ passwd \ sudo \ + tini \ && rm -rf /var/lib/apt/lists/* ARG OPENCODE_NPM_PACKAGE=opencode-ai @@ -61,7 +62,11 @@ This is a built-in fallback instruction file used when no host AGENTS.md is moun EOF COPY .opencode_web_yolo_entrypoint.sh /usr/local/bin/opencode_web_yolo_entrypoint.sh -RUN chmod +x /usr/local/bin/opencode_web_yolo_entrypoint.sh +COPY .opencode_web_yolo_runtime.sh /usr/local/bin/opencode_web_yolo_runtime.sh +COPY .opencode_web_yolo_retention.js /usr/local/bin/opencode_web_yolo_retention.js +RUN chmod +x /usr/local/bin/opencode_web_yolo_entrypoint.sh \ + /usr/local/bin/opencode_web_yolo_runtime.sh \ + /usr/local/bin/opencode_web_yolo_retention.js WORKDIR /workspace -ENTRYPOINT ["/usr/local/bin/opencode_web_yolo_entrypoint.sh"] +ENTRYPOINT ["/usr/bin/tini", "-s", "-g", "--", "/usr/local/bin/opencode_web_yolo_entrypoint.sh"] diff --git a/.opencode_web_yolo.sh b/.opencode_web_yolo.sh index 3340ea7..b9cc96e 100755 --- a/.opencode_web_yolo.sh +++ b/.opencode_web_yolo.sh @@ -39,6 +39,28 @@ normalize_bool() { fi } +validate_retention_days() { + local value + case "${OPENCODE_WEB_RETENTION_DAYS}" in + ''|*[!0-9]*) + die "OPENCODE_WEB_RETENTION_DAYS must be a non-negative integer (received '${OPENCODE_WEB_RETENTION_DAYS:-unset}')." + ;; + esac + value="${OPENCODE_WEB_RETENTION_DAYS}" + while [ "${value#0}" != "$value" ]; do value="${value#0}"; done + OPENCODE_WEB_RETENTION_DAYS="${value:-0}" +} + +validate_positive_integer() { + local name="$1" value="$2" + if ! [[ "$value" =~ ^[1-9][0-9]*$ ]]; then + die "${name} must be a positive integer (received '${value:-unset}')." + fi + if [ "${#value}" -gt 10 ] || { [ "${#value}" -eq 10 ] && (( value > 2147483647 )); }; then + die "${name} is outside the supported positive integer range (received '${value}')." + fi +} + log() { printf '%s\n' "[opencode_web_yolo] $*" } @@ -116,6 +138,8 @@ managed_files() { .opencode_web_yolo_config.sh .opencode_web_yolo.Dockerfile .opencode_web_yolo_entrypoint.sh +.opencode_web_yolo_runtime.sh +.opencode_web_yolo_retention.js .opencode_web_yolo_completion.bash .opencode_web_yolo_completion.zsh install.sh @@ -214,6 +238,8 @@ Wrapper flags: --no-pull Skip default pull-on-start behavior for this run. --playwright Build runtime image with Playwright Chromium. --wrangler Build Wrangler and mount host Wrangler config read-write. + --retention-days N Delete inactive root sessions older than N days (0 disables). + --retention-days=N Same as above, using an equals-form value. --agents-file PATH Mount a host AGENTS.md file read-only. --no-host-agents Skip mounting host AGENTS.md. --dry-run Print docker command and exit. @@ -238,6 +264,7 @@ Lifecycle defaults: Restart policy: ${OPENCODE_WEB_RESTART_POLICY} Background mode: ${OPENCODE_WEB_RUN_DETACHED} Pull-on-start: ${OPENCODE_WEB_AUTO_PULL} + Session retention: ${OPENCODE_WEB_RETENTION_DAYS} days (0 disables) First-time setup: 1) Create config file: @@ -289,6 +316,12 @@ export OPENCODE_WEB_BUILD_PLAYWRIGHT=0 # This explicit pin remains effective even when version checks are skipped. # export OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION=1.62.1 export OPENCODE_WEB_BUILD_WRANGLER=0 +export OPENCODE_WEB_RETENTION_DAYS=0 +# Set OPENCODE_WEB_RETENTION_DAYS to a non-negative integer to enable weekly cleanup. +# export OPENCODE_WEB_RETENTION_DRY_RUN=1 +export OPENCODE_WEB_RETENTION_POLL_SECONDS=3600 +export OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS=10000 +export OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS=10000 export OPENCODE_WEB_SKIP_UPDATE_CHECK=0 export OPENCODE_WEB_SKIP_VERSION_CHECK=0 # Required: set a non-empty password before running the server. @@ -329,6 +362,13 @@ show_health() { printf '%s\n' " build_pull=${OPENCODE_WEB_BUILD_PULL}" printf '%s\n' " build_playwright=${OPENCODE_WEB_BUILD_PLAYWRIGHT}" printf '%s\n' " build_wrangler=${OPENCODE_WEB_BUILD_WRANGLER}" + printf '%s\n' " retention_days=${OPENCODE_WEB_RETENTION_DAYS}" + printf '%s\n' " retention_dry_run=${OPENCODE_WEB_RETENTION_DRY_RUN}" + printf '%s\n' " retention_poll_seconds=${OPENCODE_WEB_RETENTION_POLL_SECONDS}" + printf '%s\n' " retention_fetch_timeout_ms=${OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS}" + printf '%s\n' " retention_verify_timeout_ms=${OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS}" + printf '%s\n' " retention_schedule=after-health-at-most-weekly" + printf '%s\n' " retention_marker=${runtime_xdg_state}/session-retention.last-success" printf '%s\n' " runtime_env_home=${runtime_home}" printf '%s\n' " runtime_env_xdg_config_home=${runtime_xdg_config}" printf '%s\n' " runtime_env_xdg_data_home=${runtime_xdg_data}" @@ -659,6 +699,14 @@ main() { OPENCODE_WEB_BUILD_WRANGLER=1 use_wrangler=1 ;; + --retention-days=*) + OPENCODE_WEB_RETENTION_DAYS="${1#*=}" + ;; + --retention-days) + shift + [ "$#" -gt 0 ] || die "--retention-days requires a non-negative integer value." + OPENCODE_WEB_RETENTION_DAYS="$1" + ;; --agents-file=*) host_agents_enabled=1 host_agents_source="flag" @@ -723,6 +771,13 @@ main() { OPENCODE_WEB_RUN_DETACHED="$(normalize_bool "${OPENCODE_WEB_RUN_DETACHED}")" OPENCODE_WEB_SKIP_UPDATE_CHECK="$(normalize_bool "${OPENCODE_WEB_SKIP_UPDATE_CHECK}")" OPENCODE_WEB_SKIP_VERSION_CHECK="$(normalize_bool "${OPENCODE_WEB_SKIP_VERSION_CHECK}")" + OPENCODE_WEB_RETENTION_DRY_RUN="$(normalize_bool "${OPENCODE_WEB_RETENTION_DRY_RUN}")" + validate_retention_days + if [ "$OPENCODE_WEB_RETENTION_DAYS" != "0" ]; then + validate_positive_integer OPENCODE_WEB_RETENTION_POLL_SECONDS "${OPENCODE_WEB_RETENTION_POLL_SECONDS}" + validate_positive_integer OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS "${OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS}" + validate_positive_integer OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS "${OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS}" + fi case "$mode" in version) @@ -750,6 +805,7 @@ main() { fi require_password + export OPENCODE_SERVER_PASSWORD require_command docker docker info >/dev/null 2>&1 || die "Docker daemon is not available." [ -n "${OPENCODE_WEB_CONTAINER_NAME}" ] || die "OPENCODE_WEB_CONTAINER_NAME must be non-empty." @@ -773,8 +829,14 @@ main() { -e "LOCAL_USER=$(id -un)" -e "OPENCODE_WEB_YOLO_CLEANUP=${OPENCODE_WEB_YOLO_CLEANUP}" -e "OPENCODE_WEB_YOLO_HOME=${OPENCODE_WEB_YOLO_HOME}" - -e "OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}" + -e OPENCODE_SERVER_PASSWORD -e "OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}" + -e "OPENCODE_WEB_PORT=${OPENCODE_WEB_PORT}" + -e "OPENCODE_WEB_RETENTION_DAYS=${OPENCODE_WEB_RETENTION_DAYS}" + -e "OPENCODE_WEB_RETENTION_DRY_RUN=${OPENCODE_WEB_RETENTION_DRY_RUN}" + -e "OPENCODE_WEB_RETENTION_POLL_SECONDS=${OPENCODE_WEB_RETENTION_POLL_SECONDS}" + -e "OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS=${OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS}" + -e "OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS=${OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS}" -e "HOME=${runtime_home}" -e "XDG_CONFIG_HOME=${runtime_xdg_config}" -e "XDG_DATA_HOME=${runtime_xdg_data}" @@ -883,6 +945,13 @@ main() { printf '%s\n' "build_pull=${OPENCODE_WEB_BUILD_PULL}" printf '%s\n' "build_playwright=${OPENCODE_WEB_BUILD_PLAYWRIGHT}" printf '%s\n' "build_wrangler=${OPENCODE_WEB_BUILD_WRANGLER}" + printf '%s\n' "retention_days=${OPENCODE_WEB_RETENTION_DAYS}" + printf '%s\n' "retention_dry_run=${OPENCODE_WEB_RETENTION_DRY_RUN}" + printf '%s\n' "retention_poll_seconds=${OPENCODE_WEB_RETENTION_POLL_SECONDS}" + printf '%s\n' "retention_fetch_timeout_ms=${OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS}" + printf '%s\n' "retention_verify_timeout_ms=${OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS}" + printf '%s\n' "retention_schedule=after-health-at-most-weekly" + printf '%s\n' "retention_marker=${runtime_xdg_state}/session-retention.last-success" printf '%s\n' "opencode_config_dir=${OPENCODE_WEB_CONFIG_DIR}" printf '%s\n' "opencode_data_dir=${OPENCODE_WEB_DATA_DIR}" printf '%s\n' "runtime_env_home=${runtime_home}" diff --git a/.opencode_web_yolo_completion.bash b/.opencode_web_yolo_completion.bash index 15b93cd..55197ad 100644 --- a/.opencode_web_yolo_completion.bash +++ b/.opencode_web_yolo_completion.bash @@ -3,7 +3,7 @@ _opencode_web_yolo_completion() { cur="${COMP_WORDS[COMP_CWORD]}" local opts - opts="--pull --no-pull --playwright --wrangler --detach -d --foreground -f --mount-ssh -gh --gh --health diagnostics health config --version version --verbose -v --help -h help --" + opts="--pull --no-pull --playwright --wrangler --retention-days --retention-days= --detach -d --foreground -f --mount-ssh -gh --gh --health diagnostics health config --version version --verbose -v --help -h help --" COMPREPLY=($(compgen -W "${opts}" -- "${cur}")) } diff --git a/.opencode_web_yolo_completion.zsh b/.opencode_web_yolo_completion.zsh index 0c3fb8b..b5c0eaf 100644 --- a/.opencode_web_yolo_completion.zsh +++ b/.opencode_web_yolo_completion.zsh @@ -7,6 +7,7 @@ _opencode_web_yolo_completion() { '--no-pull:Skip default pull-on-start for this run' '--playwright:Build runtime image with Playwright Chromium' '--wrangler:Build Wrangler and mount host config read-write' + '--retention-days[Delete inactive root sessions older than N days]:days' '--detach:Run in background mode' '-d:Run in background mode' '--foreground:Run attached in current terminal' diff --git a/.opencode_web_yolo_config.sh b/.opencode_web_yolo_config.sh index 7b936ea..fc2119e 100755 --- a/.opencode_web_yolo_config.sh +++ b/.opencode_web_yolo_config.sh @@ -30,6 +30,19 @@ OPENCODE_WEB_CONFIG_FILE="${OPENCODE_WEB_YOLO_CONFIG_FILE:-$OPENCODE_WEB_YOLO_CO : "${OPENCODE_WEB_YOLO_BRANCH:=main}" : "${OPENCODE_WEB_NPM_PACKAGE:=opencode-ai}" : "${OPENCODE_SERVER_USERNAME:=opencode}" +if [ "${OPENCODE_WEB_RETENTION_DAYS+x}" != x ]; then + OPENCODE_WEB_RETENTION_DAYS=0 +fi +: "${OPENCODE_WEB_RETENTION_DRY_RUN:=0}" +if [ "${OPENCODE_WEB_RETENTION_POLL_SECONDS+x}" != x ]; then + OPENCODE_WEB_RETENTION_POLL_SECONDS=3600 +fi +if [ "${OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS+x}" != x ]; then + OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS=10000 +fi +if [ "${OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS+x}" != x ]; then + OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS=10000 +fi if [ -f "$OPENCODE_WEB_CONFIG_FILE" ]; then # shellcheck disable=SC1090 diff --git a/.opencode_web_yolo_entrypoint.sh b/.opencode_web_yolo_entrypoint.sh index bee0daf..e553c07 100755 --- a/.opencode_web_yolo_entrypoint.sh +++ b/.opencode_web_yolo_entrypoint.sh @@ -9,6 +9,18 @@ OPENCODE_WEB_YOLO_CLEANUP="${OPENCODE_WEB_YOLO_CLEANUP:-1}" XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${OPENCODE_WEB_YOLO_HOME}/.config}" XDG_DATA_HOME="${XDG_DATA_HOME:-${OPENCODE_WEB_YOLO_HOME}/.local/share}" XDG_STATE_HOME="${XDG_STATE_HOME:-${XDG_DATA_HOME}/opencode/state}" +OPENCODE_WEB_RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS-0}" + +case "$OPENCODE_WEB_RETENTION_DAYS" in + ''|*[!0-9]*) + printf '%s\n' "[opencode_web_yolo] ERROR: OPENCODE_WEB_RETENTION_DAYS must be a non-negative integer." >&2 + exit 1 + ;; +esac +while [ "${OPENCODE_WEB_RETENTION_DAYS#0}" != "$OPENCODE_WEB_RETENTION_DAYS" ]; do + OPENCODE_WEB_RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS#0}" +done +OPENCODE_WEB_RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS:-0}" if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then printf '%s\n' "[opencode_web_yolo] ERROR: OPENCODE_SERVER_PASSWORD must be set and non-empty." >&2 @@ -68,4 +80,8 @@ export XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" export XDG_DATA_HOME="${XDG_DATA_HOME}" export XDG_STATE_HOME="${XDG_STATE_HOME}" -exec env HOME="${HOME}" XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" XDG_DATA_HOME="${XDG_DATA_HOME}" XDG_STATE_HOME="${XDG_STATE_HOME}" gosu "${runtime_user}" "$@" +if [ "$OPENCODE_WEB_RETENTION_DAYS" = "0" ]; then + exec env HOME="${HOME}" XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" XDG_DATA_HOME="${XDG_DATA_HOME}" XDG_STATE_HOME="${XDG_STATE_HOME}" gosu "${runtime_user}" "$@" +fi + +exec env HOME="${HOME}" XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" XDG_DATA_HOME="${XDG_DATA_HOME}" XDG_STATE_HOME="${XDG_STATE_HOME}" OPENCODE_WEB_RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS}" OPENCODE_WEB_RETENTION_DRY_RUN="${OPENCODE_WEB_RETENTION_DRY_RUN:-0}" gosu "${runtime_user}" /usr/local/bin/opencode_web_yolo_runtime.sh "$@" diff --git a/.opencode_web_yolo_retention.js b/.opencode_web_yolo_retention.js new file mode 100755 index 0000000..9e2a3e8 --- /dev/null +++ b/.opencode_web_yolo_retention.js @@ -0,0 +1,366 @@ +#!/usr/bin/env node + +const fs = require("node:fs") +const path = require("node:path") + +const WEEK_MS = 7n * 24n * 60n * 60n * 1000n +const PAGE_LIMIT = 100 +const DEFAULT_FETCH_TIMEOUT_MS = 10000 +const DEFAULT_VERIFY_TIMEOUT_MS = 10000 +const VERIFY_POLL_MS = 250 + +function log(message) { + process.stdout.write(`[opencode_web_yolo retention] ${message}\n`) +} + +function fail(message) { + process.stderr.write(`[opencode_web_yolo retention] ERROR: ${message}\n`) + process.exitCode = 1 +} + +function isTrue(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()) +} + +function positiveInteger(name, fallback) { + const value = process.env[name] ?? String(fallback) + if (!/^[1-9][0-9]*$/.test(value)) { + throw new Error(`${name} must be a positive integer`) + } + const result = Number(value) + if (!Number.isSafeInteger(result) || result > 2147483647) { + throw new Error(`${name} is outside the supported positive integer range`) + } + return result +} + +function retentionDays() { + const value = process.env.OPENCODE_WEB_RETENTION_DAYS + if (!/^[0-9]+$/.test(value || "")) { + throw new Error("OPENCODE_WEB_RETENTION_DAYS must be a non-negative integer") + } + return BigInt(value) +} + +function stateMarkerPath() { + const stateHome = process.env.XDG_STATE_HOME || `${process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`}/opencode/state` + return process.env.OPENCODE_WEB_RETENTION_MARKER || path.join(stateHome, "session-retention.last-success") +} + +function markerTime(marker) { + try { + const value = fs.readFileSync(marker, "utf8").trim() + return /^[0-9]+$/.test(value) ? BigInt(value) : undefined + } catch (error) { + if (error && error.code === "ENOENT") return undefined + throw new Error(`cannot read success marker ${marker}: ${error.message}`) + } +} + +function due(marker, now) { + const previous = markerTime(marker) + return previous === undefined || now < previous || now - previous >= WEEK_MS +} + +function writeMarker(marker, now) { + fs.mkdirSync(path.dirname(marker), { recursive: true }) + const temporary = `${marker}.tmp-${process.pid}` + fs.writeFileSync(temporary, `${now}\n`, { mode: 0o600 }) + fs.renameSync(temporary, marker) +} + +function baseUrl() { + return new URL( + process.env.OPENCODE_WEB_RETENTION_URL || `http://127.0.0.1:${process.env.OPENCODE_WEB_PORT || "4096"}`, + ) +} + +function authHeaders() { + const username = process.env.OPENCODE_SERVER_USERNAME || "opencode" + const password = process.env.OPENCODE_SERVER_PASSWORD || "" + return { + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, + Accept: "application/json", + } +} + +async function requestJson(route, options = {}, query = {}) { + const url = new URL(route, baseUrl()) + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + + let response + const timeoutMs = options.timeoutMs ?? positiveInteger("OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS", DEFAULT_FETCH_TIMEOUT_MS) + try { + response = await fetch(url, { + method: options.method || "GET", + headers: authHeaders(), + signal: AbortSignal.timeout(timeoutMs), + }) + if (response.status === 404 && options.expectNotFound === true) { + return { notFound: true, headers: response.headers } + } + if (!response.ok) { + throw new Error(`OpenCode API returned HTTP ${response.status} for ${route}`) + } + const body = await response.text() + + try { + return { value: JSON.parse(body), headers: response.headers } + } catch { + throw new Error(`OpenCode API returned incompatible JSON for ${route}`) + } + } catch (error) { + if (error?.name === "TimeoutError" || error?.name === "AbortError") { + throw new Error(`OpenCode API request timed out for ${route} after ${timeoutMs}ms`) + } + if (error instanceof Error && error.message.startsWith("OpenCode API returned ")) throw error + throw new Error(`OpenCode API request failed for ${route}: ${error.message}`) + } +} + +function validateSession(session) { + if (!session || typeof session !== "object" || typeof session.id !== "string") { + throw new Error("OpenCode global session API returned a malformed session") + } + if ( + session.parentID !== undefined && + session.parentID !== null && + (typeof session.parentID !== "string" || session.parentID.length === 0) + ) { + throw new Error("OpenCode session API returned an invalid parentID") + } + if (typeof session.directory !== "string" || !session.time || typeof session.time.updated !== "number") { + throw new Error("OpenCode global session API returned a session without directory/time.updated") + } + if (!Number.isSafeInteger(session.time.updated) || session.time.updated < 0) { + throw new Error("OpenCode global session API returned an invalid time.updated") + } +} + +async function listSessions(deadline) { + const sessions = [] + let cursor + const seenCursors = new Set() + + for (let pageNumber = 0; pageNumber < 10000; pageNumber += 1) { + const remaining = deadline === undefined ? undefined : deadline - Date.now() + if (remaining !== undefined && remaining <= 0) { + throw new Error("session deletion verification timed out") + } + const result = await requestJson("/experimental/session", remaining === undefined ? {} : { + timeoutMs: Math.min(remaining, positiveInteger("OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS", DEFAULT_FETCH_TIMEOUT_MS)), + }, { + roots: "false", + archived: "true", + limit: PAGE_LIMIT, + cursor, + }) + if (!Array.isArray(result.value)) { + throw new Error("OpenCode global session API is incompatible: expected a session array") + } + result.value.forEach(validateSession) + sessions.push(...result.value) + + const nextCursor = result.headers.get("x-next-cursor") + if (!nextCursor) return sessions + const last = result.value[result.value.length - 1] + if (!last || String(last.time.updated) !== nextCursor || !/^[0-9]+$/.test(nextCursor)) { + throw new Error("OpenCode global session API returned an invalid pagination cursor") + } + if (result.value.filter((session) => session.time.updated === last.time.updated).length > 1) { + throw new Error("OpenCode global session API cannot safely paginate equal session timestamps") + } + if (seenCursors.has(nextCursor) || nextCursor === cursor) { + throw new Error("OpenCode global session API returned a repeated pagination cursor") + } + seenCursors.add(nextCursor) + cursor = nextCursor + } + + throw new Error("OpenCode global session API pagination exceeded the safety limit") +} + +function buildHierarchy(sessions) { + const byID = new Map() + for (const session of sessions) { + if (byID.has(session.id)) { + throw new Error(`OpenCode global session API returned duplicate session ID ${session.id}`) + } + byID.set(session.id, session) + } + + const rootByID = new Map() + const resolving = new Set() + const resolveRoot = (sessionID) => { + if (rootByID.has(sessionID)) return rootByID.get(sessionID) + if (resolving.has(sessionID)) { + throw new Error("OpenCode global session API returned a cyclic session hierarchy") + } + const session = byID.get(sessionID) + if (!session) { + throw new Error(`OpenCode global session API is missing parent session ${sessionID}`) + } + resolving.add(sessionID) + const rootID = session.parentID === undefined || session.parentID === null + ? session.id + : resolveRoot(session.parentID) + resolving.delete(sessionID) + rootByID.set(sessionID, rootID) + return rootID + } + + for (const session of sessions) resolveRoot(session.id) + return { + sessions, + byID, + rootByID, + directories: [...new Set(sessions.map((session) => session.directory))], + } +} + +function validateStatusMap(statuses) { + if (!statuses || typeof statuses !== "object" || Array.isArray(statuses)) { + throw new Error("OpenCode session status API returned an incompatible response") + } + for (const status of Object.values(statuses)) { + if (!status || !["idle", "busy", "retry"].includes(status.type)) { + throw new Error("OpenCode session status API returned an unknown status") + } + } + return statuses +} + +async function activeRoots(hierarchy) { + const active = new Set() + for (const directory of hierarchy.directories) { + const result = await requestJson("/session/status", {}, { directory }) + const status = validateStatusMap(result.value) + for (const [sessionID, info] of Object.entries(status)) { + const session = hierarchy.byID.get(sessionID) + if (!session) { + throw new Error(`OpenCode session status API returned an unmapped session ${sessionID}`) + } + if (session.directory !== directory) { + throw new Error(`OpenCode session status API mapped session ${sessionID} to the wrong directory`) + } + const rootID = hierarchy.rootByID.get(sessionID) + if (!rootID) { + throw new Error(`OpenCode session hierarchy could not map session ${sessionID} to a root`) + } + if (info.type !== "busy" && info.type !== "retry") continue + active.add(rootID) + } + } + return active +} + +async function getSession(sessionID, directory, expectNotFound = false) { + return requestJson(`/session/${encodeURIComponent(sessionID)}`, { + expectNotFound, + }, { directory }) +} + +function validateRootResponse(result, sessionID, directory) { + if (result.notFound) return undefined + validateSession(result.value) + if (result.value.id !== sessionID || result.value.directory !== directory) { + throw new Error(`OpenCode session API returned a mismatched session for ${sessionID}`) + } + if (result.value.parentID !== undefined && result.value.parentID !== null) { + throw new Error(`OpenCode session API returned non-root session ${sessionID}`) + } + return result.value +} + +async function verifyDeleted(sessionID, directory, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (true) { + const result = await getSession(sessionID, directory, true) + if (result.notFound) return + validateRootResponse(result, sessionID, directory) + if (Date.now() >= deadline) { + throw new Error(`session ${sessionID} remained present after deletion verification`) + } + await new Promise((resolve) => setTimeout(resolve, VERIFY_POLL_MS)) + } +} + +async function checkServerCapability() { + const result = await requestJson("/global/health") + if (!result.value || result.value.healthy !== true || typeof result.value.version !== "string") { + throw new Error("OpenCode health API is incompatible; retention requires healthy 1.18.x capability metadata") + } + if (!/^1\.18\.[0-9]+$/.test(result.value.version)) { + throw new Error(`OpenCode version ${result.value.version} is unsupported; retention requires tested 1.18.x APIs`) + } +} + +async function runOnce() { + const days = retentionDays() + if (days === 0n) { + log("disabled (retention days is 0)") + return + } + + const now = BigInt(process.env.OPENCODE_WEB_RETENTION_NOW_MS || Date.now()) + const marker = stateMarkerPath() + if (!due(marker, now)) { + log("not due; the last successful run is less than 7 days old") + return + } + + const verifyTimeoutMs = positiveInteger("OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS", DEFAULT_VERIFY_TIMEOUT_MS) + await checkServerCapability() + const cutoff = now - days * 24n * 60n * 60n * 1000n + const hierarchy = buildHierarchy(await listSessions()) + const candidates = hierarchy.sessions.filter( + (session) => (session.parentID === undefined || session.parentID === null) && BigInt(session.time.updated) < cutoff, + ) + const active = await activeRoots(hierarchy) + const inactive = candidates.filter((session) => !active.has(session.id)) + + if (isTrue(process.env.OPENCODE_WEB_RETENTION_DRY_RUN)) { + log(`dry-run: ${inactive.length} inactive root session(s) would be deleted`) + return + } + + let deleted = 0 + for (const session of inactive) { + const refreshedResult = await getSession(session.id, session.directory, true) + const refreshed = validateRootResponse(refreshedResult, session.id, session.directory) + if (!refreshed || BigInt(refreshed.time.updated) >= cutoff) continue + + const currentHierarchy = buildHierarchy(await listSessions()) + const current = currentHierarchy.byID.get(session.id) + if ( + !current || + current.directory !== refreshed.directory || + currentHierarchy.rootByID.get(session.id) !== session.id || + BigInt(current.time.updated) >= cutoff + ) continue + const activeNow = await activeRoots(currentHierarchy) + if (activeNow.has(session.id)) continue + + const result = await requestJson(`/session/${encodeURIComponent(session.id)}`, { method: "DELETE" }, { + directory: refreshed.directory, + }) + if (result.value !== true) { + throw new Error(`OpenCode deletion API returned an incompatible response for session ${session.id}`) + } + await verifyDeleted(session.id, refreshed.directory, verifyTimeoutMs) + deleted += 1 + } + + writeMarker(marker, now) + log(`completed: deleted ${deleted} inactive root session(s); active sessions were skipped`) +} + +if (process.argv.includes("--help")) { + process.stdout.write("Usage: opencode_web_yolo_retention.js --run-once\n") +} else if (!process.argv.includes("--run-once")) { + fail("--run-once is required") +} else { + runOnce().catch((error) => fail(error.message)) +} diff --git a/.opencode_web_yolo_runtime.sh b/.opencode_web_yolo_runtime.sh new file mode 100755 index 0000000..ecf1846 --- /dev/null +++ b/.opencode_web_yolo_runtime.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +RETENTION_HELPER="${OPENCODE_WEB_RETENTION_HELPER:-/usr/local/bin/opencode_web_yolo_retention.js}" +RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS-0}" +RETENTION_POLL_SECONDS="${OPENCODE_WEB_RETENTION_POLL_SECONDS-3600}" +RETENTION_URL="${OPENCODE_WEB_RETENTION_URL:-http://127.0.0.1:${OPENCODE_WEB_PORT:-4096}}" +app_pid="" +scheduler_pid="" +stopping=0 + +case "$RETENTION_DAYS" in + ''|*[!0-9]*) + printf '%s\n' "[opencode_web_yolo retention] ERROR: OPENCODE_WEB_RETENTION_DAYS must be a non-negative integer." >&2 + exit 1 + ;; +esac +while [ "${RETENTION_DAYS#0}" != "$RETENTION_DAYS" ]; do RETENTION_DAYS="${RETENTION_DAYS#0}"; done +RETENTION_DAYS="${RETENTION_DAYS:-0}" + +if [ "$RETENTION_DAYS" = "0" ]; then + exec "$@" +fi + +if ! [[ "$RETENTION_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]]; then + printf '%s\n' "[opencode_web_yolo retention] ERROR: OPENCODE_WEB_RETENTION_POLL_SECONDS must be a positive integer (minimum 1 second)." >&2 + exit 1 +fi +if [ "${#RETENTION_POLL_SECONDS}" -gt 10 ] || { [ "${#RETENTION_POLL_SECONDS}" -eq 10 ] && (( RETENTION_POLL_SECONDS > 2147483647 )); }; then + printf '%s\n' "[opencode_web_yolo retention] ERROR: OPENCODE_WEB_RETENTION_POLL_SECONDS is outside the supported positive integer range." >&2 + exit 1 +fi + +forward_signal() { + local signal="$1" + stopping=1 + if [ -n "$app_pid" ]; then kill -"$signal" "$app_pid" >/dev/null 2>&1 || true; fi + if [ -n "$scheduler_pid" ]; then kill -"$signal" "$scheduler_pid" >/dev/null 2>&1 || true; fi +} + +trap 'forward_signal TERM' TERM +trap 'forward_signal INT' INT + +health_ok() { + local health_body auth + if ! auth="$(node -e 'process.stdout.write(Buffer.from(`${process.env.OPENCODE_SERVER_USERNAME || "opencode"}:${process.env.OPENCODE_SERVER_PASSWORD || ""}`).toString("base64"))')"; then + return 1 + fi + if ! health_body="$(printf 'header = "Authorization: Basic %s"\n' "$auth" | curl --config - -fsS --max-time 2 "${RETENTION_URL}/global/health" 2>/dev/null)"; then + return 1 + fi + node -e 'const value = JSON.parse(require("fs").readFileSync(0, "utf8")); process.exit(value && value.healthy === true ? 0 : 1)' <<<"$health_body" >/dev/null 2>&1 +} + +wait_for_health() { + while [ "$stopping" -eq 0 ] && app_running; do + if health_ok; then return 0; fi + sleep 1 || true + done + return 1 +} + +app_running() { + kill -0 "$app_pid" >/dev/null 2>&1 || return 1 + if [ -r "/proc/${app_pid}/stat" ]; then + local process_state + read -r _ _ process_state _ <"/proc/${app_pid}/stat" || return 1 + [ "$process_state" != Z ] + fi +} + +run_scheduler() { + while [ "$stopping" -eq 0 ]; do + if ! OPENCODE_WEB_RETENTION_DAYS="$RETENTION_DAYS" \ + OPENCODE_WEB_RETENTION_URL="$RETENTION_URL" \ + node "$RETENTION_HELPER" --run-once; then + printf '%s\n' "[opencode_web_yolo retention] WARNING: cleanup failed; success marker was not advanced. It will retry later." >&2 + fi + sleep "$RETENTION_POLL_SECONDS" || true + done +} + +"$@" & +app_pid=$! + +if wait_for_health; then + run_scheduler & + scheduler_pid=$! +fi + +set +e +wait "$app_pid" +app_status=$? +if [ "$stopping" -eq 1 ] && kill -0 "$app_pid" >/dev/null 2>&1; then + wait "$app_pid" + app_status=$? +fi +set -e +stopping=1 +if [ -n "$scheduler_pid" ]; then + kill -TERM "$scheduler_pid" >/dev/null 2>&1 || true + wait "$scheduler_pid" >/dev/null 2>&1 || true +fi +exit "$app_status" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d22481..aad78bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. +## [0.2.1] - 2026-09-01 + +- Hardened retention against cross-directory active descendants, stale root refreshes, stale DELETE responses, unsupported OpenCode versions, stalled APIs, unsafe pagination boundaries, signal/process-tree issues, and scheduler misconfiguration. Deletion verification now requires direct 404/not-found responses, and documentation explicitly records the API's residual delete-if-idle race. + +## [0.2.0] - 2026-09-01 + +- Added optional weekly retention for inactive OpenCode root sessions, using the authenticated OpenCode API with persistent success-marker scheduling, active-session protection, dry-run support, and mapped-user lifecycle management. + ## [0.1.10] - 2026-08-21 - Made Playwright durability and version-pin behavior deterministic: truthy build toggles normalize to canonical metadata values, explicit Playwright pins remain effective when version checks are skipped, and matching image metadata no longer causes unnecessary rebuilds. diff --git a/README.md b/README.md index 9fb0ddd..082a29f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Defaults: - Restart policy: `unless-stopped` - Launch mode: background (`-d`) - Pull behavior: pull-on-start enabled +- Session retention: disabled by default (`OPENCODE_WEB_RETENTION_DAYS=0`) ## Authentication Requirement @@ -56,6 +57,7 @@ Wrapper flags: - `--no-pull` - `--playwright` (one-shot Playwright build opt-in) - `--wrangler` +- `--retention-days N` or `--retention-days=N` (0 disables weekly cleanup) - `--agents-file ` - `--no-host-agents` - `--dry-run` @@ -115,6 +117,11 @@ Operator-facing settings: | `OPENCODE_WEB_BUILD_PLAYWRIGHT` | `0` | Set to `1` in `~/.opencode_web_yolo/config` for durable Playwright enablement; `--playwright` enables it for one run and preinstalls Chromium into `/ms-playwright`. | | `OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION` | none | Optional exact `@playwright/test` install pin. It remains the Docker build target when `OPENCODE_WEB_SKIP_VERSION_CHECK=1`; that skip suppresses npm lookup and installed-version drift comparison, but does not discard the explicit pin. When no pin is set, an enabled build resolves npm unless checks are skipped, then uses the deterministic `1.62.1` fallback. | | `OPENCODE_WEB_BUILD_WRANGLER` | `0` | Set to `1` to install `wrangler@latest` globally in the runtime image. `--wrangler` enables this and mounts host Wrangler config for the run. | +| `OPENCODE_WEB_RETENTION_DAYS` | `0` | Non-negative number of days. After health succeeds, delete inactive root sessions older than this cutoff at most once per seven days. A flag overrides the configured value for that invocation. | +| `OPENCODE_WEB_RETENTION_DRY_RUN` | `0` | Safely preview retention candidates without deleting or advancing the success marker. | +| `OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS` | `10000` | Positive per-request worker timeout in milliseconds. Requests that stall fail closed. | +| `OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS` | `10000` | Positive bounded deletion-verification timeout in milliseconds. | +| `OPENCODE_WEB_RETENTION_POLL_SECONDS` | `3600` | Positive scheduler interval; values below one second are rejected. | ### Playwright runtime @@ -137,6 +144,27 @@ Truthy toggle values such as `true`, `yes`, and `on` are accepted and normalized Provider auth/session state (for example OpenAI and GitHub Copilot links) persists across restarts from the OpenCode data path. The wrapper also pins runtime env (`HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`) to `/home/opencode` paths so app writes always land on mounted host directories. +## Weekly session retention + +Enable cleanup in the generated config, or override it for one invocation: + +```bash +export OPENCODE_WEB_RETENTION_DAYS=30 +opencode_web_yolo +opencode_web_yolo --retention-days=14 +``` + +The container starts OpenCode first, waits for authenticated `/global/health`, and then runs a lightweight scheduler as the mapped runtime user. The first enabled run is due immediately; later runs use the success marker at `$XDG_STATE_HOME/session-retention.last-success` and are no more frequent than once every seven days. Stopped containers do not lose schedule state, and failed runs do not advance the marker. The image uses `tini -s -g` as PID 1 for subreaping and signal-group forwarding. + +Cleanup first requires a healthy OpenCode `1.18.x` server, then uses OpenCode 1.18.25's experimental complete global listing (`/experimental/session` with `roots=false` and `archived=true`) and safely maps every listed session to its root across directories. It checks `/session/status` for every involved directory; any busy/retrying descendant or unmapped active ID fails closed, and the affected root is skipped. Before each delete it directly refreshes the root through `GET /session/:id?directory=...`, confirms it is still an old root, reloads the hierarchy, and re-checks activity. Deletes remain serial through `DELETE /session/:id?directory=...`; each successful delete is verified by polling the direct GET until it returns 404/not-found. This lets OpenCode recursively remove children, messages, parts, and events. It never edits SQLite directly or removes WAL/SHM files. An incompatible, unsupported, stalled, or failed API response fails closed and is retried later. Session IDs may be logged for diagnostics, but titles and content are not. + +The API has no atomic delete-if-idle operation, so a residual status-to-delete race cannot be eliminated completely. The wrapper's direct refresh and immediate activity/hierarchy recheck narrow that window; OpenCode's own authenticated deletion/cancellation behavior is the final guard, and any failed or uncertain run is retried later. + +The global API's cursor contains only `time.updated`; when a full page ends with duplicate timestamps, the worker refuses to delete rather than risk skipping sessions at that boundary. Equal timestamps without this detectable page-boundary pattern remain an upstream limitation of the experimental cursor API. +Only sessions are targeted; projects, accounts, provider auth, and OpenCode configuration are preserved. + +For a safe preview, set `OPENCODE_WEB_RETENTION_DRY_RUN=1`; previews never write the success marker. `health` and `--dry-run` show the effective retention setting and marker path. + ## Instruction File Selection Host instruction-file precedence: @@ -181,7 +209,8 @@ OPENCODE_SERVER_PASSWORD='change-me-now' opencode_web_yolo Run in background (with automatic startup on reboot): ```bash -mkdir -p "$HOME/.config/opencode" "$HOME/.local/share/opencode" && (docker rm -f opencode_web_yolo >/dev/null 2>&1 || true) && docker run -d --name opencode_web_yolo --restart unless-stopped -p 127.0.0.1:4096:4096 -e LOCAL_UID="$(id -u)" -e LOCAL_GID="$(id -g)" -e LOCAL_USER="$(id -un)" -e OPENCODE_SERVER_PASSWORD='change-me-now' -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state -v "$PWD:/workspace" -v "$HOME/.config/opencode:/home/opencode/.config/opencode" -v "$HOME/.local/share/opencode:/home/opencode/.local/share/opencode" opencode_web_yolo:latest opencode web --hostname 0.0.0.0 --port 4096 +export OPENCODE_SERVER_PASSWORD='change-me-now' +mkdir -p "$HOME/.config/opencode" "$HOME/.local/share/opencode" && (docker rm -f opencode_web_yolo >/dev/null 2>&1 || true) && docker run -d --name opencode_web_yolo --restart unless-stopped -p 127.0.0.1:4096:4096 -e LOCAL_UID="$(id -u)" -e LOCAL_GID="$(id -g)" -e LOCAL_USER="$(id -un)" -e OPENCODE_SERVER_PASSWORD -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state -v "$PWD:/workspace" -v "$HOME/.config/opencode:/home/opencode/.config/opencode" -v "$HOME/.local/share/opencode:/home/opencode/.local/share/opencode" opencode_web_yolo:latest opencode web --hostname 0.0.0.0 --port 4096 ``` Force-refresh image to the resolved latest OpenCode and Playwright versions: diff --git a/TECHNICAL.md b/TECHNICAL.md index b8a557b..09eb590 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -14,6 +14,7 @@ - Build/update defaults: - pull-on-start by default (`OPENCODE_WEB_AUTO_PULL=1`) - Reverse proxy is expected in front of localhost bind. +- Optional weekly session retention is disabled by default. ## Installation Contract @@ -73,6 +74,7 @@ Docker image includes: - `git` - `openssh-client` - runtime helpers (`gosu`, `sudo`, `passwd`, `ca-certificates`) +- PID 1 init/subreaper (`tini`) - OpenCode CLI (`opencode-ai` npm package by default) - when Playwright build is enabled: global `@playwright/test` package/`playwright` CLI and Chromium browser binaries in shared path (`PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`) - the browser install is executed by that exact installed package, coupling the Chromium revision to the package version used for the image @@ -88,6 +90,8 @@ Image metadata files: - `/opt/opencode-web-yolo-playwright-expected-version` (Docker build arg version) - `/opt/opencode-web-yolo-wrangler` - `/app/AGENTS.md` (packaged fallback document) +- `/usr/local/bin/opencode_web_yolo_runtime.sh` (app/scheduler supervisor) +- `/usr/local/bin/opencode_web_yolo_retention.js` (authenticated retention worker) Entrypoint behavior: - maps runtime user/group to host UID/GID. @@ -96,9 +100,26 @@ Entrypoint behavior: - avoids recursive ownership operations across read-only mount boundaries. - installs passwordless sudo policy for mapped user. - executes command via `gosu`. +- when retention is enabled, starts OpenCode, waits for authenticated `/global/health`, and supervises a mapped-user scheduler; TERM/INT are forwarded and the app exit status is returned. +- Docker starts `tini -s -g` so orphaned descendants are reaped and TERM/INT are forwarded to the child process group. The supervisor preserves the received signal when forwarding it to the app. - does not inject unsupported OpenCode CLI flags for instruction loading. - relies on OpenCode's native rules discovery (project AGENTS/CLAUDE files and global config-path rules). +## Session Retention + +- `OPENCODE_WEB_RETENTION_DAYS` is a non-negative integer; `0` disables cleanup. `--retention-days N` and `--retention-days=N` override it for one invocation. +- `OPENCODE_WEB_RETENTION_DRY_RUN=1` previews without deleting or advancing state. +- After authenticated health succeeds, the scheduler calls OpenCode 1.18.25's experimental complete global listing API: `GET /experimental/session?roots=false&archived=true&limit=100`, following its `x-next-cursor` pagination header. It validates the response, builds a complete root-ancestor map across directories, and fails closed on missing parents, cycles, duplicate IDs, or incompatible shapes. +- Before listing or deleting, the worker validates `/global/health` and accepts only a healthy `1.18.x` version. Every worker fetch has an `AbortSignal.timeout` deadline controlled by `OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS`; deletion verification has its own bounded timeout. +- Candidates are root sessions whose `time.updated` (epoch milliseconds) is strictly older than `now - retention_days`. Status is checked through authenticated `GET /session/status?directory=...` for every directory in the complete hierarchy; every `busy`/`retry` ID must map to a listed session and its root, otherwise the run fails closed. A busy/retrying descendant blocks its mapped candidate root even when the child is in another directory. +- Immediately before each delete, the root is refreshed through `GET /session/:sessionID?directory=...`; a missing, non-root, mismatched, or refreshed/recent session is skipped. The complete hierarchy and all relevant statuses are then reloaded before serial authenticated `DELETE /session/:sessionID?directory=...`. +- Each successful delete is verified by bounded polling of the direct `GET /session/:sessionID?directory=...` until it returns HTTP 404/not-found; other errors remain failures. OpenCode owns recursive cleanup of children, messages, parts, and events. No raw SQL or SQLite WAL/SHM mutation is performed. +- Because the experimental cursor is only `time.updated`, a full page whose final timestamp is duplicated is rejected as an unsafe equal-timestamp boundary; other equal-timestamp cases remain an upstream completeness limitation. +- A successful, non-preview run atomically writes `${XDG_STATE_HOME}/session-retention.last-success`. The marker is persistent because `XDG_STATE_HOME` is within the mounted OpenCode data path. Missing/old markers run immediately/when due; failures leave the marker unchanged for retry. +- The scheduler polls without a cron dependency and stops when OpenCode exits. It never logs session titles or content. +- `OPENCODE_WEB_RETENTION_POLL_SECONDS` is a positive integer with a one-second minimum; the production default is 3600 seconds. +- OpenCode does not provide an atomic delete-if-idle operation. The direct root refresh and immediate hierarchy/status recheck narrow the status-to-delete race, while OpenCode's authenticated deletion/cancellation behavior is the final guard; absolute active-session safety cannot be guaranteed. + ## Proxy Streaming Notes - OpenCode browser output uses long-lived event streams. @@ -164,5 +185,7 @@ Tests and CI assert: - `--wrangler` explicit read-write mount, warning, missing-directory failure, and disabled-by-default behavior. - health output includes persistence/lifecycle settings. - health output includes browser-vs-server persistence scope visibility. +- retention configuration, marker path, API schedule, and dry-run state. +- retention API compatibility, active-session skipping, serial deletion, marker retry semantics, and supervisor signal/exit behavior. - Docker image build and runtime binary presence (`gh`, `git`, `ssh`). - `VERSION` semver format and runtime-file/version drift guard. diff --git a/VERSION b/VERSION index 9767cc9..0c62199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.10 +0.2.1 diff --git a/docs/specs/weekly-session-retention.md b/docs/specs/weekly-session-retention.md new file mode 100644 index 0000000..3585c58 --- /dev/null +++ b/docs/specs/weekly-session-retention.md @@ -0,0 +1,111 @@ +# Weekly OpenCode Session Retention + +## Objective + +Optionally remove inactive OpenCode root sessions older than a configured number +of days while preserving projects, accounts, provider authentication, and +configuration. Cleanup must run safely inside the existing mapped-user runtime. + +## Non-negotiables + +- `--retention-days N` and `--retention-days=N` accept only non-negative + integers; `OPENCODE_WEB_RETENTION_DAYS` is the durable setting and defaults to + `0` (disabled). A flag wins for that invocation. +- Cleanup uses authenticated OpenCode HTTP APIs, not SQL or direct SQLite/WAL/SHM + mutation. Deletion is serial and uses OpenCode's recursive deletion behavior. +- Only old root sessions are candidates. The complete global hierarchy is mapped + to root ancestors across directories; sessions reported `busy` or `retry` + block their mapped candidate root, while unmapped active IDs and malformed + hierarchies fail closed. +- Cleanup starts only after health succeeds, runs as the mapped user, and writes + a persistent success marker under `XDG_STATE_HOME` only after a full success. +- The scheduler has no cron dependency, runs at most once per seven days, retries + failures, forwards TERM/INT through `tini -s -g`, stops with the app, and + returns the app status. +- Titles, prompts, messages, and parts are never logged. + +## Scope of changes + +- Add a Node-based authenticated retention worker for the OpenCode 1.18.25 + experimental complete global session listing, hierarchy mapping, status lookup, + pagination, direct refresh/verification, and deletion. +- Add a small shell supervisor and connect it to the existing entrypoint. +- Gate the worker on healthy OpenCode `1.18.x`, bound every fetch, directly + refresh each candidate root and require it to remain old/root, reload the + hierarchy and activity before deletion, and verify direct 404/not-found after + DELETE before marking success. +- Add wrapper parsing, configuration, dry-run/health visibility, completions, + image/install assets, tests, contracts, and operator documentation. + +## Files to change/add + +- `.opencode_web_yolo.sh`, `.opencode_web_yolo_config.sh` +- `.opencode_web_yolo_entrypoint.sh`, `.opencode_web_yolo_runtime.sh` +- `.opencode_web_yolo_retention.js`, `.opencode_web_yolo.Dockerfile` +- `install.sh`, shell completions, `tests/` +- `README.md`, `TECHNICAL.md`, runtime/quality skills and references +- `docs/specs/weekly-session-retention.md`, `VERSION`, `CHANGELOG.md` + +## Test and validation plan + +- Shell syntax and shellcheck for every changed shell script. +- Deterministic wrapper tests for defaults, both flag forms, precedence, + validation, dry-run propagation, health output, installation, and assets. +- A local HTTP mock for pagination, cutoff selection, complete cross-directory + hierarchy mapping, stale refreshes, active skips including descendants, status + races, serial deletion, direct-404 verification, dry-run, API failure/timeout/ + version gating, and marker semantics. +- Lifecycle assertions for health gating, signal forwarding, scheduler stop, and + app exit status; run the complete `tests/run.sh` suite. +- Build the Docker image and verify helper assets when Docker is available. + +## Acceptance criteria + +- Disabled retention preserves the existing direct app launch path. +- Enabled retention runs after authenticated health, immediately when no valid + marker exists, and no more than weekly after success. +- A failed or preview run never advances the marker; a later scheduler pass can + retry a failed run. +- The worker follows `x-next-cursor`, validates the complete session hierarchy, + status/delete responses, and health version, and never intentionally deletes + a refreshed active/recent/non-root session. +- A DELETE response is not trusted alone: the direct GET must return 404/not-found + within a bounded verification period before success is recorded. +- The wrapper refreshes and rechecks immediately before deletion, but the API has + no atomic delete-if-idle operation; a residual status-to-delete race remains + possible and OpenCode cancellation/deletion behavior is the final guard. +- OpenCode's delete endpoint is the only mutation path and receives the session's + directory for correct instance routing. +- Dry-run and health output expose effective retention settings and marker state. +- README, TECHNICAL, contracts, release metadata, completions, installer, and + Docker assets describe and ship the same behavior. + +## Out of scope + +- Cron/systemd integration, retention of projects/accounts/provider credentials, + browser-local UI state, or changing OpenCode itself. +- Raw database repair, WAL checkpointing, bulk/concurrent deletion, or title/content + reporting. + +## Open decisions and spec gaps + +- The global session API is experimental and may change. **Resolved default:** + require the documented 1.18.25 response shapes and fail closed with an + actionable error when they do. +- A session can become active between status lookup and deletion. **Resolved + default:** directly refresh the root, reload the complete hierarchy, recheck + all involved-directory statuses immediately before each serial deletion, and + rely on OpenCode's own authenticated cancellation/deletion behavior as the + final guard. The API has no atomic delete-if-idle operation, so residual race + risk cannot be eliminated; uncertain failures leave the marker unchanged for + retry. +- A preview is not a successful cleanup. **Resolved default:** return success for + a preview but do not write the success marker, so previews cannot suppress a + real cleanup. +- The global cursor contains only `time.updated`. **Resolved default:** reject a + full page ending in duplicate timestamps as unsafe; other equal-timestamp + boundaries remain an upstream completeness limitation and are documented + rather than presented as guaranteed complete pagination. +- No separate operator ownership or alerting service is defined. **Gap:** logs + are the current actionable failure signal; deployments needing alerting should + monitor the container logs and marker age. diff --git a/install.sh b/install.sh index 8322445..234cf23 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,8 @@ REQUIRED_FILES=( ".opencode_web_yolo_config.sh" ".opencode_web_yolo.Dockerfile" ".opencode_web_yolo_entrypoint.sh" + ".opencode_web_yolo_runtime.sh" + ".opencode_web_yolo_retention.js" ".opencode_web_yolo_completion.bash" ".opencode_web_yolo_completion.zsh" "install.sh" diff --git a/skills/opencode-web-quality-docs/SKILL.md b/skills/opencode-web-quality-docs/SKILL.md index eab6d32..34bd7fe 100644 --- a/skills/opencode-web-quality-docs/SKILL.md +++ b/skills/opencode-web-quality-docs/SKILL.md @@ -30,6 +30,7 @@ Ensure CI validates at least: - required auth failure when password is missing - package availability checks (`gh`, `git`, `ssh`) in image - config/data mount contract checks for persistence behavior +- retention flag/config validation, dry-run propagation, API mock semantics, scheduler marker persistence/retry, and process lifecycle/signal behavior - guard checks for version format and runtime-file/version drift Prefer deterministic shell tests with clear failure messages. @@ -47,6 +48,7 @@ Document these operator-critical topics: - troubleshooting and diagnostics usage Keep docs implementation-accurate; update docs in the same change as behavior. +- Document retention's experimental API dependency, strict fail-closed behavior, mapped-user marker path, recursive OpenCode deletion, and no-SQL/WAL/SHM policy. # Review Checklist diff --git a/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md b/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md index c40fa4c..ed780e2 100644 --- a/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md +++ b/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md @@ -22,6 +22,9 @@ Use this matrix when authoring tests under `tests/`. - Persistence assertions verify state files are written to mounted host path, not only any in-container path. - Docs contract check ensures Apache stream endpoints include `/event` and `/global/event`, and excludes stale `/session/event`. - Health/diagnostics command reports key prerequisites and failures clearly. +- Retention accepts both flag forms, honors config/flag precedence, rejects invalid values, and propagates dry-run state. +- Retention tests cover cutoff/pagination/root filtering, active-session skips, serial deletion, API incompatibility/failure, marker success/failure/retry, and scheduler signal/exit behavior. +- Retention tests cover complete cross-directory hierarchy mapping, active descendants/unknown IDs, stale root refreshes, status changes before deletion, DELETE-true-but-still-present direct-GET verification, request timeout, supported-version gating, future markers, invalid poll intervals, exact query/cursor use, and malformed responses. ## Test Design Rules diff --git a/skills/opencode-web-release/SKILL.md b/skills/opencode-web-release/SKILL.md index eb918a6..29e4683 100644 --- a/skills/opencode-web-release/SKILL.md +++ b/skills/opencode-web-release/SKILL.md @@ -50,6 +50,7 @@ Record version metadata in the image so checks are deterministic. - Install to `~/.opencode_web_yolo` with predictable paths. - Ensure installed command points to the managed wrapper copy. +- Ship every runtime helper used by the Dockerfile (including the retention supervisor and worker) through both bootstrap installation and self-update managed-file lists. - Install/refresh bash and zsh completion scripts idempotently. - Fail with clear messages on partial installs. diff --git a/skills/opencode-web-release/references/install-layout.md b/skills/opencode-web-release/references/install-layout.md index 7db4aea..987b7f9 100644 --- a/skills/opencode-web-release/references/install-layout.md +++ b/skills/opencode-web-release/references/install-layout.md @@ -12,6 +12,7 @@ Use this file when editing `install.sh` or completion installation behavior. - Wrapper script entrypoint. - Dockerfile and entrypoint assets required by runtime build. +- Runtime supervisor and retention worker assets required by the enabled scheduler. - Completion scripts: - `.opencode_web_yolo_completion.bash` - `.opencode_web_yolo_completion.zsh` diff --git a/skills/opencode-web-runtime/SKILL.md b/skills/opencode-web-runtime/SKILL.md index 99dc339..8780a2b 100644 --- a/skills/opencode-web-runtime/SKILL.md +++ b/skills/opencode-web-runtime/SKILL.md @@ -44,6 +44,14 @@ Enforce these contracts on every runtime change: 5. Keep diagnostics independent of container startup. 6. In entrypoint, map UID/GID, ensure writable runtime dirs, avoid recursive chown on read-only mounts, then exec via `gosu`. +# Retention lifecycle + +- For enabled retention, start the scheduler only after authenticated `/global/health` succeeds; forward TERM/INT, stop it with the app, and return the app status. +- Run retention as the mapped user and persist its success marker under `XDG_STATE_HOME`; do not use a restart-resetting sleep schedule. +- Use OpenCode's authenticated experimental global session listing and delete endpoint, serially, with strict response validation and fail-closed behavior. Never mutate SQLite directly. +- Install/use `tini` as PID 1 with subreaping and process-group signal forwarding; do not remap SIGINT to TERM in the supervisor. +- Bound every retention worker fetch, gate on a tested healthy OpenCode 1.18.x version, re-check active status before each deletion, and verify deletion disappearance before advancing state. + # Guardrails - Do not relax auth requirements for localhost usage. diff --git a/skills/opencode-web-runtime/references/flag-contracts.md b/skills/opencode-web-runtime/references/flag-contracts.md index 67d742a..d84b357 100644 --- a/skills/opencode-web-runtime/references/flag-contracts.md +++ b/skills/opencode-web-runtime/references/flag-contracts.md @@ -13,7 +13,8 @@ Use this file when adding or changing wrapper CLI behavior. - `health`, `--health`, `diagnostics` - `config` - `--version`, `version` - - `--verbose`, `-v` + - `--verbose`, `-v` + - `--retention-days N`, `--retention-days=N` ## Parsing Rules @@ -24,6 +25,7 @@ Use this file when adding or changing wrapper CLI behavior. - `--wrangler` must set `OPENCODE_WEB_BUILD_WRANGLER=1`, require the host `${XDG_CONFIG_HOME:-$HOME/.config}/.wrangler` directory, and mount it as `${OPENCODE_WEB_YOLO_HOME}/.config/.wrangler:rw` only for that run. +- `--retention-days` requires a following non-negative integer (or a non-negative integer after `=`), overrides `OPENCODE_WEB_RETENTION_DAYS` for that invocation, and never leaks into OpenCode app args. ## Verification Expectations diff --git a/skills/opencode-web-runtime/references/runtime-checklist.md b/skills/opencode-web-runtime/references/runtime-checklist.md index 6f33b7d..3a564cd 100644 --- a/skills/opencode-web-runtime/references/runtime-checklist.md +++ b/skills/opencode-web-runtime/references/runtime-checklist.md @@ -8,6 +8,7 @@ Use this checklist for runtime changes in `.opencode_web_yolo.sh`, `.opencode_we - Keep pass-through args unchanged after `--`. - Gate container startup on required auth checks. - Keep dry-run output faithful to the real docker invocation. +- Include effective retention days/dry-run state and marker path in dry-run and health output. - Keep diagnostics callable without launching the app container. - Ensure dry-run and diagnostics include both OpenCode config and OpenCode data mount contracts. @@ -30,6 +31,10 @@ Use this checklist for runtime changes in `.opencode_web_yolo.sh`, `.opencode_we - Run OpenCode web with configured host and port. - Preserve provider/auth state across restart by mounting host OpenCode data directory. - Keep Playwright opt-in: `OPENCODE_WEB_BUILD_PLAYWRIGHT=1` in the persistent config is durable, while `--playwright` is one-shot. +- Retention is opt-in with non-negative `OPENCODE_WEB_RETENTION_DAYS`; its scheduler starts only after authenticated health, runs as the mapped user, and persists a success marker under `XDG_STATE_HOME`. +- Retention must use authenticated complete `/experimental/session` pagination, `/session/status` for every involved directory, direct `/session/:id` refresh/verification, and serial `DELETE /session/:id` calls; validate compatibility and fail closed without raw SQL/WAL/SHM mutation. +- Map every listed session to a root across directories; block a mapped root when status reports a busy/retrying descendant, fail closed on unmapped active IDs or malformed hierarchies, refresh/recheck immediately before each delete, verify direct 404 before marker advancement, and reject unsafe equal-timestamp page boundaries. The API has no atomic delete-if-idle guarantee. +- Validate positive worker fetch and scheduler poll timeouts; use `tini -s -g` for PID1 subreaping/group signal forwarding and preserve SIGINT semantics. - When enabled, install global `@playwright/test` at an explicit version, run its `playwright install --with-deps chromium`, and use `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`. - Record installed/expected Playwright versions and rebuild when enabled-image metadata drifts. - Normalize accepted truthy build toggles to canonical `0`/`1` before Docker arguments and metadata comparisons; version-check skip suppresses lookup/drift comparison but preserves an explicit Playwright install pin. diff --git a/tests/run.sh b/tests/run.sh index 2b9579f..d093de2 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "${ROOT_DIR}/tests/test_dry_run.sh" +bash "${ROOT_DIR}/tests/test_session_retention.sh" bash "${ROOT_DIR}/tests/wrapper-dryrun.sh" bash "${ROOT_DIR}/tests/test_version_command.sh" bash "${ROOT_DIR}/tests/test_self_update.sh" diff --git a/tests/test_health.sh b/tests/test_health.sh index 1029e85..eb83896 100755 --- a/tests/test_health.sh +++ b/tests/test_health.sh @@ -30,6 +30,13 @@ assert_contains "$output" "auto_pull=1" assert_contains "$output" "build_pull=0" assert_contains "$output" "build_playwright=0" assert_contains "$output" "build_wrangler=0" +assert_contains "$output" "retention_days=0" +assert_contains "$output" "retention_dry_run=0" +assert_contains "$output" "retention_poll_seconds=3600" +assert_contains "$output" "retention_fetch_timeout_ms=10000" +assert_contains "$output" "retention_verify_timeout_ms=10000" +assert_contains "$output" "retention_schedule=after-health-at-most-weekly" +assert_contains "$output" "session-retention.last-success" assert_contains "$output" "image_playwright_version=disabled" assert_contains "$output" "image_playwright_expected_version=1.62.1" assert_contains "$output" "runtime_env_home=/home/opencode" diff --git a/tests/test_help.sh b/tests/test_help.sh index ddd4534..478e497 100755 --- a/tests/test_help.sh +++ b/tests/test_help.sh @@ -18,6 +18,8 @@ assert_contains "$output_long" "--foreground, -f" assert_contains "$output_long" "--no-pull" assert_contains "$output_long" "--playwright" assert_contains "$output_long" "--wrangler" +assert_contains "$output_long" "--retention-days N" +assert_contains "$output_long" "--retention-days=N" assert_contains "$output_long" "opencode_web_yolo config" output_short="$("${ROOT_DIR}/.opencode_web_yolo.sh" -h 2>&1)" diff --git a/tests/test_helpers.sh b/tests/test_helpers.sh index da08a30..e88bdfe 100755 --- a/tests/test_helpers.sh +++ b/tests/test_helpers.sh @@ -43,6 +43,8 @@ managed_wrapper_files() { .opencode_web_yolo_config.sh .opencode_web_yolo.Dockerfile .opencode_web_yolo_entrypoint.sh +.opencode_web_yolo_runtime.sh +.opencode_web_yolo_retention.js .opencode_web_yolo_completion.bash .opencode_web_yolo_completion.zsh install.sh diff --git a/tests/test_install_bootstrap.sh b/tests/test_install_bootstrap.sh index 8b5546d..95c1f4a 100755 --- a/tests/test_install_bootstrap.sh +++ b/tests/test_install_bootstrap.sh @@ -22,6 +22,8 @@ required_files=( ".opencode_web_yolo_config.sh" ".opencode_web_yolo.Dockerfile" ".opencode_web_yolo_entrypoint.sh" + ".opencode_web_yolo_runtime.sh" + ".opencode_web_yolo_retention.js" ".opencode_web_yolo_completion.bash" ".opencode_web_yolo_completion.zsh" "install.sh" diff --git a/tests/test_session_retention.sh b/tests/test_session_retention.sh new file mode 100755 index 0000000..045fd87 --- /dev/null +++ b/tests/test_session_retention.sh @@ -0,0 +1,426 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/test_helpers.sh +. "${ROOT_DIR}/tests/test_helpers.sh" + +TMP_DIR="$(mktemp -d)" +server_pid="" +cleanup() { + if [ -n "$server_pid" ]; then + kill "$server_pid" >/dev/null 2>&1 || true + wait "$server_pid" >/dev/null 2>&1 || true + fi + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +FAKE_BIN="${TMP_DIR}/bin" +WRAPPER_VERSION="$(tr -d '[:space:]' <"${ROOT_DIR}/VERSION")" +setup_fake_docker "$FAKE_BIN" "$WRAPPER_VERSION" +export PATH="${FAKE_BIN}:${PATH}" +export HOME="${TMP_DIR}/home" +mkdir -p "$HOME" +export OPENCODE_SERVER_PASSWORD=secret +export OPENCODE_WEB_SKIP_UPDATE_CHECK=1 +export OPENCODE_WEB_SKIP_VERSION_CHECK=1 +export OPENCODE_WEB_DRY_RUN=1 + +output="$("${ROOT_DIR}/.opencode_web_yolo.sh" --retention-days 5 2>&1)" +assert_contains "$output" "retention_days=5" +assert_contains "$output" "OPENCODE_WEB_RETENTION_DAYS=5" +assert_contains "$output" "-e OPENCODE_SERVER_PASSWORD" +assert_not_contains "$output" "secret" +assert_contains "$output" "retention_poll_seconds=3600" +assert_contains "$output" "retention_fetch_timeout_ms=10000" +assert_contains "$output" "retention_verify_timeout_ms=10000" +output_equals="$("${ROOT_DIR}/.opencode_web_yolo.sh" --retention-days=6 2>&1)" +assert_contains "$output_equals" "retention_days=6" + +printf '%s\n' 'export OPENCODE_WEB_RETENTION_DAYS=7' >"${HOME}/.opencode_web_yolo-config.tmp" +mkdir -p "${HOME}/.opencode_web_yolo" +mv "${HOME}/.opencode_web_yolo-config.tmp" "${HOME}/.opencode_web_yolo/config" +unset OPENCODE_WEB_RETENTION_DAYS +output_config="$("${ROOT_DIR}/.opencode_web_yolo.sh" --dry-run 2>&1)" +assert_contains "$output_config" "retention_days=7" +output_override="$("${ROOT_DIR}/.opencode_web_yolo.sh" --dry-run --retention-days 8 2>&1)" +assert_contains "$output_override" "retention_days=8" + +set +e +rm -f "${HOME}/.opencode_web_yolo/config" +invalid_output="$(OPENCODE_WEB_RETENTION_DAYS=invalid "${ROOT_DIR}/.opencode_web_yolo.sh" --dry-run 2>&1)" +invalid_status=$? +set -e +if [ "$invalid_status" -eq 0 ]; then fail "invalid retention value should fail"; fi +assert_contains "$invalid_output" "must be a non-negative integer" +set +e +missing_output="$("${ROOT_DIR}/.opencode_web_yolo.sh" --dry-run --retention-days 2>&1)" +missing_status=$? +set -e +if [ "$missing_status" -eq 0 ]; then fail "missing retention value should fail"; fi +assert_contains "$missing_output" "requires a non-negative integer value" +set +e +poll_output="$(OPENCODE_WEB_RETENTION_DAYS=1 OPENCODE_WEB_RETENTION_POLL_SECONDS=0 "${ROOT_DIR}/.opencode_web_yolo.sh" --dry-run 2>&1)" +poll_wrapper_status=$? +set -e +if [ "$poll_wrapper_status" -eq 0 ]; then fail "zero scheduler poll interval should fail"; fi +assert_contains "$poll_output" "OPENCODE_WEB_RETENTION_POLL_SECONDS must be a positive integer" + +set +e +range_output="$(OPENCODE_WEB_RETENTION_DAYS=1 OPENCODE_WEB_RETENTION_POLL_SECONDS=2147483648 "${ROOT_DIR}/.opencode_web_yolo.sh" --dry-run 2>&1)" +range_wrapper_status=$? +set -e +if [ "$range_wrapper_status" -eq 0 ]; then fail "out-of-range scheduler poll interval should fail"; fi +assert_contains "$range_output" "outside the supported positive integer range" + +if ! command -v node >/dev/null 2>&1; then + printf '%s\n' "PASS: session retention wrapper coverage (node unavailable; API mock skipped)" + exit 0 +fi + +MODE_FILE="${TMP_DIR}/mode" +DELETE_LOG="${TMP_DIR}/deletions" +REQUEST_LOG="${TMP_DIR}/requests" +PORT_FILE="${TMP_DIR}/port" +NOW=2000000000000 +printf '%s\n' normal >"$MODE_FILE" +: >"$DELETE_LOG" +: >"$REQUEST_LOG" + +node - "$MODE_FILE" "$DELETE_LOG" "$REQUEST_LOG" >"$PORT_FILE" <<'NODE' & +const fs = require("node:fs") +const http = require("node:http") +const modeFile = process.argv[2] +const deleteLog = process.argv[3] +const requestLog = process.argv[4] +const now = 2000000000000 +const sessions = [ + { id: "recent", directory: "/four", time: { updated: now - 2 * 86400000 } }, + { id: "old-a", directory: "/one", time: { updated: now - 8 * 86400000 } }, + { id: "child-cross", directory: "/two", parentID: "old-a", time: { updated: now - 9 * 86400000 } }, + { id: "old-b", directory: "/one", time: { updated: now - 10 * 86400000 } }, + { id: "active-old", directory: "/three", time: { updated: now - 10 * 86400000 } }, +] +function listedSessions(mode) { + if (mode === "missing-parent") { + return sessions.map((session) => session.id === "child-cross" ? { ...session, parentID: "missing" } : session) + } + if (mode === "cycle") { + return sessions.map((session) => { + if (session.id === "old-a") return { ...session, parentID: "child-cross" } + if (session.id === "child-cross") return { ...session, parentID: "old-a" } + return session + }) + } + if (mode === "duplicate-id") return [...sessions, { ...sessions[0] }] + return sessions +} +const deleted = new Set() +const statusCalls = new Map() +let lastMode +const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://localhost") + fs.appendFileSync(requestLog, `${request.method} ${url.pathname}${url.search}\n`) + response.setHeader("content-type", "application/json") + if (request.headers.authorization !== `Basic ${Buffer.from("opencode:secret").toString("base64")}`) { + response.statusCode = 401 + response.end("{}") + return + } + const mode = fs.readFileSync(modeFile, "utf8").trim() + if (mode !== lastMode) { + statusCalls.clear() + deleted.clear() + lastMode = mode + } + if (url.pathname === "/global/health" && request.method === "GET") { + response.end(JSON.stringify({ healthy: true, version: mode === "unsupported" ? "2.0.0" : "1.18.25" })) + return + } + if (url.pathname === "/experimental/session" && request.method === "GET") { + if (mode === "stall") { + setTimeout(() => response.end(JSON.stringify(sessions)), 500) + return + } + if (mode === "malformed") { + response.end(JSON.stringify({ sessions })) + return + } + const listed = listedSessions(mode) + const first = listed.slice(0, 3) + const second = listed.slice(3) + if (url.searchParams.has("cursor")) { + response.end(JSON.stringify(second)) + } else { + response.setHeader("x-next-cursor", String(first[first.length - 1].time.updated)) + response.end(JSON.stringify(first)) + } + return + } + if (url.pathname === "/session/status" && request.method === "GET") { + const directory = url.searchParams.get("directory") + const calls = (statusCalls.get(directory) || 0) + 1 + statusCalls.set(directory, calls) + if (mode === "cross-directory-child" && directory === "/two") { + response.end(JSON.stringify({ "child-cross": { type: "busy" } })) + } else if (mode === "unknown-retry") { + response.end(JSON.stringify({ "unknown-active": { type: "retry" } })) + } else if (mode === "status-change" && directory === "/two" && calls === 2) { + response.end(JSON.stringify({ "child-cross": { type: "retry" } })) + } else if (["normal", "stale-refresh", "status-change", "cross-directory-child"].includes(mode) && directory === "/three") { + response.end(JSON.stringify({ "active-old": { type: "busy" } })) + } else { + response.end("{}") + } + return + } + if (url.pathname.startsWith("/session/") && request.method === "GET") { + const id = decodeURIComponent(url.pathname.slice("/session/".length)) + if (deleted.has(id)) { + response.statusCode = 404 + response.end("{}") + return + } + const session = sessions.find((item) => item.id === id) + if (!session) { + response.statusCode = 404 + response.end("{}") + return + } + const refreshed = mode === "stale-refresh" && id === "old-a" + ? { ...session, time: { updated: now - 1 * 86400000 } } + : session + response.end(JSON.stringify(refreshed)) + return + } + if (url.pathname.startsWith("/session/") && request.method === "DELETE") { + if (mode === "failure") { + response.statusCode = 500 + response.end(JSON.stringify({ error: "failure" })) + return + } + const id = decodeURIComponent(url.pathname.slice("/session/".length)) + fs.appendFileSync(deleteLog, `${id}\n`) + if (mode !== "stuck") deleted.add(id) + response.end("true") + return + } + response.statusCode = 404 + response.end("{}") +}) +server.listen(0, "127.0.0.1", () => process.stdout.write(`${server.address().port}\n`)) +NODE +server_pid=$! +while [ ! -s "$PORT_FILE" ]; do sleep 0.05; done +port="$(tr -d '[:space:]' <"$PORT_FILE")" +marker="${TMP_DIR}/state/last-success" + +OPENCODE_WEB_RETENTION_DAYS=7 \ +OPENCODE_WEB_RETENTION_NOW_MS="$NOW" \ +OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" \ +OPENCODE_WEB_RETENTION_MARKER="$marker" \ +OPENCODE_SERVER_USERNAME=opencode \ +node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once +assert_contains "$(tr '\n' ' ' <"$DELETE_LOG")" "old-a" +assert_contains "$(tr '\n' ' ' <"$DELETE_LOG")" "old-b" +assert_not_contains "$(tr '\n' ' ' <"$DELETE_LOG")" "active-old" +[ -s "$marker" ] || fail "successful cleanup should write marker" +requests="$(cat "$REQUEST_LOG")" +assert_contains "$requests" "GET /global/health" +assert_contains "$requests" "GET /experimental/session?roots=false&archived=true&limit=100" +assert_contains "$requests" "GET /experimental/session?roots=false&archived=true&limit=100&cursor=" +assert_contains "$requests" "cursor=1999222400000" +assert_contains "$requests" "GET /session/status?directory=%2Fone" +assert_contains "$requests" "GET /session/status?directory=%2Ftwo" +assert_contains "$requests" "GET /session/old-a?directory=%2Fone" +assert_contains "$requests" "DELETE /session/old-a?directory=%2Fone" + +before="$(wc -l <"$DELETE_LOG")" +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null +assert_equals "$before" "$(wc -l <"$DELETE_LOG")" + +rm -f "$marker" +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" OPENCODE_WEB_RETENTION_DRY_RUN=1 node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null +[ ! -e "$marker" ] || fail "dry-run should not write marker" +assert_equals "$before" "$(wc -l <"$DELETE_LOG")" + +printf '%s\n' failure >"$MODE_FILE" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +failure_status=$? +set -e +if [ "$failure_status" -eq 0 ]; then fail "API failure should fail cleanup"; fi +[ ! -e "$marker" ] || fail "failed cleanup should not write marker" + +printf '%s\n' cross-directory-child >"$MODE_FILE" +: >"$DELETE_LOG" +rm -f "$marker" +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null +cross_deletions="$(tr '\n' ' ' <"$DELETE_LOG")" +assert_not_contains "$cross_deletions" "old-a" +assert_contains "$cross_deletions" "old-b" +assert_not_contains "$cross_deletions" "active-old" + +printf '%s\n' unknown-retry >"$MODE_FILE" +rm -f "$marker" +before="$(wc -l <"$DELETE_LOG")" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +unknown_status=$? +set -e +if [ "$unknown_status" -eq 0 ]; then fail "unmapped active status should fail closed"; fi +assert_equals "$before" "$(wc -l <"$DELETE_LOG")" + +for hierarchy_mode in missing-parent cycle duplicate-id; do + printf '%s\n' "$hierarchy_mode" >"$MODE_FILE" + rm -f "$marker" + before="$(wc -l <"$DELETE_LOG")" + set +e + OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 + hierarchy_status=$? + set -e + if [ "$hierarchy_status" -eq 0 ]; then fail "${hierarchy_mode} hierarchy should fail closed"; fi + assert_equals "$before" "$(wc -l <"$DELETE_LOG")" + [ ! -e "$marker" ] || fail "${hierarchy_mode} hierarchy should not write marker" +done + +printf '%s\n' stale-refresh >"$MODE_FILE" +: >"$DELETE_LOG" +rm -f "$marker" +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null +stale_deletions="$(tr '\n' ' ' <"$DELETE_LOG")" +assert_not_contains "$stale_deletions" "old-a" +assert_contains "$stale_deletions" "old-b" +assert_not_contains "$stale_deletions" "active-old" + +printf '%s\n' status-change >"$MODE_FILE" +: >"$DELETE_LOG" +rm -f "$marker" +before="$(wc -l <"$DELETE_LOG")" +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null +assert_equals "$((before + 1))" "$(wc -l <"$DELETE_LOG")" +status_deletions="$(tr '\n' ' ' <"$DELETE_LOG")" +assert_not_contains "$status_deletions" "old-a" +assert_contains "$status_deletions" "old-b" +assert_not_contains "$status_deletions" "active-old" + +printf '%s\n' unsupported >"$MODE_FILE" +rm -f "$marker" +before="$(wc -l <"$DELETE_LOG")" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +unsupported_status=$? +set -e +if [ "$unsupported_status" -eq 0 ]; then fail "unsupported OpenCode version should fail closed"; fi +assert_equals "$before" "$(wc -l <"$DELETE_LOG")" +[ ! -e "$marker" ] || fail "unsupported version should not write marker" + +printf '%s\n' malformed >"$MODE_FILE" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +malformed_status=$? +set -e +if [ "$malformed_status" -eq 0 ]; then fail "malformed list response should fail closed"; fi +[ ! -e "$marker" ] || fail "malformed response should not write marker" + +future_marker="${TMP_DIR}/future-marker" +printf '%s\n' "$((NOW + 86400000))" >"$future_marker" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$future_marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +future_status=$? +set -e +if [ "$future_status" -eq 0 ]; then fail "future marker should be treated as due"; fi + +printf '%s\n' stuck >"$MODE_FILE" +rm -f "$marker" +before="$(wc -l <"$DELETE_LOG")" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS=300 OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +stuck_status=$? +set -e +if [ "$stuck_status" -eq 0 ]; then fail "remaining deleted session should fail verification"; fi +assert_equals "$((before + 1))" "$(wc -l <"$DELETE_LOG")" +[ ! -e "$marker" ] || fail "failed deletion verification should not write marker" + +printf '%s\n' stall >"$MODE_FILE" +set +e +OPENCODE_WEB_RETENTION_DAYS=7 OPENCODE_WEB_RETENTION_NOW_MS="$NOW" OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS=50 OPENCODE_WEB_RETENTION_URL="http://127.0.0.1:${port}" OPENCODE_WEB_RETENTION_MARKER="$marker" node "${ROOT_DIR}/.opencode_web_yolo_retention.js" --run-once >/dev/null 2>&1 +stall_status=$? +set -e +if [ "$stall_status" -eq 0 ]; then fail "stalled API request should fail"; fi +[ ! -e "$marker" ] || fail "stalled request should not write marker" + +kill "$server_pid" >/dev/null 2>&1 || true +wait "$server_pid" >/dev/null 2>&1 || true + +runtime="$(cat "${ROOT_DIR}/.opencode_web_yolo_runtime.sh")" +assert_contains "$runtime" "trap 'forward_signal TERM' TERM" +assert_contains "$runtime" "trap 'forward_signal INT' INT" +assert_contains "$runtime" 'wait "$app_pid"' +assert_contains "$runtime" 'exit "$app_status"' +dockerfile="$(cat "${ROOT_DIR}/.opencode_web_yolo.Dockerfile")" +assert_contains "$dockerfile" ".opencode_web_yolo_retention.js" +assert_contains "$dockerfile" "tini" +assert_contains "$dockerfile" 'ENTRYPOINT ["/usr/bin/tini", "-s", "-g"' +assert_contains "$(cat "${ROOT_DIR}/install.sh")" ".opencode_web_yolo_runtime.sh" +assert_contains "$(cat "${ROOT_DIR}/install.sh")" ".opencode_web_yolo_retention.js" + +cat >"${FAKE_BIN}/curl" <<'EOF' +#!/usr/bin/env bash +[ -z "${CURL_ARGS_LOG:-}" ] || printf '%s\n' "$*" >"$CURL_ARGS_LOG" +printf '%s\n' '{"healthy":true,"version":"1.18.25"}' +EOF +chmod +x "${FAKE_BIN}/curl" +runtime_helper="${TMP_DIR}/runtime-helper" +cat >"$runtime_helper" <<'EOF' +require("node:fs").writeFileSync(process.env.RUNTIME_HELPER_CALLED, "called\n") +EOF + +set +e +OPENCODE_WEB_RETENTION_DAYS=0 "${ROOT_DIR}/.opencode_web_yolo_runtime.sh" bash -c 'exit 23' +disabled_status=$? +set -e +assert_equals 23 "$disabled_status" + +set +e +OPENCODE_WEB_RETENTION_DAYS=1 OPENCODE_WEB_RETENTION_POLL_SECONDS=0 "${ROOT_DIR}/.opencode_web_yolo_runtime.sh" true >/dev/null 2>&1 +poll_status=$? +set -e +if [ "$poll_status" -eq 0 ]; then fail "zero poll interval should fail"; fi + +set +e +OPENCODE_WEB_RETENTION_DAYS=1 OPENCODE_WEB_RETENTION_POLL_SECONDS=2147483648 "${ROOT_DIR}/.opencode_web_yolo_runtime.sh" true >/dev/null 2>&1 +poll_range_status=$? +set -e +if [ "$poll_range_status" -eq 0 ]; then fail "out-of-range poll interval should fail"; fi + +app_command="${TMP_DIR}/app-command" +cat >"$app_command" <<'EOF' +#!/usr/bin/env bash +sleep 0.3 +exit 23 +EOF +chmod +x "$app_command" +set +e +CURL_ARGS_LOG="${TMP_DIR}/curl-args" RUNTIME_HELPER_CALLED="${TMP_DIR}/helper-called" OPENCODE_SERVER_PASSWORD=secret OPENCODE_WEB_RETENTION_DAYS=1 OPENCODE_WEB_RETENTION_HELPER="$runtime_helper" OPENCODE_WEB_RETENTION_POLL_SECONDS=1 "${ROOT_DIR}/.opencode_web_yolo_runtime.sh" "$app_command" +app_status=$? +set -e +assert_equals 23 "$app_status" +[ -e "${TMP_DIR}/helper-called" ] || fail "scheduler should start after health" +curl_args="$(<"${TMP_DIR}/curl-args")" +assert_not_contains "$curl_args" "secret" + +cat >"$app_command" <<'EOF' +#!/usr/bin/env bash +trap 'exit 0' TERM INT +while true; do sleep 1; done +EOF +chmod +x "$app_command" +RUNTIME_HELPER_CALLED="${TMP_DIR}/signal-helper-called" OPENCODE_WEB_RETENTION_DAYS=1 OPENCODE_WEB_RETENTION_HELPER="$runtime_helper" OPENCODE_WEB_RETENTION_POLL_SECONDS=1 "${ROOT_DIR}/.opencode_web_yolo_runtime.sh" "$app_command" >/dev/null 2>&1 & +runtime_pid=$! +sleep 0.3 +kill -TERM "$runtime_pid" +wait "$runtime_pid" + +printf '%s\n' "PASS: session retention coverage" diff --git a/tests/version_guard.sh b/tests/version_guard.sh index 73dd40a..5b825bf 100755 --- a/tests/version_guard.sh +++ b/tests/version_guard.sh @@ -19,6 +19,8 @@ changed_runtime="$(git diff --name-only HEAD^ HEAD -- \ .opencode_web_yolo_config.sh \ .opencode_web_yolo.Dockerfile \ .opencode_web_yolo_entrypoint.sh \ + .opencode_web_yolo_runtime.sh \ + .opencode_web_yolo_retention.js \ install.sh \ .opencode_web_yolo_completion.bash \ .opencode_web_yolo_completion.zsh || true)" @@ -37,4 +39,3 @@ printf '%s\n' "Runtime/release files changed but VERSION was not updated." >&2 printf '%s\n' "Changed files:" >&2 printf '%s\n' "$changed_runtime" >&2 exit 1 - From 224cc77663494adea7168802fdad0a712ab80f7a Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Tue, 1 Sep 2026 06:32:24 +0000 Subject: [PATCH 2/4] fix: satisfy retention shell lint --- .opencode_web_yolo_runtime.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.opencode_web_yolo_runtime.sh b/.opencode_web_yolo_runtime.sh index ecf1846..b630e2b 100755 --- a/.opencode_web_yolo_runtime.sh +++ b/.opencode_web_yolo_runtime.sh @@ -31,6 +31,8 @@ if [ "${#RETENTION_POLL_SECONDS}" -gt 10 ] || { [ "${#RETENTION_POLL_SECONDS}" - exit 1 fi +# Invoked indirectly by the TERM and INT traps below. +# shellcheck disable=SC2317 forward_signal() { local signal="$1" stopping=1 @@ -43,6 +45,8 @@ trap 'forward_signal INT' INT health_ok() { local health_body auth + # This single-quoted JavaScript intentionally contains a template literal. + # shellcheck disable=SC2016 if ! auth="$(node -e 'process.stdout.write(Buffer.from(`${process.env.OPENCODE_SERVER_USERNAME || "opencode"}:${process.env.OPENCODE_SERVER_PASSWORD || ""}`).toString("base64"))')"; then return 1 fi From e06fc919b3b0b30dfaeff9a1782dc032f9960772 Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Tue, 1 Sep 2026 06:34:18 +0000 Subject: [PATCH 3/4] test: fix retention shell lint --- tests/test_session_retention.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_session_retention.sh b/tests/test_session_retention.sh index 045fd87..3e73686 100755 --- a/tests/test_session_retention.sh +++ b/tests/test_session_retention.sh @@ -357,8 +357,8 @@ wait "$server_pid" >/dev/null 2>&1 || true runtime="$(cat "${ROOT_DIR}/.opencode_web_yolo_runtime.sh")" assert_contains "$runtime" "trap 'forward_signal TERM' TERM" assert_contains "$runtime" "trap 'forward_signal INT' INT" -assert_contains "$runtime" 'wait "$app_pid"' -assert_contains "$runtime" 'exit "$app_status"' +assert_contains "$runtime" "wait \"\$app_pid\"" +assert_contains "$runtime" "exit \"\$app_status\"" dockerfile="$(cat "${ROOT_DIR}/.opencode_web_yolo.Dockerfile")" assert_contains "$dockerfile" ".opencode_web_yolo_retention.js" assert_contains "$dockerfile" "tini" From fddda40fb3f15339787b8a0fb84969c67d80c001 Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Tue, 1 Sep 2026 07:53:53 +0000 Subject: [PATCH 4/4] fix: remove unused retention deadline --- .opencode_web_yolo_retention.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.opencode_web_yolo_retention.js b/.opencode_web_yolo_retention.js index 9e2a3e8..d2201c4 100755 --- a/.opencode_web_yolo_retention.js +++ b/.opencode_web_yolo_retention.js @@ -139,19 +139,13 @@ function validateSession(session) { } } -async function listSessions(deadline) { +async function listSessions() { const sessions = [] let cursor const seenCursors = new Set() for (let pageNumber = 0; pageNumber < 10000; pageNumber += 1) { - const remaining = deadline === undefined ? undefined : deadline - Date.now() - if (remaining !== undefined && remaining <= 0) { - throw new Error("session deletion verification timed out") - } - const result = await requestJson("/experimental/session", remaining === undefined ? {} : { - timeoutMs: Math.min(remaining, positiveInteger("OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS", DEFAULT_FETCH_TIMEOUT_MS)), - }, { + const result = await requestJson("/experimental/session", {}, { roots: "false", archived: "true", limit: PAGE_LIMIT,