@oneie/claude 0.7.0 → 0.9.0
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/agents/abm-strategist.md +89 -0
- package/agents/ads-meta.md +97 -0
- package/agents/analyst.md +173 -0
- package/agents/architect.md +280 -0
- package/agents/brand-guardian.md +88 -0
- package/agents/brand-strategist.md +92 -0
- package/agents/campaign-content.md +90 -0
- package/agents/campaign-email.md +88 -0
- package/agents/campaign-sms.md +87 -0
- package/agents/campaign-social.md +87 -0
- package/agents/cco.md +99 -0
- package/agents/ceo.md +106 -0
- package/agents/chairman.md +120 -0
- package/agents/cmo.md +101 -0
- package/agents/community-greeter.md +89 -0
- package/agents/community-moderator.md +92 -0
- package/agents/compliance.md +92 -0
- package/agents/copywriter.md +100 -0
- package/agents/creative-strategist.md +95 -0
- package/agents/cro.md +98 -0
- package/agents/cto.md +267 -0
- package/agents/customer-interviewer.md +93 -0
- package/agents/customer-researcher.md +89 -0
- package/agents/customer-success-manager.md +90 -0
- package/agents/customer-trainer.md +90 -0
- package/agents/cxo.md +100 -0
- package/agents/demand-creator.md +89 -0
- package/agents/demo-mover.md +83 -0
- package/agents/demo-specialist.md +89 -0
- package/agents/demo-thai-family-law.md +87 -0
- package/agents/designer.md +112 -0
- package/agents/discovery-caller.md +98 -0
- package/agents/doctor.md +139 -0
- package/agents/educate-coach.md +100 -0
- package/agents/elevate-tutor.md +83 -0
- package/agents/email-lifecycle-marketer.md +91 -0
- package/agents/engage-specialist.md +99 -0
- package/agents/events-coordinator.md +90 -0
- package/agents/foundation-builder.md +94 -0
- package/agents/funnel-architect.md +89 -0
- package/agents/gift-creator.md +100 -0
- package/agents/google-ads.md +103 -0
- package/agents/guide.md +292 -0
- package/agents/helpdesk-dispatcher.md +88 -0
- package/agents/hook-specialist.md +91 -0
- package/agents/identify-optimizer.md +101 -0
- package/agents/implementer.md +305 -0
- package/agents/incident-commander.md +120 -0
- package/agents/insights-lead.md +93 -0
- package/agents/journey-runner.md +113 -0
- package/agents/linkedin-ads.md +123 -0
- package/agents/live-sales-chat.md +90 -0
- package/agents/market-researcher.md +88 -0
- package/agents/media-buyer.md +96 -0
- package/agents/movers-customer-researcher.md +95 -0
- package/agents/movers-foundation-builder.md +96 -0
- package/agents/movers-market-researcher.md +97 -0
- package/agents/movers-pricing-strategist.md +171 -0
- package/agents/nurture-architect.md +99 -0
- package/agents/offer-architect.md +92 -0
- package/agents/onboarder.md +222 -0
- package/agents/onboarding-specialist.md +92 -0
- package/agents/operations-dashboard.md +98 -0
- package/agents/perf-engineer.md +348 -0
- package/agents/playbook-writer.md +71 -0
- package/agents/plg-strategist.md +91 -0
- package/agents/positioning-architect.md +88 -0
- package/agents/press-officer.md +89 -0
- package/agents/pricing-strategist.md +94 -0
- package/agents/privacy-officer.md +92 -0
- package/agents/referral-manager.md +91 -0
- package/agents/refine-analyst.md +102 -0
- package/agents/release-manager.md +261 -0
- package/agents/renewals-upsell-rep.md +90 -0
- package/agents/review-engineer.md +327 -0
- package/agents/rewards-steward.md +82 -0
- package/agents/sales-call-coach.md +94 -0
- package/agents/sales-closer.md +98 -0
- package/agents/security-auditor.md +313 -0
- package/agents/sell-closer.md +101 -0
- package/agents/share-amplifier.md +99 -0
- package/agents/social-media-manager.md +92 -0
- package/agents/storyteller.md +250 -0
- package/agents/strategist.md +83 -0
- package/agents/strategy-aligner.md +94 -0
- package/agents/support-agent.md +90 -0
- package/agents/tagger.md +245 -0
- package/agents/tech-writer.md +216 -0
- package/agents/test-engineer.md +243 -0
- package/agents/tiktok-ads.md +105 -0
- package/agents/tracking-engineer.md +92 -0
- package/agents/upsell-strategist.md +101 -0
- package/agents/voice.md +85 -0
- package/agents/w4-tools.md +153 -0
- package/agents/w4-verify.md +20 -138
- package/agents/workflow-optimiser.md +84 -0
- package/commands/close.md +814 -160
- package/commands/create.md +2 -2
- package/commands/deploy.md +554 -175
- package/commands/do.md +119 -109
- package/commands/fast.md +67 -0
- package/commands/improve.md +2 -2
- package/commands/one.md +418 -0
- package/commands/see.md +1 -1
- package/commands/sync.md +1 -1
- package/commands/tasks.md +222 -0
- package/commands/vespio.md +110 -0
- package/commands/vespio.remote.md +227 -0
- package/hooks/hooks.json +25 -79
- package/hooks/lib/governor-escape-match.sh +88 -0
- package/hooks/lib/hook.sh +4 -13
- package/hooks/lib/signal.sh +9 -2
- package/hooks/scripts/branch-pin.sh +6 -0
- package/hooks/scripts/config-protect.sh +6 -0
- package/hooks/scripts/do-outcome-gate.sh +19 -2
- package/hooks/scripts/git-add-guard.sh +43 -2
- package/hooks/scripts/governor-escape.sh +96 -0
- package/hooks/scripts/load-guard.sh +127 -0
- package/hooks/scripts/post-edit-check.sh +36 -29
- package/hooks/scripts/session-start.sh +34 -80
- package/hooks/scripts/task-complete-verify.sh +46 -40
- package/package.json +1 -1
- package/rules/documentation.md +9 -0
- package/scripts/ad-copy-lint.sh +656 -0
- package/scripts/agentverse-audit.sh +256 -0
- package/scripts/asi-walk.sh +435 -0
- package/scripts/astro-build-cached.sh +143 -0
- package/scripts/blocks-manifest-cached.sh +100 -0
- package/scripts/blocks-manifest.mjs +497 -0
- package/scripts/blocks-ratchet.sh +116 -0
- package/scripts/blocks-render-probe.mjs +529 -0
- package/scripts/blocks-usage.mjs +334 -0
- package/scripts/chat-context-check.sh +89 -0
- package/scripts/close-metrics.sh +558 -0
- package/scripts/close-owner.sh +268 -0
- package/scripts/db-sync-lock-check.sh +116 -0
- package/scripts/deploy-dev.sh +35 -0
- package/scripts/deploy-emit.sh +298 -0
- package/scripts/deploy-gate-check.sh +128 -0
- package/scripts/deploy-ready.sh +78 -0
- package/scripts/deploy-record.sh +605 -0
- package/scripts/deploy.sh +1273 -0
- package/scripts/do-auto.sh +598 -48
- package/scripts/do-brief.sh +113 -0
- package/scripts/do-close.sh +1137 -0
- package/scripts/do-commit.sh +75 -0
- package/scripts/do-consumer-sweep.sh +18 -1
- package/scripts/do-cycle-shape-check.sh +160 -0
- package/scripts/do-decide.sh +476 -0
- package/scripts/do-derive-check.sh +436 -0
- package/scripts/do-fleet.sh +106 -28
- package/scripts/do-folder.sh +10 -1
- package/scripts/do-next.sh +106 -0
- package/scripts/do-orchestrate.sh +17 -5
- package/scripts/do-plan-json.mjs +201 -0
- package/scripts/do-plan-json.sh +8 -0
- package/scripts/do-preflight.sh +117 -0
- package/scripts/do-project.sh +157 -0
- package/scripts/do-prove-selftest.sh +108 -0
- package/scripts/do-prove.sh +295 -23
- package/scripts/do-rank.py +31 -1
- package/scripts/do-recon-cache.sh +7 -1
- package/scripts/do-recon-pack.sh +196 -0
- package/scripts/do-reconcile.sh +121 -4
- package/scripts/do-signal.sh +280 -23
- package/scripts/do-smoke.sh +18 -1
- package/scripts/do-test-gate.sh +80 -0
- package/scripts/do-tick.sh +102 -0
- package/scripts/do-tier.sh +6 -0
- package/scripts/do-triage.sh +182 -0
- package/scripts/do-ui-gate.sh +1 -1
- package/scripts/do-w4-gates.sh +451 -0
- package/scripts/do-walk.sh +12 -1
- package/scripts/env-sync.sh +173 -0
- package/scripts/factory-brief-check.sh +330 -0
- package/scripts/factory-check.sh +68 -14
- package/scripts/factory-close-check.sh +257 -0
- package/scripts/factory-emit.sh +211 -0
- package/scripts/factory-executor-check.mjs +353 -0
- package/scripts/factory-peak.sh +301 -0
- package/scripts/factory-repo.sh +118 -3
- package/scripts/factory-review-check.mjs +61 -0
- package/scripts/factory-ship.sh +61 -0
- package/scripts/factory-tasks-check.sh +18 -1
- package/scripts/factory-turn.sh +326 -0
- package/scripts/factory-walk.sh +396 -0
- package/scripts/factory-width.sh +57 -0
- package/scripts/fade-toxic.sh +4 -3
- package/scripts/fixtures/factory-brief-real.md +44 -0
- package/scripts/fixtures/triage-dupe.md +5 -0
- package/scripts/fleet-manifest.mjs +108 -0
- package/scripts/fleet-status.sh +110 -0
- package/scripts/full-suite-paths-check.sh +144 -0
- package/scripts/gate-reaper-check.sh +98 -0
- package/scripts/gate-reaper.sh +125 -0
- package/scripts/gate-run.sh +73 -0
- package/scripts/gc-content-check.sh +140 -0
- package/scripts/gen-dev-config.py +20 -0
- package/scripts/govern-bound-check.sh +60 -0
- package/scripts/govern-claims-check.sh +233 -0
- package/scripts/govern-mem-check.sh +290 -0
- package/scripts/governor-doors-check.sh +362 -0
- package/scripts/governor-escape-check.sh +171 -0
- package/scripts/health.sh +413 -0
- package/scripts/id-inventory.mjs +418 -0
- package/scripts/land.sh +551 -0
- package/scripts/lib/gc-finished.sh +77 -0
- package/scripts/lib/govern.sh +361 -0
- package/scripts/lib/govern.ts +756 -0
- package/scripts/lighthouse-run.sh +187 -0
- package/scripts/livekit-live-check.sh +61 -0
- package/scripts/livekit-ratchet.sh +46 -0
- package/scripts/load-guard-check.sh +49 -0
- package/scripts/machine-check.sh +102 -0
- package/scripts/machine-watch.sh +177 -0
- package/scripts/one-agents.mjs +415 -0
- package/scripts/one-resume.sh +78 -0
- package/scripts/orphan-baseline.json +182 -0
- package/scripts/orphan-modules.mjs +179 -0
- package/scripts/pr-body.sh +335 -0
- package/scripts/preview-fd-check.sh +289 -0
- package/scripts/promise-manifest.mjs +24 -1
- package/scripts/release.sh +322 -0
- package/scripts/roles-check.sh +946 -0
- package/scripts/sdk-build-cached.sh +64 -0
- package/scripts/signal-watch.sh +241 -0
- package/scripts/skills-publish.sh +94 -0
- package/scripts/speed-cache-check.sh +173 -0
- package/scripts/speed-check.mjs +907 -0
- package/scripts/speed-parity-check.sh +648 -0
- package/scripts/speed-waterfall-check.sh +355 -0
- package/scripts/substrate-env-parity.mjs +156 -0
- package/scripts/tasks-claim-race.mjs +108 -0
- package/scripts/tasks-loop.sh +185 -0
- package/scripts/test-cached.sh +255 -0
- package/scripts/test-full.sh +87 -0
- package/scripts/test-honesty.mjs +137 -0
- package/scripts/test-lanes.sh +166 -0
- package/scripts/test-speed.sh +94 -0
- package/scripts/triage-shape-check.sh +149 -0
- package/scripts/tsc-cached.sh +179 -0
- package/scripts/typedb-flake-check.sh +197 -0
- package/scripts/urls-lint.sh +15 -0
- package/scripts/verify-fast.sh +445 -0
- package/scripts/vespio-sync.sh +149 -0
- package/scripts/wf-check.mjs +104 -1
- package/scripts/worktree-preview.sh +879 -0
- package/scripts/worktree-up.sh +208 -0
- package/skills/livekit-agents/SKILL.md +285 -0
- package/skills/livekit-agents/references/freshness-rules.md +168 -0
- package/skills/shadcn/SKILL.md +1 -1
- package/skills/signal/SKILL.md +0 -1
- package/skills/voice/SKILL.md +94 -6
- package/skills/voice/corpus-check.sh +87 -0
- package/hooks/scripts/compact-hint.sh +0 -35
- package/hooks/scripts/gate-guard.sh +0 -83
- package/hooks/scripts/read-tracker.sh +0 -26
- package/hooks/scripts/session-end-verify.sh +0 -51
- package/hooks/scripts/stop-reflect.sh +0 -140
- package/hooks/scripts/sync-priority-todo.sh +0 -57
- package/hooks/scripts/sync-todo-docs.sh +0 -46
- package/hooks/scripts/tool-signal.sh +0 -48
- package/scripts/do-tasks-bridge.py +0 -366
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# worktree-preview.sh — a task's PRIVATE dev server, so the work closes with a link.
|
|
3
|
+
#
|
|
4
|
+
# manifest: needs-env
|
|
5
|
+
# It names `one.ie/web` (the app whose dev server is previewed) and reads that
|
|
6
|
+
# app's gitignored `.env`. Both degrade: PREVIEW_APP_DIR retargets the app and
|
|
7
|
+
# a missing app dir is a clean refusal, not a crash. Same bucket, same reason,
|
|
8
|
+
# as do-prove.sh — which also names one.ie/web and reads credentials.
|
|
9
|
+
#
|
|
10
|
+
# USAGE
|
|
11
|
+
# worktree-preview.sh up <slug> [--route /path] [--base main] [--port N]
|
|
12
|
+
# worktree-preview.sh down <slug>
|
|
13
|
+
# worktree-preview.sh check <slug|--dir PATH> the isolation gate
|
|
14
|
+
# worktree-preview.sh list
|
|
15
|
+
# worktree-preview.sh --self-test two servers, concurrent
|
|
16
|
+
#
|
|
17
|
+
# WHY EVERY LINE OF THIS EXISTS — all measured 2026-09-03 on a 10-core/24GB Mac.
|
|
18
|
+
#
|
|
19
|
+
# 1. `astro dev` DOES boot in a git worktree. The recorded "astro fails in a
|
|
20
|
+
# worktree" finding is about `astro build` (missing prerender entry), not
|
|
21
|
+
# `dev`: astro v6.3.7 ready in 7345 ms · GET /factory -> 200 on :4325.
|
|
22
|
+
#
|
|
23
|
+
# 2. SYMLINKING `node_modules` ITSELF IS FATAL AT TWO SERVERS. Vite WRITES to
|
|
24
|
+
# node_modules/.vite. Two worktree servers sharing one cache killed each
|
|
25
|
+
# other: 4325 -> 000 (process dead), 4326 -> 500, both reporting
|
|
26
|
+
# "The file does not exist at .../node_modules/.vite/deps_ssr/….js?v=<hash>"
|
|
27
|
+
# with TWO DIFFERENT ?v= hashes. That is the desync /kill documents.
|
|
28
|
+
# `.dev-stable` gets away with a whole-dir symlink because it is ONE tree.
|
|
29
|
+
# A factory fans out N. So: symlink the CONTENTS, keep the caches local.
|
|
30
|
+
#
|
|
31
|
+
# 3. A COLD .vite FLAPS. Three consecutive requests to a fresh worktree gave
|
|
32
|
+
# 500, 000, 200 while the dep optimiser churned, then settled and stayed.
|
|
33
|
+
# A preview handed over during the flap shows a human a 500 for work that
|
|
34
|
+
# is fine — hence warm-to-N-CONSECUTIVE-200s, never a single probe.
|
|
35
|
+
#
|
|
36
|
+
# 4. `.env` must be COPIED IN. `git worktree add` does not carry a gitignored
|
|
37
|
+
# file, and the boot log's "Using secrets defined in .env" is load-bearing.
|
|
38
|
+
#
|
|
39
|
+
# 5. ALLOCATE PORTS, NEVER SWEEP. /kill's sweep of 4321-4329 also kills
|
|
40
|
+
# neighbour projects (it names apps/one/site on 4322). 4321 is the human's.
|
|
41
|
+
# Teardown kills THIS port only, by recorded pid and by that one port.
|
|
42
|
+
#
|
|
43
|
+
# 6. NEVER `bun install` INSIDE A WORKTREE — it re-points MAIN's
|
|
44
|
+
# node_modules/@oneie/sdk at the worktree's dist-less SDK and breaks the
|
|
45
|
+
# tree you did not touch. This script never installs. Not once.
|
|
46
|
+
#
|
|
47
|
+
# Cost note the governor does not know about: ~128M of .vite PER worktree.
|
|
48
|
+
# gate_headroom prices a cycle at 2GB of MEMORY; preview disk is on top.
|
|
49
|
+
#
|
|
50
|
+
set -uo pipefail
|
|
51
|
+
|
|
52
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
53
|
+
LIB="$SCRIPT_DIR/lib/govern.sh"
|
|
54
|
+
# shellcheck source=/dev/null
|
|
55
|
+
[ -f "$LIB" ] && . "$LIB"
|
|
56
|
+
|
|
57
|
+
# The source tree is the one that HAS node_modules and .env: the main checkout.
|
|
58
|
+
# From any linked worktree, that is the parent of the common git dir.
|
|
59
|
+
_common_git="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
|
|
60
|
+
DEFAULT_SOURCE="${_common_git:+$(dirname "$_common_git")}"
|
|
61
|
+
SOURCE_ROOT="${PREVIEW_SOURCE:-${DEFAULT_SOURCE:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
|
|
62
|
+
APP_DIR="${PREVIEW_APP_DIR:-one.ie/web}"
|
|
63
|
+
WT_DIR="${PREVIEW_WT_DIR:-$SOURCE_ROOT/.do-worktrees}"
|
|
64
|
+
BASE_REF="${DO_BASE_REF:-main}"
|
|
65
|
+
|
|
66
|
+
PORT_MIN="${PREVIEW_PORT_MIN:-4322}" # 4321 belongs to the human
|
|
67
|
+
PORT_MAX="${PREVIEW_PORT_MAX:-4329}" # /restart reserves 4321-4329 for astro
|
|
68
|
+
WARM_TIMEOUT="${PREVIEW_WARM_TIMEOUT:-180}"
|
|
69
|
+
WARM_STREAK="${PREVIEW_WARM_STREAK:-3}" # 3 CONSECUTIVE 200s — see note 3
|
|
70
|
+
BOOT_TIMEOUT="${PREVIEW_BOOT_TIMEOUT:-60}"
|
|
71
|
+
|
|
72
|
+
# node_modules entries that MUST be real local dirs, because something writes
|
|
73
|
+
# to them. Sharing any one of these across two servers is defect #2.
|
|
74
|
+
LOCAL_CACHE_DIRS=(.vite .vite-temp .astro .cache .mf)
|
|
75
|
+
|
|
76
|
+
say() { printf '%s\n' "$*" >&2; }
|
|
77
|
+
die() { printf 'worktree-preview: %s\n' "$*" >&2; exit 2; }
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# node_modules — symlink the CONTENTS, keep the writable caches local.
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
link_node_modules() {
|
|
83
|
+
local src="$1" dst="$2" e n=0
|
|
84
|
+
[ -d "$src" ] || die "no node_modules at $src — run bun install in the SOURCE tree, never in a worktree"
|
|
85
|
+
if [ -L "$dst" ]; then
|
|
86
|
+
say "worktree-preview: replacing whole-dir node_modules symlink (defect #2) with per-entry links"
|
|
87
|
+
rm -f "$dst"
|
|
88
|
+
fi
|
|
89
|
+
mkdir -p "$dst"
|
|
90
|
+
while IFS= read -r e; do
|
|
91
|
+
case " ${LOCAL_CACHE_DIRS[*]} " in
|
|
92
|
+
*" $e "*) [ -L "$dst/$e" ] && rm -f "$dst/$e"; mkdir -p "$dst/$e"; continue ;;
|
|
93
|
+
esac
|
|
94
|
+
[ -e "$dst/$e" ] || [ -L "$dst/$e" ] || { ln -s "$src/$e" "$dst/$e" && n=$((n + 1)); }
|
|
95
|
+
done < <(ls -A "$src")
|
|
96
|
+
# The caches must exist even if the source tree never made them.
|
|
97
|
+
for e in "${LOCAL_CACHE_DIRS[@]}"; do mkdir -p "$dst/$e"; done
|
|
98
|
+
echo "$n"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# check — THE GATE. It must be able to go RED, and --self-test proves it does.
|
|
103
|
+
# Last line is the verdict; an exit code is not the gate result.
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
check_isolation() {
|
|
106
|
+
# NOT `local app="$1" nm="$app/node_modules"` — bash expands every word of a
|
|
107
|
+
# `local` line BEFORE it runs, so `$app` is unbound there and `set -u` kills
|
|
108
|
+
# the whole function. Caught by --self-test: BOTH the green and the red case
|
|
109
|
+
# then "passed", one of them for entirely the wrong reason.
|
|
110
|
+
local app="$1"
|
|
111
|
+
local nm="$app/node_modules" bad=0 e linked
|
|
112
|
+
|
|
113
|
+
if [ ! -e "$nm" ]; then
|
|
114
|
+
echo "RED node_modules missing at $nm"
|
|
115
|
+
echo "worktree-preview check: RED (1 fault)"; return 1
|
|
116
|
+
fi
|
|
117
|
+
if [ -L "$nm" ]; then
|
|
118
|
+
echo "RED node_modules is a SYMLINK -> $(readlink "$nm")"
|
|
119
|
+
echo " Vite WRITES to node_modules/.vite. Two worktree servers sharing"
|
|
120
|
+
echo " one cache desync on the ?v= hash and kill each other (000/500)."
|
|
121
|
+
echo " Fix: symlink the CONTENTS; keep ${LOCAL_CACHE_DIRS[*]} local."
|
|
122
|
+
bad=$((bad + 1))
|
|
123
|
+
else
|
|
124
|
+
for e in "${LOCAL_CACHE_DIRS[@]}"; do
|
|
125
|
+
if [ -L "$nm/$e" ]; then
|
|
126
|
+
echo "RED node_modules/$e is a SYMLINK -> $(readlink "$nm/$e") (shared write cache)"
|
|
127
|
+
bad=$((bad + 1))
|
|
128
|
+
elif [ ! -d "$nm/$e" ]; then
|
|
129
|
+
echo "RED node_modules/$e is not a local directory"
|
|
130
|
+
bad=$((bad + 1))
|
|
131
|
+
fi
|
|
132
|
+
done
|
|
133
|
+
[ -e "$nm/.bin" ] || { echo "RED node_modules/.bin missing — astro is unreachable"; bad=$((bad + 1)); }
|
|
134
|
+
linked=$(find "$nm" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
|
|
135
|
+
[ "${linked:-0}" -ge 100 ] || { echo "RED only ${linked:-0} linked entries — node_modules looks unpopulated"; bad=$((bad + 1)); }
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
[ -f "$app/.env" ] || { echo "RED .env not copied in (git worktree add does not carry a gitignored file)"; bad=$((bad + 1)); }
|
|
139
|
+
|
|
140
|
+
if [ "$bad" -eq 0 ]; then
|
|
141
|
+
echo "worktree-preview check: GREEN ($(find "$nm" -maxdepth 1 -type l | wc -l | tr -d ' ') linked, ${#LOCAL_CACHE_DIRS[@]} local caches, .env present)"
|
|
142
|
+
return 0
|
|
143
|
+
fi
|
|
144
|
+
echo "worktree-preview check: RED ($bad fault$([ "$bad" -gt 1 ] && echo s))"
|
|
145
|
+
return 1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Ports — ALLOCATE by probing, never sweep. Two independent guards:
|
|
150
|
+
# 1. nothing is LISTENing there right now
|
|
151
|
+
# 2. an atomic mkdir(2) reservation wins the race against every other caller
|
|
152
|
+
# 3. after boot, the SERVER's own log must name the port we asked for
|
|
153
|
+
#
|
|
154
|
+
# DO NOT use govern.sh's claim_take here, and this is measured, not stylistic.
|
|
155
|
+
# claim_take records the owner as `${GOVERN_CLAIM_PID:-$$}` and treats a
|
|
156
|
+
# re-claim by the same pid as idempotent success. macOS ships **bash 3.2.57**,
|
|
157
|
+
# which has NO `BASHPID` — inside `( … ) &` the variable `$$` is still the
|
|
158
|
+
# PARENT's pid. So two concurrent previews forked from one shell present the
|
|
159
|
+
# SAME owner, both take the "you already hold it" branch, and both are handed
|
|
160
|
+
# the same port. Measured 2026-09-03 by --self-test C:
|
|
161
|
+
#
|
|
162
|
+
# worktree-preview up: GREEN slug=preview-selftest-a port=4322 …
|
|
163
|
+
# worktree-preview up: GREEN slug=preview-selftest-b port=4322 …
|
|
164
|
+
#
|
|
165
|
+
# Both then probed 4322 and both saw 200 — from ONE server. A green that
|
|
166
|
+
# means nothing. mkdir(2) does not consult $$, so it arbitrates correctly.
|
|
167
|
+
# (The same trap applies to any caller that forks claim_take into subshells.)
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# `${PORT_LOCK_DIR:-…}`, not a bare assignment. An unconditional assignment here
|
|
170
|
+
# SILENTLY IGNORED an inherited PORT_LOCK_DIR, so --self-test D's "isolated" lock
|
|
171
|
+
# dir was never isolated: its racers used the real machine-wide one and inherited
|
|
172
|
+
# a reservation left by the previous run, inside the boot grace. 12 racers, 0
|
|
173
|
+
# winners — a RED that looked like the arbiter failing when the arbiter was fine.
|
|
174
|
+
PORT_LOCK_DIR="${PORT_LOCK_DIR:-${GOVERN_DIR:-${TMPDIR:-/tmp}/one-govern}/preview-ports}"
|
|
175
|
+
PORT_BOOT_GRACE="${PREVIEW_PORT_GRACE:-300}" # a reservation with no pid yet
|
|
176
|
+
|
|
177
|
+
port_is_free() { ! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; }
|
|
178
|
+
|
|
179
|
+
_port_stamp() { printf 'slug=%s\nstarted=%s\n' "$1" "$(date +%s)" > "$2/meta"; }
|
|
180
|
+
|
|
181
|
+
# reserve_port <port> <slug> — 0 if it is ours, 1 if someone live holds it.
|
|
182
|
+
reserve_port() {
|
|
183
|
+
# Same trap as check_isolation: a `local` line is fully word-expanded BEFORE
|
|
184
|
+
# it runs, so `d="$PORT_LOCK_DIR/$p"` on the same line sees an unbound $p and
|
|
185
|
+
# `set -u` kills the function. Caught by --self-test D: "12 racers produced 0
|
|
186
|
+
# winners". Split every self-referencing local.
|
|
187
|
+
local p="$1" slug="$2"
|
|
188
|
+
local d="$PORT_LOCK_DIR/$p" owner started age dead=1
|
|
189
|
+
mkdir -p "$PORT_LOCK_DIR" 2>/dev/null
|
|
190
|
+
if mkdir "$d" 2>/dev/null; then _port_stamp "$slug" "$d"; return 0; fi
|
|
191
|
+
|
|
192
|
+
# Held. Evaporate it only if its server is gone AND the port is really free.
|
|
193
|
+
owner="$(sed -n 's/^pid=//p' "$d/meta" 2>/dev/null | head -1)"
|
|
194
|
+
started="$(sed -n 's/^started=//p' "$d/meta" 2>/dev/null | head -1)"
|
|
195
|
+
if [ -n "$owner" ]; then
|
|
196
|
+
kill -0 "$owner" 2>/dev/null && dead=0
|
|
197
|
+
[ "$dead" = 0 ] && return 1
|
|
198
|
+
else
|
|
199
|
+
# No pid yet: the holder is mid-boot. Give it a grace window, then reap.
|
|
200
|
+
age=$(( $(date +%s) - ${started:-0} ))
|
|
201
|
+
[ "$age" -lt "$PORT_BOOT_GRACE" ] && return 1
|
|
202
|
+
fi
|
|
203
|
+
port_is_free "$p" || return 1
|
|
204
|
+
rm -rf "$d" 2>/dev/null
|
|
205
|
+
mkdir "$d" 2>/dev/null || return 1
|
|
206
|
+
_port_stamp "$slug" "$d"; return 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
port_release() { rm -rf "${PORT_LOCK_DIR:?}/${1:?}" 2>/dev/null; return 0; }
|
|
210
|
+
|
|
211
|
+
# boot_lock — machine-wide, held only while ONE astro dev is coming up. See the
|
|
212
|
+
# EADDRINUSE note in cmd_up. mkdir(2) again, and again NOT claim_take: this is
|
|
213
|
+
# taken from inside `( … ) &` subshells, where $$ collides. Stale locks reap by
|
|
214
|
+
# pid death or age; a lock nobody can acquire must never be able to wedge the
|
|
215
|
+
# factory, so the wait is bounded and expiry just takes the ground.
|
|
216
|
+
BOOT_LOCK_WAIT="${PREVIEW_BOOT_LOCK_WAIT:-240}"
|
|
217
|
+
BOOT_TOKEN=""
|
|
218
|
+
boot_lock_take() {
|
|
219
|
+
local d="$PORT_LOCK_DIR/.boot"
|
|
220
|
+
local waited=0 owner started
|
|
221
|
+
# $$ is NOT unique across subshells on bash 3.2, so ownership is a random
|
|
222
|
+
# token; the pid is recorded only so a dead holder can be reaped.
|
|
223
|
+
BOOT_TOKEN="$$-$RANDOM-$RANDOM"
|
|
224
|
+
mkdir -p "$PORT_LOCK_DIR" 2>/dev/null
|
|
225
|
+
while [ "$waited" -lt "$BOOT_LOCK_WAIT" ]; do
|
|
226
|
+
if mkdir "$d" 2>/dev/null; then
|
|
227
|
+
printf 'token=%s\npid=%s\nstarted=%s\n' "$BOOT_TOKEN" "$$" "$(date +%s)" > "$d/meta"
|
|
228
|
+
return 0
|
|
229
|
+
fi
|
|
230
|
+
owner="$(sed -n 's/^pid=//p' "$d/meta" 2>/dev/null | head -1)"
|
|
231
|
+
started="$(sed -n 's/^started=//p' "$d/meta" 2>/dev/null | head -1)"
|
|
232
|
+
if { [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; } \
|
|
233
|
+
|| [ $(( $(date +%s) - ${started:-0} )) -gt "$BOOT_LOCK_WAIT" ]; then
|
|
234
|
+
rm -rf "$d" 2>/dev/null; continue
|
|
235
|
+
fi
|
|
236
|
+
[ "$waited" = 0 ] && say "worktree-preview: another preview is booting — queueing (this is a WAIT, not a hang)"
|
|
237
|
+
sleep 1; waited=$((waited + 1))
|
|
238
|
+
done
|
|
239
|
+
BOOT_TOKEN=""
|
|
240
|
+
say "worktree-preview: boot lock not acquired in ${BOOT_LOCK_WAIT}s — booting anyway (inspector port may race)"
|
|
241
|
+
return 0
|
|
242
|
+
}
|
|
243
|
+
boot_lock_release() {
|
|
244
|
+
local d="$PORT_LOCK_DIR/.boot"
|
|
245
|
+
[ -n "$BOOT_TOKEN" ] || return 0
|
|
246
|
+
[ "$(sed -n 's/^token=//p' "$d/meta" 2>/dev/null | head -1)" = "$BOOT_TOKEN" ] \
|
|
247
|
+
&& rm -rf "$d" 2>/dev/null
|
|
248
|
+
BOOT_TOKEN=""
|
|
249
|
+
return 0
|
|
250
|
+
}
|
|
251
|
+
port_adopt() { [ -d "$PORT_LOCK_DIR/$1" ] && echo "pid=$2" >> "$PORT_LOCK_DIR/$1/meta"; return 0; }
|
|
252
|
+
|
|
253
|
+
alloc_port() {
|
|
254
|
+
local slug="$1" p
|
|
255
|
+
for ((p = PORT_MIN; p <= PORT_MAX; p++)); do
|
|
256
|
+
port_is_free "$p" || continue
|
|
257
|
+
reserve_port "$p" "$slug" || continue
|
|
258
|
+
echo "$p"; return 0
|
|
259
|
+
done
|
|
260
|
+
return 1
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# log_banner_port <astro.log> — the port astro ACTUALLY bound, or empty.
|
|
264
|
+
#
|
|
265
|
+
# It must match every host form astro prints, and this is measured, not guessed:
|
|
266
|
+
# with `--host 127.0.0.1` the banner reads
|
|
267
|
+
#
|
|
268
|
+
# ┃ Local http://127.0.0.1:4323/
|
|
269
|
+
#
|
|
270
|
+
# NOT `localhost:`. The first version of this grep only matched `localhost:[0-9]+`,
|
|
271
|
+
# so it never matched anything — the port-contention check silently never fired
|
|
272
|
+
# AND every healthy boot burned the full BOOT_TIMEOUT waiting for a line that
|
|
273
|
+
# could not appear. A dead assertion that also costs 60s a boot.
|
|
274
|
+
log_banner_port() {
|
|
275
|
+
grep -oE '(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):[0-9]+' "$1" 2>/dev/null \
|
|
276
|
+
| head -1 | sed 's/.*://'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# warm — the whole point of this function is that it WAITS.
|
|
281
|
+
# A cold .vite gave 500, 000, 200 on three consecutive requests. One 200 proves
|
|
282
|
+
# nothing; N CONSECUTIVE 200s proves the optimiser settled. No -L: a 302 to
|
|
283
|
+
# /signin is NOT a 200 on the route asked for (the do-prove landing rule).
|
|
284
|
+
# Prints "probes=<n> streak_broken=<n> elapsed=<s>" so a caller can see it waited.
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
warm_route() {
|
|
287
|
+
local url="$1" timeout="${2:-$WARM_TIMEOUT}" want="${3:-$WARM_STREAK}"
|
|
288
|
+
local t0 deadline streak=0 probes=0 broken=0 code first=""
|
|
289
|
+
t0=$(date +%s); deadline=$((t0 + timeout))
|
|
290
|
+
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
291
|
+
# `curl … || echo 000` CONCATENATES: on a refused connection curl already
|
|
292
|
+
# printed 000 and then failed, so the variable held "000000" and no branch
|
|
293
|
+
# matched. Normalise instead of appending.
|
|
294
|
+
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null)"
|
|
295
|
+
case "$code" in ''|*[!0-9]*) code=000 ;; esac
|
|
296
|
+
[ ${#code} -ne 3 ] && code=000
|
|
297
|
+
probes=$((probes + 1)); [ -z "$first" ] && first="$code"
|
|
298
|
+
if [ "$code" = "200" ]; then
|
|
299
|
+
streak=$((streak + 1))
|
|
300
|
+
if [ "$streak" -ge "$want" ]; then
|
|
301
|
+
echo "probes=$probes first=$first streak_broken=$broken elapsed=$(( $(date +%s) - t0 ))s verdict=GREEN"
|
|
302
|
+
return 0
|
|
303
|
+
fi
|
|
304
|
+
else
|
|
305
|
+
[ "$streak" -gt 0 ] && broken=$((broken + 1))
|
|
306
|
+
streak=0
|
|
307
|
+
fi
|
|
308
|
+
sleep 1
|
|
309
|
+
done
|
|
310
|
+
echo "probes=$probes first=$first streak_broken=$broken elapsed=$(( $(date +%s) - t0 ))s last=$code verdict=RED"
|
|
311
|
+
return 1
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# up
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
cmd_up() {
|
|
318
|
+
local slug="" route="/factory" port="" base="$BASE_REF"
|
|
319
|
+
slug="${1:-}"; shift || true
|
|
320
|
+
[ -n "$slug" ] || die "up needs a slug"
|
|
321
|
+
while [ $# -gt 0 ]; do
|
|
322
|
+
case "$1" in
|
|
323
|
+
--route) route="$2"; shift 2 ;;
|
|
324
|
+
--port) port="$2"; shift 2 ;;
|
|
325
|
+
--base) base="$2"; shift 2 ;;
|
|
326
|
+
*) die "unknown flag: $1" ;;
|
|
327
|
+
esac
|
|
328
|
+
done
|
|
329
|
+
|
|
330
|
+
local src_app="$SOURCE_ROOT/$APP_DIR"
|
|
331
|
+
[ -d "$src_app" ] || die "no app at $src_app (set PREVIEW_APP_DIR)"
|
|
332
|
+
local wt="$WT_DIR/$slug" app="$WT_DIR/$slug/$APP_DIR"
|
|
333
|
+
|
|
334
|
+
# 1. worktree — attach if it is already there, never clobber
|
|
335
|
+
if [ -d "$wt" ]; then
|
|
336
|
+
say "worktree-preview: reusing worktree $wt"
|
|
337
|
+
else
|
|
338
|
+
mkdir -p "$WT_DIR"
|
|
339
|
+
if git -C "$SOURCE_ROOT" show-ref --verify --quiet "refs/heads/feat/$slug"; then
|
|
340
|
+
git -C "$SOURCE_ROOT" worktree add "$wt" "feat/$slug" >&2 || die "worktree add failed"
|
|
341
|
+
else
|
|
342
|
+
git -C "$SOURCE_ROOT" worktree add -b "feat/$slug" "$wt" "$base" >&2 || die "worktree add failed"
|
|
343
|
+
fi
|
|
344
|
+
# OWNERSHIP. `down` may only remove a worktree THIS script created. Two agents
|
|
345
|
+
# on 2026-09-05 pointed PREVIEW_WT_DIR at their own checkout so `up` would
|
|
346
|
+
# serve it, and `down` then removed the checkout with every uncommitted file
|
|
347
|
+
# in it. The marker is written only on the create path, so an attached
|
|
348
|
+
# (pre-existing) tree is never ours to delete.
|
|
349
|
+
mkdir -p "$wt/$APP_DIR/.preview" && : > "$wt/$APP_DIR/.preview/owned"
|
|
350
|
+
fi
|
|
351
|
+
[ -d "$app" ] || die "worktree has no $APP_DIR"
|
|
352
|
+
|
|
353
|
+
# 2. node_modules by CONTENTS + local caches. NEVER an install.
|
|
354
|
+
local n; n="$(link_node_modules "$src_app/node_modules" "$app/node_modules")"
|
|
355
|
+
say "worktree-preview: linked $n entries, ${#LOCAL_CACHE_DIRS[@]} local caches (no install)"
|
|
356
|
+
|
|
357
|
+
# 3. .env — git worktree add does not carry a gitignored file, and the boot
|
|
358
|
+
# log's "Using secrets defined in .env" is load-bearing. Indirected through
|
|
359
|
+
# ONE_ENV_FILE (DO_ENV_FILE alias) like every other credential-reading
|
|
360
|
+
# script here, so a collaborator tree can point it elsewhere. We never read
|
|
361
|
+
# a VALUE out of it — we copy the file — but the path is still theirs.
|
|
362
|
+
local env_src="${ONE_ENV_FILE:-${DO_ENV_FILE:-$src_app/.env}}"
|
|
363
|
+
[ -f "$env_src" ] || die "no .env at $env_src (set ONE_ENV_FILE)"
|
|
364
|
+
cp "$env_src" "$app/.env"
|
|
365
|
+
[ -f "$src_app/.dev.vars" ] && cp "$src_app/.dev.vars" "$app/.dev.vars"
|
|
366
|
+
|
|
367
|
+
# 4. the gate, BEFORE we boot anything
|
|
368
|
+
if ! check_isolation "$app"; then
|
|
369
|
+
die "isolation check RED — refusing to boot (see above)"
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
# 5. port
|
|
373
|
+
if [ -n "$port" ]; then
|
|
374
|
+
port_is_free "$port" && reserve_port "$port" "$slug" || die "port $port is already taken"
|
|
375
|
+
else
|
|
376
|
+
port="$(alloc_port "$slug")" || die "no free port in $PORT_MIN-$PORT_MAX (cap is 8 minus whatever a neighbour holds)"
|
|
377
|
+
fi
|
|
378
|
+
|
|
379
|
+
# 6. boot, detached into its own process group so a gate-run group-kill or a
|
|
380
|
+
# dying parent shell does not take the preview with it.
|
|
381
|
+
#
|
|
382
|
+
# SERIALISE THE BOOT — the servers stay concurrent, only their STARTS queue.
|
|
383
|
+
# Measured 2026-09-03 by --self-test C, second defect it caught: `astro dev`
|
|
384
|
+
# here also opens an INSPECTOR socket (miniflare/workerd, default 9229). Two
|
|
385
|
+
# previews launched at the same instant both read 9229 as busy, both fell back
|
|
386
|
+
# to 9231, and one DIED:
|
|
387
|
+
#
|
|
388
|
+
# [WARN] [vite] Default inspector port 9229 not available, using 9231 instead
|
|
389
|
+
# Error: listen EADDRINUSE: address already in use 127.0.0.1:9231
|
|
390
|
+
#
|
|
391
|
+
# That port is not settable from the CLI, and its own fallback scan is only
|
|
392
|
+
# unsafe when raced. Holding a machine-wide boot lock until the banner appears
|
|
393
|
+
# lets the second launch see 9231 taken and step to 9233. It costs one boot of
|
|
394
|
+
# latency and removes a crash that reads as "the preview just never came up".
|
|
395
|
+
local state="$app/.preview"; mkdir -p "$state"
|
|
396
|
+
local log="$state/astro.log"; : > "$log"
|
|
397
|
+
local astro="$app/node_modules/.bin/astro"
|
|
398
|
+
[ -x "$astro" ] || astro="npx astro"
|
|
399
|
+
local pid
|
|
400
|
+
boot_lock_take
|
|
401
|
+
if command -v perl >/dev/null 2>&1; then
|
|
402
|
+
( cd "$app" && perl -e 'setsid; exec @ARGV' -- sh -c "exec $astro dev --port $port --host 127.0.0.1" >>"$log" 2>&1 & echo $! > "$state/pid" )
|
|
403
|
+
else
|
|
404
|
+
( cd "$app" && nohup sh -c "exec $astro dev --port $port --host 127.0.0.1" >>"$log" 2>&1 & echo $! > "$state/pid" )
|
|
405
|
+
fi
|
|
406
|
+
pid="$(cat "$state/pid" 2>/dev/null)"
|
|
407
|
+
echo "$port" > "$state/port"
|
|
408
|
+
# The reservation now tracks the SERVER's lifetime, so a dead server frees its
|
|
409
|
+
# port with no human in the loop — the leaked `preview:` tag the spec worries
|
|
410
|
+
# about. This is where a real pid finally exists, hence adopt-after-boot.
|
|
411
|
+
port_adopt "$port" "$pid"
|
|
412
|
+
|
|
413
|
+
local url="http://localhost:$port$route"
|
|
414
|
+
echo "$url" > "$state/url"
|
|
415
|
+
|
|
416
|
+
# 6b. THE SERVER MUST HAVE THE PORT WE ASKED FOR, AND MUST STILL BE ALIVE.
|
|
417
|
+
# `astro dev --port N` is NOT strict: on a busy port Vite increments and boots
|
|
418
|
+
# somewhere else, printing the port it actually took. Two previews handed the
|
|
419
|
+
# same number would then both probe N, both get 200 from ONE server, and the
|
|
420
|
+
# two-server test would go green while proving nothing. Read the boot banner —
|
|
421
|
+
# and watch for the process dying, so an EADDRINUSE is a 10s honest RED rather
|
|
422
|
+
# than a 300s warm timeout that looks like slowness.
|
|
423
|
+
local waited=0 banner="" dead=0
|
|
424
|
+
while [ "$waited" -lt "$BOOT_TIMEOUT" ]; do
|
|
425
|
+
banner="$(log_banner_port "$log")"
|
|
426
|
+
[ -n "$banner" ] && break
|
|
427
|
+
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then dead=1; break; fi
|
|
428
|
+
sleep 1; waited=$((waited + 1))
|
|
429
|
+
done
|
|
430
|
+
boot_lock_release
|
|
431
|
+
if [ "$dead" = 1 ]; then
|
|
432
|
+
say "--- last 25 lines of $log ---"; tail -25 "$log" >&2
|
|
433
|
+
cmd_down "$slug" --keep-worktree >&2 || true
|
|
434
|
+
echo "worktree-preview up: RED slug=$slug port=$port — the server DIED during boot (see log above); port freed, worktree kept at $wt/$APP_DIR/.preview/astro.log.failed"
|
|
435
|
+
return 1
|
|
436
|
+
fi
|
|
437
|
+
if [ -n "$banner" ] && [ "$banner" != "$port" ]; then
|
|
438
|
+
say "worktree-preview: astro bound port $banner, not $port — the port was contended"
|
|
439
|
+
cmd_down "$slug" --keep-worktree >&2 || true
|
|
440
|
+
echo "worktree-preview up: RED slug=$slug — asked for $port, astro bound $banner (port contention; reservation released)"
|
|
441
|
+
return 1
|
|
442
|
+
fi
|
|
443
|
+
|
|
444
|
+
# 7. WARM — do not hand over a URL during the flap.
|
|
445
|
+
say "worktree-preview: warming $url (need $WARM_STREAK consecutive 200s)…"
|
|
446
|
+
local w rc
|
|
447
|
+
w="$(warm_route "$url" "$WARM_TIMEOUT" "$WARM_STREAK")"; rc=$?
|
|
448
|
+
say "worktree-preview: warm $w"
|
|
449
|
+
if [ $rc -ne 0 ]; then
|
|
450
|
+
say "--- last 25 lines of $log ---"; tail -25 "$log" >&2
|
|
451
|
+
# Free the port and kill the server we booted. A RED that leaves a server
|
|
452
|
+
# running and a port claimed makes the next attempt allocate a SECOND port
|
|
453
|
+
# and leaves 128M of .vite behind; the tree is kept for forensics.
|
|
454
|
+
cmd_down "$slug" --keep-worktree >&2 || true
|
|
455
|
+
echo "worktree-preview up: RED slug=$slug port=$port url=$url ($w) — server killed, port freed, worktree kept at $wt"
|
|
456
|
+
return 1
|
|
457
|
+
fi
|
|
458
|
+
|
|
459
|
+
# Record it the way the spec asks the task to carry it.
|
|
460
|
+
echo "tag preview:$port"
|
|
461
|
+
echo "note $url"
|
|
462
|
+
echo "worktree-preview up: GREEN slug=$slug port=$port pid=$pid url=$url ($w)"
|
|
463
|
+
return 0
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
# down — frees THIS port. Never sweeps the range: /kill's sweep also kills a
|
|
468
|
+
# neighbour project's dev server (it names apps/one/site on 4322).
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
cmd_down() {
|
|
471
|
+
local slug="${1:-}" keep=0
|
|
472
|
+
[ -n "$slug" ] || die "down needs a slug"
|
|
473
|
+
shift || true
|
|
474
|
+
[ "${1:-}" = "--keep-worktree" ] && keep=1
|
|
475
|
+
local wt="$WT_DIR/$slug" app="$WT_DIR/$slug/$APP_DIR" state="$WT_DIR/$slug/$APP_DIR/.preview"
|
|
476
|
+
local port="" pid=""
|
|
477
|
+
[ -f "$state/port" ] && port="$(cat "$state/port")"
|
|
478
|
+
[ -f "$state/pid" ] && pid="$(cat "$state/pid")"
|
|
479
|
+
|
|
480
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
481
|
+
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
|
|
482
|
+
sleep 1
|
|
483
|
+
kill -0 "$pid" 2>/dev/null && { kill -9 -- "-$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true; }
|
|
484
|
+
fi
|
|
485
|
+
if [ -n "$port" ]; then
|
|
486
|
+
local victim
|
|
487
|
+
while IFS= read -r victim; do
|
|
488
|
+
[ -n "$victim" ] && kill -9 "$victim" 2>/dev/null
|
|
489
|
+
done < <(lsof -ti:"$port" 2>/dev/null)
|
|
490
|
+
port_release "$port"
|
|
491
|
+
fi
|
|
492
|
+
# Keeping the worktree "for forensics" while deleting the ONLY artifact that
|
|
493
|
+
# says why it failed is not forensics. On the --keep-worktree path preserve
|
|
494
|
+
# astro.log; drop only the live-state files. (Measured: when a preview died on
|
|
495
|
+
# EADDRINUSE the log was gone and the failure had to be reconstructed from the
|
|
496
|
+
# caller's captured stderr.)
|
|
497
|
+
# Read the ownership marker BEFORE the state dir is cleared — it lives there.
|
|
498
|
+
local owned=0; [ -f "$state/owned" ] && owned=1
|
|
499
|
+
if [ "$keep" -eq 1 ] && [ -f "$state/astro.log" ]; then
|
|
500
|
+
mv "$state/astro.log" "$state/astro.log.failed" 2>/dev/null || true
|
|
501
|
+
rm -f "$state/pid" "$state/port" "$state/url" 2>/dev/null || true
|
|
502
|
+
else
|
|
503
|
+
rm -rf "$state" 2>/dev/null || true
|
|
504
|
+
fi
|
|
505
|
+
|
|
506
|
+
if [ "$keep" -eq 0 ] && [ -d "$wt" ]; then
|
|
507
|
+
if [ "$owned" -eq 1 ]; then
|
|
508
|
+
git -C "$SOURCE_ROOT" worktree remove --force "$wt" >/dev/null 2>&1 || rm -rf "$wt"
|
|
509
|
+
git -C "$SOURCE_ROOT" worktree prune >/dev/null 2>&1 || true
|
|
510
|
+
else
|
|
511
|
+
say "worktree-preview down: worktree KEPT at $wt — not created by this script (no .preview/owned marker), so it is not ours to remove"
|
|
512
|
+
fi
|
|
513
|
+
fi
|
|
514
|
+
local still="bound"; [ -z "$port" ] && still="n/a"
|
|
515
|
+
[ -n "$port" ] && { port_is_free "$port" && still="free"; }
|
|
516
|
+
echo "worktree-preview down: GREEN slug=$slug port=${port:-none} port_now=$still worktree=$([ -d "$wt" ] && echo kept || echo removed)"
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
cmd_list() {
|
|
520
|
+
local wt slug d port url alive
|
|
521
|
+
printf '%-28s %-6s %-6s %s\n' SLUG PORT STATE URL
|
|
522
|
+
for wt in "$WT_DIR"/*; do
|
|
523
|
+
[ -d "$wt" ] || continue
|
|
524
|
+
d="$wt/$APP_DIR/.preview"; [ -d "$d" ] || continue
|
|
525
|
+
slug="$(basename "$wt")"
|
|
526
|
+
port="$(cat "$d/port" 2>/dev/null)"; url="$(cat "$d/url" 2>/dev/null)"
|
|
527
|
+
alive=down; [ -n "$port" ] && { port_is_free "$port" || alive=up; }
|
|
528
|
+
printf '%-28s %-6s %-6s %s\n' "$slug" "${port:-?}" "$alive" "${url:-?}"
|
|
529
|
+
done
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# --self-test
|
|
534
|
+
#
|
|
535
|
+
# A. the warm loop actually WAITS (stub servers, no astro)
|
|
536
|
+
# B. the isolation gate goes RED on the shared-cache arrangement
|
|
537
|
+
# C. TWO previews, concurrent, both 200 (the whole point)
|
|
538
|
+
#
|
|
539
|
+
# A one-server test is exactly what missed the defect the first time.
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
ST_FAIL=0
|
|
542
|
+
st_ok() { printf ' ok %s\n' "$*"; }
|
|
543
|
+
st_fail() { printf ' FAIL %s\n' "$*" >&2; ST_FAIL=$((ST_FAIL + 1)); }
|
|
544
|
+
st_head() { printf '\n== %s\n' "$*"; }
|
|
545
|
+
|
|
546
|
+
# A stub HTTP server whose status codes are scripted: "<prefix>|<cycle>".
|
|
547
|
+
# "500,500|200" -> 500, 500, then 200 forever (a cold cache settling)
|
|
548
|
+
# "|200,500" -> 200,500,200,500 forever (a flap that never settles)
|
|
549
|
+
# Kill a stub without bash's "Terminated: 15" job notification landing on stdout.
|
|
550
|
+
st_stub_kill() {
|
|
551
|
+
[ -n "${1:-}" ] || return 0
|
|
552
|
+
kill "$1" 2>/dev/null
|
|
553
|
+
wait "$1" 2>/dev/null
|
|
554
|
+
return 0
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
st_stub() {
|
|
558
|
+
local port="$1" pattern="$2" pidfile="$3"
|
|
559
|
+
node -e '
|
|
560
|
+
const http = require("http");
|
|
561
|
+
const [pre, cyc] = process.argv[2].split("|");
|
|
562
|
+
const prefix = pre ? pre.split(",").map(Number) : [];
|
|
563
|
+
const cycle = cyc ? cyc.split(",").map(Number) : [200];
|
|
564
|
+
let i = 0;
|
|
565
|
+
http.createServer((req, res) => {
|
|
566
|
+
const c = i < prefix.length ? prefix[i] : cycle[(i - prefix.length) % cycle.length];
|
|
567
|
+
i++;
|
|
568
|
+
res.writeHead(c, { "content-type": "text/plain" });
|
|
569
|
+
res.end(String(c));
|
|
570
|
+
}).listen(parseInt(process.argv[1]), "127.0.0.1");
|
|
571
|
+
' "$port" "$pattern" &
|
|
572
|
+
echo $! > "$pidfile"
|
|
573
|
+
local t=0
|
|
574
|
+
while [ $t -lt 60 ]; do port_is_free "$port" || return 0; sleep 0.1; t=$((t + 1)); done
|
|
575
|
+
return 1
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
self_test() {
|
|
579
|
+
local tmp; tmp="$(mktemp -d "${TMPDIR:-/tmp}/wtpreview-selftest.XXXXXX")"
|
|
580
|
+
trap 'rm -rf "$tmp"' RETURN
|
|
581
|
+
|
|
582
|
+
# ---------------------------------------------------------------- A. warm
|
|
583
|
+
st_head "A — the warm loop WAITS (a cold .vite gave 500, 000, 200)"
|
|
584
|
+
local sp=4399 out rc naive
|
|
585
|
+
st_stub "$sp" "500,500|200" "$tmp/stub1.pid" || { st_fail "stub server did not bind"; return 1; }
|
|
586
|
+
naive="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$sp/")"
|
|
587
|
+
[ "$naive" = "500" ] && st_ok "a naive SINGLE probe sees $naive — this is what a human would have been handed" \
|
|
588
|
+
|| st_fail "expected the first probe to be 500, got $naive"
|
|
589
|
+
out="$(warm_route "http://127.0.0.1:$sp/" 30 3)"; rc=$?
|
|
590
|
+
if [ $rc -eq 0 ] && grep -q 'verdict=GREEN' <<<"$out"; then
|
|
591
|
+
st_ok "warm_route waited through the flap and returned GREEN: $out"
|
|
592
|
+
# It CANNOT have returned on the first probe: the stub served 500 first.
|
|
593
|
+
# >= streak+1 probes is the arithmetic proof that it waited.
|
|
594
|
+
grep -qE 'probes=([4-9]|[1-9][0-9])' <<<"$out" && grep -q 'first=500' <<<"$out" \
|
|
595
|
+
&& st_ok "it needed >=4 probes and its FIRST was 500 — it waited, it did not get lucky" \
|
|
596
|
+
|| st_fail "warm returned too early: $out"
|
|
597
|
+
else
|
|
598
|
+
st_fail "warm_route should have settled: $out"
|
|
599
|
+
fi
|
|
600
|
+
st_stub_kill "$(cat "$tmp/stub1.pid")"
|
|
601
|
+
|
|
602
|
+
st_head "A(red) — a route that NEVER settles must NOT pass"
|
|
603
|
+
sp=4398
|
|
604
|
+
st_stub "$sp" "|200,500" "$tmp/stub2.pid" || { st_fail "stub server did not bind"; return 1; }
|
|
605
|
+
out="$(warm_route "http://127.0.0.1:$sp/" 8 3)"; rc=$?
|
|
606
|
+
if [ $rc -ne 0 ] && grep -q 'verdict=RED' <<<"$out"; then
|
|
607
|
+
st_ok "RED as required — a lucky 200 in an alternating flap does not pass: $out"
|
|
608
|
+
grep -q 'streak_broken=[1-9]' <<<"$out" && st_ok "and it SAW the streak break (streak_broken>0)" \
|
|
609
|
+
|| st_fail "expected streak_broken>0: $out"
|
|
610
|
+
else
|
|
611
|
+
st_fail "flapping route passed the warm gate — the streak rule is not enforced: $out"
|
|
612
|
+
fi
|
|
613
|
+
st_stub_kill "$(cat "$tmp/stub2.pid")"
|
|
614
|
+
|
|
615
|
+
# ------------------------------------------------------- B. isolation gate
|
|
616
|
+
st_head "B — the isolation gate goes RED on the shared-cache arrangement"
|
|
617
|
+
local shared="$tmp/shared_nm"; mkdir -p "$shared/.bin" "$shared/.vite"
|
|
618
|
+
local i; for i in $(seq 1 120); do mkdir -p "$shared/pkg$i"; done
|
|
619
|
+
|
|
620
|
+
local good="$tmp/good"; mkdir -p "$good"; : > "$good/.env"
|
|
621
|
+
link_node_modules "$shared" "$good/node_modules" >/dev/null
|
|
622
|
+
if out="$(check_isolation "$good")"; then
|
|
623
|
+
st_ok "contents-linked tree is GREEN: $(tail -1 <<<"$out")"
|
|
624
|
+
else
|
|
625
|
+
st_fail "the GOOD arrangement went red:"; sed 's/^/ /' <<<"$out" >&2
|
|
626
|
+
fi
|
|
627
|
+
|
|
628
|
+
local bad="$tmp/bad"; mkdir -p "$bad"; : > "$bad/.env"
|
|
629
|
+
ln -s "$shared" "$bad/node_modules" # the FATAL arrangement, forced
|
|
630
|
+
if out="$(check_isolation "$bad")"; then
|
|
631
|
+
st_fail "shared-cache arrangement passed the gate — the checker cannot go red"
|
|
632
|
+
else
|
|
633
|
+
st_ok "RED as required. Verbatim:"; sed 's/^/ | /' <<<"$out"
|
|
634
|
+
fi
|
|
635
|
+
|
|
636
|
+
local half="$tmp/half"; mkdir -p "$half"; : > "$half/.env"
|
|
637
|
+
link_node_modules "$shared" "$half/node_modules" >/dev/null
|
|
638
|
+
rm -rf "$half/node_modules/.vite"; ln -s "$shared/.vite" "$half/node_modules/.vite"
|
|
639
|
+
if check_isolation "$half" >/dev/null; then
|
|
640
|
+
st_fail "a shared .vite INSIDE a real node_modules passed — the subtler half is unguarded"
|
|
641
|
+
else
|
|
642
|
+
st_ok "RED on a shared .vite inside an otherwise-local node_modules"
|
|
643
|
+
fi
|
|
644
|
+
|
|
645
|
+
# ------------------------------------------------- C. TWO REAL PREVIEWS
|
|
646
|
+
if [ "${PREVIEW_SELFTEST_SERVERS:-1}" != "1" ]; then
|
|
647
|
+
st_head "C — SKIPPED (PREVIEW_SELFTEST_SERVERS=0). This is the whole point; do not skip it in CI."
|
|
648
|
+
else
|
|
649
|
+
st_head "C — two previews, concurrent, both 200 (a one-server test missed this defect)"
|
|
650
|
+
local A=preview-selftest-a B=preview-selftest-b
|
|
651
|
+
# WT_DIR is computed ONCE at load; a `PREVIEW_WT_DIR=… cmd_up` prefix sets
|
|
652
|
+
# the env var but not the already-assigned shell variable, so the first run
|
|
653
|
+
# of this test silently built its worktrees in the SHARED .do-worktrees.
|
|
654
|
+
# Assign the real variable.
|
|
655
|
+
local saved_wt="$WT_DIR"; WT_DIR="$tmp/wt"
|
|
656
|
+
cmd_down "$A" >/dev/null 2>&1 || true
|
|
657
|
+
cmd_down "$B" >/dev/null 2>&1 || true
|
|
658
|
+
git -C "$SOURCE_ROOT" branch -D "feat/$A" >/dev/null 2>&1 || true
|
|
659
|
+
git -C "$SOURCE_ROOT" branch -D "feat/$B" >/dev/null 2>&1 || true
|
|
660
|
+
git -C "$SOURCE_ROOT" worktree prune >/dev/null 2>&1 || true
|
|
661
|
+
|
|
662
|
+
local base="${PREVIEW_SELFTEST_BASE:-$(git rev-parse HEAD)}"
|
|
663
|
+
local ra rb
|
|
664
|
+
( cmd_up "$A" --route "${PREVIEW_SELFTEST_ROUTE:-/factory}" --base "$base" > "$tmp/a.out" 2>"$tmp/a.err" ) &
|
|
665
|
+
local pa=$!
|
|
666
|
+
( cmd_up "$B" --route "${PREVIEW_SELFTEST_ROUTE:-/factory}" --base "$base" > "$tmp/b.out" 2>"$tmp/b.err" ) &
|
|
667
|
+
local pb=$!
|
|
668
|
+
wait $pa; ra=$?
|
|
669
|
+
wait $pb; rb=$?
|
|
670
|
+
|
|
671
|
+
printf ' --- A ---\n'; sed 's/^/ | /' "$tmp/a.out"
|
|
672
|
+
printf ' --- B ---\n'; sed 's/^/ | /' "$tmp/b.out"
|
|
673
|
+
[ $ra -eq 0 ] && st_ok "preview A: $(tail -1 "$tmp/a.out")" || { st_fail "preview A did not come up"; tail -20 "$tmp/a.err" | sed 's/^/ /' >&2; }
|
|
674
|
+
[ $rb -eq 0 ] && st_ok "preview B: $(tail -1 "$tmp/b.out")" || { st_fail "preview B did not come up"; tail -20 "$tmp/b.err" | sed 's/^/ /' >&2; }
|
|
675
|
+
|
|
676
|
+
local pA pB
|
|
677
|
+
pA="$(cat "$tmp/wt/$A/$APP_DIR/.preview/port" 2>/dev/null)"
|
|
678
|
+
pB="$(cat "$tmp/wt/$B/$APP_DIR/.preview/port" 2>/dev/null)"
|
|
679
|
+
[ -n "$pA" ] && [ -n "$pB" ] && [ "$pA" != "$pB" ] && st_ok "distinct ports: A=$pA B=$pB (allocated, not swept)" \
|
|
680
|
+
|| st_fail "ports collided or were not recorded: A=${pA:-none} B=${pB:-none}"
|
|
681
|
+
|
|
682
|
+
# The real defect was a SHARED .vite. Prove each server wrote its OWN.
|
|
683
|
+
# `find` FOLLOWS a symlinked .vite into main's populated cache, so a
|
|
684
|
+
# non-zero count alone is satisfied by the very arrangement under test —
|
|
685
|
+
# assert the dirs are real AND that the two caches are not the same inode.
|
|
686
|
+
local nmA="$tmp/wt/$A/$APP_DIR/node_modules" nmB="$tmp/wt/$B/$APP_DIR/node_modules"
|
|
687
|
+
local vA vB iA iB
|
|
688
|
+
vA="$(find "$nmA/.vite" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
|
689
|
+
vB="$(find "$nmB/.vite" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
|
690
|
+
iA="$(stat -f '%d:%i' "$nmA/.vite" 2>/dev/null)"
|
|
691
|
+
iB="$(stat -f '%d:%i' "$nmB/.vite" 2>/dev/null)"
|
|
692
|
+
if [ ! -L "$nmA/.vite" ] && [ ! -L "$nmB/.vite" ] && [ -n "$iA" ] && [ "$iA" != "$iB" ] \
|
|
693
|
+
&& [ "${vA:-0}" -gt 0 ] && [ "${vB:-0}" -gt 0 ]; then
|
|
694
|
+
st_ok "each server wrote its OWN .vite — distinct inodes ($iA vs $iB), A=$vA files, B=$vB files"
|
|
695
|
+
else
|
|
696
|
+
st_fail "the two .vite caches are not independent: symlinkA=$([ -L "$nmA/.vite" ] && echo yes || echo no) symlinkB=$([ -L "$nmB/.vite" ] && echo yes || echo no) inodes=$iA/$iB files=${vA:-0}/${vB:-0}"
|
|
697
|
+
fi
|
|
698
|
+
|
|
699
|
+
# The worktree keeps its own .wrangler — local D1/KV/R2 per tree, so a
|
|
700
|
+
# preview cannot corrupt the human's database. Observed, then asserted.
|
|
701
|
+
local wA="$tmp/wt/$A/$APP_DIR/.wrangler" wB="$tmp/wt/$B/$APP_DIR/.wrangler"
|
|
702
|
+
if [ -d "$wA" ] && [ ! -L "$wA" ] && [ -d "$wB" ] && [ ! -L "$wB" ]; then
|
|
703
|
+
st_ok "each worktree has its OWN real .wrangler (local D1/KV/R2 is per-tree)"
|
|
704
|
+
elif [ ! -e "$wA" ] && [ ! -e "$wB" ]; then
|
|
705
|
+
st_ok "no .wrangler in either worktree — astro dev never created one, so there is nothing shared to corrupt"
|
|
706
|
+
else
|
|
707
|
+
st_fail ".wrangler is not per-tree: A=$([ -L "$wA" ] && echo symlink || ([ -d "$wA" ] && echo dir || echo absent)) B=$([ -L "$wB" ] && echo symlink || ([ -d "$wB" ] && echo dir || echo absent))"
|
|
708
|
+
fi
|
|
709
|
+
|
|
710
|
+
# Both still serving AFTER the other has been up. The measured desync
|
|
711
|
+
# appeared on the SECOND and THIRD request while both were serving
|
|
712
|
+
# (4325 -> 000, 4326 -> 500), so probe INTERLEAVED, three times each —
|
|
713
|
+
# one probe apiece only proves each answered once after boot.
|
|
714
|
+
local uA uB k cA cB okA=1 okB=1 seqA="" seqB=""
|
|
715
|
+
uA="$(cat "$tmp/wt/$A/$APP_DIR/.preview/url" 2>/dev/null)"
|
|
716
|
+
uB="$(cat "$tmp/wt/$B/$APP_DIR/.preview/url" 2>/dev/null)"
|
|
717
|
+
for k in 1 2 3; do
|
|
718
|
+
cA="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$uA" 2>/dev/null)"; [ ${#cA} -ne 3 ] && cA=000
|
|
719
|
+
cB="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$uB" 2>/dev/null)"; [ ${#cB} -ne 3 ] && cB=000
|
|
720
|
+
seqA="$seqA $cA"; seqB="$seqB $cB"
|
|
721
|
+
[ "$cA" = "200" ] || okA=0
|
|
722
|
+
[ "$cB" = "200" ] || okB=0
|
|
723
|
+
done
|
|
724
|
+
[ "$okA" = 1 ] && [ "$okB" = 1 ] \
|
|
725
|
+
&& st_ok "both STILL 200 under interleaved traffic — A:$seqA B:$seqB" \
|
|
726
|
+
|| st_fail "concurrent servers interfered — A:$seqA B:$seqB (the shared-cache signature is 000 and 500)"
|
|
727
|
+
|
|
728
|
+
cmd_down "$A" | sed 's/^/ /'
|
|
729
|
+
cmd_down "$B" | sed 's/^/ /'
|
|
730
|
+
[ -n "$pA" ] && { port_is_free "$pA" && st_ok "teardown freed port $pA" || st_fail "port $pA still bound after down"; }
|
|
731
|
+
[ -n "$pB" ] && { port_is_free "$pB" && st_ok "teardown freed port $pB" || st_fail "port $pB still bound after down"; }
|
|
732
|
+
git -C "$SOURCE_ROOT" branch -D "feat/$A" >/dev/null 2>&1 || true
|
|
733
|
+
git -C "$SOURCE_ROOT" branch -D "feat/$B" >/dev/null 2>&1 || true
|
|
734
|
+
git -C "$SOURCE_ROOT" worktree prune >/dev/null 2>&1 || true
|
|
735
|
+
port_release "${pA:-0}"; port_release "${pB:-0}"
|
|
736
|
+
WT_DIR="$saved_wt"
|
|
737
|
+
fi
|
|
738
|
+
|
|
739
|
+
# ----------------------------------------------- D. the port arbiter
|
|
740
|
+
# This section exists because C caught the real thing: both previews were
|
|
741
|
+
# handed 4322. Guard the fix, and keep the reason for it honest.
|
|
742
|
+
st_head "D — the port arbiter gives exactly one winner"
|
|
743
|
+
local saved_lock="$PORT_LOCK_DIR"
|
|
744
|
+
PORT_LOCK_DIR="$tmp/ports"; export PORT_LOCK_DIR
|
|
745
|
+
local i wins
|
|
746
|
+
: > "$tmp/race.out"
|
|
747
|
+
for i in $(seq 1 12); do
|
|
748
|
+
( PORT_LOCK_DIR="$tmp/ports" bash "$0" __reserve 65000 "racer$i" >/dev/null 2>&1 \
|
|
749
|
+
&& echo win >> "$tmp/race.out" ) &
|
|
750
|
+
done
|
|
751
|
+
wait
|
|
752
|
+
wins="$(grep -c win "$tmp/race.out" 2>/dev/null | tr -d ' ')"
|
|
753
|
+
# Assert the ISOLATION before the count. A racer that wrote to the real
|
|
754
|
+
# machine-wide dir would be racing whatever the last run left there, and a
|
|
755
|
+
# 0-winner result would look like a broken arbiter instead of a leaky test.
|
|
756
|
+
[ -d "$tmp/ports/65000" ] \
|
|
757
|
+
&& st_ok "the racers used the ISOLATED lock dir (PORT_LOCK_DIR was honoured, not overwritten)" \
|
|
758
|
+
|| st_fail "no reservation under $tmp/ports — PORT_LOCK_DIR was ignored, so this race was run against shared state"
|
|
759
|
+
[ "${wins:-0}" = "1" ] && st_ok "12 independent racers, exactly $wins winner (mkdir(2) is the arbiter)" \
|
|
760
|
+
|| st_fail "12 racers produced ${wins:-0} winners — the port arbiter does not exclude"
|
|
761
|
+
|
|
762
|
+
st_head "D(red) — WHY claim_take is not used here: it double-grants across subshells"
|
|
763
|
+
if declare -F claim_take >/dev/null 2>&1; then
|
|
764
|
+
local before after
|
|
765
|
+
GOVERN_CLAIMS_DIR="$tmp/claims"; export GOVERN_CLAIMS_DIR
|
|
766
|
+
mkdir -p "$GOVERN_CLAIMS_DIR"
|
|
767
|
+
: > "$tmp/claim.out"
|
|
768
|
+
( claim_take "wtpreview-selftest-region" one >/dev/null 2>&1 && echo win >> "$tmp/claim.out" ) &
|
|
769
|
+
( claim_take "wtpreview-selftest-region" two >/dev/null 2>&1 && echo win >> "$tmp/claim.out" ) &
|
|
770
|
+
wait
|
|
771
|
+
after="$(grep -c win "$tmp/claim.out" 2>/dev/null | tr -d ' ')"
|
|
772
|
+
if [ "${after:-0}" -ge 2 ]; then
|
|
773
|
+
st_ok "confirmed: claim_take granted the SAME region to $after subshells of one shell."
|
|
774
|
+
st_ok " bash $BASH_VERSION has no BASHPID, so \$\$ is identical in both — this is the"
|
|
775
|
+
st_ok " measured cause of 'A port=4322 / B port=4322'. Hence reserve_port."
|
|
776
|
+
else
|
|
777
|
+
st_fail "claim_take no longer double-grants ($after winner) — the note above reserve_port is STALE, re-check whether govern.sh can arbitrate here"
|
|
778
|
+
fi
|
|
779
|
+
unset GOVERN_CLAIMS_DIR
|
|
780
|
+
else
|
|
781
|
+
st_fail "govern.sh not loaded — cannot demonstrate the claim_take trap"
|
|
782
|
+
fi
|
|
783
|
+
PORT_LOCK_DIR="$saved_lock"
|
|
784
|
+
|
|
785
|
+
# ------------------------------------- E. the boot banner is READ, not assumed
|
|
786
|
+
# A check that cannot match anything is decoration. This one shipped dead: the
|
|
787
|
+
# grep looked for `localhost:` and astro prints `127.0.0.1:` under --host.
|
|
788
|
+
st_head "E — log_banner_port reads the port astro ACTUALLY bound"
|
|
789
|
+
local L="$tmp/banner.log" got
|
|
790
|
+
printf '%s\n' '18:03:40 [WARN] [vite] Default inspector port 9229 not available, using 9231 instead' \
|
|
791
|
+
'┃ Local http://127.0.0.1:4323/' > "$L"
|
|
792
|
+
got="$(log_banner_port "$L")"
|
|
793
|
+
[ "$got" = "4323" ] && st_ok "the REAL banner form (--host 127.0.0.1) parses to $got" \
|
|
794
|
+
|| st_fail "the real banner form parsed to '${got:-empty}', expected 4323 — the check is dead again"
|
|
795
|
+
|
|
796
|
+
printf '%s\n' '┃ Local http://localhost:4327/' > "$L"
|
|
797
|
+
got="$(log_banner_port "$L")"
|
|
798
|
+
[ "$got" = "4327" ] && st_ok "the localhost form parses to $got" \
|
|
799
|
+
|| st_fail "localhost form parsed to '${got:-empty}'"
|
|
800
|
+
|
|
801
|
+
st_head "E(red) — a server that took a DIFFERENT port must be caught"
|
|
802
|
+
printf '%s\n' '┃ Local http://127.0.0.1:4399/' > "$L"
|
|
803
|
+
got="$(log_banner_port "$L")"
|
|
804
|
+
if [ -n "$got" ] && [ "$got" != "4322" ]; then
|
|
805
|
+
st_ok "RED as required: asked for 4322, the log says the server bound $got — cmd_up refuses and tears down"
|
|
806
|
+
else
|
|
807
|
+
st_fail "a mismatched banner was not detected (parsed '${got:-empty}') — a second preview could silently probe the first server's port"
|
|
808
|
+
fi
|
|
809
|
+
printf '%s\n' 'no banner here at all' > "$L"
|
|
810
|
+
[ -z "$(log_banner_port "$L")" ] && st_ok "a log with no banner yields empty, so the boot loop keeps waiting rather than passing" \
|
|
811
|
+
|| st_fail "a bannerless log produced a port"
|
|
812
|
+
|
|
813
|
+
# ------------------------------- F. the RED path's side effects are REAL
|
|
814
|
+
# cmd_up's failure line promises three things: the server is killed, the port
|
|
815
|
+
# is freed, and the worktree is kept WITH its log. Those were only ever
|
|
816
|
+
# verified by reading the code — and one of them was false: `rm -rf "$state"`
|
|
817
|
+
# deleted the very astro.log the message called forensics. Drive it.
|
|
818
|
+
st_head "F — a RED teardown frees the port and KEEPS the log"
|
|
819
|
+
local fslug=preview-selftest-red fwt="$tmp/redwt" fapp
|
|
820
|
+
fapp="$fwt/$fslug/$APP_DIR"
|
|
821
|
+
mkdir -p "$fapp/.preview"
|
|
822
|
+
printf 'Error: listen EADDRINUSE: address already in use 127.0.0.1:9231\n' > "$fapp/.preview/astro.log"
|
|
823
|
+
echo 999999 > "$fapp/.preview/pid" # a pid that is not alive
|
|
824
|
+
echo 65001 > "$fapp/.preview/port"
|
|
825
|
+
echo "http://localhost:65001/x" > "$fapp/.preview/url"
|
|
826
|
+
PORT_LOCK_DIR="$tmp/ports"; export PORT_LOCK_DIR
|
|
827
|
+
reserve_port 65001 "$fslug" >/dev/null 2>&1
|
|
828
|
+
[ -d "$tmp/ports/65001" ] && st_ok "reservation for 65001 exists before teardown" \
|
|
829
|
+
|| st_fail "could not set up the reservation fixture"
|
|
830
|
+
|
|
831
|
+
local saved2="$WT_DIR"; WT_DIR="$fwt"
|
|
832
|
+
cmd_down "$fslug" --keep-worktree > "$tmp/red-down.out" 2>&1
|
|
833
|
+
WT_DIR="$saved2"
|
|
834
|
+
|
|
835
|
+
[ -f "$fapp/.preview/astro.log.failed" ] \
|
|
836
|
+
&& st_ok "the log SURVIVED as astro.log.failed — the RED is actually diagnosable" \
|
|
837
|
+
|| st_fail "astro.log.failed is missing: a RED that keeps the worktree but deletes the log is not forensics"
|
|
838
|
+
grep -q EADDRINUSE "$fapp/.preview/astro.log.failed" 2>/dev/null \
|
|
839
|
+
&& st_ok "and it still carries the failure reason verbatim" \
|
|
840
|
+
|| st_fail "the kept log lost its content"
|
|
841
|
+
{ [ ! -f "$fapp/.preview/pid" ] && [ ! -f "$fapp/.preview/port" ] && [ ! -f "$fapp/.preview/url" ]; } \
|
|
842
|
+
&& st_ok "the live-state files (pid/port/url) are gone, so nothing reads a dead preview as up" \
|
|
843
|
+
|| st_fail "stale live-state survived the teardown"
|
|
844
|
+
[ ! -d "$tmp/ports/65001" ] \
|
|
845
|
+
&& st_ok "the port reservation was released — the next allocator can take 65001" \
|
|
846
|
+
|| st_fail "the port reservation LEAKED; the range would shrink by one every failure"
|
|
847
|
+
[ -d "$fwt/$fslug" ] && st_ok "the worktree itself was kept, as the message claims" \
|
|
848
|
+
|| st_fail "--keep-worktree removed the tree"
|
|
849
|
+
PORT_LOCK_DIR="$saved_lock"
|
|
850
|
+
|
|
851
|
+
printf '\n'
|
|
852
|
+
if [ "$ST_FAIL" -eq 0 ]; then
|
|
853
|
+
echo "worktree-preview --self-test: GREEN (0 failures)"; return 0
|
|
854
|
+
fi
|
|
855
|
+
echo "worktree-preview --self-test: RED ($ST_FAIL failures)"; return 1
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
# ---------------------------------------------------------------------------
|
|
859
|
+
case "${1:---help}" in
|
|
860
|
+
up) shift; cmd_up "$@" ;;
|
|
861
|
+
down) shift; cmd_down "$@" ;;
|
|
862
|
+
list) shift; cmd_list "$@" ;;
|
|
863
|
+
check)
|
|
864
|
+
shift
|
|
865
|
+
if [ "${1:-}" = "--dir" ]; then check_isolation "$2";
|
|
866
|
+
else check_isolation "$WT_DIR/${1:?check needs a slug}/$APP_DIR"; fi ;;
|
|
867
|
+
--self-test)
|
|
868
|
+
# A heavy gate goes through the governor. gate-run.sh exports
|
|
869
|
+
# GOVERN_IN_GATE=1, so this re-exec cannot recurse.
|
|
870
|
+
if [ "${GOVERN_IN_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/gate-run.sh" ]; then
|
|
871
|
+
exec "$SCRIPT_DIR/gate-run.sh" worktree-preview-selftest -- "$0" --self-test
|
|
872
|
+
fi
|
|
873
|
+
self_test ;;
|
|
874
|
+
__reserve) # internal — one fresh process taking one port, for --self-test D
|
|
875
|
+
shift; reserve_port "${1:?port}" "${2:-racer}" ;;
|
|
876
|
+
-h|--help|help)
|
|
877
|
+
sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
878
|
+
*) die "unknown command: $1" ;;
|
|
879
|
+
esac
|