/ scripts / state-management.sh
state-management.sh
  1  #!/usr/bin/env bash
  2  # APC State Management Module (macOS/Linux)
  3  # Provides standardized state management for APC plugin development workflow.
  4  # Source this file: . scripts/state-management.sh
  5  
  6  set -euo pipefail
  7  
  8  # --- PATH RESOLUTION ---
  9  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 10  REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
 11  
 12  # --- SCHEMA CONSTANTS ---
 13  STATE_PHASES=("ideation" "plan" "design" "code" "ship" "complete")
 14  STATE_FRAMEWORKS=("visage" "webview" "pending")
 15  STATE_REQUIRED_FIELDS=("plugin_name" "version" "current_phase" "ui_framework" "complexity_score" "created_at" "last_modified" "phase_history" "validation" "framework_selection" "error_recovery")
 16  STATE_VALIDATION_FIELDS=("creative_brief_exists" "parameter_spec_exists" "architecture_defined" "ui_framework_selected" "design_complete" "code_complete" "tests_passed" "ship_ready")
 17  
 18  # --- HELPERS ---
 19  
 20  _check_jq() {
 21      if ! command -v jq &>/dev/null; then
 22          echo "ERROR: jq is required for state management. Install with: brew install jq" >&2
 23          return 1
 24      fi
 25  }
 26  
 27  _iso_date() {
 28      date -u +"%Y-%m-%dT%H:%M:%SZ"
 29  }
 30  
 31  _array_contains() {
 32      local needle="$1"; shift
 33      for item in "$@"; do
 34          [[ "$item" == "$needle" ]] && return 0
 35      done
 36      return 1
 37  }
 38  
 39  _phase_index() {
 40      local phase="$1"
 41      for i in "${!STATE_PHASES[@]}"; do
 42          [[ "${STATE_PHASES[$i]}" == "$phase" ]] && echo "$i" && return 0
 43      done
 44      echo "-1"
 45  }
 46  
 47  # --- FUNCTIONS ---
 48  
 49  new_plugin_state() {
 50      # Initialize a new plugin state from template
 51      # Usage: new_plugin_state <PluginName> <PluginPath>
 52      _check_jq || return 1
 53      local plugin_name="$1"
 54      local plugin_path="$2"
 55  
 56      # Try multiple locations for the template
 57      local template_path=""
 58      local candidates=(
 59          "$REPO_ROOT/.kilocode/templates/status-template.json"
 60          "$REPO_ROOT/templates/status-template.json"
 61          "$SCRIPT_DIR/../templates/status-template.json"
 62      )
 63      for path in "${candidates[@]}"; do
 64          if [[ -f "$path" ]]; then
 65              template_path="$path"
 66              break
 67          fi
 68      done
 69  
 70      if [[ -z "$template_path" ]]; then
 71          echo "WARNING: Status template not found." >&2
 72          return 1
 73      fi
 74  
 75      local now
 76      now="$(_iso_date)"
 77      local status_path="$plugin_path/status.json"
 78  
 79      jq --arg name "$plugin_name" --arg now "$now" \
 80          '.plugin_name = $name | .created_at = $now | .last_modified = $now' \
 81          "$template_path" > "$status_path"
 82  
 83      echo "Initialized state for $plugin_name"
 84  }
 85  
 86  get_plugin_state() {
 87      # Read and return plugin state JSON
 88      # Usage: get_plugin_state <PluginPath>
 89      _check_jq || return 1
 90      local plugin_path="$1"
 91      local status_path="$plugin_path/status.json"
 92  
 93      if [[ ! -f "$status_path" ]]; then
 94          return 1
 95      fi
 96  
 97      cat "$status_path"
 98  }
 99  
