From f3f40b51679f96cde6286991f854321fcbbe28d3 Mon Sep 17 00:00:00 2001 From: "skye.yue" Date: Tue, 19 May 2026 15:44:00 +0800 Subject: [PATCH] feat: improve ota packaging and people-flow runtime --- README.md | 35 ++++++ deploy/install-managed-portal-ota.sh | 110 +++++++++++++++++- deploy/package-managed-portal-ota.sh | 98 ++++++++++++++++ .../src/people_flow/models.py | 2 +- .../src/people_flow/pipeline.py | 80 ++++++++++++- .../tests/test_pipeline.py | 104 +++++++++++++++++ tasks/lessons.md | 12 ++ tasks/todo.md | 40 +++---- 8 files changed, 452 insertions(+), 29 deletions(-) create mode 100644 deploy/package-managed-portal-ota.sh create mode 100644 managed/people_flow_project/tests/test_pipeline.py diff --git a/README.md b/README.md index b7b67fa..2dab2d7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,41 @@ docker compose --env-file managed-portal.env up -d --build http://<服务器IP>:13000/ ``` +## OTA 发布包 + +OTA 安装脚本默认从发布目录下载一个主 ZIP,再用 `deploy/docker-compose.ota-release.yml` 启动服务。主 ZIP 现在只需要包含安装端真正依赖的内容: + +- `deploy/docker-compose.ota-release.yml` +- `deploy/Dockerfile.runtime-overlay` +- `deploy/managed-portal.release.env` +- `release-manifest.env` +- `managed/store_dwell_alert/config/` +- `managed/people_flow_project/config/` + +仓库提供了一个最小打包脚本:`deploy/package-managed-portal-ota.sh`。 + +示例: + +```bash +RELEASE_VERSION=20260518-7b32b21-11 \ +RELEASE_MANIFEST_SOURCE=/path/to/release-manifest.env \ +RELEASE_ENV_SOURCE=deploy/managed-portal.10.8.0.12.env \ +sh deploy/package-managed-portal-ota.sh +``` + +默认情况下,主 ZIP 不包含 `managed/people_flow_project/weights/`。OTA installer 会优先复用主机上的共享权重目录,避免每次只改安装脚本或配置时都重复打包、上传大体积权重。 + +只有两种场景才建议把权重重新打进 ZIP: + +- 首次在一台没有预置权重的新主机上安装 +- `people_flow_project` 的权重文件本身发生变更 + +这两种场景可以临时打开: + +```bash +INCLUDE_WEIGHTS=1 sh deploy/package-managed-portal-ota.sh +``` + ## 模型权重 子服务镜像构建前需要以下权重文件: diff --git a/deploy/install-managed-portal-ota.sh b/deploy/install-managed-portal-ota.sh index 2032a7a..cf6baab 100644 --- a/deploy/install-managed-portal-ota.sh +++ b/deploy/install-managed-portal-ota.sh @@ -1,11 +1,13 @@ #!/usr/bin/env sh set -eu -RELEASE_VERSION="${RELEASE_VERSION:-20260513-330373b-11}" +RELEASE_VERSION="${RELEASE_VERSION:-20260518-7b32b21-11}" BASE_URL="${BASE_URL:-http://10.8.0.1/ai_deploy}" BUNDLE_NAME="${BUNDLE_NAME:-managed-portal-${RELEASE_VERSION}.zip}" INSTALL_ROOT="${INSTALL_ROOT:-/opt/managed-portal-releases}" TARGET_DIR="${TARGET_DIR:-${INSTALL_ROOT}/managed-portal-${RELEASE_VERSION}}" +SHARED_ROOT="${SHARED_ROOT:-${INSTALL_ROOT}/shared}" +DEFAULT_PEOPLE_FLOW_WEIGHTS_DIR="${SHARED_ROOT}/people_flow_project/weights" require_command() { if ! command -v "$1" >/dev/null 2>&1; then @@ -14,6 +16,23 @@ require_command() { fi } +pull_or_use_local() { + image="$1" + + if docker pull "$image"; then + return 0 + fi + + echo "docker pull failed for $image, checking local image cache" >&2 + if docker image inspect "$image" >/dev/null 2>&1; then + echo "using existing local image $image" >&2 + return 0 + fi + + echo "image unavailable locally after pull failure: $image" >&2 + exit 1 +} + run_compose() { if command -v docker-compose >/dev/null 2>&1; then docker-compose "$@" @@ -22,6 +41,19 @@ run_compose() { docker compose "$@" } +dir_has_files() { + directory="$1" + [ -d "$directory" ] && [ -n "$(find "$directory" -mindepth 1 -print -quit 2>/dev/null)" ] +} + +copy_dir_contents() { + source_dir="$1" + target_dir="$2" + + mkdir -p "$target_dir" + cp -R "$source_dir"/. "$target_dir"/ +} + download_bundle() { tmp_dir="$1" bundle_zip="$tmp_dir/$BUNDLE_NAME" @@ -58,6 +90,66 @@ build_overlay_image() { printf '%s\n' "$overlay_image" } +clear_stale_runtime_state() { + people_flow_status="$TARGET_DIR/managed/people_flow_project/outputs/rtsp_stream/worker_status.json" + + if [ -f "$people_flow_status" ]; then + rm -f "$people_flow_status" + fi +} + +ensure_runtime_directories() { + mkdir -p \ + "$TARGET_DIR/managed/store_dwell_alert/config" \ + "$TARGET_DIR/managed/store_dwell_alert/data" \ + "$TARGET_DIR/managed/people_flow_project/config" \ + "$TARGET_DIR/managed/people_flow_project/outputs/rtsp_stream" +} + +find_existing_people_flow_weights() { + if dir_has_files "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then + printf '%s\n' "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + return 0 + fi + + for candidate in "$INSTALL_ROOT"/managed-portal-*/managed/people_flow_project/weights; do + if [ "$candidate" = "$TARGET_DIR/managed/people_flow_project/weights" ]; then + continue + fi + + if dir_has_files "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +prepare_people_flow_weights() { + bundle_weights_dir="$TARGET_DIR/managed/people_flow_project/weights" + source_weights_dir="" + + if dir_has_files "$bundle_weights_dir"; then + source_weights_dir="$bundle_weights_dir" + echo "seeding shared people-flow weights from bundle" >&2 + elif source_weights_dir="$(find_existing_people_flow_weights 2>/dev/null)"; then + echo "reusing existing people-flow weights from $source_weights_dir" >&2 + else + echo "people-flow weights not found; seed $MANAGED_PEOPLE_FLOW_WEIGHTS_DIR or include managed/people_flow_project/weights in the release zip" >&2 + exit 1 + fi + + if [ "$source_weights_dir" != "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" ]; then + rm -rf "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + copy_dir_contents "$source_weights_dir" "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + fi + + rm -rf "$bundle_weights_dir" + mkdir -p "$(dirname "$bundle_weights_dir")" + ln -s "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" "$bundle_weights_dir" +} + require_command curl require_command unzip require_command docker @@ -79,11 +171,18 @@ set -a . "$TARGET_DIR/release-manifest.env" set +a +MANAGED_PEOPLE_FLOW_WEIGHTS_DIR="${MANAGED_PEOPLE_FLOW_WEIGHTS_DIR:-$DEFAULT_PEOPLE_FLOW_WEIGHTS_DIR}" + +ensure_runtime_directories +prepare_people_flow_weights + +clear_stale_runtime_state + echo "pulling release images" -docker pull "$MANAGED_PORTAL_IMAGE" -docker pull "$MANAGED_PORTAL_WEB_IMAGE" -docker pull "$PEOPLE_FLOW_PROJECT_IMAGE" -docker pull "$STORE_DWELL_ALERT_IMAGE" +pull_or_use_local "$MANAGED_PORTAL_IMAGE" +pull_or_use_local "$MANAGED_PORTAL_WEB_IMAGE" +pull_or_use_local "$PEOPLE_FLOW_PROJECT_IMAGE" +pull_or_use_local "$STORE_DWELL_ALERT_IMAGE" PEOPLE_FLOW_PROJECT_IMAGE="$(build_overlay_image \ people-flow-project \ @@ -101,6 +200,7 @@ export MANAGED_PORTAL_IMAGE export MANAGED_PORTAL_WEB_IMAGE export PEOPLE_FLOW_PROJECT_IMAGE export STORE_DWELL_ALERT_IMAGE +export MANAGED_PEOPLE_FLOW_WEIGHTS_DIR cd "$TARGET_DIR/deploy" run_compose \ diff --git a/deploy/package-managed-portal-ota.sh b/deploy/package-managed-portal-ota.sh new file mode 100644 index 0000000..9badff1 --- /dev/null +++ b/deploy/package-managed-portal-ota.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" + +RELEASE_VERSION="${RELEASE_VERSION:?RELEASE_VERSION is required}" +RELEASE_MANIFEST_SOURCE="${RELEASE_MANIFEST_SOURCE:?RELEASE_MANIFEST_SOURCE is required}" +RELEASE_ENV_SOURCE="${RELEASE_ENV_SOURCE:-$SCRIPT_DIR/managed-portal.env}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist/ota}" +STAGE_DIR="${OUTPUT_DIR}/managed-portal-${RELEASE_VERSION}" +BUNDLE_PATH="${OUTPUT_DIR}/managed-portal-${RELEASE_VERSION}.zip" +INSTALLER_PATH="${OUTPUT_DIR}/install-managed-portal-${RELEASE_VERSION}.sh" +INCLUDE_WEIGHTS="${INCLUDE_WEIGHTS:-0}" +PEOPLE_FLOW_WEIGHTS_SOURCE="${PEOPLE_FLOW_WEIGHTS_SOURCE:-$REPO_ROOT/managed/people_flow_project/weights}" + +require_path() { + target="$1" + + if [ ! -e "$target" ]; then + echo "missing required path: $target" >&2 + exit 1 + fi +} + +dir_has_files() { + directory="$1" + [ -d "$directory" ] && [ -n "$(find "$directory" -mindepth 1 -print -quit 2>/dev/null)" ] +} + +copy_dir() { + source_dir="$1" + target_dir="$2" + + mkdir -p "$(dirname "$target_dir")" + cp -R "$source_dir" "$target_dir" +} + +require_path "$RELEASE_MANIFEST_SOURCE" +require_path "$RELEASE_ENV_SOURCE" +require_path "$SCRIPT_DIR/docker-compose.ota-release.yml" +require_path "$SCRIPT_DIR/Dockerfile.runtime-overlay" +require_path "$SCRIPT_DIR/install-managed-portal-ota.sh" +require_path "$REPO_ROOT/managed/store_dwell_alert/config" +require_path "$REPO_ROOT/managed/people_flow_project/config" + +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR/deploy" "$OUTPUT_DIR" + +cp "$SCRIPT_DIR/docker-compose.ota-release.yml" "$STAGE_DIR/deploy/docker-compose.ota-release.yml" +cp "$SCRIPT_DIR/Dockerfile.runtime-overlay" "$STAGE_DIR/deploy/Dockerfile.runtime-overlay" +cp "$RELEASE_ENV_SOURCE" "$STAGE_DIR/deploy/managed-portal.release.env" +cp "$RELEASE_MANIFEST_SOURCE" "$STAGE_DIR/release-manifest.env" + +copy_dir "$REPO_ROOT/managed/store_dwell_alert/config" "$STAGE_DIR/managed/store_dwell_alert/config" +copy_dir "$REPO_ROOT/managed/people_flow_project/config" "$STAGE_DIR/managed/people_flow_project/config" + +if [ "$INCLUDE_WEIGHTS" = "1" ]; then + if ! dir_has_files "$PEOPLE_FLOW_WEIGHTS_SOURCE"; then + echo "people-flow weights requested but missing under $PEOPLE_FLOW_WEIGHTS_SOURCE" >&2 + exit 1 + fi + + copy_dir "$PEOPLE_FLOW_WEIGHTS_SOURCE" "$STAGE_DIR/managed/people_flow_project/weights" +fi + +python3 - "$OUTPUT_DIR" "$STAGE_DIR" "$BUNDLE_PATH" <<'PY' +from pathlib import Path +import sys +import zipfile + +output_dir = Path(sys.argv[1]) +stage_dir = Path(sys.argv[2]) +bundle_path = Path(sys.argv[3]) + +if bundle_path.exists(): + bundle_path.unlink() + +with zipfile.ZipFile(bundle_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(stage_dir.rglob("*")): + arcname = path.relative_to(output_dir) + if path.is_dir(): + if not any(path.iterdir()): + archive.writestr(f"{arcname.as_posix().rstrip('/')}/", "") + continue + archive.write(path, arcname.as_posix()) +PY + +cp "$SCRIPT_DIR/install-managed-portal-ota.sh" "$INSTALLER_PATH" +chmod +x "$INSTALLER_PATH" + +echo "OTA bundle created: $BUNDLE_PATH" +echo "Versioned installer created: $INSTALLER_PATH" +if [ "$INCLUDE_WEIGHTS" = "1" ]; then + echo "Bundle includes managed/people_flow_project/weights" +else + echo "Bundle excludes managed/people_flow_project/weights; the installer will reuse the shared host weights directory if available" +fi \ No newline at end of file diff --git a/managed/people_flow_project/src/people_flow/models.py b/managed/people_flow_project/src/people_flow/models.py index b3eee55..3a2b95e 100644 --- a/managed/people_flow_project/src/people_flow/models.py +++ b/managed/people_flow_project/src/people_flow/models.py @@ -31,7 +31,7 @@ class CountingConfig: @dataclass class AttributeConfig: - enabled: bool = True + enabled: bool = False sample_every_n_frames: int = 12 max_samples_per_track: int = 5 min_person_box_width: int = 80 diff --git a/managed/people_flow_project/src/people_flow/pipeline.py b/managed/people_flow_project/src/people_flow/pipeline.py index fefe118..0a9de14 100644 --- a/managed/people_flow_project/src/people_flow/pipeline.py +++ b/managed/people_flow_project/src/people_flow/pipeline.py @@ -27,6 +27,7 @@ from .webhook import dispatch_json_event from .worker_status import write_worker_status SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"} +LIVE_SUMMARY_INTERVAL_SECONDS = 15.0 def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]: @@ -40,10 +41,40 @@ def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]: return sorted(videos) +def resolve_inference_device(requested_device: str | None) -> str: + device = (requested_device or "").strip() + if not device: + return "cpu" + + normalized = device.lower() + if normalized == "cpu": + return "cpu" + + if normalized.startswith("cuda") or normalized.replace(",", "").isdigit(): + try: + import torch + except ImportError: + print( + f"Requested inference device {device!r} but torch is unavailable; falling back to cpu.", + flush=True, + ) + return "cpu" + + if not torch.cuda.is_available(): + print( + f"Requested inference device {device!r} but torch.cuda.is_available() is False; falling back to cpu.", + flush=True, + ) + return "cpu" + + return device + + class PeopleFlowPipeline: def __init__(self, config: AppConfig, output_root: Path) -> None: self.config = config self.output_root = ensure_dir(output_root) + self.inference_device = resolve_inference_device(self.config.yolo.device) self.model = self._load_model() def _load_model(self) -> Any: @@ -174,6 +205,7 @@ class PeopleFlowPipeline: last_processed_at = 0.0 last_processed_wall_time: datetime | None = None next_heartbeat_at = time.monotonic() + 60.0 + next_live_summary_at = time.monotonic() frame_index = 0 capture = None pixel_line = None @@ -254,6 +286,7 @@ class PeopleFlowPipeline: window_index += 1 window_start = window_end window_end = window_start + timedelta(seconds=window_seconds) + next_live_summary_at = time.monotonic() if counter is not None: counter.reset() if queue_tracker is not None: @@ -329,6 +362,18 @@ class PeopleFlowPipeline: next_heartbeat_at = current_time + 60.0 last_processed_wall_time = now frame_index += 1 + if current_time >= next_live_summary_at: + self._write_live_rtsp_summary( + latest_path=rtsp_paths["latest_json"], + source=source, + window_index=window_index, + window_start=window_start, + observed_at=now, + counter=counter, + attributes=attributes, + queue_tracker=queue_tracker, + ) + next_live_summary_at = current_time + LIVE_SUMMARY_INTERVAL_SECONDS update_status("processed_frame", force=True) except KeyboardInterrupt: pass @@ -350,7 +395,7 @@ class PeopleFlowPipeline: conf=self.config.yolo.conf, iou=self.config.yolo.iou, imgsz=self.config.yolo.imgsz, - device=self.config.yolo.device, + device=self.inference_device, verbose=False, classes=[0], ) @@ -483,6 +528,8 @@ class PeopleFlowPipeline: counter: LineCrossCounter | None, attributes: AttributeAggregator, queue_tracker: QueueWindowTracker | None, + *, + commit_queue_level: bool = True, ) -> dict: age_counts, gender_counts, unknown_attributes, track_summaries = ( self._collect_track_summaries( @@ -492,7 +539,11 @@ class PeopleFlowPipeline: ) total_people = 0 if counter is None else counter.total_people queue_metrics = ( - queue_tracker.build_queue_metrics(window_start, window_end) + queue_tracker.build_queue_metrics( + window_start, + window_end, + commit_queue_level=commit_queue_level, + ) if queue_tracker is not None and self.config.queue.enabled else { "queue_time_threshold_seconds": self.config.queue.queue_time_threshold_seconds, @@ -529,6 +580,31 @@ class PeopleFlowPipeline: "queue_metrics": queue_metrics, } + def _write_live_rtsp_summary( + self, + latest_path: Path, + source: str, + window_index: int, + window_start: datetime, + observed_at: datetime, + counter: LineCrossCounter | None, + attributes: AttributeAggregator, + queue_tracker: QueueWindowTracker | None, + ) -> None: + payload = self._build_rtsp_summary( + source=source, + window_index=window_index, + window_start=window_start, + window_end=observed_at, + counter=counter, + attributes=attributes, + queue_tracker=queue_tracker, + commit_queue_level=False, + ) + payload["event"] = "rtsp_live_summary" + payload["window_status"] = "in_progress" + write_json(latest_path, payload) + def _finalize_summary( self, video_path: Path, diff --git a/managed/people_flow_project/tests/test_pipeline.py b/managed/people_flow_project/tests/test_pipeline.py new file mode 100644 index 0000000..d040fe1 --- /dev/null +++ b/managed/people_flow_project/tests/test_pipeline.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +from src.people_flow.models import AttributeConfig +from src.people_flow.pipeline import PeopleFlowPipeline, resolve_inference_device + + +def test_resolve_inference_device_keeps_cpu(): + assert resolve_inference_device("cpu") == "cpu" + + +def test_resolve_inference_device_falls_back_when_cuda_unavailable(monkeypatch): + fake_torch = SimpleNamespace( + cuda=SimpleNamespace(is_available=lambda: False), + ) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + + assert resolve_inference_device("cuda:0") == "cpu" + + +def test_resolve_inference_device_keeps_cuda_when_available(monkeypatch): + fake_torch = SimpleNamespace( + cuda=SimpleNamespace(is_available=lambda: True), + ) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + + assert resolve_inference_device("cuda:0") == "cuda:0" + + +def test_attribute_config_defaults_to_disabled(): + assert AttributeConfig().enabled is False + + +def test_write_live_rtsp_summary_updates_latest_json(tmp_path: Path): + pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline) + observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00") + window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00") + + def fake_build_rtsp_summary(**kwargs): + return { + "event": "half_hour_report", + "window_start": kwargs["window_start"].isoformat(), + "window_end": kwargs["window_end"].isoformat(), + "total_people": 4, + "queue_metrics": {"queue_level": "normal"}, + } + + pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign] + latest_path = tmp_path / "latest.json" + + pipeline._write_live_rtsp_summary( + latest_path=latest_path, + source="rtsp://example", + window_index=0, + window_start=window_start, + observed_at=observed_at, + counter=None, + attributes=SimpleNamespace(), + queue_tracker=None, + ) + + payload = json.loads(latest_path.read_text(encoding="utf-8")) + assert payload["event"] == "rtsp_live_summary" + assert payload["window_status"] == "in_progress" + assert payload["window_start"] == window_start.isoformat() + assert payload["window_end"] == observed_at.isoformat() + + +def test_write_live_rtsp_summary_does_not_commit_queue_level(tmp_path: Path): + pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline) + observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00") + window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00") + captured: dict[str, object] = {} + + def fake_build_rtsp_summary(**kwargs): + captured.update(kwargs) + return { + "event": "half_hour_report", + "window_start": kwargs["window_start"].isoformat(), + "window_end": kwargs["window_end"].isoformat(), + "total_people": 4, + "queue_metrics": {"queue_level": "normal"}, + } + + pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign] + latest_path = tmp_path / "latest.json" + + pipeline._write_live_rtsp_summary( + latest_path=latest_path, + source="rtsp://example", + window_index=0, + window_start=window_start, + observed_at=observed_at, + counter=None, + attributes=SimpleNamespace(), + queue_tracker=SimpleNamespace(), + ) + + assert captured["commit_queue_level"] is False diff --git a/tasks/lessons.md b/tasks/lessons.md index 3455d18..35b5eab 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -17,3 +17,15 @@ - Trigger: the user required deployment to use `docker compose` only and explicitly disallowed host environment changes. - Rule: for remote rollout tasks in this repo, prefer repository-contained `docker compose` changes and do not install packages, edit host configs, or mutate global environment state unless the user explicitly approves it. - Preventive action: when a deployment is blocked, first fix Dockerfiles, compose files, env files, and mounted paths inside the repo before considering any host-level workaround. + +## 2026-05-15 + +- Trigger: the `.11` OTA bundle host did not have a `zip` executable, and the current Python containers no longer exposed the historical `lap` overlay paths. +- Rule: OTA bundle publication must not assume host archive tools or historical runtime overlay paths are present. +- Preventive action: when cutting a release, package the ZIP with Python stdlib if `zip` is unavailable, treat overlay extraction as optional unless the paths are verified live in containers, and validate the final archive contents before upload. + +## 2026-05-18 + +- Trigger: the user clarified that OTA installer updates should not keep repackaging and uploading the whole repository tree or fixed `people_flow_project` weights. +- Rule: managed-portal OTA releases should ship a minimal ZIP with deploy metadata and managed config only; `people_flow_project` weights should be reused from a stable host location unless the weights themselves changed or the host is new. +- Preventive action: when preparing OTA artifacts, use the minimal packaging script, exclude `managed/people_flow_project/weights` by default, and only publish a weights-bearing bundle for first-time installs or actual weight updates. diff --git a/tasks/todo.md b/tasks/todo.md index d716e56..8e50114 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -2,33 +2,31 @@ ## Checklist -- [x] Verify the local managed config files already match the requested webhook, 30-minute window, 5-minute queue threshold, and `2-5 normal / >5 crowded` defaults. -- [x] Restore any queue-level code or tests that still reflect the temporary `>= threshold` crowded behavior. -- [x] Run targeted validation for the touched queue-analytics logic. -- [x] Update `tasks/todo.md` review evidence and create a scoped git commit containing only the intended files. +- [x] Classify the remaining modified and untracked files into non-config code changes to commit versus local config/artifact files to exclude. +- [x] Run targeted validation for the code and script changes that will be committed. +- [x] Create a scoped git commit containing the non-config code changes only. +- [ ] Push the scoped commit to `origin/main`. ## Scope And Risks -- Scope: restore repository-local managed-service defaults so webhook delivery remains enabled, people-flow reporting flushes every 30 minutes, queue threshold is 5 minutes, fewer than 2 qualifying people means `人少`, `2-5` means `人数正常`, and more than `5` means `人多`, then commit the relevant changes. -- Expected touch points: `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/tests/test_queue_analytics.py`, and verification of local config files under `managed/*/config/` plus any generated OTA test output that should remain aligned. -- Risk: the worktree already contains unrelated modified and untracked files, so the commit must be scoped carefully to avoid pulling in unrelated work. -- Risk: `store_dwell_alert` still has a separate queue-level implementation with the same `>` crowded comparison, but the user asked specifically to restore local config defaults plus "the code"; if we only revert people-flow logic, we should be explicit about any remaining asymmetry. +- Scope: commit and push the remaining local non-config code changes in this repository while excluding local configuration files and generated artifacts. +- Expected touch points: currently modified code/docs/scripts such as `README.md`, `deploy/install-managed-portal-ota.sh`, `deploy/package-managed-portal-ota.sh`, `managed/people_flow_project/src/people_flow/{models.py,pipeline.py}`, `managed/people_flow_project/tests/test_pipeline.py`, and `tasks/lessons.md`. +- Risk: the worktree also contains local artifacts and config-adjacent outputs such as `test_output/`, `sim_workspace/`, `release-manifest.env`, `api_response.json`, and `output.txt`; these must not be swept into the commit by accident. +- Risk: some modified files, especially docs and deployment scripts, are related to earlier OTA work and may need only narrow validation rather than full end-to-end execution. ## Validation Intent -- Prove the local config files already encode `window_seconds: 1800`, `queue_time_threshold_seconds: 300`, `crowded_count_threshold: 5`, `normal_count_threshold: 2`, and webhook URLs. -- Prove the restored queue-level comparison classifies `over_threshold_count == 5` as `normal` and only `>5` as `crowded`. +- Prove the selected commit excludes local config files and generated artifacts. +- Run the narrowest meaningful checks for the people-flow pipeline/test changes and at least a syntax-level check for the OTA scripts. ## Review -- Status: completed. -- Config verification: local repository configs already matched the requested defaults before code edits. - - `managed/people_flow_project/config/local.yaml`: `webhook.url` points to managed queue, `rtsp.window_seconds=1800`, `queue_time_threshold_seconds=300`, `crowded_count_threshold=5`, `normal_count_threshold=2`. - - `managed/store_dwell_alert/config/local.yaml`: `webhook.url` points to managed queue, `queue_time_threshold_seconds=300`, `crowded_count_threshold=5`, `normal_count_threshold=2`. - - Generated OTA test configs under `test_output/managed-portal-test-ota/managed/.../config/local.yaml` were already aligned with the same values. -- Code restoration: reverted `managed/people_flow_project/src/people_flow/queue_analytics.py` so `queue_level="crowded"` now requires `over_threshold_count > crowded_count_threshold`, which restores the requested `2-5 normal / >5 crowded` behavior. -- Test restoration: updated `managed/people_flow_project/tests/test_queue_analytics.py` so the preview-state coverage remains intact while the boundary assertion now proves `over_threshold_count == crowded_count_threshold` stays `normal`. -- Validation: - - `PYTHONPATH=. pytest tests/test_queue_analytics.py` in `managed/people_flow_project` - - Result: `3 passed in 0.03s` -- Commit scope note: only `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/tests/test_queue_analytics.py`, and `tasks/todo.md` should be committed for this task because the worktree contains unrelated modified and untracked files. +- Status: in progress. +- Commit set selected: + - Included: `README.md`, `deploy/install-managed-portal-ota.sh`, `deploy/package-managed-portal-ota.sh`, `managed/people_flow_project/src/people_flow/models.py`, `managed/people_flow_project/src/people_flow/pipeline.py`, `managed/people_flow_project/tests/test_pipeline.py`, `tasks/lessons.md`, `tasks/todo.md`. + - Excluded as local config or generated artifacts: `test_output/`, `sim_workspace/`, `release-manifest.env`, `api_response.json`, `output.txt`. +- Validation completed: + - `PYTHONPATH=. pytest tests/test_pipeline.py tests/test_queue_analytics.py` in `managed/people_flow_project` + - Result: `9 passed in 0.66s` + - `sh -n deploy/install-managed-portal-ota.sh` + - `sh -n deploy/package-managed-portal-ota.sh`