From 5c7b7172da449a43835887b059dad52fe150ce84 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Thu, 24 Sep 2026 16:05:59 +0800 Subject: [PATCH 1/2] chore(devtools): bump markdownlint-cli2 pin to 0.23.2 Align with workspace toolchain policy (bitty-plugin-sdk#117, bitty-plugin-template justfile). No behavior change. Closes #135 --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index a7cd18c..50e4409 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ # Version pins live here only (one place per pin). prettier_version := "3.9.6" -markdownlint_version := "0.23.1" +markdownlint_version := "0.23.2" # Lint every Markdown file selected by .markdownlint-cli2.jsonc. lint: From 45141e3420a011b7d5e4eb29cc12938f137d11f9 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Sat, 26 Sep 2026 08:45:31 +0800 Subject: [PATCH 2/2] [CTX-0078] fix(import): fail-closed CarryCtx import probe --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- README.md | 2 +- scripts/workflow-import.sh | 1310 +++++++++++++++++++++++-- tests/workflow-import.test.ts | 1536 +++++++++++++++++++++++++++++- 4 files changed, 2754 insertions(+), 96 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c9742fd..4d71a8b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ ## Testing - [ ] `just fmt-check` passes (Prettier 3.9.6) -- [ ] `just lint` passes (markdownlint-cli2 0.23.1) +- [ ] `just lint` passes (markdownlint-cli2 0.23.2) - [ ] `just check` passes (aggregate gate) ## Checklist diff --git a/README.md b/README.md index aa66da3..01aadfb 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ repository `justfile`: ```text just check # fmt-check + lint + type-check + test + cargo-check just fmt-check # Prettier 3.9.6 check without writing files -just lint # markdownlint-cli2 0.23.1 +just lint # markdownlint-cli2 0.23.2 just type-check # tsc --noEmit strict just test # bun:test headless unit tests just cargo-check # cargo check + clippy -D warnings + cargo test diff --git a/scripts/workflow-import.sh b/scripts/workflow-import.sh index 288a118..3e42866 100755 --- a/scripts/workflow-import.sh +++ b/scripts/workflow-import.sh @@ -7,9 +7,9 @@ # touches the network, so this target fetches the branch itself. # # Safety (thin, no Python): -# - refuses to replace a non-empty local DB (one project row with data rows) +# - refuses to replace any non-empty local DB (project or durable rows) # without --force, leaving the DB untouched; -# - initializes a fresh clone's CarryCtx state (no project row) before import; +# - initializes an absent or known-empty CarryCtx state before import; # - --dry-run validates the snapshot and writes nothing; # - restores the committed `.carryctx/config.toml` byte-identically, because # `carryctx import` rewrites it with current config defaults; @@ -30,6 +30,18 @@ SNAP_BRANCH="refs/heads/carryctx-snapshots" PROJECT="$REPO_ROOT" REMOTE="${WORKFLOW_REMOTE:-origin}" GIT_TIMEOUT="${GIT_TIMEOUT:-120}" +MAX_SCHEMA_TABLES=256 +MAX_SCHEMA_NAME_BYTES=256 +MAX_SCHEMA_OBJECTS=512 +MAX_SCHEMA_LINE_BYTES=131072 +REQUIRED_CARRYCTX_VERSION="0.11.6" +REQUIRED_DB_SCHEMA=18 +REQUIRED_CTXPACK_FORMAT="carryctx-pack-dir" +REQUIRED_CTXPACK_FORMAT_VERSION=2 +SNAPSHOT_RETRIES=3 +MAX_DUMP_BYTES=67108864 +DRY_RUN_REF="refs/heads/carryctx-snapshots" +SETSID_MODE="" FORCE=0 DRY_RUN=0 @@ -97,24 +109,234 @@ warn() { have() { command -v "$1" >/dev/null 2>&1; } +run_timed() { + local seconds="$1" leader status=0 timer_pid status_fifo release_fifo wrapper + shift + status_fifo="$TMP_ROOT/timed-status.$$.$RANDOM" + release_fifo="$TMP_ROOT/timed-release.$$.$RANDOM" + rm -f "$status_fifo" "$release_fifo" + mkfifo "$status_fifo" "$release_fifo" || return 1 + if ! exec 7<>"$status_fifo"; then + rm -f "$status_fifo" "$release_fifo" + return 1 + fi + if ! exec 8<>"$release_fifo"; then + exec 7>&- + rm -f "$status_fifo" "$release_fifo" + return 1 + fi + if [[ -z "$SETSID_MODE" ]]; then + if have setsid && setsid --wait -- true >/dev/null 2>&1; then + SETSID_MODE="wait" + elif have setsid && setsid true >/dev/null 2>&1; then + SETSID_MODE="direct" + else + SETSID_MODE="job-control" + fi + fi + wrapper=' +child= +command_status=0 +terminate() { + trap - TERM INT + ( + sleep 1 + kill -KILL -- -$$ 2>/dev/null || : + ) & + escalation=$! + if [[ -n "$child" ]]; then + wait "$child" 2>/dev/null || : + fi + wait "$escalation" 2>/dev/null || : + exit 143 +} +trap terminate TERM INT +"$@" 7>&- 8>&- & +child=$! +wait "$child" || command_status=$? +printf "%s\n" "$command_status" >&7 +IFS= read -r _ <&8 || : +exit "$command_status" +' + if [[ "$SETSID_MODE" == "wait" ]]; then + setsid --wait -- bash -c "$wrapper" bash "$@" 7>&7 8>&8 & + elif [[ "$SETSID_MODE" == "direct" ]]; then + setsid bash -c "$wrapper" bash "$@" 7>&7 8>&8 & + else + set -m + bash -c "$wrapper" bash "$@" 7>&7 8>&8 & + set +m + fi + leader=$! + ( + if IFS= read -r -t "$seconds" _ <&7; then + printf 'release\n' >&8 || : + else + kill -TERM -- "-$leader" 2>/dev/null || : + fi + ) >/dev/null 2>&1 & + timer_pid=$! + wait "$leader" || status=$? + printf 'release\n' >&8 || : + wait "$timer_pid" 2>/dev/null || : + exec 7>&- 8>&- + rm -f "$status_fifo" "$release_fifo" + if [[ "$status" -eq 137 || "$status" -eq 143 ]]; then + return 124 + fi + return "$status" +} + +path_has_symlink() { + local current="$1" + while [[ -n "$current" && "$current" != "/" ]]; do + if [[ -L "$current" ]]; then + return 0 + fi + current="$(dirname "$current")" + done + [[ -L "/" ]] && return 0 + return 1 +} + +assert_project_paths() { + local carryctx_dir="$PROJECT/.carryctx" + if path_has_symlink "$PROJECT" || path_has_symlink "$CFG"; then + return 1 + fi + [[ -d "$PROJECT" && ! -L "$PROJECT" ]] || return 1 + if [[ -e "$carryctx_dir" || -L "$carryctx_dir" ]]; then + [[ -d "$carryctx_dir" && ! -L "$carryctx_dir" ]] || return 1 + fi + if [[ -e "$CFG" || -L "$CFG" ]]; then + [[ -f "$CFG" && ! -L "$CFG" ]] || return 1 + fi + return 0 +} + +path_identity() { + local path="$1" value + if value="$(stat -c '%d:%i' -- "$path" 2>/dev/null)"; then + printf '%s\n' "$value" + return 0 + fi + if value="$(stat -f '%d:%i' -- "$path" 2>/dev/null)"; then + printf '%s\n' "$value" + return 0 + fi + return 1 +} + +capture_path_identity() { + local path="$1" + if [[ -e "$path" || -L "$path" ]]; then + path_identity "$path" + else + printf '\n' + fi +} + +assert_captured_path() { + local path="$1" expected="$2" current + if [[ -n "$expected" ]]; then + [[ -e "$path" && ! -L "$path" ]] || return 1 + current="$(path_identity "$path" 2>/dev/null)" || return 1 + [[ "$current" == "$expected" ]] || return 1 + else + [[ ! -e "$path" && ! -L "$path" ]] || return 1 + fi + return 0 +} + +assert_stable_paths() { + assert_project_paths || return 1 + assert_captured_path "$PROJECT" "$PROJECT_ID" || return 1 + assert_captured_path "$GIT_COMMON" "$GIT_COMMON_ID" || return 1 + assert_captured_path "$CARRYCTX_DIR" "$CARRYCTX_DIR_ID" || return 1 + assert_captured_path "$CFG" "$CFG_ID" || return 1 + assert_captured_path "$DB" "$DB_ID" || return 1 + assert_captured_path "$DB-wal" "$DB_WAL_ID" || return 1 + assert_captured_path "$DB-shm" "$DB_SHM_ID" || return 1 + [[ ! -e "$DB-journal" && ! -L "$DB-journal" ]] || return 1 + return 0 +} + have git || fail "git not on PATH" have carryctx || fail "carryctx not on PATH" -have timeout || fail "timeout not on PATH" +have awk || fail "awk not on PATH" +have mkfifo || fail "mkfifo not on PATH" +have cp || fail "cp not on PATH" +have mv || fail "mv not on PATH" +have cmp || fail "cmp not on PATH" +have wc || fail "wc not on PATH" +have stat || fail "stat not on PATH" +have sleep || fail "sleep not on PATH" +[[ "$GIT_TIMEOUT" =~ ^[1-9][0-9]*$ ]] || fail "git timeout must be a positive integer" -PROJECT="$(cd "$PROJECT" && pwd)" || fail "project directory $PROJECT not found" +PROJECT_LOGICAL="$(cd -L "$PROJECT" 2>/dev/null && pwd -L)" || + fail "project directory $PROJECT not found" +PROJECT="$(cd -P "$PROJECT" 2>/dev/null && pwd -P)" || + fail "project directory $PROJECT not found" +[[ "$PROJECT_LOGICAL" == "$PROJECT" ]] || + fail "project path contains a symlink" +CFG="$PROJECT/.carryctx/config.toml" +assert_project_paths || fail "unsafe CarryCtx configuration path" TRACK_REF="refs/remotes/$REMOTE/carryctx-snapshots" TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/workflow-import.XXXXXX")" -CFG="$PROJECT/.carryctx/config.toml" +TMP_ROOT="$(cd -P "$TMP_ROOT" && pwd -P)" || fail "cannot resolve temporary workflow directory" CFG_BACKUP="" RESTORE_FAILED=0 +AUTHORITY_DB="" +AUTHORITY_SCHEMA="" +DRY_RUN_PROJECT="" +VERSION_CONTRACT="" +VERSION_CONTRACT_READY=0 +PRE_IMPORT_ROOT="" +PRE_IMPORT_DB="" +PRE_IMPORT_DUMP="" +PRE_IMPORT_RAW_DUMP="" +IMPORT_STAGING_PROJECT="" +IMPORT_STAGING_COMMON="" +IMPORT_STAGING_REF="refs/heads/workflow-import-snapshot" +IMPORT_GUARD_DB="" +HANDOFF_DB_ID="" +HANDOFF_WAL_ID="" +HANDOFF_SHM_ID="" +AUTHORITY_MIGRATIONS="" +PROBE_RESULT="" +PROBE_SNAPSHOT="" +PROJECT_ID="" +GIT_COMMON_ID="" +CARRYCTX_DIR_ID="" +CFG_ID="" +DB_ID="" +DB_WAL_ID="" +DB_SHM_ID="" restore_config() { - if [[ -n "$CFG_BACKUP" ]] && ! cmp -s "$CFG_BACKUP" "$CFG"; then - if ! mkdir -p "$PROJECT/.carryctx" || ! cp -p "$CFG_BACKUP" "$CFG"; then + if [[ -n "$CFG_BACKUP" ]]; then + if ! assert_project_paths || [[ ! -f "$CFG_BACKUP" || -L "$CFG_BACKUP" ]]; then RESTORE_FAILED=1 - warn "cannot restore $CFG; recovery backup retained at $CFG_BACKUP" + warn "cannot restore $CFG through an unsafe path; recovery backup retained at $CFG_BACKUP" return 1 fi - log "restored pre-existing .carryctx/config.toml (import rewrites local config defaults)" + if ! cmp -s "$CFG_BACKUP" "$CFG"; then + if [[ ! -d "$PROJECT/.carryctx" ]] && ! mkdir "$PROJECT/.carryctx"; then + RESTORE_FAILED=1 + warn "cannot restore $CFG; recovery backup retained at $CFG_BACKUP" + return 1 + fi + if ! cp -p -- "$CFG_BACKUP" "$CFG"; then + RESTORE_FAILED=1 + warn "cannot restore $CFG; recovery backup retained at $CFG_BACKUP" + return 1 + fi + if ! cmp -s -- "$CFG_BACKUP" "$CFG"; then + RESTORE_FAILED=1 + warn "cannot restore $CFG; recovery backup retained at $CFG_BACKUP" + return 1 + fi + log "restored pre-existing .carryctx/config.toml (import rewrites local config defaults)" + fi fi } cleanup() { @@ -127,79 +349,1018 @@ cleanup() { } trap cleanup EXIT -# Read-only probe of the local CarryCtx DB. Never creates or mutates it. Prints -# " "; project_rows is 1 when a projects row exists. +sqlite_query() { + local db="$1" sql="$2" + run_timed "$GIT_TIMEOUT" sqlite3 -readonly -batch -noheader -separator '|' "$db" "$sql" +} + +dump_database() { + local db="$1" output="$2" canonical="$2.vacuum.sqlite" size parameter_path + rm -f "$canonical" + parameter_path="${canonical//\'/\'\'}" + if ! run_timed "$GIT_TIMEOUT" sqlite3 -readonly -batch \ + -cmd '.parameter init' \ + -cmd ".parameter set @out '$parameter_path'" \ + -cmd 'VACUUM INTO @out;' "$db"; then + rm -f "$canonical" + return 1 + fi + [[ -f "$canonical" && ! -L "$canonical" ]] || { + rm -f "$canonical" + return 1 + } + if ! run_timed "$GIT_TIMEOUT" sqlite3 -readonly -batch "$canonical" .dump >"$output" 2>/dev/null; then + rm -f "$canonical" + return 1 + fi + size="$(wc -c <"$output")" || { + rm -f "$canonical" + return 1 + } + [[ "$size" =~ ^[0-9]+$ ]] || { + rm -f "$canonical" + return 1 + } + ((size > 0 && size <= MAX_DUMP_BYTES)) || { + rm -f "$canonical" + return 1 + } + rm -f "$canonical" + return 0 +} + +dump_raw_database() { + local db="$1" output="$2" size + rm -f "$output" + if ! run_timed "$GIT_TIMEOUT" sqlite3 -readonly -batch "$db" .dump >"$output" 2>/dev/null; then + return 1 + fi + size="$(wc -c <"$output")" || return 1 + [[ "$size" =~ ^[0-9]+$ ]] || return 1 + ((size > 0 && size <= MAX_DUMP_BYTES)) +} + +prepare_version_contract() { + local root="$TMP_ROOT/version-contract" timeout="$GIT_TIMEOUT" project + project="$root/project" + mkdir -p "$root/home" "$root/data" "$root/config" "$root/state" "$root/cache" || return 1 + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + for name in ${!CARRYCTX_@}; do + unset "$name" + done + export HOME="$root/home" + export GIT_CONFIG_NOSYSTEM=1 + export XDG_CONFIG_HOME="$root/config" + export XDG_DATA_HOME="$root/data" + export XDG_STATE_HOME="$root/state" + export XDG_CACHE_HOME="$root/cache" + run_timed "$timeout" git init --quiet "$project" + run_timed "$timeout" carryctx version --json --project "$project" + ) >"$TMP_ROOT/version.json" 2>"$TMP_ROOT/version.err"; then + return 1 + fi + VERSION_CONTRACT="$(<"$TMP_ROOT/version.json")" || return 1 + [[ -n "$VERSION_CONTRACT" ]] || return 1 + VERSION_CONTRACT_READY=1 + return 0 +} + +carryctx_schema_version() { + local output version + [[ "$VERSION_CONTRACT_READY" == 1 && -n "$VERSION_CONTRACT" ]] || return 1 + output="$VERSION_CONTRACT" + if [[ "$output" == *$'\n'* || "$output" == *$'\r'* ]]; then + return 1 + fi + if ! version="$(printf '%s' "$output" | LC_ALL=C awk -v required_version="$REQUIRED_CARRYCTX_VERSION" -v required_schema="$REQUIRED_DB_SCHEMA" -v required_format="$REQUIRED_CTXPACK_FORMAT" -v required_format_version="$REQUIRED_CTXPACK_FORMAT_VERSION" ' +BEGIN { + pattern = "^[{]\"schema_version\":1,\"command\":\"version\",\"success\":true,\"data\":[{]\"contract_versions\":[{]\"cli\":\"[0-9A-Za-z._-]+\",\"ctxpack_format\":[{]\"format\":\"[0-9A-Za-z._-]+\",\"format_version\":[0-9]+[}],\"db_schema\":[0-9]+,\"skill_surface\":[{]\"min_carryctx\":\"[0-9A-Za-z._-]+\",\"skill\":\"[0-9A-Za-z._-]+\",\"version\":\"[0-9A-Za-z._-]+\"[}][}]},\"meta\":[{]\"timestamp\":\"[0-9T:.+Z-]+\"[}]}$" +} +{ + if (match($0, pattern) == 0) exit 1 + matched = substr($0, RSTART, RLENGTH) + if (match(matched, /"cli":"[^"]+"/) == 0) exit 1 + cli = substr(matched, RSTART, RLENGTH) + sub(/^"cli":"/, "", cli) + sub(/"$/, "", cli) + if (cli != required_version) exit 1 + if (match(matched, /"min_carryctx":"[^"]+"/) == 0) exit 1 + min_version = substr(matched, RSTART, RLENGTH) + sub(/^"min_carryctx":"/, "", min_version) + sub(/"$/, "", min_version) + if (min_version != required_version) exit 1 + if (match(matched, /"db_schema":[0-9]+/) == 0) exit 1 + schema = substr(matched, RSTART, RLENGTH) + sub(/^"db_schema":/, "", schema) + if (schema != required_schema) exit 1 + if (match(matched, /"format":"[^"]+"/) == 0) exit 1 + format = substr(matched, RSTART, RLENGTH) + sub(/^"format":"/, "", format) + sub(/"$/, "", format) + if (format != required_format) exit 1 + if (match(matched, /"format_version":[0-9]+/) == 0) exit 1 + format_version = substr(matched, RSTART, RLENGTH) + sub(/^"format_version":/, "", format_version) + if (format_version != required_format_version) exit 1 + print schema + exit 0 +} +')"; then + return 1 + fi + [[ "$version" == "$REQUIRED_DB_SCHEMA" ]] || return 1 + printf '%s\n' "$version" +} + +decode_hex_name() { + local hex="$1" octal output="" index + [[ "$hex" =~ ^[0-9A-Fa-f]+$ ]] || return 1 + ((${#hex} % 2 == 0)) || return 1 + [[ "$hex" != *"00"* ]] || return 1 + for ((index = 0; index < ${#hex}; index += 2)); do + printf -v octal '%03o' "$((16#${hex:index:2}))" + output+="\\$octal" + done + printf '%b' "$output" +} + +unknown_state() { + PROBE_RESULT="unknown 0 0" +} + +is_count() { + [[ "$1" =~ ^(0|[1-9][0-9]*)$ && "${#1}" -le 18 ]] +} + +snapshot_matches_source() { + local source="$1" slot="$2" compare_shm="$3" suffix slot_db + slot_db="$slot/state.sqlite" + [[ -f "$source" && ! -L "$source" && -f "$slot_db" && ! -L "$slot_db" ]] || return 1 + cmp -s -- "$source" "$slot_db" || return 1 + for suffix in -wal -shm; do + if [[ -e "$source$suffix" || -L "$source$suffix" ]]; then + [[ -f "$source$suffix" && ! -L "$source$suffix" ]] || return 1 + if [[ "$suffix" == "-shm" && "$compare_shm" != 1 ]]; then + continue + fi + [[ -f "$slot_db$suffix" && ! -L "$slot_db$suffix" ]] || return 1 + cmp -s -- "$source$suffix" "$slot_db$suffix" || return 1 + elif [[ "$suffix" == "-wal" && "$compare_shm" != 1 ]]; then + continue + elif [[ "$suffix" == "-shm" && "$compare_shm" != 1 ]]; then + continue + elif [[ -e "$slot_db$suffix" || -L "$slot_db$suffix" ]]; then + return 1 + fi + done + [[ ! -e "$source-journal" && ! -L "$source-journal" ]] || return 1 + return 0 +} + +copy_probe_snapshot() { + local source="$1" slot="$2" suffix slot_db + slot_db="$slot/state.sqlite" + rm -rf "$slot" + mkdir -p "$slot" || return 1 + cp -p -- "$source" "$slot_db" || return 1 + for suffix in -wal -shm; do + if [[ -e "$source$suffix" || -L "$source$suffix" ]]; then + [[ -f "$source$suffix" && ! -L "$source$suffix" && -r "$source$suffix" ]] || return 1 + cp -p -- "$source$suffix" "$slot_db$suffix" || return 1 + fi + done + snapshot_matches_source "$source" "$slot" 1 +} + +assert_probe_snapshot_unchanged() { + local suffix + if [[ -n "$PROBE_SNAPSHOT" ]]; then + assert_captured_path "$DB" "$DB_ID" || return 1 + assert_captured_path "$DB-wal" "$DB_WAL_ID" || return 1 + assert_captured_path "$DB-shm" "$DB_SHM_ID" || return 1 + snapshot_matches_source "$DB" "$PROBE_SNAPSHOT" 0 + return + fi + [[ ! -e "$DB" && ! -L "$DB" ]] || return 1 + for suffix in -wal -shm -journal; do + [[ ! -e "$DB$suffix" && ! -L "$DB$suffix" ]] || return 1 + done +} + +assert_no_command_lock() { + local locks_dir="$GIT_COMMON/carryctx/locks" lock_dir="$GIT_COMMON/carryctx/locks/command.lock" + path_has_symlink "$locks_dir" && return 1 + if [[ -e "$locks_dir" || -L "$locks_dir" ]]; then + [[ -d "$locks_dir" && ! -L "$locks_dir" ]] || return 1 + fi + [[ ! -e "$lock_dir" && ! -L "$lock_dir" ]] +} + +prepare_dry_run_project() { + local root="$TMP_ROOT/dry-run" project commit timeout="$GIT_TIMEOUT" + DRY_RUN_PROJECT="$root/project" + project="$DRY_RUN_PROJECT" + mkdir -p "$root/home" "$root/data" "$root/config" "$root/state" "$root/cache" || return 1 + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + export HOME="$root/home" + export GIT_CONFIG_NOSYSTEM=1 + run_timed "$timeout" git init --quiet "$project" + ); then + return 1 + fi + commit="$(run_timed "$GIT_TIMEOUT" git -C "$PROJECT" rev-parse "$TRACK_REF" 2>/dev/null)" || return 1 + [[ "$commit" =~ ^[0-9a-f]{40,64}$ ]] || return 1 + if ! run_timed "$GIT_TIMEOUT" git -C "$project" fetch "$PROJECT" "$commit:$DRY_RUN_REF"; then + return 1 + fi + if [[ -f "$CFG" ]]; then + mkdir -p "$project/.carryctx" || return 1 + cp -p -- "$CFG" "$project/.carryctx/config.toml" || return 1 + fi + return 0 +} + +run_dry_run_validation() { + local root="$TMP_ROOT/dry-run" timeout="$GIT_TIMEOUT" + prepare_dry_run_project || return 1 + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + for name in ${!CARRYCTX_@}; do + unset "$name" + done + export HOME="$root/home" + export GIT_CONFIG_NOSYSTEM=1 + export XDG_DATA_HOME="$root/data" + export XDG_CONFIG_HOME="$root/config" + export XDG_STATE_HOME="$root/state" + export XDG_CACHE_HOME="$root/cache" + run_timed "$timeout" carryctx import --from-git "$DRY_RUN_REF" \ + --mode replace --yes --dry-run --json --project "$DRY_RUN_PROJECT" + ) >"$TMP_ROOT/dry-run.json" 2>"$TMP_ROOT/dry-run.err"; then + return 1 + fi + return 0 +} + +prepare_import_staging_project() { + local root="$TMP_ROOT/import-staging" timeout="$GIT_TIMEOUT" project commit + project="$root/project" + IMPORT_STAGING_PROJECT="$project" + IMPORT_STAGING_COMMON="$project/.git" + mkdir -p "$root/home" "$root/data" "$root/config" "$root/state" "$root/cache" || return 1 + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + export HOME="$root/home" + export GIT_CONFIG_NOSYSTEM=1 + export XDG_CONFIG_HOME="$root/config" + export XDG_DATA_HOME="$root/data" + export XDG_STATE_HOME="$root/state" + export XDG_CACHE_HOME="$root/cache" + run_timed "$timeout" git init --quiet "$project" + ); then + return 1 + fi + commit="$(run_timed "$GIT_TIMEOUT" git -C "$PROJECT" rev-parse "$TRACK_REF" 2>/dev/null)" || return 1 + [[ "$commit" =~ ^[0-9a-f]{40,64}$ ]] || return 1 + if ! run_timed "$GIT_TIMEOUT" git -C "$project" fetch "$PROJECT" "$commit:$IMPORT_STAGING_REF"; then + return 1 + fi + if [[ -f "$CFG" ]]; then + mkdir -p "$project/.carryctx" || return 1 + cp -p -- "$CFG" "$project/.carryctx/config.toml" || return 1 + fi + copy_probe_snapshot "$PRE_IMPORT_DB" "$IMPORT_STAGING_COMMON/carryctx" +} + +run_staging_import() { + local root="$TMP_ROOT/import-staging" timeout="$GIT_TIMEOUT" + prepare_import_staging_project || return 1 + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + for name in ${!CARRYCTX_@}; do + unset "$name" + done + export HOME="$root/home" + export GIT_CONFIG_NOSYSTEM=1 + export XDG_CONFIG_HOME="$root/config" + export XDG_DATA_HOME="$root/data" + export XDG_STATE_HOME="$root/state" + export XDG_CACHE_HOME="$root/cache" + export WORKFLOW_IMPORT_STAGING_PROJECT="$IMPORT_STAGING_PROJECT" + export WORKFLOW_IMPORT_STAGING_COMMON="$IMPORT_STAGING_COMMON" + run_timed "$timeout" carryctx import --from-git "$IMPORT_STAGING_REF" \ + --mode replace --yes --json --project "$IMPORT_STAGING_PROJECT" + ) >"$TMP_ROOT/staging-import.json" 2>"$TMP_ROOT/staging-import.err"; then + return 1 + fi + return 0 +} + +reanchor_staging_project() { + local staged_db="$IMPORT_STAGING_COMMON/carryctx/state.sqlite" root common + root="${PROJECT//\'/\'\'}" + common="${GIT_COMMON//\'/\'\'}" + run_timed "$GIT_TIMEOUT" sqlite3 -batch \ + -cmd '.parameter init' \ + -cmd ".parameter set @root '$root'" \ + -cmd ".parameter set @common '$common'" \ + -cmd 'UPDATE projects SET repository_root = @root, git_common_dir = @common;' \ + "$staged_db" +} + +validate_staging_import() { + local output backup staged_db="$IMPORT_STAGING_COMMON/carryctx/state.sqlite" integrity suffix + output="$(<"$TMP_ROOT/staging-import.json")" || return 1 + backup="$(extract_import_backup "$output")" || return 1 + validate_import_backup "$backup" "$IMPORT_STAGING_COMMON/carryctx/backups" || return 1 + dump_database "$backup" "$TMP_ROOT/staging-backup.dump" || return 1 + cmp -s -- "$PRE_IMPORT_DUMP" "$TMP_ROOT/staging-backup.dump" || return 1 + [[ -f "$staged_db" && ! -L "$staged_db" ]] || return 1 + for suffix in -wal -shm; do + if [[ -e "$staged_db$suffix" || -L "$staged_db$suffix" ]]; then + [[ -f "$staged_db$suffix" && ! -L "$staged_db$suffix" ]] || return 1 + fi + done + [[ ! -e "$staged_db-journal" && ! -L "$staged_db-journal" ]] || return 1 + integrity="$(sqlite_query "$staged_db" 'PRAGMA integrity_check;' 2>/dev/null)" || return 1 + [[ "$integrity" == "ok" && "$integrity" != *$'\n'* ]] || return 1 + dump_database "$staged_db" "$TMP_ROOT/staged-committed.dump" +} + +restore_import_guard() { + local suffix + [[ -n "$IMPORT_GUARD_DB" && -e "$IMPORT_GUARD_DB" ]] || return 0 + if [[ -e "$DB" || -L "$DB" || -e "$DB-wal" || -L "$DB-wal" || -e "$DB-shm" || -L "$DB-shm" ]]; then + warn "retained pre-import database at $IMPORT_GUARD_DB because the target path is occupied" + return 0 + fi + if ! mv -- "$IMPORT_GUARD_DB" "$DB"; then + warn "retained pre-import database at $IMPORT_GUARD_DB; target restore was not safe" + return 1 + fi + for suffix in -wal -shm; do + if [[ -e "$IMPORT_GUARD_DB$suffix" || -L "$IMPORT_GUARD_DB$suffix" ]]; then + mv -- "$IMPORT_GUARD_DB$suffix" "$DB$suffix" || return 1 + fi + done + IMPORT_GUARD_DB="" + return 0 +} + +assert_guard_sidecar_identity() { + local path="$1" expected="$2" current + if [[ -n "$expected" ]]; then + [[ -f "$path" && ! -L "$path" ]] || return 1 + current="$(path_identity "$path" 2>/dev/null)" || return 1 + [[ "$current" == "$expected" ]] + else + [[ ! -e "$path" && ! -L "$path" ]] + fi +} + +dump_locked_guard() { + local integrity="$TMP_ROOT/guard-locked-integrity" schema="$TMP_ROOT/guard-locked-schema" migrations="$TMP_ROOT/guard-locked-migrations" dump="$TMP_ROOT/guard-locked.dump" output="$TMP_ROOT/guard-locked.log" validator="$TMP_ROOT/validate-locked-guard" marker="$TMP_ROOT/guard-locked.ok" guard_path_file="$TMP_ROOT/guard-path" guard_id_file="$TMP_ROOT/guard-id" guard_id + local integrity_path schema_path migrations_path dump_path validator_command + integrity_path="${integrity//\'/\'\'}" + schema_path="${schema//\'/\'\'}" + migrations_path="${migrations//\'/\'\'}" + dump_path="${dump//\'/\'\'}" + [[ -f "$IMPORT_GUARD_DB" && ! -L "$IMPORT_GUARD_DB" ]] || return 1 + guard_id="$(path_identity "$IMPORT_GUARD_DB" 2>/dev/null || true)" + [[ "$guard_id" == "$HANDOFF_DB_ID" ]] || return 1 + assert_guard_sidecar_identity "$IMPORT_GUARD_DB-wal" "$HANDOFF_WAL_ID" || return 1 + assert_guard_sidecar_identity "$IMPORT_GUARD_DB-shm" "$HANDOFF_SHM_ID" || return 1 + printf '%s\n' "$IMPORT_GUARD_DB" >"$guard_path_file" || return 1 + printf '%s\n' "$guard_id" >"$guard_id_file" || return 1 + rm -f "$integrity" "$schema" "$migrations" "$dump" "$output" "$marker" "$validator" + cat >"$validator" <<'VALIDATOR' +set -euo pipefail +integrity="$1" +schema="$2" +migrations="$3" +dump="$4" +authority_schema="$5" +authority_migrations="$6" +pre_import_dump="$7" +guard_path_file="$8" +guard_id_file="$9" +max_dump_bytes="${10}" +marker="${11}" +for path in "$integrity" "$schema" "$migrations" "$dump" "$authority_schema" "$authority_migrations" "$pre_import_dump"; do + [[ -f "$path" && ! -L "$path" ]] +done +[[ "$(<"$integrity")" == "ok" ]] +cmp -s -- "$authority_schema" "$schema" +cmp -s -- "$authority_migrations" "$migrations" +cmp -s -- "$pre_import_dump" "$dump" +size="$(wc -c <"$dump")" +[[ "$size" =~ ^[0-9]+$ ]] +((size > 0 && size <= max_dump_bytes)) +guard_path="$(<"$guard_path_file")" +expected_guard_id="$(<"$guard_id_file")" +[[ -f "$guard_path" && ! -L "$guard_path" ]] +current_guard_id="$(stat -c '%d:%i' -- "$guard_path" 2>/dev/null)" || current_guard_id="$(stat -f '%d:%i' -- "$guard_path" 2>/dev/null)" +[[ "$current_guard_id" == "$expected_guard_id" ]] +for path in "$guard_path-wal" "$guard_path-shm"; do + if [[ -e "$path" || -L "$path" ]]; then + [[ -f "$path" && ! -L "$path" ]] + fi +done +: >"$marker" +VALIDATOR + printf -v validator_command 'bash %q %q %q %q %q %q %q %q %q %q %q %q' "$validator" "$integrity" "$schema" "$migrations" "$dump" "$AUTHORITY_SCHEMA" "$AUTHORITY_MIGRATIONS" "$PRE_IMPORT_RAW_DUMP" "$guard_path_file" "$guard_id_file" "$MAX_DUMP_BYTES" "$marker" + if ! run_timed "$GIT_TIMEOUT" sqlite3 -batch \ + -cmd 'BEGIN IMMEDIATE;' \ + -cmd ".once '$integrity_path'" -cmd 'PRAGMA integrity_check;' \ + -cmd ".once '$schema_path'" -cmd "SELECT hex(type)||'|'||hex(name)||'|'||hex(tbl_name)||'|'||hex(COALESCE(sql,'')) FROM sqlite_master ORDER BY type,name,tbl_name;" \ + -cmd ".once '$migrations_path'" -cmd "SELECT version||'|'||hex(name)||'|'||hex(checksum) FROM schema_migrations ORDER BY version;" \ + -cmd ".once '$dump_path'" -cmd '.dump' \ + -cmd ".shell $validator_command" -cmd 'ROLLBACK;' "$IMPORT_GUARD_DB" >"$output" 2>&1; then + cat "$output" >&2 + return 1 + fi + [[ -f "$marker" && ! -L "$marker" ]] +} + +validate_handoff_state() { + local post="$TMP_ROOT/post-handoff" post_schema="$TMP_ROOT/post-schema" post_migrations="$TMP_ROOT/post-migrations" post_dump="$TMP_ROOT/post-handoff.dump" integrity guard_id + assert_import_paths || return 1 + assert_no_command_lock || return 1 + assert_guard_sidecar_identity "$IMPORT_GUARD_DB-wal" "$HANDOFF_WAL_ID" || return 1 + assert_guard_sidecar_identity "$IMPORT_GUARD_DB-shm" "$HANDOFF_SHM_ID" || return 1 + guard_id="$(path_identity "$IMPORT_GUARD_DB" 2>/dev/null || true)" + [[ "$guard_id" == "$HANDOFF_DB_ID" ]] || return 1 + snapshot_matches_source "$IMPORT_GUARD_DB" "$PRE_IMPORT_ROOT" 0 || return 1 + copy_probe_snapshot "$DB" "$post" || return 1 + integrity="$(sqlite_query "$post/state.sqlite" 'PRAGMA integrity_check;' 2>/dev/null)" || return 1 + [[ "$integrity" == "ok" && "$integrity" != *$'\n'* ]] || return 1 + write_schema_snapshot "$post/state.sqlite" "$post_schema" || return 1 + write_migration_snapshot "$post/state.sqlite" "$post_migrations" || return 1 + cmp -s -- "$AUTHORITY_SCHEMA" "$post_schema" || return 1 + cmp -s -- "$AUTHORITY_MIGRATIONS" "$post_migrations" || return 1 + dump_database "$post/state.sqlite" "$post_dump" || return 1 + cmp -s -- "$TMP_ROOT/staged-committed.dump" "$post_dump" || return 1 + dump_locked_guard +} + +commit_staged_import() { + local staged_db="$IMPORT_STAGING_COMMON/carryctx/state.sqlite" candidate guard suffix guard_id + local candidate_db_id candidate_wal_id candidate_shm_id + HANDOFF_DB_ID="$DB_ID" + HANDOFF_WAL_ID="$DB_WAL_ID" + HANDOFF_SHM_ID="$DB_SHM_ID" + assert_stable_paths || fail "project paths changed while staging import" + assert_no_command_lock || fail "local CarryCtx operation started while staging import" + assert_probe_snapshot_unchanged || fail "local CarryCtx state changed while staging import" + candidate="$CARRYCTX_DIR/.workflow-import-candidate.$$" + guard="$CARRYCTX_DIR/.workflow-import-original.$$" + rm -f "$candidate" "$guard" + [[ ! -e "$candidate" && ! -L "$candidate" && ! -e "$guard" && ! -L "$guard" ]] || fail "cannot allocate import handoff paths" + for suffix in -wal -shm; do + [[ ! -e "$candidate$suffix" && ! -L "$candidate$suffix" && ! -e "$guard$suffix" && ! -L "$guard$suffix" ]] || fail "cannot allocate import sidecar handoff paths" + done + cp -p -- "$staged_db" "$candidate" || fail "cannot stage imported database beside target" + for suffix in -wal -shm; do + if [[ -e "$staged_db$suffix" || -L "$staged_db$suffix" ]]; then + cp -p -- "$staged_db$suffix" "$candidate$suffix" || fail "cannot stage imported sidecar beside target" + fi + done + candidate_db_id="$(capture_path_identity "$candidate")" + candidate_wal_id="$(capture_path_identity "$candidate-wal")" + candidate_shm_id="$(capture_path_identity "$candidate-shm")" + if ! mv -- "$DB" "$guard"; then + rm -f "$candidate" + fail "cannot preserve target database before import handoff" + fi + IMPORT_GUARD_DB="$guard" + for suffix in -wal -shm; do + if [[ -e "$DB$suffix" || -L "$DB$suffix" ]]; then + mv -- "$DB$suffix" "$guard$suffix" || { + restore_import_guard || true + fail "cannot preserve target sidecar before import handoff" + } + fi + done + if ! assert_guard_sidecar_identity "$guard-wal" "$DB_WAL_ID" || ! assert_guard_sidecar_identity "$guard-shm" "$DB_SHM_ID"; then + restore_import_guard || true + fail "target CarryCtx sidecar changed at the final import boundary; writer was preserved" + fi + guard_id="$(path_identity "$guard" 2>/dev/null || true)" + if [[ "$guard_id" != "$DB_ID" ]] || ! snapshot_matches_source "$guard" "$PRE_IMPORT_ROOT" 0; then + restore_import_guard || true + fail "target CarryCtx state changed at the final import boundary; writer was preserved" + fi + for suffix in -wal -shm; do + if [[ -e "$candidate$suffix" || -L "$candidate$suffix" ]]; then + if [[ -e "$DB" || -L "$DB" ]]; then + restore_import_guard || true + fail "import handoff was refused because the target database changed; writer was preserved" + fi + if ! mv -n -- "$candidate$suffix" "$DB$suffix"; then + restore_import_guard || true + fail "cannot install imported sidecar without overwriting a concurrent writer" + fi + if [[ -e "$candidate$suffix" || -L "$candidate$suffix" ]]; then + restore_import_guard || true + fail "import sidecar handoff was refused because the target changed; writer was preserved" + fi + fi + done + if [[ -e "$DB" || -L "$DB" ]]; then + restore_import_guard || true + fail "import handoff was refused because the target database changed; writer was preserved" + fi + if ! mv -n -- "$candidate" "$DB"; then + restore_import_guard || true + fail "cannot install imported database without overwriting a concurrent writer" + fi + if [[ -e "$candidate" || ! -f "$DB" || -L "$DB" ]]; then + restore_import_guard || true + fail "import handoff was refused because the target database changed; writer was preserved" + fi + if ! assert_captured_path "$DB" "$candidate_db_id" || ! assert_captured_path "$DB-wal" "$candidate_wal_id" || ! assert_captured_path "$DB-shm" "$candidate_shm_id"; then + fail "active database identity changed during handoff; active state and recovery guard were preserved" + fi + DB_ID="$(capture_path_identity "$DB")" + DB_WAL_ID="$(capture_path_identity "$DB-wal")" + DB_SHM_ID="$(capture_path_identity "$DB-shm")" + log "retained pre-import database at $IMPORT_GUARD_DB" + return 0 +} + +validate_hex_file() { + local file="$1" kind="$2" line line_count=0 pattern + if [[ "$kind" == "object" ]]; then + pattern='^[0-9A-F]+\|[0-9A-F]+\|[0-9A-F]*\|[0-9A-F]*$' + else + pattern='^[1-9][0-9]*\|[0-9A-F]+\|[0-9A-F]+$' + fi + while IFS= read -r line || [[ -n "$line" ]]; do + line_count=$((line_count + 1)) + ((${#line} <= MAX_SCHEMA_LINE_BYTES)) || return 1 + if [[ "$kind" == "object" ]]; then + ((line_count <= MAX_SCHEMA_OBJECTS)) || return 1 + else + ((line_count <= REQUIRED_DB_SCHEMA)) || return 1 + fi + [[ "$line" =~ $pattern ]] || return 1 + done <"$file" + ((line_count > 0)) || return 1 + return 0 +} + +write_schema_snapshot() { + local db="$1" output="$2" + sqlite_query "$db" "SELECT hex(type)||'|'||hex(name)||'|'||hex(tbl_name)||'|'||hex(COALESCE(sql,'')) FROM sqlite_master ORDER BY type,name,tbl_name;" >"$output" 2>/dev/null || return 1 + validate_hex_file "$output" object +} + +write_migration_snapshot() { + local db="$1" output="$2" + sqlite_query "$db" "SELECT version||'|'||hex(name)||'|'||hex(checksum) FROM schema_migrations ORDER BY version;" >"$output" 2>/dev/null || return 1 + validate_hex_file "$output" migration +} + +prepare_schema_authority() { + local root="$TMP_ROOT/schema-authority" project authority_timeout="$GIT_TIMEOUT" + [[ -n "$AUTHORITY_DB" ]] && return 0 + project="$root/project" + mkdir -p "$root/home" "$root/data" "$root/config" "$root/state" "$root/cache" || return 1 + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + export GIT_CONFIG_NOSYSTEM=1 + run_timed "$authority_timeout" git init --quiet "$project" + ) >"$TMP_ROOT/authority-git.log" 2>&1; then + return 1 + fi + if ! ( + for name in ${!GIT_@}; do + unset "$name" + done + for name in ${!CARRYCTX_@}; do + unset "$name" + done + export HOME="$root/home" + export GIT_CONFIG_NOSYSTEM=1 + export XDG_DATA_HOME="$root/data" + export XDG_CONFIG_HOME="$root/config" + export XDG_STATE_HOME="$root/state" + export XDG_CACHE_HOME="$root/cache" + run_timed "$authority_timeout" carryctx init --non-interactive --minimal --project "$project" + ) >"$TMP_ROOT/authority-init.log" 2>&1; then + return 1 + fi + AUTHORITY_DB="$project/.git/carryctx/state.sqlite" + [[ -f "$AUTHORITY_DB" && ! -L "$AUTHORITY_DB" ]] || return 1 + return 0 +} + +assert_probe_schema() { + local target_schema="$TMP_ROOT/target-schema" target_migrations="$TMP_ROOT/target-migrations" + local authority_schema="$TMP_ROOT/authority-schema" authority_migrations="$TMP_ROOT/authority-migrations" + [[ -f "$PROBE_SNAPSHOT/state.sqlite" && ! -L "$PROBE_SNAPSHOT/state.sqlite" ]] || return 1 + [[ -f "$AUTHORITY_DB" && ! -L "$AUTHORITY_DB" ]] || return 1 + write_schema_snapshot "$PROBE_SNAPSHOT/state.sqlite" "$target_schema" || return 1 + write_migration_snapshot "$PROBE_SNAPSHOT/state.sqlite" "$target_migrations" || return 1 + if [[ ! -f "$AUTHORITY_SCHEMA" ]]; then + write_schema_snapshot "$AUTHORITY_DB" "$authority_schema" || return 1 + write_migration_snapshot "$AUTHORITY_DB" "$authority_migrations" || return 1 + AUTHORITY_SCHEMA="$authority_schema" + AUTHORITY_MIGRATIONS="$authority_migrations" + fi + cmp -s -- "$target_schema" "$AUTHORITY_SCHEMA" || return 1 + cmp -s -- "$target_migrations" "$AUTHORITY_MIGRATIONS" +} + +extract_import_backup() { + local output="$1" + [[ "$output" != *$'\n'* && "$output" != *$'\r'* ]] || return 1 + printf '%s' "$output" | LC_ALL=C awk ' +{ + if (index($0, "\"command\":\"import.create\"") == 0 || index($0, "\"success\":true") == 0) exit 1 + rest = $0 + count = 0 + path = "" + while (match(rest, /"preImportBackupPath":"[^"]+"/)) { + count++ + token = substr(rest, RSTART, RLENGTH) + sub(/^"preImportBackupPath":"/, "", token) + sub(/"$/, "", token) + path = token + rest = substr(rest, RSTART + RLENGTH) + } + if (count != 1) exit 1 + print path + exit 0 +}' +} + +validate_import_backup() { + local path="$1" parent="${2:-$GIT_COMMON/carryctx/backups}" + [[ -n "$path" && "$path" != *$'\n'* && "$path" != *$'\r'* ]] || return 1 + [[ "$path" == "$parent/"* ]] || return 1 + path_has_symlink "$path" && return 1 + [[ -d "$parent" && ! -L "$parent" && -f "$path" && ! -L "$path" && -r "$path" ]] +} + +assert_import_paths() { + local suffix + assert_project_paths || return 1 + assert_captured_path "$PROJECT" "$PROJECT_ID" || return 1 + assert_captured_path "$GIT_COMMON" "$GIT_COMMON_ID" || return 1 + assert_captured_path "$CARRYCTX_DIR" "$CARRYCTX_DIR_ID" || return 1 + [[ -f "$DB" && ! -L "$DB" ]] || return 1 + path_has_symlink "$DB" && return 1 + assert_captured_path "$DB" "$DB_ID" || return 1 + assert_captured_path "$DB-wal" "$DB_WAL_ID" || return 1 + assert_captured_path "$DB-shm" "$DB_SHM_ID" || return 1 + for suffix in -wal -shm; do + if [[ -e "$DB$suffix" || -L "$DB$suffix" ]]; then + [[ -f "$DB$suffix" && ! -L "$DB$suffix" && -r "$DB$suffix" ]] || return 1 + fi + done + [[ ! -e "$DB-journal" && ! -L "$DB-journal" ]] +} + +refresh_post_stats_paths() { + local suffix + assert_project_paths || return 1 + assert_captured_path "$DB" "$DB_ID" || return 1 + for suffix in -wal -shm; do + if [[ -e "$DB$suffix" || -L "$DB$suffix" ]]; then + [[ -f "$DB$suffix" && ! -L "$DB$suffix" && -r "$DB$suffix" ]] || return 1 + fi + done + [[ ! -e "$DB-journal" && ! -L "$DB-journal" ]] || return 1 + DB_WAL_ID="$(capture_path_identity "$DB-wal")" + DB_SHM_ID="$(capture_path_identity "$DB-shm")" +} + +prepare_pre_import_boundary() { + PRE_IMPORT_ROOT="$TMP_ROOT/pre-import" + PRE_IMPORT_DB="$PRE_IMPORT_ROOT/state.sqlite" + PRE_IMPORT_DUMP="$TMP_ROOT/pre-import.dump" + copy_probe_snapshot "$PROBE_SNAPSHOT/state.sqlite" "$PRE_IMPORT_ROOT" || return 1 + dump_database "$PRE_IMPORT_DB" "$PRE_IMPORT_DUMP" || return 1 + PRE_IMPORT_RAW_DUMP="$TMP_ROOT/pre-import-raw.dump" + dump_raw_database "$PRE_IMPORT_DB" "$PRE_IMPORT_RAW_DUMP" || return 1 + assert_stable_paths || return 1 + assert_no_command_lock || return 1 + assert_probe_snapshot_unchanged || return 1 + return 0 +} + probe_state() { - local db="$1" projects=0 rows=0 expr="" table present - [[ -f "$db" ]] || { - printf '0 0\n' + local db="$1" expected integrity schema_line table_rows table_summary project_line foreign_keys + local table encoded count suffix data_expr="" seen="" table_count=0 projects=0 rows=0 has_migrations=0 has_projects=0 + local schema_count schema_min schema_max schema_distinct schema_invalid + local project_count project_invalid table_total table_distinct table_long table_newline + local attempt slot + + PROBE_RESULT="" + PROBE_SNAPSHOT="" + if [[ -e "$db-wal" || -L "$db-wal" || -e "$db-shm" || -L "$db-shm" ]]; then + for suffix in -wal -shm; do + if [[ -e "$db$suffix" || -L "$db$suffix" ]]; then + [[ -f "$db$suffix" && ! -L "$db$suffix" && -r "$db$suffix" ]] || { + unknown_state + return 0 + } + fi + done + fi + if [[ -e "$db-journal" || -L "$db-journal" ]]; then + unknown_state + return 0 + fi + if [[ ! -e "$db" && ! -L "$db" ]]; then + for suffix in -wal -shm -journal; do + if [[ -e "$db$suffix" || -L "$db$suffix" ]]; then + unknown_state + return 0 + fi + done + carryctx_schema_version >/dev/null || { + unknown_state + return 0 + } + PROBE_RESULT="absent 0 0" + return 0 + fi + if [[ -L "$db" || ! -f "$db" || ! -r "$db" ]]; then + unknown_state + return 0 + fi + have sqlite3 || { + unknown_state + return 0 + } + expected="$(carryctx_schema_version)" || { + unknown_state return 0 } - have sqlite3 || fail "sqlite3 not on PATH (needed to inspect the local state DB); install it or pass a fresh --project" - present="$(timeout "$GIT_TIMEOUT" sqlite3 -readonly "$db" \ - "SELECT name FROM sqlite_master WHERE type='table';" 2>/dev/null || true)" - if grep -qx 'projects' <<<"$present"; then - projects="$(timeout "$GIT_TIMEOUT" sqlite3 -readonly "$db" 'SELECT COUNT(*) FROM projects;' 2>/dev/null || echo 0)" - fi - for table in agents tasks task_dependencies progress_items sessions worktrees \ - checkpoints checkpoint_corrections scopes decisions handoffs events \ - graph_nodes graph_edges teams team_members; do - if grep -qx "$table" <<<"$present"; then - expr+="+(SELECT COUNT(*) FROM \"$table\")" + [[ "$expected" == "$REQUIRED_DB_SCHEMA" ]] || { + unknown_state + return 0 + } + + for ((attempt = 1; attempt <= SNAPSHOT_RETRIES; attempt++)); do + slot="$(mktemp -d "$TMP_ROOT/probe.XXXXXX" 2>/dev/null)" || { + unknown_state + return 0 + } + if copy_probe_snapshot "$db" "$slot"; then + break fi + rm -rf "$slot" + slot="" done - if [[ -n "$expr" ]]; then - rows="$(timeout "$GIT_TIMEOUT" sqlite3 -readonly "$db" "SELECT 0${expr};" 2>/dev/null || echo 0)" + [[ -n "$slot" && -f "$slot/state.sqlite" ]] || { + unknown_state + return 0 + } + local probe_db="$slot/state.sqlite" + if ! integrity="$(sqlite_query "$probe_db" 'PRAGMA integrity_check;' 2>/dev/null)" || + [[ "$integrity" != "ok" || "$integrity" == *$'\n'* ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + if ! schema_line="$(sqlite_query "$probe_db" "SELECT COUNT(*), COALESCE(MIN(version), 0), COALESCE(MAX(version), 0), COUNT(DISTINCT version), COALESCE(SUM(CASE WHEN typeof(version) = 'integer' AND version > 0 AND typeof(name) = 'text' AND length(trim(name)) > 0 AND typeof(checksum) = 'text' AND length(checksum) = 64 AND checksum NOT GLOB '*[^0-9a-f]*' AND typeof(applied_at) = 'text' AND length(trim(applied_at)) > 0 THEN 0 ELSE 1 END), 0) FROM schema_migrations;" 2>/dev/null)" || + [[ "$schema_line" == *$'\n'* ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + IFS='|' read -r schema_count schema_min schema_max schema_distinct schema_invalid <<<"$schema_line" + if ! is_count "$schema_count" || ! is_count "$schema_min" || ! is_count "$schema_max" || + ! is_count "$schema_distinct" || ! is_count "$schema_invalid" || + [[ "$schema_count" != "$REQUIRED_DB_SCHEMA" || "$schema_min" != "1" || + "$schema_max" != "$REQUIRED_DB_SCHEMA" || "$schema_distinct" != "$REQUIRED_DB_SCHEMA" || + "$schema_invalid" != "0" ]]; then + rm -rf "$slot" + unknown_state + return 0 fi - printf '%s %s\n' "$projects" "$rows" + if ! foreign_keys="$(sqlite_query "$probe_db" 'SELECT COUNT(*) FROM pragma_foreign_key_check;' 2>/dev/null)" || + ! is_count "$foreign_keys" || [[ "$foreign_keys" != "0" ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + if ! project_line="$(sqlite_query "$probe_db" "SELECT COUNT(*), COALESCE(SUM(CASE WHEN typeof(id) = 'text' AND length(trim(id)) > 0 AND typeof(name) = 'text' AND length(trim(name)) > 0 AND typeof(task_prefix) = 'text' AND length(trim(task_prefix)) > 0 AND typeof(repository_root) = 'text' AND length(trim(repository_root)) > 0 AND typeof(git_common_dir) = 'text' AND length(trim(git_common_dir)) > 0 AND typeof(main_branch) = 'text' AND length(trim(main_branch)) > 0 AND typeof(schema_version) = 'integer' AND schema_version > 0 AND typeof(created_at) = 'text' AND length(trim(created_at)) > 0 AND typeof(updated_at) = 'text' AND length(trim(updated_at)) > 0 THEN 0 ELSE 1 END), 0) FROM projects;" 2>/dev/null)" || + [[ "$project_line" == *$'\n'* ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + IFS='|' read -r project_count project_invalid <<<"$project_line" + if ! is_count "$project_count" || ! is_count "$project_invalid" || + [[ "$project_count" -gt 1 || "$project_invalid" != "0" ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + projects="$project_count" + if ! table_summary="$(sqlite_query "$probe_db" "SELECT COUNT(*), COUNT(DISTINCT name), COALESCE(SUM(CASE WHEN length(CAST(name AS BLOB)) > $MAX_SCHEMA_NAME_BYTES THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN instr(name, char(10)) > 0 THEN 1 ELSE 0 END), 0) FROM sqlite_master WHERE type = 'table';" 2>/dev/null)" || + [[ "$table_summary" == *$'\n'* ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + IFS='|' read -r table_total table_distinct table_long table_newline <<<"$table_summary" + if ! is_count "$table_total" || ! is_count "$table_distinct" || ! is_count "$table_long" || ! is_count "$table_newline" || + ((table_total > MAX_SCHEMA_TABLES)) || [[ "$table_distinct" != "$table_total" || "$table_long" != "0" || "$table_newline" != "0" ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + if ! table_rows="$(sqlite_query "$probe_db" "SELECT hex(name) FROM sqlite_master WHERE type = 'table' ORDER BY name;" 2>/dev/null)"; then + rm -rf "$slot" + unknown_state + return 0 + fi + while IFS= read -r encoded; do + table_count=$((table_count + 1)) + if ((table_count > MAX_SCHEMA_TABLES || ${#encoded} > MAX_SCHEMA_NAME_BYTES * 2)) || [[ -z "$encoded" || ! "$encoded" =~ ^[0-9A-Fa-f]+$ ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + if [[ "$seen" == *"|$encoded|"* ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + seen+="|$encoded|" + if ! table="$(decode_hex_name "$encoded")" || + [[ -z "$table" || ! "$table" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + case "$table" in + schema_migrations) + has_migrations=1 + continue + ;; + projects) + has_projects=1 + continue + ;; + esac + data_expr+="+(SELECT COUNT(*) FROM \"$table\")" + done <<<"$table_rows" + if [[ "$table_count" != "$table_total" || "$has_migrations" != 1 || "$has_projects" != 1 ]]; then + rm -rf "$slot" + unknown_state + return 0 + fi + if [[ -n "$data_expr" ]]; then + if ! count="$(sqlite_query "$probe_db" "SELECT COALESCE(0${data_expr}, 0);" 2>/dev/null)" || ! is_count "$count"; then + rm -rf "$slot" + unknown_state + return 0 + fi + [[ "$count" == "0" ]] || rows=1 + fi + if ! prepare_schema_authority; then + rm -rf "$slot" + unknown_state + return 0 + fi + PROBE_SNAPSHOT="$slot" + if ! assert_probe_schema || ! snapshot_matches_source "$db" "$slot" 0; then + PROBE_SNAPSHOT="" + rm -rf "$slot" + unknown_state + return 0 + fi + if [[ "$projects" == "0" && "$rows" == "0" ]]; then + PROBE_RESULT="empty 0 0" + else + PROBE_RESULT="non-empty $projects $rows" + fi + return 0 } -GIT_COMMON="$(timeout "$GIT_TIMEOUT" git -C "$PROJECT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || - fail "cannot resolve the git common dir for $PROJECT" -DB="$GIT_COMMON/carryctx/state.sqlite" -STATE_LINE="$(probe_state "$DB")" || fail "cannot inspect local CarryCtx state" -read -r PROJECTS ROWS <<<"$STATE_LINE" -log "local state: db_exists=$([[ -f "$DB" ]] && echo 1 || echo 0) project_rows=$PROJECTS data_rows=$ROWS" +if ! prepare_version_contract; then + VERSION_CONTRACT_READY=0 +fi -if [[ "$PROJECTS" -gt 1 ]]; then - fail "local DB has $PROJECTS project rows; refusing to replace an ambiguous state DB" +GIT_COMMON_RAW="$(run_timed "$GIT_TIMEOUT" git -C "$PROJECT" rev-parse --git-common-dir 2>/dev/null)" || + fail "cannot resolve the git common dir for $PROJECT" +[[ -n "$GIT_COMMON_RAW" && "$GIT_COMMON_RAW" != *$'\n'* ]] || + fail "git returned an ambiguous common directory" +if [[ "$GIT_COMMON_RAW" == /* ]]; then + GIT_COMMON_INPUT="$GIT_COMMON_RAW" +else + GIT_COMMON_INPUT="$PROJECT/$GIT_COMMON_RAW" fi -if [[ "$DRY_RUN" == 0 && "$PROJECTS" -ge 1 && "$ROWS" -gt 0 && "$FORCE" == 0 ]]; then - fail "local DB already holds $ROWS data row(s); refusing to overwrite non-empty state without --force (local DB untouched)" +GIT_COMMON_LOGICAL="$(cd -L "$GIT_COMMON_INPUT" 2>/dev/null && pwd -L)" || + fail "cannot resolve the git common dir for $PROJECT" +GIT_COMMON="$(cd -P "$GIT_COMMON_INPUT" 2>/dev/null && pwd -P)" || + fail "cannot resolve the git common dir for $PROJECT" +[[ "$GIT_COMMON_LOGICAL" == "$GIT_COMMON" ]] || + fail "git common directory path contains a symlink" +CARRYCTX_DIR="$GIT_COMMON/carryctx" +DB="$CARRYCTX_DIR/state.sqlite" +path_has_symlink "$DB" && fail "local CarryCtx database path contains a symlink" +path_has_symlink "$DB-wal" && fail "local CarryCtx WAL sidecar path contains a symlink" +path_has_symlink "$DB-shm" && fail "local CarryCtx SHM sidecar path contains a symlink" +PROJECT_ID="$(path_identity "$PROJECT")" || fail "cannot identify project path" +GIT_COMMON_ID="$(path_identity "$GIT_COMMON")" || fail "cannot identify git common path" +CARRYCTX_DIR_ID="$(capture_path_identity "$CARRYCTX_DIR")" +CFG_ID="$(capture_path_identity "$CFG")" +DB_ID="$(capture_path_identity "$DB")" +DB_WAL_ID="$(capture_path_identity "$DB-wal")" +DB_SHM_ID="$(capture_path_identity "$DB-shm")" +assert_stable_paths || fail "project paths changed during setup" +assert_no_command_lock || fail "local CarryCtx operation is already in progress" +probe_state "$DB" || fail "cannot inspect local CarryCtx state" +STATE_LINE="$PROBE_RESULT" +if ! read -r STATE PROJECTS ROWS <<<"$STATE_LINE" || + [[ ! "$PROJECTS" =~ ^[0-9]+$ || ! "$ROWS" =~ ^[0-9]+$ ]]; then + fail "cannot classify local CarryCtx state" fi +log "local state: db_exists=$([[ -e "$DB" || -L "$DB" ]] && echo 1 || echo 0) state=$STATE project_rows=$PROJECTS data_rows=$ROWS" + +case "$STATE" in +absent | empty) + ;; +non-empty) + if [[ "$DRY_RUN" == 0 && "$FORCE" == 0 ]]; then + fail "local CarryCtx state is non-empty; refusing to overwrite without --force (local DB untouched)" + fi + ;; +unknown) + fail "local CarryCtx state is unknown; refusing to initialize or import" + ;; +*) + fail "cannot classify local CarryCtx state" + ;; +esac log "fetching $REMOTE $SNAP_BRANCH -> $TRACK_REF" -if ! timeout "$GIT_TIMEOUT" git -C "$PROJECT" fetch "$REMOTE" \ +assert_stable_paths || fail "project paths changed before fetch" +assert_no_command_lock || fail "local CarryCtx operation is already in progress" +assert_probe_snapshot_unchanged || fail "local CarryCtx state changed before fetch" +if ! run_timed "$GIT_TIMEOUT" git -C "$PROJECT" fetch "$REMOTE" \ "refs/heads/carryctx-snapshots:refs/remotes/$REMOTE/carryctx-snapshots"; then fail "git fetch failed (has refs/heads/carryctx-snapshots been published? network/auth?)" fi +assert_stable_paths || fail "project paths changed during fetch" +assert_no_command_lock || fail "local CarryCtx operation started during fetch" +assert_probe_snapshot_unchanged || fail "local CarryCtx state changed during fetch" print_provenance() { local commit export_id source_line message - commit="$(timeout "$GIT_TIMEOUT" git -C "$PROJECT" rev-parse --short "$TRACK_REF" 2>/dev/null || echo '?')" - message="$(timeout "$GIT_TIMEOUT" git -C "$PROJECT" log -1 --format=%B "$TRACK_REF" 2>/dev/null || true)" + commit="$(run_timed "$GIT_TIMEOUT" git -C "$PROJECT" rev-parse --short "$TRACK_REF" 2>/dev/null || printf '?')" + message="$(run_timed "$GIT_TIMEOUT" git -C "$PROJECT" log -1 --format=%B "$TRACK_REF" 2>/dev/null || true)" export_id="$(sed -n 's/^CarryCtx-Export-Id: //p' <<<"$message" | head -n1)" source_line="$(sed -n 's/^CarryCtx-Source: //p' <<<"$message" | head -n1)" log "provenance: snapshot=$commit export_id=${export_id:-?} source=${source_line:-?}" } if [[ "$DRY_RUN" == 1 ]]; then - if ! timeout "$GIT_TIMEOUT" carryctx import --from-git "$TRACK_REF" \ - --mode replace --yes --dry-run --project "$PROJECT" >"$TMP_ROOT/import.json" 2>"$TMP_ROOT/import.err"; then - cat "$TMP_ROOT/import.err" >&2 2>/dev/null || true - fail "carryctx import --dry-run failed; local DB untouched" + assert_stable_paths || fail "project paths changed before dry-run" + assert_no_command_lock || fail "local CarryCtx operation is already in progress" + assert_probe_snapshot_unchanged || fail "local CarryCtx state changed before dry-run" + if ! run_dry_run_validation; then + cat "$TMP_ROOT/dry-run.err" >&2 2>/dev/null || true + fail "carryctx import --dry-run failed in isolated validation state; local DB untouched" fi + assert_stable_paths || fail "project paths changed after dry-run" + assert_no_command_lock || fail "local CarryCtx operation started during dry-run" + assert_probe_snapshot_unchanged || fail "local CarryCtx state changed during dry-run" print_provenance - if [[ "$PROJECTS" -ge 1 && "$ROWS" -gt 0 ]]; then - log "dry-run PASS: snapshot fetched + validated; local DB has $ROWS data row(s), a real run needs --force" + if [[ "$STATE" == "non-empty" ]]; then + log "dry-run PASS: snapshot fetched + validated in isolation; local DB is non-empty, a real run needs --force" else - log "dry-run PASS: snapshot fetched + validated; nothing imported" + log "dry-run PASS: snapshot fetched + validated in isolation; local DB untouched" fi exit 0 fi +assert_stable_paths || fail "project paths changed before local backup" +assert_no_command_lock || fail "local CarryCtx operation is already in progress" +assert_probe_snapshot_unchanged || fail "local CarryCtx state changed before local backup" if [[ -f "$CFG" ]]; then - cp -p "$CFG" "$TMP_ROOT/config.toml.before" || fail "cannot back up $CFG" + cp -p -- "$CFG" "$TMP_ROOT/config.toml.before" || fail "cannot back up $CFG" CFG_BACKUP="$TMP_ROOT/config.toml.before" fi @@ -207,27 +1368,56 @@ fi # documented fresh-clone path never depends on importer auto-init. The # committed `.carryctx/config.toml` (when present) is restored byte-identically # below, because `carryctx init` rewrites it with current config defaults. -if [[ "$PROJECTS" -eq 0 ]]; then - log "no project row in local DB: initializing CarryCtx state (carryctx init --non-interactive)" - if ! timeout "$GIT_TIMEOUT" carryctx init --non-interactive --project "$PROJECT" >"$TMP_ROOT/init.log" 2>&1; then +if [[ "$STATE" == "absent" || "$STATE" == "empty" ]]; then + assert_stable_paths || fail "project paths changed before initialization" + assert_no_command_lock || fail "local CarryCtx operation is already in progress" + assert_probe_snapshot_unchanged || fail "local CarryCtx state changed before initialization" + log "local CarryCtx state is known-empty: initializing CarryCtx state (carryctx init --non-interactive)" + if ! run_timed "$GIT_TIMEOUT" carryctx init --non-interactive --project "$PROJECT" >"$TMP_ROOT/init.log" 2>&1; then cat "$TMP_ROOT/init.log" >&2 2>/dev/null || true fail "carryctx init failed" fi + assert_project_paths || fail "unsafe CarryCtx path after initialization" + [[ -f "$DB" && ! -L "$DB" ]] || fail "CarryCtx initialization did not create a safe database" + CARRYCTX_DIR_ID="$(capture_path_identity "$CARRYCTX_DIR")" + CFG_ID="$(capture_path_identity "$CFG")" + DB_ID="$(capture_path_identity "$DB")" + DB_WAL_ID="$(capture_path_identity "$DB-wal")" + DB_SHM_ID="$(capture_path_identity "$DB-shm")" + probe_state "$DB" || fail "cannot inspect initialized CarryCtx state" + read -r STATE PROJECTS ROWS <<<"$PROBE_RESULT" || fail "cannot classify initialized CarryCtx state" + [[ "$STATE" == "non-empty" && "$PROJECTS" == "1" ]] || fail "CarryCtx initialization did not produce a project state" fi -if [[ "$PROJECTS" -ge 1 && "$ROWS" -gt 0 ]]; then - warn "--force: replacing $ROWS existing local data row(s) in $PROJECT" +if [[ "$STATE" == "non-empty" ]]; then + warn "--force: replacing existing local CarryCtx state in $PROJECT" fi -log "importing snapshot $TRACK_REF into $PROJECT (replace mode)" -if ! timeout "$GIT_TIMEOUT" carryctx import --from-git "$TRACK_REF" \ - --mode replace --yes --project "$PROJECT" >"$TMP_ROOT/import.json" 2>"$TMP_ROOT/import.err"; then - cat "$TMP_ROOT/import.err" >&2 2>/dev/null || true - fail "carryctx import failed; local DB is left in the state carryctx reports above" +assert_stable_paths || fail "project paths changed before import" +assert_no_command_lock || fail "local CarryCtx operation is already in progress" +assert_probe_snapshot_unchanged || fail "local CarryCtx state changed before import" +prepare_pre_import_boundary || fail "cannot create a verified pre-import boundary" +log "importing snapshot $TRACK_REF into isolated validation state" +if ! run_staging_import; then + cat "$TMP_ROOT/staging-import.err" >&2 2>/dev/null || true + fail "carryctx import failed in isolated state; target CarryCtx state was not modified" fi +if ! reanchor_staging_project; then + fail "isolated CarryCtx import could not be re-anchored to the target project" +fi +if ! validate_staging_import; then + cat "$TMP_ROOT/staging-import.err" >&2 2>/dev/null || true + fail "isolated CarryCtx import could not be verified; target CarryCtx state was not modified" +fi +commit_staged_import +assert_import_paths || fail "project paths changed during import; manual recovery required" restore_config || exit 1 print_provenance +run_timed "$GIT_TIMEOUT" carryctx stats --project "$PROJECT" || fail "carryctx stats failed" +refresh_post_stats_paths || fail "active CarryCtx state changed during post-handoff statistics" +if ! validate_handoff_state; then + fail "post-handoff committed-state validation failed; active state and recovery guard were preserved" +fi log "restore complete; local counts:" -timeout "$GIT_TIMEOUT" carryctx stats --project "$PROJECT" || fail "carryctx stats failed" diff --git a/tests/workflow-import.test.ts b/tests/workflow-import.test.ts index a7602b7..5001461 100644 --- a/tests/workflow-import.test.ts +++ b/tests/workflow-import.test.ts @@ -1,75 +1,796 @@ import { describe, expect, test } from "bun:test"; import { + appendFileSync, chmodSync, + copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, + renameSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; + +type FixtureOptions = { + database?: "absent" | "empty" | "non-empty" | "directory"; + rowTable?: string; + projectRows?: number; + sqliteMode?: string; + sqliteAvailable?: boolean; + realFts?: boolean; + realNewlineTable?: boolean; + realSchemaDrift?: boolean; + realMigrationDrift?: boolean; + realWal?: boolean; + walWithoutShm?: boolean; + freshWal?: boolean; + sidecars?: "wal" | "shm" | "both"; + authorityFailure?: boolean; + commandLock?: boolean; + race?: + | "database" + | "config" + | "import" + | "import-path" + | "carryctx-dir" + | "import-inode" + | "fetch-shm-inode" + | "guard-wal" + | "guard-after-validation"; + descendant?: boolean; + noSetsid?: boolean; + databaseSymlink?: boolean; + sidecarSymlink?: boolean; + orphanSidecar?: boolean; + configSymlink?: boolean; + carryctxSymlink?: boolean; + projectSymlink?: boolean; + projectParentSymlink?: boolean; + schemaVersion?: number; + migrationCount?: number; + migrationMin?: number; + migrationMax?: number; + migrationDistinct?: number; + migrationInvalid?: number; + projectInvalid?: number; + versionOutput?: string; + versionFails?: boolean; + versionMutatesTarget?: boolean; + gitTimeout?: number; + force?: boolean; + dryRun?: boolean; +}; const script = resolve(import.meta.dir, "../scripts/workflow-import.sh"); const original = "[project]\nname = 'benign-fixture'\n"; +const currentSchemaObjects = "74|6E|74|"; +const authoritySchemaBase64 = + "H4sIAAAAAAAC/+0ca2/jxvG7fwV7XyQFuoMPaAMU7QXQ2bSjxpFTyU4uCFKCIlcSY4pkSMq2WvS/d/b95EOyUjeHBriY5M7Mzs7Ozs7Mzupi7k/ufO9u8vHG96pog7ZhsE3WZVgneVZ5wzPPe0RlBS/edHbnX/tz77v59NvJ/EfvG/9H7+Jr/+Ibb8hBvvLOR2NAycIt8u78T3fe7Bb+3d/ccMgUZet6M6zLZDvEUKORQILOo4dqt3UjAoDnMWwOOfI+eF/+kbRMZpeSAMa9vrn96A2++Okf52//HL5d/fzFAOBIP2FRpAmKg7DuZlHCMkbPRn85u1BlVpT5LyiqqaiSmJJ0iEilmsTKsI+SVR1WD0FRolXy3I2rACskSlTkVVLn5T4o89yUxf1s+vd730XNwFMorpM6iPLtNs+COCl7E9TRFHrbMMmCZRlm0aZ7kAqwQoJptKnCJiEDTGhkicK6p6ZIWKX7XRH3JiBhm1QtL5C6Lo9Rtocki7tZwVCqEGtgzI1Fm6YzbzgABSvCEsWDsTeA6SxSVNOXVZik8DQitIpwn+ZhHPxSgZydJHFL8BimSTxUgSk6prUrEehLTDl6/WnCBFQbxWTyQQyc2CaN8elCdoEbVfoqIFcETPZ23kPaI3df0A8mYmsUekTZC0wXs31BYurU3L/y5/7swl8I+4gxvduZd+nf+ND/3F/czacXZP7qfdHDAmIope9wvS7RGuQR9EPX4Z2EkvgQMrokXqbWeRTtyrKnBirAXEHGXhiBRQ7CNcynGIcyC6SBzMHYq1CFrRwHG9PNxIGEvxMcqTbT2aX/CXTlOZDGKCBqGbCFAaOBWZatVGnH6hq79BcXLpJUGwNlfJQY/a6Ou4sEnuJGOrhx7B1Ajat5E0G5DNrIgrpf472HES0RwaFiOfvoX93Ofe/+u0sMKkjD9+vpDDpbwKq5gMmZTBf+cPLxdg7TNmCrF8wA9mlQFr/Ns3Q/gC792WVHvzHCFoP3yxblafulLiX6dYeyCFEj8wKDcTFZXEwu/aM3sQw913j97ZDtBVz6V5P7mzvvPaejAH/1wXtPCKiGUZty0pFlWsmiihEWD4w/Qa9iZRWhaYvcRUKsdjc+bDglzGUCrh+Y0BcSc8wgn4NBVZd5th5wsRBQsuWxBlDAJFvl5ZbYlzBljsWLnQAG5xznHz5w+akTzVxaai+s+Q5QvAar+Cs1FVbzkBEcOyVrWiNu13CrMDFgonTDximaRmjsqRRF3AI7WVUF0Ou2UTlPp39xUhVpuLepUCmeQkWrfFdGKDD2N0Kl3c0gLUTH6jzOsYYt0zx6QCV+LJPqAf/N8hoxXcNb2q5q9oihjVCDXTCz3OESbfNH4Q9HeVbD5PXQWQqo2gUEo81q2NqlRVOQ1WbqAxIfUvkMpu2cOIzKt7+CuTs/Z746jvNcYVPbemvzp+mAmSxkK2thgtG+n5kLQagtURYq7IAzKleErt1yZVCEsRiaa2U85eVDXSJ00kVh6bdLrck+hS1bmvwTBFGE9cYWYFuUjduVgBm/blAYixeMW2VhUW3yWvqqBCvfZYdNZpMhFOITfhMehzCEolnbQ41Rd5IGdzd5ZNaynbKw2573w9cgcTELShBm+SzEgJx8/nXv3IWhuOl99YWP2gUoJcKAGyN5NbSkssWmqgh3FTVaeNsiDwCXkrZwGWZxnsnAvswfkxg5FBJzkWRrrK0IRyn7w3R2i+oQNDB0xVbCafjXvweOQEtDFaa7bDJNaVjVVLOSeu+EIGLgLYTcbrsN2Yh6LhaqylzHwB+v8nSnWy/eqCky156xOuXUokE4Y/FuBB9EsUmCtMiTlpj/JLatde8mYjO2aAVOjP0Y/dZWWJdigT7W++Zg4JypyxqmcwWJnKpVAX/6eUCUNY+TVXIIBg3EDkAoEc4FH4Cwy+oyBG/mEKaS1YpCc/mIr0lWoZImIc0mMhSrBWwE3YX7DQ7ncbG16I3CXLV+5MGX6wVIAsCqRkU/aHANewE2OU2QwHmMqmCJp6k5MoLdGyxvtAmz3jppmh3FAtDNk3MkbI8CId0mhW+XXaki8HRf2aSA21Cj0pl80/xoBtcjJ66EnkkWpbuY7HvomT62R522vSci4uHdM7Hy5JMUMskjGJKFg7hiE2SQyK1YerlBwFgJY1culDVah0z0O9CNyqQgXj63i3LLNTC8t2+9vy1uZwSmzf8/YB/EaTZlkHQAIBzl21CMrAuZjNJExgdnTqni+JxLlYWOprbRwYDLsEZ1Q2OJUpKIaBR9u6B463J/oPy1dJRgfyyZHeusjVpETyQRUEwpP/J1KOh1ofPeTAI6F4LI99P53f3kRsmWVcEK/JL7xXR27cHTn4Z1Uqfg2ig62oSuh3smHR46WylRGy0IE29ydQdt09nCn9/ZsaTHU6MMALa6WwehYZk/EcvJ+gaE7yc392C1ILn49I614kfJnStr62IxZizKrG0Diwzgan77rUtENB4irMBxVZ7GlK3+jOwYIzJtfWJGfmsp61qkbo6GCi3zeG/rj4HgUh7V5XZpjkGCD4j0p4yGGnExJHrQSP4vcvTrMt8VkBnIorAektw17FYeHAkSqXNgAU6z26SNREwojDZEaIbDNhKIEIrDgCZgxPuScnlzL6En/SxOhf4lh5tutbWmyF48rilSFdZUi4OWjdW/vWZO1v//Neu/olnEVryJUZSQQPXN7yaU7pOCx1tupw9NoNTKLWxcn2VWhMsmwMomyzSAFXEayD+W7BRHVnOEKfbPQ5yD6hVTQYR+UOy13NNjckdqWkIw0Sl5HsiRQy5M99SOyYFbbphQJCsgw8ZBtPYIxvTdTNI19jI+zcrMWZNmzpc2VWf2Tqh15toHBYBzF9TQuaVi7h9n0WBQZ08y1+IDMILCHcBEiV3SCbN2hTihIzpwG3pDAvY2Y0tANfL6bB20xRg92xvMSXr+fCaMBhwo3L72kfxxVbJw9AOZd2QU+/xv1MGx0xotb802H1cTGVoz3tgxWAKOa1am1zO7FKMBh2zl6gYKkx9s0XYJNl0/LMINarLdUdeh4J6wpoX1bO8j7kMjYo9zvlWz6SEflLNerWo313fsV1eVxnIaew5ap5yBj8z5rUwldMu9ka7o3XE+14uyS6flGQ5n29KvN8S7e/PKpqnRVXR0L2HVAvUmR9Jr9SRd+UhntYXw7qDrDJ9CuuoveBupugjjPa0dCngeQRZ6MJDHBD1Z9RoRnCChVKlgLpO8hEO2JoboUfZA1hMxcMJRmj/RWhICA0+bZL3Bf3clVg3WRf6UoV7FnMrkLXxpGXBlMKB2nh83oJ/2xPX1TY1x5uusQqGHT1CFhcumbMuqtTSZWBVIXQqKaT+5KePz1laWJisx5FKFqglelqaTV1azEajopFjpDVVVFqzY1Hi9ja7RvXhN4jY2HexhSQlsrgicM0uMHfXI6jYfkBcHxXYfoqsHbbQEi8m0UZqCNAW0wzCRRneFYKTRGX4JNMOTVzPvPXx0PVHvClUU/uwASedPDVHk6cBBgZHSmx0Uvai31xIfUfEoRWFJjTv+YNdwE7AzYH7m+Z+mizvoleW/3isjVMqhqneK1/DBu725VD/gCkEKxS0ZBcHeC5cfkysljG2SBCWmkvbV2YtBv1kKYmmycnbp9JMqwjB1S4WjtQqHRIZMOPj5XR+2q3eilY9CNjliNgoqw4zWgntwQSBByQokPVLbC/krTFtGO4PRMdJyXz+48lod8s9DmsfIkix0LsQyr3T7zcVomFwqnpn/gxiLef8LvxwlPky0WXxKlx0iwXxSGWx3Ve0tUQqV9l6dkwFwZThSKofo2OcvMxrjUQ/+ze8x/+Su85RxwcBFgyOp149zJecvS3C0Wx/Sz5alOHKR4mrU3TJU4qb2SJHVtboCRVnymmTyOUbkOaxF7Pe5RUayahRxIk2BBFVZsbKxVjDn3JEU0Ytu2ugIF9+dWjEWzgbXHK9WJy81W5X5Nji4PDvvdd/yFBdb+p/x9arwxreQEnqVKowiVLBMB6TV04TlS9BzkfDrxWleiRXQ4wxRrYk2rn6QHH7X4rEKdY0i3mNvnvChqiE/H7L6jQ69ciwIqsFcCeUVFMRDNt6kXzhBzRSkChn4ssGiYRargDtX4sr6risLCkabLqoFoC23AQ9fZK5K4NaaX3d1r6OOt6li11GbSzsM1Vcmvj5ntW6hB1Hhqp9VQYaa8Md6n93ZAb1K13NnBzqKdEUhPY4fMyjowPkqVNWvfQrWu8C/IVsp8O3rUq4RaODKYAwjc2zuVNEtiw3WRi8akrN+Nc8MXs1O3Gbt+ZMb0o6Xuyxjj0pK2/U7HI6UNsMIjIWBK6W3BdZjuELQclGCc6bD49uFI55WBT3r6aeo0JanwjpoS+Raa9bSeyY1uWQbV4aVykRjbTSdXfFy70P6sW4AO6/ACRrMezMvwbX3oaw5eTGut2JZR6L5dlnVYN1PeiAaLlMU9AtRJKwWYDxhFvB/3UpHYLVzKOOOatv5l4B1EFCqhdjq4gy1H4KKEY3ZQOyTQnGRk07cCX9fAe153Vz3ryygvTJqWl9nY/62J8aYByyd/wCBW/vlzEwAAA=="; +const currentMigrations = [ + "1|0001_foundation|a99535ad2f10c5b40f2866d1486de8797137387ec34ff7517c60e291a0f2df7b", + "2|0002_work_model|64825504899f4a8ed5c12d1c74ce5111af05e349760406fd016d2a30205f65b2", + "3|0003_progress|7f8e37642a709cb3777e1115089093dce9ce5f338e67b002cc882a37644b20a2", + "4|0004_worktrees_sessions|0c04b1d12d31c5d813746428e48d5c82350a8d3e49b7f197762a3c3cdf8bfbd6", + "5|0005_checkpoints|bf6a6a6c65b6fd5f38f337e5cdbdada181010e1cc444d5c02e5d86a72e7f6d02", + "6|0006_collaboration|9c4cc4f87092188b2f5be346f24ecec8a7de31f4889efaf6a70dca6923b077f2", + "7|0007_context_graph|0c8c136e1939b8b2603f6618591270431fd664ebdec2a45be97f01755f7f44d2", + "8|0008_jj_compat|a0af35f9442fc171f4bb957b4aec534ca625d4923ce27e0d60832f4c31a46d96", + "9|0009_search|1ef479e5a7bd7530306ce36ce8cbcf4d7bdb01c2f2da4eb92aff5a925464babe", + "10|0010_decision_rationale|0ca5559cb1302a2d9f3988c77061b60ba88dfbe6ddda35bb9df5d1c5d99e4787", + "11|0011_backfill_session_ended_at|80fb48cefa7614bedbba3783e5ab75d9a68e5c5f178c6d6620026ed93718f5d2", + "12|0012_agent_teams|03a2a5e979991567ea92ba906e4601f1d13004f5dde3887a47f3a8486c8bfb8b", + "13|0013_agent_kind_constraint|270672953d22c3ec60d33c8c1dfaa157867e9c46eaa075b34f1c910f7340864f", + "14|0014_cascade_task_refs|394edcd0184c1c96c17329f4c140b2edfd6633e09716d5dd2ad58c1a184ba796", + "15|0015_agent_name_unique|1ab8e410be54b62c2e9afe02b0848c70143161fa4b730d6ad6a4b727b8086d3e", + "16|0016_task_list_index|8ac609f74238325c25cc5da357e6609a792e50fbd7b1e4a4ef8fbd42c9b04342", + "17|0017_worktree_cleanup_requests|2fe11628e1e59889a1413f77df4fc0b766cd533d071c466998b4d376ed8aa451", + "18|0018_tombstones_snapshot_state|6eb192c81bd28ec7d5d4dee6dfb6ff1ffb992f742ef0c714fa4865b5a22c4e7e", +]; +const currentMigrationSnapshot = currentMigrations + .map((row) => { + const [version, name, checksum] = row.split("|"); + return `${version}|${Buffer.from(name).toString("hex").toUpperCase()}|${checksum.toUpperCase()}`; + }) + .join("\n"); + +const currentTables = [ + "schema_migrations", + "projects", + "operations", + "events", + "sequences", + "agents", + "tasks", + "task_dependencies", + "progress_items", + "worktrees", + "sessions", + "checkpoints", + "checkpoint_corrections", + "scopes", + "decisions", + "handoffs", + "graph_nodes", + "graph_edges", + "teams", + "team_members", + "worktree_cleanup_requests", + "tombstones", + "snapshot_state", + "tasks_fts", + "tasks_fts_config", + "tasks_fts_content", + "tasks_fts_data", + "tasks_fts_docsize", + "tasks_fts_idx", + "checkpoints_fts", + "checkpoints_fts_config", + "checkpoints_fts_content", + "checkpoints_fts_data", + "checkpoints_fts_docsize", + "checkpoints_fts_idx", + "progress_items_fts", + "progress_items_fts_config", + "progress_items_fts_content", + "progress_items_fts_data", + "progress_items_fts_docsize", + "progress_items_fts_idx", + "decisions_fts", + "decisions_fts_config", + "decisions_fts_content", + "decisions_fts_data", + "decisions_fts_docsize", + "decisions_fts_idx", +]; + +function realVersionEnvelope(dbSchema: number) { + return JSON.stringify({ + schema_version: 1, + command: "version", + success: true, + data: { + contract_versions: { + cli: "0.11.6", + ctxpack_format: { + format: "carryctx-pack-dir", + format_version: 2, + }, + db_schema: dbSchema, + skill_surface: { + min_carryctx: "0.11.6", + skill: "use-carryctx", + version: "1.3.0", + }, + }, + }, + meta: { + timestamp: "2026-09-24T09:00:00Z", + }, + }); +} + +function createDisposableAuthorityFixture(root: string) { + const python = Bun.which("python3"); + if (!python) throw new Error("Missing fixture utility: python3"); + const path = join(root, "authority.sqlite"); + const program = ` +import base64 +import gzip +import json +import os +import sqlite3 +import sys +path = sys.argv[1] +schema = gzip.decompress(base64.b64decode(sys.argv[2])).decode() +migrations = json.loads(sys.argv[3]) +if len(migrations) != 18: + raise RuntimeError("expected 18 migration rows") +connection = sqlite3.connect(path) +connection.execute("PRAGMA journal_mode=DELETE") +connection.execute("PRAGMA user_version=18") +connection.executescript(schema) +for row in migrations: + version, name, checksum = row.split("|") + connection.execute( + "INSERT INTO schema_migrations(version, name, checksum, applied_at) VALUES (?, ?, ?, ?)", + (int(version), name, checksum, "2026-01-01T00:00:00Z"), + ) +connection.execute( + "INSERT INTO projects(id, name, task_prefix, repository_root, git_common_dir, main_branch, schema_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("fixture-project", "fixture", "FIX", "/fixture/project", "/fixture/git", "main", 4, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), +) +object_count = connection.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] +fts_count = connection.execute("SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%fts%'").fetchone()[0] +if object_count != 129 or fts_count != 36: + raise RuntimeError("expected 129 schema objects and 36 FTS-related objects") +connection.commit() +os._exit(0) +`; + const result = Bun.spawnSync( + [ + python, + "-c", + program, + path, + authoritySchemaBase64, + JSON.stringify(currentMigrations), + ], + { timeout: 10_000 }, + ); + if (result.exitCode !== 0) { + throw new Error( + `Cannot create disposable authority fixture: ${result.stderr.toString()}`, + ); + } + return path; +} -function runFixture(stage: string, change: string, restoreFails = false) { +function createDisposableCurrentDatabase( + path: string, + authorityDatabase: string, + options: { + fts?: boolean; + newline?: boolean; + schemaDrift?: boolean; + migrationDrift?: boolean; + wal?: boolean; + withoutShm?: boolean; + freshWal?: boolean; + }, +) { + const python = Bun.which("python3"); + if (!python) throw new Error("Missing fixture utility: python3"); + copyFileSync(authorityDatabase, path); + for (const suffix of ["-wal", "-shm"]) { + const source = `${authorityDatabase}${suffix}`; + if (existsSync(source)) copyFileSync(source, `${path}${suffix}`); + } + const modes = [ + options.fts ? "fts" : "", + options.newline ? "newline" : "", + options.schemaDrift ? "schema-drift" : "", + options.migrationDrift ? "migration-drift" : "", + options.wal ? "wal" : "", + options.freshWal ? "fresh-wal" : "", + ] + .filter(Boolean) + .join(","); + const program = ` +import os +import sqlite3 +import sys +path = sys.argv[1] +modes = set(filter(None, sys.argv[2].split(","))) +connection = sqlite3.connect(path) +if "fts" in modes: + fts_count = connection.execute("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND (name LIKE 'tasks_fts%' OR name LIKE 'checkpoints_fts%' OR name LIKE 'progress_items_fts%' OR name LIKE 'decisions_fts%')").fetchone()[0] + if fts_count != 24: + raise RuntimeError("expected 24 FTS objects") +connection.execute("PRAGMA journal_mode=" + ("WAL" if "wal" in modes or "fresh-wal" in modes else "DELETE")) +if "wal" in modes: + connection.execute("PRAGMA wal_autocheckpoint=0") + connection.execute("UPDATE projects SET updated_at = updated_at || '-wal'") +if "fts" in modes: + connection.execute("INSERT INTO tasks_fts(rowid, title, description) VALUES (1, 'shadow-row', 'shadow-row')") + connection.execute("DELETE FROM tasks_fts WHERE rowid = 1") +if "newline" in modes: + connection.execute('CREATE TABLE "bad\\nname" (value TEXT)') + connection.execute('INSERT INTO "bad\\nname"(value) VALUES (\\'row\\')') +if "schema-drift" in modes: + connection.execute('CREATE TABLE schema_drift (value TEXT)') +if "migration-drift" in modes: + connection.execute("UPDATE schema_migrations SET checksum = '0' || substr(checksum, 2) WHERE version = 1") +connection.commit() +os._exit(0) +`; + const result = Bun.spawnSync([python, "-c", program, path, modes], { + timeout: 10_000, + }); + if (result.exitCode !== 0) { + throw new Error( + `Cannot create disposable current-schema fixture: ${result.stderr.toString()}`, + ); + } + if (options.withoutShm) rmSync(`${path}-shm`, { force: true }); + if (options.freshWal) { + rmSync(`${path}-wal`, { force: true }); + rmSync(`${path}-shm`, { force: true }); + } +} + +function runFixture( + stage: string, + change: string, + restoreFails = false, + options: FixtureOptions = {}, +) { const root = mkdtempSync(join(tmpdir(), "workflow-import-test-")); - const project = join(root, "project"); + let project = join(root, "project"); + let projectTarget = project; + if (options.projectParentSymlink) { + const actualParent = join(root, "project-parent"); + const linkedParent = join(root, "linked-parent"); + projectTarget = join(actualParent, "project"); + mkdirSync(actualParent, { recursive: true }); + symlinkSync(actualParent, linkedParent, "dir"); + project = join(linkedParent, "project"); + } else if (options.projectSymlink) { + projectTarget = join(root, "project-target"); + project = join(root, "project-link"); + } const bin = join(root, "bin"); const temp = join(root, "temp"); const config = join(project, ".carryctx/config.toml"); + const log = join(root, "commands.log"); const executable = (name: string, body: string) => { const path = join(bin, name); writeFileSync(path, `#!${process.execPath}\n${body}`); chmodSync(path, 0o700); }; + const realFixture = Boolean( + options.realFts || + options.realNewlineTable || + options.realSchemaDrift || + options.realMigrationDrift || + options.realWal || + options.walWithoutShm || + options.freshWal, + ); + let authorityDatabase: string | undefined; + let databaseBefore: Buffer | undefined; + let databaseIdentityBefore: string | undefined; + let walIdentityBefore: string | undefined; + let shmIdentityBefore: string | undefined; + let projectUpdatedAtBefore: string | undefined; + let walBefore: { wal: Buffer; shm?: Buffer } | undefined; + if (realFixture) authorityDatabase = createDisposableAuthorityFixture(root); try { - mkdirSync(join(project, ".carryctx"), { recursive: true }); + mkdirSync(projectTarget, { recursive: true }); + if (options.projectSymlink) { + symlinkSync(projectTarget, project, "dir"); + } + if (options.carryctxSymlink) { + const targetDir = join(root, "carryctx-target"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(targetDir, "config.toml"), original, { mode: 0o600 }); + symlinkSync(targetDir, join(project, ".carryctx"), "dir"); + } else { + mkdirSync(join(projectTarget, ".carryctx"), { recursive: true }); + if (options.configSymlink) { + const target = join(root, "config-target"); + writeFileSync(target, original, { mode: 0o600 }); + symlinkSync(target, config); + } else { + writeFileSync(join(projectTarget, ".carryctx/config.toml"), original, { + mode: 0o600, + }); + } + } mkdirSync(bin); mkdirSync(temp); - writeFileSync(config, original, { mode: 0o600 }); + mkdirSync(join(project, "fake-git"), { recursive: true }); + const database = options.database ?? "absent"; + if (realFixture || database !== "absent") { + const stateDir = join(project, "fake-git/carryctx"); + mkdirSync(stateDir, { recursive: true }); + const databasePath = join(stateDir, "state.sqlite"); + if (realFixture) { + if (!authorityDatabase) throw new Error("Missing authority database"); + createDisposableCurrentDatabase(databasePath, authorityDatabase, { + fts: options.realFts, + newline: options.realNewlineTable, + schemaDrift: options.realSchemaDrift, + migrationDrift: options.realMigrationDrift, + wal: options.realWal || options.walWithoutShm, + withoutShm: options.walWithoutShm, + freshWal: options.freshWal, + }); + databaseBefore = readFileSync(databasePath); + databaseIdentityBefore = `${statSync(databasePath).dev}:${statSync(databasePath).ino}`; + if (existsSync(`${databasePath}-wal`)) { + walIdentityBefore = `${statSync(`${databasePath}-wal`).dev}:${statSync(`${databasePath}-wal`).ino}`; + } + if (existsSync(`${databasePath}-shm`)) { + shmIdentityBefore = `${statSync(`${databasePath}-shm`).dev}:${statSync(`${databasePath}-shm`).ino}`; + } + if (options.realWal || options.walWithoutShm) { + walBefore = { + wal: readFileSync(`${databasePath}-wal`), + shm: existsSync(`${databasePath}-shm`) + ? readFileSync(`${databasePath}-shm`) + : undefined, + }; + } + } else if (database === "directory") { + mkdirSync(databasePath, { recursive: true }); + } else if (options.databaseSymlink) { + const target = join(root, "state-target.sqlite"); + writeFileSync(target, "fixture"); + symlinkSync(target, databasePath); + } else { + writeFileSync(databasePath, "fixture"); + } + if ( + !realFixture && + (options.sidecars === "wal" || options.sidecars === "both") + ) { + writeFileSync(`${databasePath}-wal`, "sidecar"); + } + if ( + !realFixture && + (options.sidecars === "shm" || options.sidecars === "both") + ) { + writeFileSync(`${databasePath}-shm`, "sidecar"); + } + if (options.sidecarSymlink) { + const target = join(root, "sidecar-target"); + writeFileSync(target, "sidecar"); + symlinkSync(target, `${databasePath}-wal`); + } + } + if (options.orphanSidecar) { + const stateDir = join(project, "fake-git/carryctx"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "state.sqlite-wal"), "orphan"); + } + if (options.commandLock) { + const lock = join(project, "fake-git/carryctx/locks/command.lock"); + mkdirSync(lock, { recursive: true }); + writeFileSync(join(lock, "meta.json"), "{}"); + } for (const name of [ "bash", "dirname", "mktemp", - "timeout", "cat", "sed", "head", "cmp", + "wc", "mkdir", "rm", + "mkfifo", + "awk", + "stat", + "setsid", + "sleep", ]) { + if (name === "setsid" && options.noSetsid) continue; const command = Bun.which(name); if (!command) throw new Error(`Missing fixture utility: ${name}`); symlinkSync(command, join(bin, name)); } + const realMv = Bun.which("mv"); + const python = Bun.which("python3"); + if (!realMv || !python) + throw new Error("Missing fixture utility: mv or python3"); + executable( + "mv", + `import { existsSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +const args = process.argv.slice(2); +if (process.env.FIXTURE_RACE === "guard-wal") { + const source = args.at(-2) ?? ""; + const destination = args.at(-1) ?? ""; + if (source.includes(".workflow-import-candidate.") && destination.endsWith("/state.sqlite")) { + const guard = readdirSync(dirname(destination)).find((name) => name.startsWith(".workflow-import-original.") && !name.endsWith("-wal") && !name.endsWith("-shm")); + if (guard) { + const guardPath = join(dirname(destination), guard); + const result = Bun.spawnSync([process.env.FIXTURE_PYTHON, "-c", "import os,sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('PRAGMA journal_mode=WAL'); c.execute('PRAGMA wal_autocheckpoint=0'); c.execute('BEGIN IMMEDIATE'); c.execute(\\\"UPDATE projects SET name = name || '-handoff-injected'\\\"); c.commit(); os._exit(0)", guardPath], { env: process.env }); + if (result.exitCode !== 0) process.exit(result.exitCode); + } + } +} +const result = Bun.spawnSync([process.env.FIXTURE_MV, ...args], { env: process.env, stdio: ["ignore", "inherit", "inherit"] }); +process.exit(result.exitCode);`, + ); + if ( + options.realFts || + options.realNewlineTable || + options.realSchemaDrift || + options.realMigrationDrift || + options.realWal || + options.walWithoutShm || + options.freshWal + ) { + const sqlite = Bun.which("sqlite3"); + if (!sqlite) throw new Error("Missing fixture utility: sqlite3"); + symlinkSync(sqlite, join(bin, "sqlite3")); + } else if (options.sqliteAvailable !== false) { + executable( + "sqlite3", + `import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +const args = process.argv.slice(2); +const last = args.at(-1) ?? ""; +const commandSql = args.filter((value, index) => args[index - 1] === "-cmd").join("\\n"); +const sql = last.endsWith(".sqlite") ? commandSql : last; +const lockedPaths = [...commandSql.matchAll(/\\.once '([^']+)'/g)].map((match) => match[1]); +if (commandSql.includes("BEGIN IMMEDIATE;") && lockedPaths.length === 4) { + const [integrity, schema, migrations, dump] = lockedPaths; + const preImportDump = join(dirname(dump), "pre-import-raw.dump"); + if (!existsSync(preImportDump)) process.exit(1); + writeFileSync(integrity, "ok\\n"); + writeFileSync(schema, ${JSON.stringify(currentSchemaObjects)} + "\\n"); + writeFileSync(migrations, ${JSON.stringify(currentMigrationSnapshot)} + "\\n"); + writeFileSync(dump, readFileSync(preImportDump)); + const shellLine = commandSql.split("\\n").find((line) => line.startsWith(".shell ")); + const marker = shellLine?.trim().split(/\\s+/).at(-1); + if (!marker) process.exit(1); + writeFileSync(marker, "ok\\n"); + process.exit(0); +} +const mode = process.env.FIXTURE_SQLITE_MODE ?? "valid"; +const initialized = Boolean(process.env.FIXTURE_INIT_MARKER && existsSync(process.env.FIXTURE_INIT_MARKER)); +appendFileSync(process.env.FIXTURE_LOG, "sqlite3 " + sql.replace(/\\s+/g, " ") + "\\n"); +if (mode === "unreadable" || mode === "locked" || mode === "schema-command-error" || mode === "wal-invalid") process.exit(1); +if (mode === "timeout") process.exit(124); +if (mode === "hang") await new Promise((resolve) => setTimeout(resolve, 2000)); +if (mode === "hang-descendant") { + const descendant = Bun.spawn([process.execPath, "-e", "await Bun.sleep(1500); await Bun.write(process.env.FIXTURE_DESCENDANT_MARKER, 'leaked');"], { env: { ...process.env, FIXTURE_DESCENDANT_MARKER: process.env.FIXTURE_DESCENDANT_MARKER } }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +if (sql.includes("UPDATE projects SET repository_root")) { + process.exit(0); +} +if (sql.includes("VACUUM INTO")) { + if (mode === "dump-error") process.exit(1); + const match = sql.match(/\\.parameter set @out '([^']+)'/); + if (!match) process.exit(1); + const content = existsSync(last) ? readFileSync(last).toString("base64") : ""; + writeFileSync(match[1], content); + process.exit(0); +} +if (sql === ".dump") { + if (mode === "dump-error") process.exit(1); + const database = args.at(-2) ?? ""; + const content = [database, ...["-wal", "-shm"].map((suffix) => database + suffix)] + .filter((path) => existsSync(path)) + .map((path) => readFileSync(path).toString("base64")) + .join(":"); + console.log("fixture-dump:" + content); + process.exit(0); +} +if (sql.includes("hex(type)||")) { + const database = args.at(-2) ?? ""; + const objects = mode === "schema-line-overflow" ? "A".repeat(131073) : mode === "schema-drift" && !database.includes("schema-authority") ? "74|6E|75|" : ${JSON.stringify(currentSchemaObjects)}; + console.log(objects); + process.exit(0); +} +if (sql.includes("version||") && sql.includes("schema_migrations")) { + const database = args.at(-2) ?? ""; + const migrations = mode === "migration-drift" && !database.includes("schema-authority") ? ${JSON.stringify(currentMigrationSnapshot)}.replace("1|", "1|00") : ${JSON.stringify(currentMigrationSnapshot)}; + console.log(migrations); + process.exit(0); +} +if (sql.includes("PRAGMA integrity_check")) { + console.log(mode === "corrupt" ? "corrupt" : "ok"); + process.exit(0); +} +if (sql.includes("FROM schema_migrations;")) { + const version = Number(process.env.FIXTURE_SCHEMA_VERSION ?? "18"); + const count = Number(process.env.FIXTURE_MIGRATION_COUNT ?? version); + const min = Number(process.env.FIXTURE_MIGRATION_MIN ?? 1); + const max = Number(process.env.FIXTURE_MIGRATION_MAX ?? version); + const distinct = Number(process.env.FIXTURE_MIGRATION_DISTINCT ?? version); + const invalid = Number(process.env.FIXTURE_MIGRATION_INVALID ?? 0); + console.log(mode === "malformed" ? "not-a-number|1|18|18|0" : [count, min, max, distinct, invalid].join("|")); + process.exit(0); +} +if (sql.includes("pragma_foreign_key_check")) { + console.log(mode === "foreign-key" ? "1" : "0"); + process.exit(0); +} +if (sql.includes("FROM projects;")) { + if (mode === "project-count-error") process.exit(1); + const count = initialized ? 1 : Number(process.env.FIXTURE_PROJECT_ROWS ?? "0"); + const invalid = Number(process.env.FIXTURE_PROJECT_INVALID ?? "0"); + console.log([count, invalid].join("|")); + process.exit(0); +} +if (sql.includes("COUNT(DISTINCT name)")) { + if (mode === "too-many-tables") console.log("257|257|0|0"); + else if (mode === "long-table-name") console.log("1|1|1|0"); + else if (mode === "newline-table") console.log("1|1|0|1"); + else if (mode === "duplicate-table") console.log("${currentTables.length + 1}|${currentTables.length}|0|0"); + else console.log("${currentTables.length}|${currentTables.length}|0|0"); + process.exit(0); +} +if (sql.includes("hex(name) FROM sqlite_master")) { + if (mode === "table-list-error") process.exit(1); + let tables = ${JSON.stringify(currentTables)}.filter((name) => { + if (mode === "missing-projects") return name !== "projects"; + if (mode === "missing-schema-migrations") return name !== "schema_migrations"; + return true; + }); + if (mode === "newline-table") tables = ["bad\\ntable"]; + if (mode === "duplicate-table") tables.push("projects"); + console.log(tables.map((name) => Buffer.from(name, "utf8").toString("hex").toUpperCase()).join("\\n")); + process.exit(0); +} +if (sql.includes("SELECT COALESCE(0")) { + if (mode === "count-error") process.exit(1); + const rowTable = process.env.FIXTURE_ROW_TABLE ?? ""; + const count = initialized ? 1 : rowTable && ${JSON.stringify(currentTables)}.includes(rowTable) ? 1 : 0; + console.log(String(count)); + process.exit(0); +} +const match = sql.match(/FROM "([^"]+)"/); +if (!match) process.exit(90); +const table = match[1]; +if (mode === "count-error") process.exit(1); +const projectRows = Number(process.env.FIXTURE_PROJECT_ROWS ?? "0"); +const rowTable = process.env.FIXTURE_ROW_TABLE ?? ""; +const count = table === "projects" ? projectRows : table === rowTable ? 1 : 0; +console.log(String(count));`, + ); + } const cp = Bun.which("cp"); if (!cp) throw new Error("Missing fixture utility: cp"); executable( "git", - `const args = process.argv.slice(2); -if (args[2] === "rev-parse") console.log(args.includes("--git-common-dir") ? process.env.FIXTURE_COMMON : "fixture-revision"); + `import { appendFileSync, copyFileSync, existsSync, mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(process.env.FIXTURE_LOG, "git " + args.join(" ") + "\\n"); +if (args[0] === "init") { + mkdirSync(args.at(-1), { recursive: true }); + process.exit(0); +} +if (args[2] === "rev-parse") { + if (args.includes("--git-common-dir")) console.log(process.env.FIXTURE_COMMON); + else if (args.includes("--short")) console.log("0123456"); + else console.log("0".repeat(40)); +} else if (args[2] === "log") console.log("CarryCtx-Export-Id: fixture"); -else if (args[2] !== "fetch") process.exit(90);`, +else if (args[2] === "fetch") { + if (process.env.FIXTURE_RACE === "database") appendFileSync(process.env.FIXTURE_DB, "race"); + if (process.env.FIXTURE_RACE === "config") { + rmSync(process.env.FIXTURE_CONFIG, { force: true }); + symlinkSync(process.env.FIXTURE_RACE_TARGET, process.env.FIXTURE_CONFIG); + } + if (process.env.FIXTURE_RACE === "fetch-shm-inode" && existsSync(process.env.FIXTURE_DB + "-shm")) { + const source = process.env.FIXTURE_DB + "-shm"; + const replacement = source + ".replacement"; + copyFileSync(source, replacement); + renameSync(replacement, source); + } + process.exit(0); +} +else process.exit(90);`, ); executable( "carryctx", - `import { writeFileSync, rmSync } from "node:fs"; -import { dirname } from "node:path"; + `import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; const stage = process.argv[2]; +const projectIndex = process.argv.indexOf("--project"); +const project = projectIndex >= 0 ? process.argv[projectIndex + 1] : ""; +const staging = project.includes("/import-staging/"); +const stateRoot = staging ? join(project, ".git") : process.env.FIXTURE_COMMON; +const configPath = staging ? join(project, ".carryctx/config.toml") : process.env.FIXTURE_CONFIG; +let importBackup = ""; +const authority = project.includes("/schema-authority/"); +const dryRun = project.includes("/dry-run/"); +appendFileSync(process.env.FIXTURE_LOG, (authority ? "carryctx-authority " : dryRun ? "carryctx-dry-run " : "carryctx ") + process.argv.slice(2).join(" ") + "\\n"); +if (stage === "version") { + if (process.env.FIXTURE_VERSION_MUTATES_TARGET === "1" && (!project || project === process.env.FIXTURE_PROJECT)) { + const state = join(process.env.FIXTURE_COMMON, "carryctx/state.sqlite"); + if (existsSync(state)) { + appendFileSync(state, "version-mutation"); + for (const suffix of ["-wal", "-shm"]) rmSync(state + suffix, { force: true }); + } + } + if (process.env.FIXTURE_VERSION_FAILS === "1") process.exit(1); + const envelope = process.env.FIXTURE_VERSION_OUTPUT ?? ${JSON.stringify(realVersionEnvelope(18))}; + console.log(envelope); + process.exit(0); +} +if (stage === "init" && authority) { + if (process.env.FIXTURE_AUTHORITY_FAILURE === "1") process.exit(9); + const destination = join(project, ".git/carryctx/state.sqlite"); + mkdirSync(dirname(destination), { recursive: true }); + if (process.env.FIXTURE_AUTHORITY_DB && existsSync(process.env.FIXTURE_AUTHORITY_DB)) { + copyFileSync(process.env.FIXTURE_AUTHORITY_DB, destination); + for (const suffix of ["-wal", "-shm"]) { + const source = process.env.FIXTURE_AUTHORITY_DB + suffix; + if (existsSync(source)) copyFileSync(source, destination + suffix); + } + } else { + writeFileSync(destination, "authority"); + } + process.exit(0); +} +if (stage === "import" && dryRun) { + console.log(JSON.stringify({ schema_version: 1, command: "import.create", success: true, data: { operation: { applied: false } } })); + process.exit(0); + } +if (stage === "stats" && process.env.FIXTURE_RACE === "guard-after-validation") { + const stateDirectory = join(stateRoot, "carryctx"); + const guard = readdirSync(stateDirectory).find((name) => name.startsWith(".workflow-import-original.") && !name.endsWith("-wal") && !name.endsWith("-shm")); + if (guard) { + const guardPath = join(stateDirectory, guard); + const result = Bun.spawnSync([process.env.FIXTURE_PYTHON, "-c", "import os,sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('PRAGMA journal_mode=WAL'); c.execute('PRAGMA wal_autocheckpoint=0'); c.execute('BEGIN IMMEDIATE'); c.execute(\\\"UPDATE projects SET name = name || '-held-final'\\\"); c.commit(); os._exit(0)", guardPath], { env: process.env }); + if (result.exitCode !== 0) process.exit(result.exitCode); + } +} + if (stage === "project" && process.argv[3] === "restore") { + if (process.env.FIXTURE_RESTORE_FAILS === "1") process.exit(8); + const restoreIndex = process.argv.indexOf("restore"); + const source = process.argv[restoreIndex + 1]; + const destination = join(stateRoot, "carryctx/state.sqlite"); + if (source && existsSync(source)) { + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(source, destination); + } + process.exit(0); +} if (!["init", "import", "stats"].includes(stage)) process.exit(90); if (stage === "init" || stage === "import") { - const config = process.env.FIXTURE_CONFIG; + const config = configPath; + if (stage === "import") { + const state = join(stateRoot, "carryctx/state.sqlite"); + const raceState = staging ? join(process.env.FIXTURE_COMMON, "carryctx/state.sqlite") : state; + if (process.env.FIXTURE_RACE === "import-inode" && existsSync(raceState)) { + const replacement = raceState + ".inode"; + copyFileSync(raceState, replacement); + for (const suffix of ["-wal", "-shm"]) { + if (existsSync(raceState + suffix)) copyFileSync(raceState + suffix, replacement + suffix); + } + renameSync(replacement, raceState); + for (const suffix of ["-wal", "-shm"]) { + if (existsSync(replacement + suffix)) { + copyFileSync(replacement + suffix, raceState + suffix); + rmSync(replacement + suffix, { force: true }); + } + } + } + if (process.env.FIXTURE_RACE === "import" && existsSync(raceState)) appendFileSync(raceState, "import-race"); + importBackup = join(stateRoot, "carryctx/backups/pre_import_fixture.sqlite"); + mkdirSync(dirname(importBackup), { recursive: true }); + if (existsSync(state)) { + copyFileSync(state, importBackup); + for (const suffix of ["-wal", "-shm"]) { + if (existsSync(state + suffix)) copyFileSync(state + suffix, importBackup + suffix); + } + } else writeFileSync(importBackup, "absent"); + if (process.env.FIXTURE_RACE === "import-path") { + const replacement = raceState + ".replacement"; + writeFileSync(replacement, "replacement"); + rmSync(raceState, { force: true }); + symlinkSync(replacement, raceState); + } + if (process.env.FIXTURE_RACE === "carryctx-dir") { + const stateDir = dirname(raceState); + const moved = stateDir + ".moved"; + renameSync(stateDir, moved); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(raceState, "replacement"); + } + } if (stage === "init" || process.env.FIXTURE_CHANGE === "overwrite") writeFileSync(config, "benign-replacement"); + if (stage === "init" && process.env.FIXTURE_INIT_MARKER) writeFileSync(process.env.FIXTURE_INIT_MARKER, "initialized"); + if (stage === "init" && process.env.FIXTURE_AUTHORITY_DB) { + const destination = join(process.env.FIXTURE_COMMON, "carryctx/state.sqlite"); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(process.env.FIXTURE_AUTHORITY_DB, destination); + } else if (stage === "init") { + const destination = join(process.env.FIXTURE_COMMON, "carryctx/state.sqlite"); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, "initialized"); + } if (stage === "import" && process.env.FIXTURE_CHANGE === "delete") rmSync(config); if (stage === "import" && process.env.FIXTURE_CHANGE === "directory") rmSync(dirname(config), { recursive: true }); } -if (stage === process.env.FIXTURE_STAGE) process.exit(7);`, +if (stage === process.env.FIXTURE_STAGE) process.exit(7); +if (stage === "import") { + console.log(JSON.stringify({ schema_version: 1, command: "import.create", success: true, data: { preImportBackupPath: importBackup, operation: { applied: true } } })); +}`, ); executable( "cp", @@ -78,41 +799,251 @@ if (process.env.FIXTURE_RESTORE_FAILS === "1" && args.at(-1) === process.env.FIX const result = Bun.spawnSync([process.env.FIXTURE_CP, ...args]); process.exit(result.exitCode);`, ); - const result = Bun.spawnSync( - [join(bin, "bash"), script, "--project", project], - { - cwd: project, - env: { - PATH: bin, - HOME: root, - TMPDIR: temp, - FIXTURE_COMMON: join(project, "fake-git"), - FIXTURE_CONFIG: config, - FIXTURE_CP: cp, - FIXTURE_STAGE: stage, - FIXTURE_CHANGE: change, - FIXTURE_RESTORE_FAILS: restoreFails ? "1" : "0", - }, - timeout: 10_000, + const initMarker = join(root, "init.marker"); + const raceTarget = join(root, "race-config-target"); + const fixtureDatabase = join(project, "fake-git/carryctx/state.sqlite"); + if (options.realWal || options.walWithoutShm) { + const beforeProject = Bun.spawnSync( + [ + join(bin, "sqlite3"), + "-readonly", + "-batch", + fixtureDatabase, + "SELECT updated_at FROM projects;", + ], + { timeout: 10_000 }, + ); + if (beforeProject.exitCode !== 0) { + throw new Error(beforeProject.stderr.toString()); + } + projectUpdatedAtBefore = beforeProject.stdout.toString().trim(); + databaseBefore = readFileSync(fixtureDatabase); + databaseIdentityBefore = `${statSync(fixtureDatabase).dev}:${statSync(fixtureDatabase).ino}`; + walIdentityBefore = existsSync(`${fixtureDatabase}-wal`) + ? `${statSync(`${fixtureDatabase}-wal`).dev}:${statSync(`${fixtureDatabase}-wal`).ino}` + : undefined; + shmIdentityBefore = existsSync(`${fixtureDatabase}-shm`) + ? `${statSync(`${fixtureDatabase}-shm`).dev}:${statSync(`${fixtureDatabase}-shm`).ino}` + : undefined; + walBefore = { + wal: readFileSync(`${fixtureDatabase}-wal`), + shm: existsSync(`${fixtureDatabase}-shm`) + ? readFileSync(`${fixtureDatabase}-shm`) + : undefined, + }; + } + writeFileSync(raceTarget, original); + const args = [join(bin, "bash"), script, "--project", project]; + if (options.force) args.push("--force"); + if (options.dryRun) args.push("--dry-run"); + const schemaVersion = options.schemaVersion ?? 18; + const result = Bun.spawnSync(args, { + cwd: project, + env: { + PATH: bin, + HOME: root, + TMPDIR: temp, + FIXTURE_COMMON: join(project, "fake-git"), + FIXTURE_PROJECT: project, + FIXTURE_CONFIG: config, + FIXTURE_CP: cp, + FIXTURE_MV: realMv, + FIXTURE_PYTHON: python, + FIXTURE_LOG: log, + FIXTURE_AUTHORITY_DB: authorityDatabase ?? "", + FIXTURE_AUTHORITY_FAILURE: options.authorityFailure ? "1" : "0", + FIXTURE_INIT_MARKER: initMarker, + FIXTURE_RACE: options.race ?? "", + FIXTURE_RACE_TARGET: raceTarget, + FIXTURE_DB: join(project, "fake-git/carryctx/state.sqlite"), + FIXTURE_DESCENDANT_MARKER: join(root, "descendant-leak.marker"), + FIXTURE_STAGE: stage, + FIXTURE_CHANGE: change, + FIXTURE_RESTORE_FAILS: restoreFails ? "1" : "0", + FIXTURE_SQLITE_MODE: options.sqliteMode ?? "valid", + FIXTURE_SCHEMA_VERSION: String(schemaVersion), + FIXTURE_MIGRATION_COUNT: String( + options.migrationCount ?? schemaVersion, + ), + FIXTURE_MIGRATION_MIN: String(options.migrationMin ?? 1), + FIXTURE_MIGRATION_MAX: String(options.migrationMax ?? schemaVersion), + FIXTURE_MIGRATION_DISTINCT: String( + options.migrationDistinct ?? schemaVersion, + ), + FIXTURE_MIGRATION_INVALID: String(options.migrationInvalid ?? 0), + FIXTURE_PROJECT_ROWS: String(options.projectRows ?? 0), + FIXTURE_PROJECT_INVALID: String(options.projectInvalid ?? 0), + FIXTURE_ROW_TABLE: options.rowTable ?? "", + FIXTURE_VERSION_FAILS: options.versionFails ? "1" : "0", + FIXTURE_VERSION_MUTATES_TARGET: options.versionMutatesTarget + ? "1" + : "0", + GIT_TIMEOUT: String(options.gitTimeout ?? 120), + FIXTURE_VERSION_OUTPUT: + options.versionOutput ?? realVersionEnvelope(schemaVersion), }, - ); - const backups = readdirSync(temp).map((entry) => - join(temp, entry, "config.toml.before"), - ); - return { + timeout: 10_000, + }); + const backups = readdirSync(temp) + .map((entry) => join(temp, entry, "config.toml.before")) + .filter((path) => existsSync(path)); + const currentDatabase = fixtureDatabase; + const walSidecarsStable = walBefore + ? existsSync(`${currentDatabase}-wal`) && + readFileSync(`${currentDatabase}-wal`).equals(walBefore.wal) && + (walBefore.shm + ? existsSync(`${currentDatabase}-shm`) && + readFileSync(`${currentDatabase}-shm`).equals(walBefore.shm) + : !existsSync(`${currentDatabase}-shm`)) + : undefined; + let authoritySchemaObjectCount: number | undefined; + let authorityFtsRelatedCount: number | undefined; + if (authorityDatabase) { + const inventory = Bun.spawnSync( + [ + join(bin, "sqlite3"), + "-readonly", + "-batch", + "-noheader", + "-separator", + "|", + authorityDatabase, + "SELECT COUNT(*), COALESCE(SUM(CASE WHEN name LIKE '%fts%' THEN 1 ELSE 0 END), 0) FROM sqlite_master;", + ], + { timeout: 10_000 }, + ); + if (inventory.exitCode !== 0) { + throw new Error(inventory.stderr.toString()); + } + const [objects, ftsRelated] = inventory.stdout + .toString() + .trim() + .split("|") + .map(Number); + authoritySchemaObjectCount = objects; + authorityFtsRelatedCount = ftsRelated; + } + const databaseStable = databaseBefore + ? existsSync(currentDatabase) && + readFileSync(currentDatabase).equals(databaseBefore) + : undefined; + let projectUpdatedAtAfter: string | undefined; + if (projectUpdatedAtBefore !== undefined && existsSync(currentDatabase)) { + const afterProject = Bun.spawnSync( + [ + join(bin, "sqlite3"), + "-readonly", + "-batch", + currentDatabase, + "SELECT updated_at FROM projects;", + ], + { timeout: 10_000 }, + ); + if (afterProject.exitCode !== 0) { + throw new Error(afterProject.stderr.toString()); + } + projectUpdatedAtAfter = afterProject.stdout.toString().trim(); + } + const currentDatabaseIdentity = existsSync(currentDatabase) + ? `${statSync(currentDatabase).dev}:${statSync(currentDatabase).ino}` + : undefined; + const currentWalIdentity = existsSync(`${currentDatabase}-wal`) + ? `${statSync(`${currentDatabase}-wal`).dev}:${statSync(`${currentDatabase}-wal`).ino}` + : undefined; + const currentShmIdentity = existsSync(`${currentDatabase}-shm`) + ? `${statSync(`${currentDatabase}-shm`).dev}:${statSync(`${currentDatabase}-shm`).ino}` + : undefined; + const sqliteFixture = join(bin, "sqlite3"); + const readProjectName = (path: string) => { + if (!existsSync(path) || !existsSync(sqliteFixture)) return undefined; + const query = Bun.spawnSync( + [ + sqliteFixture, + "-readonly", + "-batch", + path, + "SELECT name FROM projects;", + ], + { timeout: 10_000 }, + ); + return query.exitCode === 0 ? query.stdout.toString().trim() : undefined; + }; + const activeProjectName = readProjectName(currentDatabase); + const stateDirectory = dirname(currentDatabase); + const guardFile = existsSync(stateDirectory) + ? readdirSync(stateDirectory).find( + (name) => + name.startsWith(".workflow-import-original.") && + !name.endsWith("-wal") && + !name.endsWith("-shm"), + ) + : undefined; + const guardProjectName = guardFile + ? readProjectName(join(stateDirectory, guardFile)) + : undefined; + const fixtureResult = { exitCode: result.exitCode, + stdout: result.stdout.toString(), stderr: result.stderr.toString(), + commands: existsSync(log) ? readFileSync(log, "utf8") : "", config: existsSync(config) ? readFileSync(config, "utf8") : undefined, + descendantLeak: existsSync(join(root, "descendant-leak.marker")), + tempEntries: readdirSync(temp), + databaseStable, + projectStatePreserved: + projectUpdatedAtBefore === undefined + ? undefined + : projectUpdatedAtAfter === projectUpdatedAtBefore, + databaseIdentityStable: + databaseIdentityBefore === undefined + ? undefined + : currentDatabaseIdentity === databaseIdentityBefore, + walIdentityStable: + walIdentityBefore === undefined + ? undefined + : currentWalIdentity === walIdentityBefore, + shmIdentityStable: + shmIdentityBefore === undefined + ? undefined + : currentShmIdentity === shmIdentityBefore, + walSidecarsStable, + walPendingBytes: walBefore?.wal.length, + shmPendingBytes: walBefore?.shm?.length ?? 0, + authoritySchemaObjectCount, + authorityFtsRelatedCount, + activeProjectName, + guardProjectName, + databaseContent: + existsSync(currentDatabase) && statSync(currentDatabase).isFile() + ? readFileSync(currentDatabase).toString() + : undefined, backups: backups.map((path) => ({ path, content: readFileSync(path, "utf8"), })), }; + return fixtureResult; } finally { rmSync(root, { recursive: true, force: true }); } } +function expectNoImport( + result: ReturnType, + allowFetch = false, +) { + if (!allowFetch) + expect(result.commands).not.toMatch(/\bgit\b[^\n]*\bfetch\b/); + expect(result.commands).not.toMatch(/carryctx (?:init|import)\b/); +} + +function expectUnknown(result: ReturnType) { + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expect(result.config).toBe(original); + expectNoImport(result); +} + describe("workflow import config preservation with fake commands", () => { for (const stage of ["init", "import", "stats", "success"]) { test(`restores configuration after ${stage}`, () => { @@ -144,3 +1075,540 @@ describe("workflow import config preservation with fake commands", () => { }); } }); + +describe("workflow import local state classification with fake adapters", () => { + test("accepts the real version envelope shape and classifies an absent database", () => { + const result = runFixture("success", "none", false, { database: "absent" }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=absent"); + expect(result.commands).toMatch(/carryctx init\b/); + expect(result.commands).toMatch(/carryctx import\b/); + }); + + test("classifies a current-schema empty database and initializes before import", () => { + const result = runFixture("success", "none", false, { database: "empty" }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=empty"); + expect(result.commands).toMatch(/carryctx init\b/); + expect(result.commands).toMatch(/carryctx import\b/); + }); + + test("treats a project row as non-empty even without data rows", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + projectRows: 1, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expectNoImport(result); + }); + + for (const table of [ + "operations", + "sequences", + "worktree_cleanup_requests", + "tombstones", + "snapshot_state", + ]) { + test(`blocks rows found only in ${table}`, () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: table, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expectNoImport(result); + }); + } + + test("blocks rows in actual disposable FTS shadow tables", () => { + const result = runFixture("success", "none", false, { realFts: true }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expect(result.authoritySchemaObjectCount).toBe(129); + expect(result.authorityFtsRelatedCount).toBe(36); + expect(result.config).toBe(original); + expectNoImport(result); + }); + + test("rejects schema drift in a real 0.11.6 database", () => { + const result = runFixture("success", "none", false, { + realSchemaDrift: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expectNoImport(result); + }); + + test("rejects migration drift in a real 0.11.6 database", () => { + const result = runFixture("success", "none", false, { + realMigrationDrift: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expectNoImport(result); + }); + + test("force-imports a real pending WAL database with valid sidecars", () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=non-empty"); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.shmPendingBytes).toBeGreaterThan(0); + expect(result.projectStatePreserved).toBe(true); + expect(result.commands).toMatch(/carryctx import\b/); + }); + + test("force-imports a real pending WAL database without SHM", () => { + const result = runFixture("success", "none", false, { + walWithoutShm: true, + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=non-empty"); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.projectStatePreserved).toBe(true); + }); + + test("accepts fresh sidecar-free WAL mode for dry-run and force import", () => { + const dryRun = runFixture("success", "none", false, { + freshWal: true, + dryRun: true, + versionMutatesTarget: true, + }); + expect(dryRun.exitCode).toBe(0); + expect(dryRun.databaseStable).toBe(true); + + const force = runFixture("success", "none", false, { + freshWal: true, + force: true, + }); + expect(force.exitCode).toBe(0); + expect(force.stdout).toContain("state=non-empty"); + }); + + test("blocks a real disposable table name containing a newline", () => { + const result = runFixture("success", "none", false, { + realNewlineTable: true, + }); + expectUnknown(result); + }); + + test("allows --force for a valid non-empty database", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("--force"); + expect(result.commands).toMatch(/carryctx import\b/); + expect(result.commands).not.toMatch(/carryctx init\b/); + }); + + test("allows a dry-run for a valid non-empty database without init", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + dryRun: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("dry-run PASS"); + expect(result.commands).toMatch(/carryctx-dry-run import\b/); + expect(result.commands).not.toMatch(/carryctx init\b/); + }); + + for (const [name, options] of [ + ["WAL and SHM", { realWal: true }], + ["WAL without SHM", { walWithoutShm: true }], + ] as const) { + test(`keeps real ${name} state unchanged during dry-run`, () => { + const result = runFixture("success", "none", false, { + ...options, + dryRun: true, + versionMutatesTarget: true, + }); + expect(result.exitCode).toBe(0); + expect(result.databaseStable).toBe(true); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.walSidecarsStable).toBe(true); + if (name === "WAL and SHM") { + expect(result.shmPendingBytes).toBeGreaterThan(0); + } + expect(result.commands).toMatch( + /carryctx version --json --project .*version-contract/, + ); + expect(result.commands).not.toMatch(/carryctx version --json\n/); + }); + } + + const unknownScenarios: Array<[string, FixtureOptions]> = [ + ["unreadable", { database: "non-empty", sqliteMode: "unreadable" }], + ["locked", { database: "non-empty", sqliteMode: "locked" }], + ["timed out", { database: "non-empty", sqliteMode: "timeout" }], + ["corrupt", { database: "non-empty", sqliteMode: "corrupt" }], + [ + "newer", + { + database: "non-empty", + migrationCount: 19, + migrationMax: 19, + migrationDistinct: 19, + }, + ], + [ + "partial migration history", + { + database: "non-empty", + migrationCount: 17, + migrationMax: 17, + migrationDistinct: 17, + }, + ], + [ + "malformed schema output", + { database: "non-empty", sqliteMode: "malformed" }, + ], + [ + "migration identity drift", + { database: "non-empty", sqliteMode: "migration-drift" }, + ], + [ + "schema object drift", + { database: "non-empty", sqliteMode: "schema-drift" }, + ], + [ + "oversized schema object", + { database: "non-empty", sqliteMode: "schema-line-overflow" }, + ], + [ + "foreign-key violation", + { database: "non-empty", sqliteMode: "foreign-key" }, + ], + [ + "table inventory failure", + { database: "non-empty", sqliteMode: "table-list-error" }, + ], + [ + "missing project table", + { database: "non-empty", sqliteMode: "missing-projects" }, + ], + [ + "missing migration table", + { database: "non-empty", sqliteMode: "missing-schema-migrations" }, + ], + ["row count failure", { database: "non-empty", sqliteMode: "count-error" }], + [ + "authority schema failure", + { database: "non-empty", authorityFailure: true }, + ], + ["malformed project row", { database: "non-empty", projectInvalid: 1 }], + [ + "missing sqlite adapter", + { database: "non-empty", sqliteAvailable: false }, + ], + ["non-file database path", { database: "directory" }], + [ + "unavailable schema contract", + { database: "non-empty", versionFails: true }, + ], + [ + "unavailable schema contract for absent database", + { database: "absent", versionFails: true }, + ], + ["orphan WAL sidecar", { database: "absent", orphanSidecar: true }], + [ + "newline-containing table name", + { database: "non-empty", sqliteMode: "newline-table" }, + ], + [ + "overlong table name", + { database: "non-empty", sqliteMode: "long-table-name" }, + ], + [ + "too many tables", + { database: "non-empty", sqliteMode: "too-many-tables" }, + ], + [ + "duplicate table inventory entry", + { database: "non-empty", sqliteMode: "duplicate-table" }, + ], + [ + "invalid WAL sidecar", + { database: "non-empty", sidecars: "wal", sqliteMode: "wal-invalid" }, + ], + [ + "invalid SHM sidecar", + { database: "non-empty", sidecars: "shm", sqliteMode: "wal-invalid" }, + ], + [ + "invalid WAL and SHM sidecars", + { database: "non-empty", sidecars: "both", sqliteMode: "wal-invalid" }, + ], + ["symlinked database", { database: "non-empty", databaseSymlink: true }], + ["symlinked WAL sidecar", { database: "non-empty", sidecarSymlink: true }], + ]; + + for (const [name, options] of unknownScenarios) { + test(`blocks ${name} before fetch, init, or import`, () => { + const result = runFixture("success", "none", false, options); + if (name === "symlinked database" || name === "symlinked WAL sidecar") { + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("symlink"); + expect(result.config).toBe(original); + expectNoImport(result); + } else { + expectUnknown(result); + } + }); + } + + test("enforces the portable command timeout", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + sqliteMode: "hang", + gitTimeout: 1, + }); + expectUnknown(result); + }); + + test("blocks an existing CarryCtx admission lock", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + commandLock: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("already in progress"); + expectNoImport(result); + }); + + test("blocks a database mutation during fetch", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "database", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("changed during fetch"); + expectNoImport(result, true); + }); + + test("preserves a writer that commits during import and aborts", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("state changed while staging import"); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + expect(result.databaseContent).toBe("fixtureimport-race"); + }); + + test("does not restore over a writer when recovery would fail", () => { + const result = runFixture("success", "none", true, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("state changed while staging import"); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }); + + test("fails closed when import replaces the database path with a symlink", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import-path", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }); + + test("fails closed when import replaces the CarryCtx directory", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "carryctx-dir", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }); + + test("fails closed on a byte-identical database inode replacement at import", () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "import-inode", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.databaseStable).toBe(true); + expect(result.databaseIdentityStable).toBe(false); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }); + + test("fails closed when WAL is injected into the hidden guard at handoff", () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "guard-wal", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "post-handoff committed-state validation failed", + ); + expect(result.activeProjectName).not.toContain("handoff-injected"); + expect(result.guardProjectName).toContain("handoff-injected"); + }); + + test("fails closed when WAL is injected after the prior handoff validation", () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "guard-after-validation", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "post-handoff committed-state validation failed", + ); + expect(result.activeProjectName).not.toContain("held-final"); + expect(result.guardProjectName).toContain("held-final"); + }); + + test("blocks an SHM inode replacement during fetch", () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "fetch-shm-inode", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("changed during fetch"); + expect(result.shmIdentityStable).toBe(false); + expectNoImport(result, true); + }); + + test("blocks a configuration path replacement during fetch", () => { + const result = runFixture("success", "none", false, { + race: "config", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("paths changed during fetch"); + expectNoImport(result, true); + }); + + test("uses job-control groups when setsid is unavailable", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + noSetsid: true, + }); + expect(result.exitCode).toBe(0); + expect(result.commands).toMatch(/carryctx import\b/); + }); + + test("terminates descendants when a probe command times out", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + sqliteMode: "hang-descendant", + gitTimeout: 1, + }); + expectUnknown(result); + expect(result.descendantLeak).toBe(false); + expect( + result.tempEntries.filter((entry) => + entry.startsWith("workflow-import."), + ), + ).toEqual([]); + }); + + test("does not allow --force to bypass unknown state", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + sqliteMode: "corrupt", + force: true, + }); + expectUnknown(result); + }); +}); + +describe("workflow import path safety", () => { + const pathScenarios: Array<[string, FixtureOptions]> = [ + ["symlinked project path", { projectSymlink: true }], + ["symlinked project path component", { projectParentSymlink: true }], + ["symlinked .carryctx directory", { carryctxSymlink: true }], + ["symlinked config", { configSymlink: true }], + ]; + + for (const [name, options] of pathScenarios) { + test(`rejects ${name} before init, restore, or import`, () => { + const result = runFixture("success", "none", false, options); + expect(result.exitCode).toBe(1); + expect(result.config).toBe(original); + expectNoImport(result); + }); + } +}); + +describe("workflow import strict version envelope parsing", () => { + const envelope = realVersionEnvelope(18); + const malformed: Array<[string, string]> = [ + ["success false", envelope.replace('"success":true', '"success":false')], + ["unsupported cli", envelope.replace('"cli":"0.11.6"', '"cli":"0.11.5"')], + [ + "fractional db_schema", + envelope.replace('"db_schema":18', '"db_schema":18.0'), + ], + [ + "exponent db_schema", + envelope.replace('"db_schema":18', '"db_schema":1e2'), + ], + [ + "duplicate db_schema", + envelope.replace('"db_schema":18,', '"db_schema":18,"db_schema":18,'), + ], + [ + "unknown envelope field", + envelope.replace( + '"command":"version",', + '"command":"version","extra":true,', + ), + ], + ["missing data path", envelope.replace(',"data":{', ",{")], + ["multiple JSON lines", `${envelope}\n{}`], + ["malformed JSON", "{"], + ]; + + for (const [name, versionOutput] of malformed) { + test(`rejects ${name} before sqlite or import`, () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + versionOutput, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expect(result.commands).not.toMatch(/sqlite3 /); + expectNoImport(result); + }); + } +});