@rubytech/create-realagent-code 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent-code",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent-code": "./dist/index.js"
@@ -62,6 +62,10 @@ Failure signals to grep in `~/.maxy/logs/server.log` (or `~/.realagent/logs/serv
62
62
  - `[onboarding-gate] step=null complete=true` (the pre-Task-033 bug)
63
63
  - missing `[skill-load] name=onboarding` while `[onboarding-gate]` reports `complete=false`
64
64
 
65
+ ### QA regression replay
66
+
67
+ `platform/scripts/qa/onboarding-fresh-install.sh <brand> [--watch]` replays the success chain end-to-end on a freshly-installed Pi. It exits zero only when every line above lands, and exits non-zero with the missing line named verbatim on any miss — so a future regression in this chain is caught by replay instead of by a customer. Pass `--watch` to keep polling Neo4j while the operator drives steps 1–9 in the UI; each `currentStep` advance asserts the matching `stepNCompletedAt` is persisted before the next step renders. Provide the admin PIN via `MAXY_ADMIN_PIN`.
68
+
65
69
  ## Service Management
66
70
 
67
71
  {{productName}} runs via systemd and starts automatically on boot. You don't need to start it manually. To check if it's running, ask {{productName}} "Check system status."
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env bash
2
+ # Task 035 — Onboarding fresh-install Pi integration script.
3
+ #
4
+ # Replays the Task 033 success chain against a freshly-installed brand on
5
+ # this Pi (Maxy Code or Real Agent Code). Exit zero ↔ the entire chain is
6
+ # observable end-to-end. Any miss exits non-zero with the failing
7
+ # observability line named verbatim.
8
+ #
9
+ # Success chain (fresh install, currentStep=0):
10
+ # [onboarding-seed] currentStep=0
11
+ # → installer log
12
+ # [install-invariant] onboarding-state-present
13
+ # → installer log
14
+ # [onboarding-gate] step=0 complete=false phase=create
15
+ # → server.log, fires on POST /api/admin/session
16
+ # [onboarding-inject] agent=admin step=0 injected=true
17
+ # → server.log, fires on first POST /api/admin/claude-sessions/:id/input
18
+ # [skill-load] skillName=onboarding result=unique
19
+ # → server.log, fires when admin MCP serves skill-load for the agent
20
+ #
21
+ # CONTRACT
22
+ # Exit 0 — every chain link landed; --watch (if set) walked 9 steps.
23
+ # Exit 1 — at least one chain assertion failed; missing line(s) named
24
+ # verbatim in the final report.
25
+ # Exit 2 — environment fault (cypher-shell unreachable, brand.json
26
+ # missing, refused-as-non-fresh-install, etc.) — never PASS.
27
+ #
28
+ # USAGE
29
+ # $ onboarding-fresh-install.sh <brand> [--watch]
30
+ #
31
+ # <brand> maxy-code | realagent-code (matches brands/<brand>/brand.json).
32
+ # --watch After the fresh-install chain passes, poll Neo4j every 2s.
33
+ # For every observed currentStep advance N→N+1, assert that
34
+ # step(N+1)CompletedAt is non-null at the same moment. Exits 0
35
+ # when currentStep reaches 9 or the operator presses Ctrl-C
36
+ # after at least one advance.
37
+ #
38
+ # ENV
39
+ # MAXY_ADMIN_PIN Admin PIN (required for the session-mint step).
40
+ # May also be passed as the 2nd positional argument.
41
+ # MAXY_NEO4J_PASSWORD
42
+ # Neo4j password (default: read from <installDir>/platform/config/.neo4j-password if present).
43
+ # MAXY_QA_TIMEOUT Seconds to wait for the runtime log lines to appear
44
+ # after the first /input call (default 30).
45
+ # MAXY_QA_HOST Admin host (default 127.0.0.1).
46
+ #
47
+ # OUT-OF-SCOPE (per task 035)
48
+ # * Multi-Pi orchestration — one brand per invocation.
49
+ # * CI pipeline gating.
50
+ # * Driving steps 1–9 by curl. Steps 7 (Cloudflare OAuth), 8 (Anthropic
51
+ # API key paste), and 9 (free-form business profile data) cannot be
52
+ # deterministically scripted on a fresh install; the operator drives
53
+ # each step in the UI under --watch while this script polls Neo4j.
54
+
55
+ set -euo pipefail
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Argv + env
59
+ # ---------------------------------------------------------------------------
60
+
61
+ if [ $# -lt 1 ]; then
62
+ echo "usage: $0 <brand> [--watch] [pin]" >&2
63
+ echo " <brand> = maxy-code | realagent-code" >&2
64
+ exit 2
65
+ fi
66
+
67
+ BRAND=""
68
+ WATCH=0
69
+ PIN_POSITIONAL=""
70
+ for arg in "$@"; do
71
+ case "$arg" in
72
+ --watch) WATCH=1 ;;
73
+ -*) echo "unknown flag: $arg" >&2; exit 2 ;;
74
+ *) if [ -z "$BRAND" ]; then BRAND="$arg"; else PIN_POSITIONAL="$arg"; fi ;;
75
+ esac
76
+ done
77
+
78
+ if [ -z "$BRAND" ]; then
79
+ echo "error: brand argument required" >&2
80
+ exit 2
81
+ fi
82
+
83
+ PIN="${MAXY_ADMIN_PIN:-$PIN_POSITIONAL}"
84
+ if [ -z "$PIN" ]; then
85
+ echo "error: PIN required — set MAXY_ADMIN_PIN env or pass as second positional argument" >&2
86
+ exit 2
87
+ fi
88
+
89
+ HOST="${MAXY_QA_HOST:-127.0.0.1}"
90
+ TIMEOUT="${MAXY_QA_TIMEOUT:-30}"
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Brand resolution — read live brand.json on the Pi for ports + paths.
94
+ # ---------------------------------------------------------------------------
95
+
96
+ INSTALL_DIR="$HOME/$BRAND"
97
+ BRAND_JSON="$INSTALL_DIR/platform/config/brand.json"
98
+ LOG_DIR="$HOME/.$BRAND/logs"
99
+ SERVER_LOG="$LOG_DIR/server.log"
100
+
101
+ if [ ! -f "$BRAND_JSON" ]; then
102
+ echo "error: brand.json not found at $BRAND_JSON" >&2
103
+ echo "hint: this script expects a brand installed via @rubytech/create-${BRAND}" >&2
104
+ exit 2
105
+ fi
106
+
107
+ # jq is on the deployment-doctrine list; if it's absent, the script must
108
+ # loud-fail rather than fall back to a regex parse of brand.json.
109
+ if ! command -v jq >/dev/null 2>&1; then
110
+ echo "error: jq required but not installed" >&2
111
+ exit 2
112
+ fi
113
+
114
+ NEO4J_PORT="$(jq -r '.neo4jPort' "$BRAND_JSON")"
115
+ if [ -z "$NEO4J_PORT" ] || [ "$NEO4J_PORT" = "null" ]; then
116
+ echo "error: brand.json at $BRAND_JSON has no neo4jPort field" >&2
117
+ exit 2
118
+ fi
119
+
120
+ # Admin server PORT is set by the installer as a systemd Environment var, not
121
+ # in brand.json (verified packages/create-maxy-code/src/port-resolution.ts).
122
+ # Resolution order: MAXY_QA_PORT env → systemctl --user show <serviceName>
123
+ # → fail loud (never a hard-coded default — silent-fallback masks root cause).
124
+ ADMIN_PORT="${MAXY_QA_PORT:-}"
125
+ if [ -z "$ADMIN_PORT" ]; then
126
+ SERVICE_NAME="$(jq -r '.serviceName' "$BRAND_JSON")"
127
+ if [ -n "$SERVICE_NAME" ] && [ "$SERVICE_NAME" != "null" ] && command -v systemctl >/dev/null 2>&1; then
128
+ ADMIN_PORT="$(systemctl --user show "$SERVICE_NAME" -p Environment 2>/dev/null \
129
+ | tr ' ' '\n' \
130
+ | grep -E '^PORT=' \
131
+ | head -n 1 \
132
+ | cut -d= -f2)"
133
+ fi
134
+ fi
135
+ if [ -z "$ADMIN_PORT" ]; then
136
+ echo "error: admin PORT not resolvable — set MAXY_QA_PORT env (e.g. MAXY_QA_PORT=19200)" >&2
137
+ exit 2
138
+ fi
139
+
140
+ NEO4J_URI="bolt://localhost:$NEO4J_PORT"
141
+ ADMIN_BASE="http://$HOST:$ADMIN_PORT"
142
+
143
+ NEO4J_PASSWORD="${MAXY_NEO4J_PASSWORD:-}"
144
+ NEO4J_PASSWORD_FILE="$INSTALL_DIR/platform/config/.neo4j-password"
145
+ if [ -z "$NEO4J_PASSWORD" ] && [ -r "$NEO4J_PASSWORD_FILE" ]; then
146
+ NEO4J_PASSWORD="$(cat "$NEO4J_PASSWORD_FILE")"
147
+ fi
148
+ if [ -z "$NEO4J_PASSWORD" ]; then
149
+ echo "error: Neo4j password required — set MAXY_NEO4J_PASSWORD env or place it at $NEO4J_PASSWORD_FILE" >&2
150
+ exit 2
151
+ fi
152
+
153
+ for tool in cypher-shell curl grep tail; do
154
+ if ! command -v "$tool" >/dev/null 2>&1; then
155
+ echo "error: $tool not on PATH" >&2
156
+ exit 2
157
+ fi
158
+ done
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Cypher helper — exit 2 on unreachable, captures stderr tail for the report.
162
+ # ---------------------------------------------------------------------------
163
+
164
+ cypher() {
165
+ local query="$1"
166
+ cypher-shell -u neo4j -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" --format plain "$query"
167
+ }
168
+
169
+ cypher_or_die() {
170
+ local query="$1"
171
+ local out
172
+ if ! out="$(cypher "$query" 2>&1)"; then
173
+ echo "error: cypher-shell unreachable at $NEO4J_URI" >&2
174
+ echo "$out" | tail -n 5 >&2
175
+ exit 2
176
+ fi
177
+ printf '%s' "$out"
178
+ }
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Assertion bookkeeping — every assertion appends one row to the report so
182
+ # the final block names every link verbatim, pass or fail.
183
+ # ---------------------------------------------------------------------------
184
+
185
+ REPORT=()
186
+ FAIL_COUNT=0
187
+
188
+ record() {
189
+ # record <status> <label> <detail>
190
+ REPORT+=("$1|$2|$3")
191
+ if [ "$1" = "FAIL" ]; then
192
+ FAIL_COUNT=$((FAIL_COUNT + 1))
193
+ fi
194
+ }
195
+
196
+ print_report() {
197
+ echo ""
198
+ echo "## onboarding fresh-install — final report"
199
+ echo ""
200
+ printf '| %-4s | %-50s | %s\n' "Step" "Assertion" "Detail"
201
+ printf '|------|----------------------------------------------------|-----\n'
202
+ local i=1
203
+ for row in "${REPORT[@]}"; do
204
+ local status="${row%%|*}"
205
+ local rest="${row#*|}"
206
+ local label="${rest%%|*}"
207
+ local detail="${rest#*|}"
208
+ local marker
209
+ case "$status" in
210
+ PASS) marker="PASS" ;;
211
+ FAIL) marker="FAIL" ;;
212
+ *) marker="$status" ;;
213
+ esac
214
+ printf '| %-4s | %-50s | %s\n' "$marker" "$label" "$detail"
215
+ i=$((i + 1))
216
+ done
217
+ echo ""
218
+ echo "brand=$BRAND neo4j=$NEO4J_URI logs=$LOG_DIR"
219
+ }
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Step 1 — Pre-flight: refuse if not a fresh install.
223
+ # ---------------------------------------------------------------------------
224
+
225
+ echo "[1/6] Pre-flight: assert OnboardingState{currentStep:0}"
226
+ PREFLIGHT_OUT="$(cypher_or_die 'MATCH (o:OnboardingState) RETURN o.accountId AS accountId, o.currentStep AS currentStep, o.createdAt AS createdAt')"
227
+ ROW_COUNT="$(printf '%s' "$PREFLIGHT_OUT" | tail -n +2 | grep -c . || true)"
228
+
229
+ if [ "$ROW_COUNT" -eq 0 ]; then
230
+ echo "error: not a fresh install — no OnboardingState row exists" >&2
231
+ echo "hint: the installer's [install-invariant] onboarding-state-MISSING line would have fired; check ~/.${BRAND}/logs/install-*.log" >&2
232
+ exit 2
233
+ fi
234
+ if [ "$ROW_COUNT" -gt 1 ]; then
235
+ echo "error: not a fresh install — $ROW_COUNT OnboardingState rows exist (expected 1)" >&2
236
+ printf '%s\n' "$PREFLIGHT_OUT" >&2
237
+ exit 2
238
+ fi
239
+
240
+ CURRENT_STEP="$(printf '%s' "$PREFLIGHT_OUT" | tail -n 1 | awk -F', ' '{print $2}')"
241
+ ACCOUNT_ID="$(printf '%s' "$PREFLIGHT_OUT" | tail -n 1 | awk -F', ' '{print $1}' | tr -d '"')"
242
+ CREATED_AT="$(printf '%s' "$PREFLIGHT_OUT" | tail -n 1 | awk -F', ' '{print $3}' | tr -d '"')"
243
+
244
+ if [ "$CURRENT_STEP" != "0" ]; then
245
+ echo "error: not a fresh install — currentStep=$CURRENT_STEP (refusing to drive on top of completed state)" >&2
246
+ echo "expected: currentStep=0" >&2
247
+ echo "actual: $PREFLIGHT_OUT" >&2
248
+ exit 2
249
+ fi
250
+ record PASS "pre-flight: OnboardingState{currentStep:0}" "accountId=${ACCOUNT_ID:0:8} createdAt=$CREATED_AT"
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Step 2 — Installer log: [onboarding-seed] currentStep=0 + [install-invariant] onboarding-state-present.
254
+ #
255
+ # Installer logs live in $LOG_DIR/install-*.log (separate from server.log).
256
+ # Both lines fire once per install run; pin to the newest install log so a
257
+ # stale prior install on the same Pi cannot produce a false PASS.
258
+ # ---------------------------------------------------------------------------
259
+
260
+ echo "[2/6] Installer log: [onboarding-seed] + [install-invariant] onboarding-state-present"
261
+ LATEST_INSTALL_LOG="$(ls -t "$LOG_DIR"/install-*.log 2>/dev/null | head -n 1 || true)"
262
+ if [ -z "$LATEST_INSTALL_LOG" ]; then
263
+ record FAIL "[onboarding-seed] currentStep=0" "no install log under $LOG_DIR"
264
+ record FAIL "[install-invariant] onboarding-state-present" "no install log under $LOG_DIR"
265
+ else
266
+ if grep -qF "[onboarding-seed] accountId=$ACCOUNT_ID currentStep=0" "$LATEST_INSTALL_LOG"; then
267
+ record PASS "[onboarding-seed] currentStep=0" "$(basename "$LATEST_INSTALL_LOG")"
268
+ else
269
+ record FAIL "[onboarding-seed] currentStep=0" "missing from $(basename "$LATEST_INSTALL_LOG")"
270
+ fi
271
+ if grep -qF "[install-invariant] onboarding-state-present accountId=$ACCOUNT_ID" "$LATEST_INSTALL_LOG"; then
272
+ record PASS "[install-invariant] onboarding-state-present" "$(basename "$LATEST_INSTALL_LOG")"
273
+ else
274
+ record FAIL "[install-invariant] onboarding-state-present" "missing from $(basename "$LATEST_INSTALL_LOG")"
275
+ fi
276
+ fi
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Step 3 — POST /api/admin/session: assert onboardingComplete:false in response,
280
+ # then assert [onboarding-gate] step=0 complete=false in server.log.
281
+ # ---------------------------------------------------------------------------
282
+
283
+ echo "[3/6] Mint admin session via PIN and assert [onboarding-gate] step=0 complete=false"
284
+ TURN_MARKER_BEFORE="$(wc -l < "$SERVER_LOG" 2>/dev/null | tr -d ' ' || echo 0)"
285
+ TURN_MARKER_BEFORE="${TURN_MARKER_BEFORE:-0}"
286
+ SESSION_RESPONSE="$(curl -sS -X POST "$ADMIN_BASE/api/admin/session" \
287
+ -H 'content-type: application/json' \
288
+ -d "{\"pin\":\"$PIN\"}")" || {
289
+ record FAIL "POST /api/admin/session" "curl failed against $ADMIN_BASE"
290
+ print_report
291
+ exit 1
292
+ }
293
+
294
+ if ! printf '%s' "$SESSION_RESPONSE" | jq -e '.onboardingComplete == false' >/dev/null 2>&1; then
295
+ record FAIL "POST /api/admin/session onboardingComplete:false" "response=$SESSION_RESPONSE"
296
+ print_report
297
+ exit 1
298
+ fi
299
+ SESSION_KEY="$(printf '%s' "$SESSION_RESPONSE" | jq -r '.session_key')"
300
+ if [ -z "$SESSION_KEY" ] || [ "$SESSION_KEY" = "null" ]; then
301
+ record FAIL "session_key" "absent in /api/admin/session response"
302
+ print_report
303
+ exit 1
304
+ fi
305
+ record PASS "POST /api/admin/session onboardingComplete:false" "session minted"
306
+
307
+ # Tail server.log from the byte offset captured before the call. grep -F
308
+ # matches the substring literally — the gate phrasing in session.ts:193 is
309
+ # `[onboarding-gate] session=... accountId=... step=0 complete=false phase=create`.
310
+ if tail -n +"$TURN_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -qF "[onboarding-gate]" \
311
+ && tail -n +"$TURN_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -F "[onboarding-gate]" | grep -qF "step=0 complete=false"; then
312
+ record PASS "[onboarding-gate] step=0 complete=false" "phase=create"
313
+ else
314
+ record FAIL "[onboarding-gate] step=0 complete=false" "not found in $SERVER_LOG since session-mint"
315
+ fi
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Step 4 — Spawn an admin claude-session so the next /input is injection-eligible.
319
+ # ---------------------------------------------------------------------------
320
+
321
+ echo "[4/6] Spawn admin claude-session and capture sessionId"
322
+ SPAWN_RESPONSE="$(curl -sS -X POST "$ADMIN_BASE/api/admin/claude-sessions/?session_key=$SESSION_KEY" \
323
+ -H 'content-type: application/json' \
324
+ -d '{"channel":"browser"}')" || {
325
+ record FAIL "POST /api/admin/claude-sessions/" "curl failed"
326
+ print_report
327
+ exit 1
328
+ }
329
+ SESSION_ID="$(printf '%s' "$SPAWN_RESPONSE" | jq -r '.sessionId // empty')"
330
+ if [ -z "$SESSION_ID" ]; then
331
+ record FAIL "POST /api/admin/claude-sessions/" "no sessionId in response: $SPAWN_RESPONSE"
332
+ print_report
333
+ exit 1
334
+ fi
335
+ record PASS "spawn claude-session" "sessionId=${SESSION_ID:0:8}"
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Step 5 — First /input call. This triggers buildOnboardingPromptBlock,
339
+ # which logs [onboarding-inject] step=0 injected=true. The agent then runs
340
+ # the prepended skill-load directive, which logs [skill-load] skillName=onboarding.
341
+ # ---------------------------------------------------------------------------
342
+
343
+ echo "[5/6] Submit first admin input and wait up to ${TIMEOUT}s for [onboarding-inject] and [skill-load]"
344
+ INPUT_MARKER_BEFORE="$(wc -l < "$SERVER_LOG" 2>/dev/null | tr -d ' ' || echo 0)"
345
+ INPUT_MARKER_BEFORE="${INPUT_MARKER_BEFORE:-0}"
346
+ INPUT_RESPONSE="$(curl -sS -X POST "$ADMIN_BASE/api/admin/claude-sessions/$SESSION_ID/input?session_key=$SESSION_KEY" \
347
+ -H 'content-type: application/json' \
348
+ -d '{"text":"hello"}')" || {
349
+ record FAIL "POST /api/admin/claude-sessions/:id/input" "curl failed"
350
+ print_report
351
+ exit 1
352
+ }
353
+ # Status-only check — the manager owns the per-input contract.
354
+ record PASS "POST first /input" "(skill-load arrives async)"
355
+
356
+ # Poll-tail server.log for the two remaining lines.
357
+ deadline=$(( $(date +%s) + TIMEOUT ))
358
+ inject_seen=0
359
+ skill_seen=0
360
+ while [ "$(date +%s)" -lt "$deadline" ]; do
361
+ if [ "$inject_seen" -eq 0 ] \
362
+ && tail -n +"$INPUT_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -F "[onboarding-inject]" | grep -qF "step=0 injected=true"; then
363
+ inject_seen=1
364
+ fi
365
+ if [ "$skill_seen" -eq 0 ] \
366
+ && tail -n +"$INPUT_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -F "[skill-load]" | grep -qF "skillName=onboarding"; then
367
+ skill_seen=1
368
+ fi
369
+ if [ "$inject_seen" -eq 1 ] && [ "$skill_seen" -eq 1 ]; then break; fi
370
+ sleep 1
371
+ done
372
+
373
+ if [ "$inject_seen" -eq 1 ]; then
374
+ record PASS "[onboarding-inject] step=0 injected=true" "server.log"
375
+ else
376
+ record FAIL "[onboarding-inject] step=0 injected=true" "not found within ${TIMEOUT}s"
377
+ fi
378
+ if [ "$skill_seen" -eq 1 ]; then
379
+ record PASS "[skill-load] skillName=onboarding" "server.log"
380
+ else
381
+ record FAIL "[skill-load] skillName=onboarding" "not found within ${TIMEOUT}s"
382
+ fi
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Step 6 — --watch mode: poll Neo4j and assert step-advance + timestamp.
386
+ #
387
+ # The operator drives steps 1–9 in the UI. For every observed currentStep
388
+ # transition N→N+1, assert step(N+1)CompletedAt is non-null at the same
389
+ # moment. The script never advances steps itself — steps 7 (Cloudflare
390
+ # OAuth), 8 (Anthropic API key paste), and 9 (free-form business profile)
391
+ # require operator-side inputs that cannot be forged from a script.
392
+ # ---------------------------------------------------------------------------
393
+
394
+ if [ "$FAIL_COUNT" -gt 0 ]; then
395
+ print_report
396
+ echo "Fresh-install chain failed before --watch could start." >&2
397
+ exit 1
398
+ fi
399
+
400
+ if [ "$WATCH" -eq 1 ]; then
401
+ echo "[6/6] --watch: polling Neo4j every 2s. Drive steps 1-9 in the UI now. Ctrl-C to abort."
402
+ last_step="$CURRENT_STEP"
403
+ while :; do
404
+ sleep 2
405
+ WATCH_OUT="$(cypher_or_die 'MATCH (o:OnboardingState) RETURN o.currentStep AS s, o.step1CompletedAt AS s1, o.step2CompletedAt AS s2, o.step3CompletedAt AS s3, o.step4CompletedAt AS s4, o.step5CompletedAt AS s5, o.step6CompletedAt AS s6, o.step7CompletedAt AS s7, o.step8CompletedAt AS s8, o.step9CompletedAt AS s9')"
406
+ NOW_STEP="$(printf '%s' "$WATCH_OUT" | tail -n 1 | awk -F', ' '{print $1}')"
407
+ if [ "$NOW_STEP" = "$last_step" ]; then continue; fi
408
+ # Advance observed. Check the timestamp for the new step.
409
+ new_step="$NOW_STEP"
410
+ if [ "$new_step" -le "$last_step" ]; then
411
+ record FAIL "step regression" "last=$last_step now=$new_step"
412
+ print_report
413
+ exit 1
414
+ fi
415
+ ts_field=$(( new_step + 1 )) # awk fields are 1-indexed; currentStep is field 1, stepNCompletedAt is field N+1.
416
+ stamp="$(printf '%s' "$WATCH_OUT" | tail -n 1 | awk -F', ' "{print \$$ts_field}" | tr -d '"')"
417
+ if [ -z "$stamp" ] || [ "$stamp" = "NULL" ] || [ "$stamp" = "null" ]; then
418
+ record FAIL "step$new_step advanced without timestamp" "step${new_step}CompletedAt is NULL"
419
+ print_report
420
+ exit 1
421
+ fi
422
+ record PASS "step $new_step advance" "step${new_step}CompletedAt=$stamp"
423
+ last_step="$new_step"
424
+ if [ "$new_step" -ge 9 ]; then
425
+ echo " currentStep=9 — onboarding complete"
426
+ break
427
+ fi
428
+ done
429
+ fi
430
+
431
+ print_report
432
+ if [ "$FAIL_COUNT" -gt 0 ]; then
433
+ exit 1
434
+ fi
435
+ exit 0