feat(installer): add transactional P0-P9 state machine

This commit is contained in:
2026-08-05 14:02:44 -05:00
parent c5a5f9d362
commit c395ecae84
13 changed files with 2123 additions and 136 deletions
+121 -32
View File
@@ -58,6 +58,7 @@ done
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
# shellcheck source=tools/_lib/manifest.sh
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
source "$SOURCE_DIR/tools/_lib/manifest.sh"
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
@@ -222,12 +223,14 @@ prune_durable_snapshots() {
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
list="$(mktemp)"
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
rm -f "$list"; return 0
fi
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
# dependency); if it is somehow unavailable, leave the backups untouched rather
# than risk pruning in an undefined order.
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
rm -f "$list"; return 0
fi
while IFS= read -r d; do
@@ -266,7 +269,11 @@ make_durable_snapshot() {
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
return 0
fi
chmod 700 "$root" 2>/dev/null || true
if ! chmod 700 "$root"; then
umask "$old_umask"
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
return 0
fi
dir="$root/pre-update-$ts"
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
@@ -281,7 +288,10 @@ make_durable_snapshot() {
if ! enumerate_operator_files "$list"; then
umask "$old_umask"
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
rm -f "$list"
if ! rmdir "$dir"; then
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
fi
return 0
fi
while IFS= read -r -d '' rel; do
@@ -292,12 +302,18 @@ make_durable_snapshot() {
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
continue
fi
chmod 600 "$dst" 2>/dev/null || true
if ! chmod 600 "$dst"; then
rm -f "$dst"
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
continue
fi
count=$((count + 1))
done < "$list"
rm -f "$list"
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
# Tighten every dir the copy created (mkdir -p already honored umask 077).
if ! find "$dir" -type d -exec chmod 700 {} +; then
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
fi
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
DURABLE_SNAPSHOT_DIR="$dir"
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
@@ -344,7 +360,9 @@ verify_operator_surface() {
continue
fi
if cp "$snap" "$cur"; then
chmod 600 "$cur" 2>/dev/null || true
if ! chmod 600 "$cur"; then
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
fi
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
healed=$((healed + 1))
else
@@ -535,7 +553,7 @@ sync_framework_keep() {
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
# the "directory not empty" races we tolerate are ignored via -delete's own
# rc, not by hiding stderr — so a real error is still visible to the operator.
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
if ! find "$dst/$root" -type d -empty -delete; then
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
fi
done < <(manifest_subtree_roots)
@@ -581,7 +599,7 @@ run_migrations() {
MIGRATION_REMOVED_PATHS+=("bin" "rails")
if [[ -d "$TARGET_DIR/bin" ]]; then
ok "Removing legacy bin/ directory (executables now in npm CLI)"
rm -rf "$TARGET_DIR/bin"
rm -rf "${TARGET_DIR:?}/bin"
fi
# Remove old mosaic PATH entry from shell profiles
@@ -706,13 +724,23 @@ mkdir -p "$TARGET_DIR/credentials"
# by `mosaic init` from templates with user-supplied values.
reconcile_framework_files
# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
# Ensure tool scripts are executable. These are P4 postconditions, not
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
fail "Could not mark shipped shell tools executable."
exit 1
fi
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
fail "Could not mark shipped runtime scripts executable."
exit 1
fi
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
# suffix — git resolves credential helpers by exact name/path, not extension
# so the *.sh glob above does not cover it; chmod it explicitly.
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
# suffix — git resolves credential helpers by exact name/path, not extension.
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
fail "Could not mark git-credential-mosaic executable."
exit 1
fi
ok "Framework synced to $TARGET_DIR"
@@ -739,49 +767,110 @@ step "Post-install tasks"
SCRIPTS="$TARGET_DIR/tools/_scripts"
# Capture every fallible post-install command. A failure's text is surfaced and
# also appended to the parent transaction's private command log. Failure to
# write that log is fatal: continuing would recreate the false-clean diagnosis
# INV-C forbids.
record_phase_outcome() {
local phase="$1" status="$2" reason="$3"
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
fail "Could not durably record $phase action outcome for the parent transaction."
exit 1
fi
}
run_captured() {
local label="$1" output status=0
shift
output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")"
if "$@" >"$output" 2>&1; then status=0; else status=$?; fi
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
cat "$output" >&2
rm -f "$output"
fail "Could not durably append '$label' diagnostics to the install command log."
exit 1
fi
fi
if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi
rm -f "$output"
return "$status"
}
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
link_args=()
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
# stdout is suppressed as before, but stderr is left connected: the
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
# the operator, not be swallowed silently.
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
record_phase_outcome P6 committed "runtime asset linker exited zero"
ok "Runtime assets linked"
else
warn "Runtime asset linking failed (non-fatal) — see message above for details."
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
fi
else
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
fi
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
ok "sequential-thinking MCP configured"
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
else
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
else
fail "sequential-thinking MCP setup failed (hard requirement)."
exit 1
fi
fail "sequential-thinking MCP setup failed (hard requirement)."
exit 1
fi
fi
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
ok "excalidraw MCP configured"
else
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
fi
fi
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
record_phase_outcome P4 failed "required skills sync explicitly skipped"
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
record_phase_outcome P4 committed "skills sync exited zero"
ok "Skills synced"
else
record_phase_outcome P4 failed "skills sync exited non-zero"
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
fi
else
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
fi
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
ok "Local skills migrated"
else
record_phase_outcome P4 failed "local skills migration exited non-zero"
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
fi
fi
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
ok "Health audit passed"
else
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
fi
fi
# Write version stamp AFTER everything succeeds
# The version stamp records the successfully committed framework file sync.
# Post-install failures are carried separately into P4/P6 and cannot be erased
# by this stamp.
write_framework_version
# ── Summary ──────────────────────────────────────────────────