100  get_state_field() {
101      # Get a specific field from state using jq query
102      # Usage: get_state_field <PluginPath> <jq_query>
103      # Example: get_state_field "plugins/MyPlugin" ".ui_framework"
104      _check_jq || return 1
105      local plugin_path="$1"
106      local query="$2"
107      local status_path="$plugin_path/status.json"
108  
109      if [[ ! -f "$status_path" ]]; then
110          return 1
111      fi
112  
113      jq -r "$query" "$status_path"
114  }
115  
116  update_plugin_state() {
117      # Update plugin state with key=value pairs, optional phase and framework
118      # Usage: update_plugin_state <PluginPath> [--phase <phase>] [--framework <framework>] [key=value ...]
119      _check_jq || return 1
120      local plugin_path="$1"; shift
121      local status_path="$plugin_path/status.json"
122      local phase=""
123      local framework=""
124      local -a updates=()
125  
126      if [[ ! -f "$status_path" ]]; then
127          echo "WARNING: Status file not found at $status_path" >&2
128          return 1
129      fi
130  
131      # Parse arguments
132      while [[ $# -gt 0 ]]; do
133          case "$1" in
134              --phase)   phase="$2"; shift 2 ;;
135              --framework) framework="$2"; shift 2 ;;
136              *=*)       updates+=("$1"); shift ;;
137              *)         shift ;;
138          esac
139      done
140  
141      local jq_filter="."
142      local now
143      now="$(_iso_date)"
144  
145      # Apply key=value updates (supports dot notation like "validation.build_completed=true")
146      for update in "${updates[@]}"; do
147          local key="${update%%=*}"
148          local value="${update#*=}"
149  
150          # Convert dot notation to jq path
151          local jq_path
152          jq_path="$(echo "$key" | sed 's/\./"."/g')"
153          jq_path=".\"$jq_path\""
154  
155          # Detect value type
156          if [[ "$value" == "true" || "$value" == "false" ]]; then
157              jq_filter="$jq_filter | $jq_path = $value"
158          elif [[ "$value" =~ ^[0-9]+$ ]]; then
159              jq_filter="$jq_filter | $jq_path = $value"
160          elif [[ "$value" == "null" ]]; then
161              jq_filter="$jq_filter | $jq_path = null"
162          else
163              jq_filter="$jq_filter | $jq_path = \"$value\""
164          fi
165      done
166  
167      # Update phase if specified
168      if [[ -n "$phase" ]]; then
169          if ! _array_contains "$phase" "${STATE_PHASES[@]}"; then
170              echo "WARNING: Invalid phase: $phase" >&2
171              return 1
172          fi
173          local fw_selected
174          fw_selected="${framework:-$(jq -r '.ui_framework' "$status_path")}"
175          jq_filter="$jq_filter | .current_phase = \"$phase\" | .last_modified = \"$now\""
176          jq_filter="$jq_filter | .phase_history += [{\"phase\": \"$phase\", \"completed_at\": \"$now\", \"framework_selected\": \"$fw_selected\"}]"
177      fi
178  
179      # Update framework if specified
180      if [[ -n "$framework" ]]; then
181          if ! _array_contains "$framework" "${STATE_FRAMEWORKS[@]}"; then
182              echo "WARNING: Invalid framework: $framework" >&2
183              return 1
184          fi
185          jq_filter="$jq_filter | .ui_framework = \"$framework\" | .framework_selection.decision = \"$framework\" | .last_modified = \"$now\""
186      fi
187  
188      # Apply updates
189      local tmp_file="${status_path}.tmp"
190      if jq "$jq_filter" "$status_path" > "$tmp_file"; then
191          # Validate before saving
192          if test_state_schema "$tmp_file"; then
193              mv "$tmp_file" "$status_path"
194              echo "Updated state${phase:+: $phase}${framework:+ ($framework)}"
195              return 0
196          else
197              rm -f "$tmp_file"
198              echo "WARNING: State validation failed during update." >&2
199              return 1
200          fi
201      else
202          rm -f "$tmp_file"
203          echo "WARNING: jq filter failed." >&2
204          return 1
205      fi
206  }
207  
208  test_plugin_state() {
209      # Validate plugin state prerequisites
210      # Usage: test_plugin_state <PluginPath> [--required-phase <phase>] [--required-files <file1> <file2> ...]
211      _check_jq || return 1
212      local plugin_path="$1"; shift
213      local required_phase=""
214      local -a required_files=()
215  
216      while [[ $# -gt 0 ]]; do
217          case "$1" in
218              --required-phase) required_phase="$2"; shift 2 ;;
219              --required-files) shift; while [[ $# -gt 0 && "$1" != --* ]]; do required_files+=("$1"); shift; done ;;
220              *) shift ;;
221          esac
222      done
223  
224      local status_path="$plugin_path/status.json"
225      if [[ ! -f "$status_path" ]]; then
226          echo "WARNING: Status file not found" >&2
227          return 1
228      fi
229  
230      # Schema validation
231      if ! test_state_schema "$status_path"; then
232          echo "WARNING: State schema validation failed" >&2
233          return 1
234      fi
235  
236      # Phase prerequisite check
237      if [[ -n "$required_phase" ]]; then
238          local current_phase
239          current_phase="$(jq -r '.current_phase' "$status_path")"
240          local required_idx current_idx
241          required_idx="$(_phase_index "$required_phase")"
242          current_idx="$(_phase_index "$current_phase")"
243  
244          if (( current_idx < required_idx )); then
245              echo "WARNING: Cannot proceed: Current phase '$current_phase' must complete '$required_phase' first" >&2
246              return 1
247          fi
248      fi
249  
250      # Required files check
251      for file in "${required_files[@]}"; do
252          if [[ ! -f "$plugin_path/$file" ]]; then
253              echo "WARNING: Required file missing: $file" >&2
254              return 1
255          fi
256      done
257  
258      return 0
259  }
260  
261  test_state_schema() {
262      # Validate state JSON against schema
263      # Usage: test_state_schema <status.json path>
264      _check_jq || return 1
265      local file="$1"
266  
267      if [[ ! -f "$file" ]]; then
268          return 1
269      fi
270  
271      # Check required fields exist
272      for field in "${STATE_REQUIRED_FIELDS[@]}"; do
273          if ! jq -e "has(\"$field\")" "$file" &>/dev/null; then
274              echo "WARNING: Missing required field: $field" >&2
275              return 1
276          fi
277      done
278  
279      # Check phase validity
280      local phase
281      phase="$(jq -r '.current_phase' "$file")"
282      if ! _array_contains "$phase" "${STATE_PHASES[@]}"; then
283          echo "WARNING: Invalid phase: $phase" >&2
284          return 1
285      fi
286  
287      # Check framework validity
288      local fw
289      fw="$(jq -r '.ui_framework' "$file")"
290      if ! _array_contains "$fw" "${STATE_FRAMEWORKS[@]}"; then
291          echo "WARNING: Invalid framework: $fw" >&2
292          return 1
293      fi
294  
295      # Check validation fields
296      for field in "${STATE_VALIDATION_FIELDS[@]}"; do
297          if ! jq -e ".validation | has(\"$field\")" "$file" &>/dev/null; then
298              echo "WARNING: Missing validation field: $field" >&2
299              return 1
300          fi
301      done
302  
303      return 0
304  }
305  
306  backup_plugin_state() {
307      # Create a backup of status.json
308      # Usage: backup_plugin_state <PluginPath>
309      _check_jq || return 1
310      local plugin_path="$1"
311      local status_path="$plugin_path/status.json"
312  
313      if [[ ! -f "$status_path" ]]; then
314          return 1
315      fi
316  
317      local backup_dir="$plugin_path/_state_backups"
318      mkdir -p "$backup_dir"
319  
320      local timestamp
321      timestamp="$(date +%Y%m%d_%H%M%S)"
322      local backup_file="$backup_dir/status_backup_${timestamp}.json"
323  
324      cp "$status_path" "$backup_file"
325      echo "State backed up to $backup_file"
326  
327      # Update error recovery info in current state
328      local tmp_file="${status_path}.tmp"
329      jq --arg bf "$backup_file" \
330          '.error_recovery.last_backup = $bf | .error_recovery.rollback_available = true' \
331          "$status_path" > "$tmp_file" && mv "$tmp_file" "$status_path"
332  
333      echo "$backup_file"
334  }
335  
336  restore_plugin_state() {
337      # Restore status.json from a backup
338      # Usage: restore_plugin_state <PluginPath> [backup_file]
339      _check_jq || return 1
340      local plugin_path="$1"
341      local backup_file="${2:-}"
342      local status_path="$plugin_path/status.json"
343      local backup_dir="$plugin_path/_state_backups"
344  
345      if [[ ! -d "$backup_dir" ]]; then
346          echo "WARNING: No backup directory found" >&2
347          return 1
348      fi
349  
350      if [[ -n "$backup_file" && -f "$backup_file" ]]; then
351          local source="$backup_file"
352      else
353          # Get latest backup
354          local source
355          source="$(ls -t "$backup_dir"/status_backup_*.json 2>/dev/null | head -1)"
356          if [[ -z "$source" ]]; then
357              echo "WARNING: No backup files found" >&2
358              return 1
359          fi
360      fi
361  
362      echo "Restoring state from $source"
363      cp "$source" "$status_path"
364  
365      # Update error recovery info
366      local now
367      now="$(_iso_date)"
368      local tmp_file="${status_path}.tmp"
369      jq --arg msg "Rollback performed from $source at $now" \
370          '.error_recovery.rollback_available = false | .error_recovery.error_log += [$msg]' \
371          "$status_path" > "$tmp_file" && mv "$tmp_file" "$status_path"
372  
373      echo "State restored"
374  }
375  
376  add_state_error() {
377      # Append an error message to error_recovery.error_log
378      # Usage: add_state_error <PluginPath> <ErrorMessage>
379      _check_jq || return 1
380      local plugin_path="$1"
381      local error_message="$2"
382      local status_path="$plugin_path/status.json"
383  
384      if [[ ! -f "$status_path" ]]; then
385          return 1
386      fi
387  
388      local now
389      now="$(_iso_date)"
390      local tmp_file="${status_path}.tmp"
391      jq --arg msg "$now: $error_message" \
392          '.error_recovery.error_log += [$msg]' \
393          "$status_path" > "$tmp_file" && mv "$tmp_file" "$status_path"
394  }
395  
396  set_plugin_framework() {
397      # Set the UI framework for a plugin
398      # Usage: set_plugin_framework <PluginPath> <visage|webview> <Rationale>
399      _check_jq || return 1
400      local plugin_path="$1"
401      local framework="$2"
402      local rationale="$3"
403  
404      if [[ "$framework" != "visage" && "$framework" != "webview" ]]; then
405          echo "ERROR: Framework must be 'visage' or 'webview'" >&2
406          return 1
407      fi
408  
409      update_plugin_state "$plugin_path" \
410          --framework "$framework" \
411          "framework_selection.rationale=$rationale" \
412          "framework_selection.implementation_strategy=single-pass"
413  }