#!/usr/bin/env bash # # scripts/demo-pr-1466.sh # # End-to-end demo for PR #1466 (storage redesign + cross-project CLI rework). # Designed to be run live for a screencast — silent narration via section # banners, no live typing, deterministic output. # # Strict sandbox: redirects $HOME to /tmp/ao-demo-1466 so getAoBaseDir() # resolves there instead of touching the operator's real ~/.agent-orchestrator. # After the script exits the original $HOME of the parent shell is unaffected. # # Usage: # scripts/demo-pr-1466.sh # # Re-run is idempotent — wipes and recreates the sandbox each time. # set -euo pipefail # ─── config ──────────────────────────────────────────────────────────────── DEMO_HOME="/tmp/ao-demo-1466" DEMO_PORT="3947" AO_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" AO_CLI="$AO_REPO/packages/cli/dist/index.js" # Save the operator's real HOME so we can restore it for sub-commands # that legitimately need it (running the full test suites — tests read # getGlobalConfigPath() which honors AO_GLOBAL_CONFIG, and a sandboxed # global config would break tests that touch the global registry). REAL_HOME="$HOME" # Sandbox the entire script under a fake HOME so AO's hardcoded # ~/.agent-orchestrator path is redirected. Belt-and-suspenders: also # pin AO_GLOBAL_CONFIG explicitly. export HOME="$DEMO_HOME" export AO_GLOBAL_CONFIG="$DEMO_HOME/.agent-orchestrator/config.yaml" # Local CLI invoker — never use the system `ao`. ao() { node "$AO_CLI" "$@" } banner() { printf '\n' printf '═══════════════════════════════════════════════════════════════════════\n' printf ' %s\n' "$1" printf '═══════════════════════════════════════════════════════════════════════\n' sleep 2 } step() { printf '\n→ %s\n' "$1" sleep 1 } note() { printf ' %s\n' "$1" } # ─── pre-flight ──────────────────────────────────────────────────────────── banner "Pre-flight: build CLI + reset sandbox" if [[ ! -f "$AO_CLI" ]]; then note "CLI bundle not found, building..." (cd "$AO_REPO" && pnpm --filter @aoagents/ao-cli build >/dev/null) fi rm -rf "$DEMO_HOME" mkdir -p "$DEMO_HOME/.agent-orchestrator" # Minimal git config so worktree operations during the demo don't fail # from the redirected HOME. cat >"$DEMO_HOME/.gitconfig" <<'GITCONFIG' [user] name = AO Demo email = demo@example.com [init] defaultBranch = main [advice] detachedHead = false GITCONFIG note "Repo: $AO_REPO" note "CLI: $AO_CLI" note "Sandbox: $DEMO_HOME" note "Port: $DEMO_PORT (no real ao daemon spawned in this demo)" sleep 2 # ─── helpers: realistic project + session seeding ───────────────────────── # seed_project DIR PKG_NAME # Creates a real-looking Node/TypeScript project at DIR with multiple # commits so reviewers see a credible source tree, not an empty README. seed_project() { local dir="$1" local pkg="$2" mkdir -p "$dir/src/lib" "$dir/tests" cat >"$dir/package.json" <"$dir/src/index.ts" <<'EOF' import { processInput, validate } from "./lib/util.js"; import type { Result } from "./lib/types.js"; export async function main(input: string): Promise { const validated = validate(input); return processInput(validated); } if (import.meta.url === `file://${process.argv[1]}`) { main(process.argv[2] ?? "") .then((r) => console.log(r)) .catch((e) => { console.error("error:", e.message); process.exit(1); }); } EOF cat >"$dir/src/lib/util.ts" <<'EOF' import type { Result, Validated } from "./types.js"; export function validate(input: string): Validated { if (!input || input.length < 2) throw new Error("input must be at least 2 chars"); return { value: input.trim(), receivedAt: new Date().toISOString() }; } export async function processInput(v: Validated): Promise { return { input: v.value, output: v.value.toUpperCase(), processedAt: new Date().toISOString() }; } EOF cat >"$dir/src/lib/types.ts" <<'EOF' export interface Validated { value: string; receivedAt: string } export interface Result { input: string; output: string; processedAt: string } EOF cat >"$dir/tests/index.test.ts" <<'EOF' import { describe, it, expect } from "vitest"; import { main } from "../src/index.js"; describe("main", () => { it("uppercases input", async () => { expect((await main("hello")).output).toBe("HELLO"); }); it("rejects too-short input", async () => { await expect(main("")).rejects.toThrow(); }); }); EOF cat >"$dir/.gitignore" <<'EOF' node_modules/ dist/ *.log .env .DS_Store EOF cat >"$dir/tsconfig.json" <<'EOF' { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "strict": true, "esModuleInterop": true, "outDir": "dist", "declaration": true, "skipLibCheck": true }, "include": ["src/**/*"] } EOF cat >"$dir/README.md" <"$hash_dir/sessions/$sid" } # ─────────────────────────────────────────────────────────────────────────── banner "Act 1 — Migration: V1 hash dirs → V2 projects/ (most-reviewed code)" # ─────────────────────────────────────────────────────────────────────────── step "Seed a realistic environment: 2 projects, 6 sessions, real worktree content" # Project 1: myproject (TS package, full source tree, 6-commit history) DEMO_REPO_A="$DEMO_HOME/myproject" seed_project "$DEMO_REPO_A" "myproject" # Project 2: frontend (also TS package — different code so two distinct projects) DEMO_REPO_B="$DEMO_HOME/frontend" seed_project "$DEMO_REPO_B" "frontend" # ── V1 hash dir 1: myproject ────────────────────────────────────────────── # In V1: terminated sessions were MOVED from sessions/ into # sessions/archive/_ as separate files. Active sessions # stayed in sessions/. # In V2 (this PR): the archive directory is removed entirely. Terminated # sessions just stay in sessions/.json with lifecycle.session.state = # "terminated". The migrator flattens archive entries into sessions/. HASH_DIR_A="$DEMO_HOME/.agent-orchestrator/aaaaaa000000-myproject" mkdir -p "$HASH_DIR_A/sessions/archive" "$HASH_DIR_A/worktrees" LIFECYCLE_WORKING='{"version":2,"session":{"kind":"worker","state":"working"},"runtime":{"state":"alive"},"pr":{"state":"unknown"}}' LIFECYCLE_STUCK='{"version":2,"session":{"kind":"worker","state":"stuck","reason":"agent_idle_too_long"},"runtime":{"state":"alive"},"pr":{"state":"unknown"}}' LIFECYCLE_ORCH='{"version":2,"session":{"kind":"orchestrator","state":"working"},"runtime":{"state":"alive"},"pr":{"state":"none"}}' # ao-1: the headline session — has BOTH agent-report state AND report-watcher # counters set, plus PR fields. Exercises the entire @ashish921998 flat-key # contract in a single record. seed_session "$HASH_DIR_A" "ao-1" \ "project=myproject agent=claude-code status=working createdAt=2026-04-21T12:00:00.000Z agentReportedState=needs_input agentReportedAt=2026-04-21T12:35:00.000Z agentReportedNote=please clarify the spec agentReportedPrUrl=https://github.com/demo/myproject/pull/41 agentReportedPrNumber=41 agentReportedPrIsDraft=true reportWatcherTriggerCount=2 reportWatcherActiveTrigger=stale_report reportWatcherTriggerActivatedAt=2026-04-21T12:30:00.000Z reportWatcherLastAuditedAt=2026-04-21T12:36:00.000Z prAutoDetect=on dashboardPort=3000 terminalWsPort=3001 branch=session/ao-1 worktree=$DEMO_REPO_A/worktrees/ao-1 statePayload=$LIFECYCLE_WORKING stateVersion=2 issue=demo/myproject#101 pr=demo/myproject#41 runtimeHandle={\"id\":\"ao-1\",\"runtimeName\":\"tmux\",\"data\":{\"name\":\"my-1\"}}" # ao-3: stuck session with report-watcher counter (also exercises @ashish921998 fix) seed_session "$HASH_DIR_A" "ao-3" \ "project=myproject agent=claude-code status=stuck createdAt=2026-04-21T13:00:00.000Z agentReportedState=working agentReportedAt=2026-04-21T14:50:00.000Z reportWatcherTriggerCount=3 reportWatcherActiveTrigger=stale_report reportWatcherTriggerActivatedAt=2026-04-21T14:00:00.000Z reportWatcherLastAuditedAt=2026-04-21T14:55:00.000Z prAutoDetect=on dashboardPort=3000 branch=session/ao-3 worktree=$DEMO_REPO_A/worktrees/ao-3 statePayload=$LIFECYCLE_STUCK stateVersion=2" # Orchestrator session — different `kind`, exercises lifecycle.session.kind=orchestrator seed_session "$HASH_DIR_A" "my-orchestrator-1" \ "project=myproject agent=claude-code status=working createdAt=2026-04-21T11:00:00.000Z prAutoDetect=on dashboardPort=3000 branch=orchestrator/my-orchestrator-1 worktree=$DEMO_REPO_A/worktrees/my-orchestrator-1 statePayload=$LIFECYCLE_ORCH stateVersion=2 role=orchestrator" # ao-2 in archive — V1 archive file at sessions/archive/_. # Migration's job: flatten this into sessions/ao-2.json with terminated lifecycle. cat >"$HASH_DIR_A/sessions/archive/ao-2_20260420T100000Z" <<'V1META' project=myproject agent=claude-code status=killed createdAt=2026-04-20T08:00:00.000Z branch=session/ao-2 statePayload={"version":2,"session":{"kind":"worker","state":"terminated","reason":"manually_killed"},"runtime":{"state":"missing","reason":"manual_kill_requested"},"pr":{"state":"unknown"}} stateVersion=2 V1META # Real worktree content for ao-1 — proves worktree migration moves files, # not just empty directories. Uses git worktree add so it's a registered # worktree, exactly what the migrator handles in production. git -C "$DEMO_REPO_A" worktree add -b session/ao-1 "$HASH_DIR_A/worktrees/ao-1" main >/dev/null 2>&1 || true echo "// pending edits for issue #101" >"$HASH_DIR_A/worktrees/ao-1/src/lib/util.ts.draft" # ── V1 hash dir 2: frontend ─────────────────────────────────────────────── HASH_DIR_B="$DEMO_HOME/.agent-orchestrator/bbbbbb111111-frontend" mkdir -p "$HASH_DIR_B/sessions/archive" "$HASH_DIR_B/worktrees" # fe-1: working session with PR fields populated seed_session "$HASH_DIR_B" "fe-1" \ "project=frontend agent=claude-code status=pr_open createdAt=2026-04-21T09:00:00.000Z prAutoDetect=on dashboardPort=3000 branch=session/fe-1 worktree=$DEMO_REPO_B/worktrees/fe-1 issue=demo/frontend#7 pr=demo/frontend#12 statePayload={\"version\":2,\"session\":{\"kind\":\"worker\",\"state\":\"working\"},\"runtime\":{\"state\":\"alive\"},\"pr\":{\"state\":\"open\",\"number\":12}} stateVersion=2" # fe-2 in archive — terminated by runtime_lost cat >"$HASH_DIR_B/sessions/archive/fe-2_20260419T160000Z" <<'V1META' project=frontend agent=claude-code status=killed createdAt=2026-04-19T14:00:00.000Z branch=session/fe-2 statePayload={"version":2,"session":{"kind":"worker","state":"terminated","reason":"runtime_lost"},"runtime":{"state":"missing","reason":"agent_process_exited"},"pr":{"state":"unknown"}} stateVersion=2 V1META # ── Pre-seed the global config the migrator reads ──────────────────────── cat >"$DEMO_HOME/.agent-orchestrator/config.yaml" </dev/null | tr '\n' ' ')" echo " worktrees/ : $(ls "$HASH_DIR_A/worktrees" 2>/dev/null | tr '\n' ' ')" echo echo " V1 hash dir 2 (frontend):" echo " sessions/ : $(ls "$HASH_DIR_B/sessions" | grep -v '^archive$' | tr '\n' ' ')" echo " sessions/archive/ : $(ls "$HASH_DIR_B/sessions/archive" 2>/dev/null | tr '\n' ' ')" echo echo " Session metadata format (key=value, flat strings) — ao-1:" sed 's/^/ /' "$HASH_DIR_A/sessions/ao-1" sleep 6 step "ao migrate-storage --dry-run (shows the plan, mutates nothing)" ao migrate-storage --dry-run --force || true sleep 3 step "ao migrate-storage (atomic per-project, with rollback on failure)" ao migrate-storage --force sleep 2 step "After — V2 layout (projects/{projectId}/sessions/{sid}.json)" echo " Top level:" ls -1 "$DEMO_HOME/.agent-orchestrator/" | sed 's/^/ /' echo echo " projects/ (one dir per project, archives flattened into sessions/):" for p in "$DEMO_HOME"/.agent-orchestrator/projects/*; do pname=$(basename "$p") [[ "$pname" == *.migrated ]] && continue echo " $pname/" echo " sessions/ : $(ls "$p/sessions" 2>/dev/null | tr '\n' ' ')" if [[ -d "$p/worktrees" ]]; then echo " worktrees/ : $(ls "$p/worktrees" 2>/dev/null | tr '\n' ' ')" fi done echo # Inspect the JSON for ao-1 (the headline session: agent-report state set, # PR fields, runtimeHandle as embedded JSON — exercises the most fields). MIGRATED_PROJECT="myproject" SESSION_JSON="$DEMO_HOME/.agent-orchestrator/projects/$MIGRATED_PROJECT/sessions/ao-1.json" sleep 4 step "Migrated session JSON (note: typed fields, no key=value soup)" node -e " const fs = require('fs'); const d = JSON.parse(fs.readFileSync('$SESSION_JSON', 'utf-8')); const out = { branch: d.branch, status: d.status, agent: d.agent, prAutoDetect: d.prAutoDetect, dashboard: d.dashboard, lifecycle: d.lifecycle ? '(...)' : undefined, agentReportedState: d.agentReportedState, agentReportedAt: d.agentReportedAt, agentReportedNote: d.agentReportedNote, reportWatcherTriggerCount: d.reportWatcherTriggerCount, reportWatcherActiveTrigger: d.reportWatcherActiveTrigger, agentReport_nested_wrapper: d.agentReport ?? '(undefined — correct)', reportWatcher_nested_wrapper: d.reportWatcher ?? '(undefined — correct)', }; console.log(JSON.stringify(out, null, 2).split('\n').map(l => ' ' + l).join('\n')); " sleep 4 step "Verify @ashish921998 fix: agent-report keys stayed FLAT after migration" note "Live runtime readers (parseExistingAgentReport, lifecycle-manager)" note "look up flat keys on session.metadata. readMetadataRaw → flattenToStringRecord" note "does NOT unfold nested objects, so a nested agentReport.* would silently" note "drop this state. Migration keeps these flat — proven below:" echo node -e " const fs = require('fs'); const d = JSON.parse(fs.readFileSync('$SESSION_JSON', 'utf-8')); const required = [ 'agentReportedState', 'agentReportedAt', 'agentReportedNote', 'reportWatcherTriggerCount', 'reportWatcherActiveTrigger', ]; let ok = true; for (const k of required) { const present = d[k] !== undefined; console.log(' ' + (present ? '✓' : '✗') + ' ' + k + ' = ' + (d[k] ?? 'MISSING')); if (!present) ok = false; } if (d.agentReport !== undefined || d.reportWatcher !== undefined) { console.log(' ✗ nested wrapper present — would shadow flat keys via flattenToStringRecord'); ok = false; } console.log(); console.log(' ' + (ok ? 'PASS' : 'FAIL') + ' — agent-report flat-key contract preserved'); " sleep 5 step "Rollback safety: re-running migration is a no-op (markers prevent re-process)" ao migrate-storage --force 2>&1 | tail -5 sleep 3 # ─────────────────────────────────────────────────────────────────────────── banner "Act 2 — Cross-project CLI (the P1 review fix)" # ─────────────────────────────────────────────────────────────────────────── note "Behavior under test:" note " 1. ao start (project=A) → running.json {pid, projects:[A]}" note " 2. ao stop A → projects:[] (parent alive)" note " 3. ao start A → projects:[A] same pid (ATTACH, no 2nd daemon)" note "" note "Pre-fix: step 3 fell through to runStartup() → spawned a SECOND dashboard" note "on a new port, clobbered running.json. Reproduced and fixed in commit bfc7f48f." sleep 3 step "The regression test that asserts no second daemon is registered" echo sed -n '/attaches to existing daemon (no second dashboard)/,/^ });$/p' \ "$AO_REPO/packages/cli/__tests__/commands/start.test.ts" \ | head -50 | sed 's/^/ /' sleep 5 step "Run the test live (filtered by test name via vitest -t)" (cd "$AO_REPO" && pnpm --filter @aoagents/ao-cli test -- start.test.ts -t "attaches to existing daemon" 2>&1 \ | grep -E "✓|✗|FAIL|Test Files|^\s*Tests " | head -10 | sed 's/^/ /') || true sleep 3 step "removeProjectFromRunning + addProjectToRunning are the round-trip primitives" echo grep -n "export async function \(removeProjectFromRunning\|addProjectToRunning\)" \ "$AO_REPO/packages/cli/src/lib/running-state.ts" | sed 's/^/ /' sleep 3 # ─────────────────────────────────────────────────────────────────────────── banner "Act 3 — Dashboard sidebar shows ALL projects regardless of route" # ─────────────────────────────────────────────────────────────────────────── note "useSessionEvents on the dashboard is now called WITHOUT a project filter." note "Per-project filtering happens client-side via the projectSessions memo." sleep 2 step "The fix in Dashboard.tsx" grep -B 1 -A 7 "No project filter — sidebar needs all sessions" \ "$AO_REPO/packages/web/src/components/Dashboard.tsx" 2>/dev/null \ | sed 's/^/ /' || note "(see commit 53e8476f)" sleep 4 # ─────────────────────────────────────────────────────────────────────────── banner "Act 4 — Restore from ao stop (last-stop.json round-trip)" # ─────────────────────────────────────────────────────────────────────────── step "Simulate what ao stop writes to last-stop.json" cat >"$DEMO_HOME/.agent-orchestrator/last-stop.json" <" # ─────────────────────────────────────────────────────────────────────────── note "Before fix: empty repos caused 'Unable to resolve base ref' deep inside" note "the worktree plugin. Now we detect via origin/HEAD and fail early with" note "a useful message before ensureOrchestrator runs." sleep 2 step "The detection helper + the early-exit message" echo sed -n '/detectClonedRepoDefaultBranch/,/^}/p' \ "$AO_REPO/packages/cli/src/commands/start.ts" \ | head -25 | sed 's/^/ /' echo grep -B 1 -A 4 "appears to be empty (no commits or refs)" \ "$AO_REPO/packages/cli/src/commands/start.ts" | head -10 | sed 's/^/ /' sleep 5 # ─────────────────────────────────────────────────────────────────────────── banner "Test summary: 560 CLI + 981 core (last full run)" # ─────────────────────────────────────────────────────────────────────────── # Restore the real HOME and unset sandbox env vars when running the full # test suites — otherwise tests that read getGlobalConfigPath() see the # demo's sparse config and fail spuriously. step "pnpm --filter @aoagents/ao-cli test" (cd "$AO_REPO" && env -u AO_GLOBAL_CONFIG HOME="$REAL_HOME" pnpm --filter @aoagents/ao-cli test 2>&1 \ | grep -E "^\s*(Tests|Test Files|Duration)" | sed 's/^/ /') || true step "pnpm --filter @aoagents/ao-core test" (cd "$AO_REPO" && env -u AO_GLOBAL_CONFIG HOME="$REAL_HOME" pnpm --filter @aoagents/ao-core test 2>&1 \ | grep -E "^\s*(Tests|Test Files|Duration)" | sed 's/^/ /') || true # ─────────────────────────────────────────────────────────────────────────── banner "Demo complete — sandbox left in $DEMO_HOME for inspection" # ─────────────────────────────────────────────────────────────────────────── note "To re-run: scripts/demo-pr-1466.sh" note "To clean: rm -rf $DEMO_HOME" note "" note "Reviewer next steps:" note " • Inspect $DEMO_HOME/.agent-orchestrator/ to verify V2 shape" note " • Diff against base: git diff origin/main...storage-redesign" note " • Visual spec: pr-1466.html" note " • Behavior dashboard: https://theharshitsingh.com/static/pr-1466.html" echo