# App Store Hybrid Batch Upload Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a formal hybrid browser/API execution path to the existing `appstore` batch uploader so it can perform real multi-package upload and update flows while preserving the current workbook-driven API path.

**Architecture:** Keep `appstore/upload_batch.py` as the only CLI entrypoint. Add a browser session/runtime layer, a platform policy layer, and a browser submission orchestrator that can execute staged multi-package updates. Use `auto|api|browser` execution mode resolution to route each release group.

**Tech Stack:** Python 3, existing `requests` client, existing `pyppeteer` dependency, `unittest`, `openpyxl`

---

### Task 1: Add execution mode and platform policy primitives

**Files:**
- Create: `appstore/platform_policy.py`
- Modify: `appstore/models.py`
- Test: `appstore/tests/test_platform_policy.py`

- [ ] **Step 1: Write the failing tests**

```python
import unittest

from appstore.models import PackageRecord, ReleaseRecord, TargetRecord
from appstore.platform_policy import (
    decide_execution_mode,
    resolve_target_system_line,
)


class PlatformPolicyTests(unittest.TestCase):
    def test_release_execution_mode_defaults_to_cli_mode(self) -> None:
        release = ReleaseRecord(
            row_id=2,
            enabled=True,
            app_key="labelnova",
            release_key="stable",
            release_name="Stable",
            region="1",
            note="",
            execution_mode="",
        )
        self.assertEqual(decide_execution_mode(release=release, cli_mode="browser"), "browser")

    def test_release_execution_mode_prefers_release_override(self) -> None:
        release = ReleaseRecord(
            row_id=2,
            enabled=True,
            app_key="labelnova",
            release_key="stable",
            release_name="Stable",
            region="1",
            note="",
            execution_mode="api",
        )
        self.assertEqual(decide_execution_mode(release=release, cli_mode="browser"), "api")

    def test_loong64_target_is_promoted_to_v25(self) -> None:
        package = PackageRecord(
            row_id=3,
            enabled=True,
            app_key="labelnova",
            release_key="stable",
            package_key="loong",
            package_family="deb",
            package_format="deb",
            file_path="labelnova_loong64.deb",
            declared_arch="loong64",
            pkg_channel="",
            note="",
        )
        target = TargetRecord(
            row_id=4,
            enabled=True,
            app_key="labelnova",
            release_key="stable",
            package_key="loong",
            sup_sys_code="11",
            baseline_id="2300",
            unsupport_baseline_ids="",
            target_note="",
        )
        resolved = resolve_target_system_line(package=package, target=target)
        self.assertEqual(resolved.sup_sys_code, "21")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_platform_policy -v`
Expected: FAIL because `execution_mode` support and policy helpers do not exist yet

- [ ] **Step 3: Write minimal implementation**

```python
# appstore/platform_policy.py
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ResolvedTarget:
    sup_sys_code: str
    baseline_id: str


def decide_execution_mode(*, release, cli_mode: str) -> str:
    normalized_release_mode = (getattr(release, "execution_mode", "") or "").strip().lower()
    if normalized_release_mode:
        return normalized_release_mode
    return (cli_mode or "auto").strip().lower()


def resolve_target_system_line(*, package, target) -> ResolvedTarget:
    arch = (getattr(package, "declared_arch", "") or "").strip().lower()
    sup_sys_code = (getattr(target, "sup_sys_code", "") or "").strip()
    baseline_id = (getattr(target, "baseline_id", "") or "").strip()
    if arch in {"loong64", "loongarch64"} and sup_sys_code == "11":
        return ResolvedTarget(sup_sys_code="21", baseline_id=baseline_id)
    return ResolvedTarget(sup_sys_code=sup_sys_code, baseline_id=baseline_id)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_platform_policy -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add appstore/platform_policy.py appstore/models.py appstore/tests/test_platform_policy.py
git commit -m "feat: add appstore execution policy primitives"
```

### Task 2: Add persistent browser session storage

**Files:**
- Create: `appstore/browser_session.py`
- Modify: `appstore/appstore_client.py`
- Test: `appstore/tests/test_browser_session.py`

- [ ] **Step 1: Write the failing tests**

```python
import tempfile
import unittest
from pathlib import Path

from appstore.browser_session import BrowserSessionStore


class BrowserSessionStoreTests(unittest.TestCase):
    def test_round_trips_state(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            store = BrowserSessionStore(Path(tmp))
            state = {
                "cookies": [{"name": "token", "value": "abc"}],
                "local_storage": {"authorization": "Bearer 1"},
                "session_storage": {},
                "user_agent": "UA",
            }
            store.save("odatacc", state)
            self.assertEqual(store.load("odatacc")["user_agent"], "UA")

    def test_invalidate_removes_cached_state(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            store = BrowserSessionStore(Path(tmp))
            store.save("odatacc", {"cookies": [], "local_storage": {}, "session_storage": {}, "user_agent": "UA"})
            store.invalidate("odatacc")
            self.assertIsNone(store.load("odatacc"))
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_browser_session -v`
Expected: FAIL because `BrowserSessionStore` does not exist

- [ ] **Step 3: Write minimal implementation**

```python
# appstore/browser_session.py
from __future__ import annotations

import json
from datetime import datetime
from pathlib import Path


class BrowserSessionStore:
    def __init__(self, root: Path) -> None:
        self.root = Path(root)
        self.root.mkdir(parents=True, exist_ok=True)

    def _path_for(self, account: str) -> Path:
        safe_name = account.replace("/", "_")
        return self.root / f"{safe_name}.json"

    def load(self, account: str):
        path = self._path_for(account)
        if not path.exists():
            return None
        return json.loads(path.read_text(encoding="utf-8"))

    def save(self, account: str, state: dict) -> Path:
        payload = dict(state)
        payload["last_verified_at"] = datetime.now().isoformat()
        path = self._path_for(account)
        path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        return path

    def invalidate(self, account: str) -> None:
        path = self._path_for(account)
        if path.exists():
            path.unlink()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_browser_session -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add appstore/browser_session.py appstore/appstore_client.py appstore/tests/test_browser_session.py
git commit -m "feat: add persistent appstore browser sessions"
```

### Task 3: Add browser runtime and grouped staged browser orchestration

**Files:**
- Create: `appstore/browser_runtime.py`
- Create: `appstore/browser_flow.py`
- Create: `appstore/browser_submission.py`
- Test: `appstore/tests/test_browser_submission.py`

- [ ] **Step 1: Write the failing tests**

```python
import unittest

from appstore.browser_submission import build_release_browser_plan
from appstore.models import PackageRecord, ReleaseRecord, TargetRecord


class BrowserSubmissionPlanTests(unittest.TestCase):
    def test_multi_package_release_uses_staged_save_plan(self) -> None:
        release = ReleaseRecord(
            row_id=2,
            enabled=True,
            app_key="labelnova",
            release_key="stable",
            release_name="Stable",
            region="1",
            note="",
            execution_mode="browser",
        )
        packages = [
            PackageRecord(3, True, "labelnova", "stable", "arm", "deb", "deb", "a.deb", "arm64", "", ""),
            PackageRecord(4, True, "labelnova", "stable", "loong", "deb", "deb", "b.deb", "loong64", "", ""),
        ]
        targets = {
            "arm": [TargetRecord(5, True, "labelnova", "stable", "arm", "11", "2300", "", "")],
            "loong": [TargetRecord(6, True, "labelnova", "stable", "loong", "11", "2300", "", "")],
        }
        plan = build_release_browser_plan(release=release, packages=packages, targets_by_package=targets)
        self.assertEqual(plan.steps[0].action, "open_release")
        self.assertEqual([step.action for step in plan.steps].count("save_release"), 2)
        self.assertEqual(plan.steps[-1].action, "submit_release")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_browser_submission -v`
Expected: FAIL because browser plan helpers do not exist

- [ ] **Step 3: Write minimal implementation**

```python
# appstore/browser_submission.py
from __future__ import annotations

from dataclasses import dataclass

from appstore.platform_policy import resolve_target_system_line


@dataclass(frozen=True)
class BrowserStep:
    action: str
    payload: dict


@dataclass(frozen=True)
class BrowserReleasePlan:
    steps: list[BrowserStep]


def build_release_browser_plan(*, release, packages, targets_by_package):
    steps: list[BrowserStep] = [BrowserStep(action="open_release", payload={"release_key": release.release_key})]
    for index, package in enumerate(packages):
        steps.append(BrowserStep(action="upload_package", payload={"package_key": package.package_key, "file_path": str(package.file_path)}))
        for target in targets_by_package[package.package_key]:
            resolved = resolve_target_system_line(package=package, target=target)
            steps.append(
                BrowserStep(
                    action="configure_target",
                    payload={
                        "package_key": package.package_key,
                        "sup_sys_code": resolved.sup_sys_code,
                        "baseline_id": resolved.baseline_id,
                    },
                )
            )
        steps.append(BrowserStep(action="save_release", payload={"stage": index + 1}))
        if index != len(packages) - 1:
            steps.append(BrowserStep(action="reopen_release", payload={"release_key": release.release_key}))
    steps.append(BrowserStep(action="submit_release", payload={"release_key": release.release_key}))
    return BrowserReleasePlan(steps=steps)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_browser_submission -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add appstore/browser_runtime.py appstore/browser_flow.py appstore/browser_submission.py appstore/tests/test_browser_submission.py
git commit -m "feat: add staged browser submission flow"
```

### Task 4: Integrate browser and hybrid execution into the CLI

**Files:**
- Modify: `appstore/upload_batch.py`
- Modify: `appstore/submission.py`
- Modify: `appstore/appstore_client.py`
- Test: `appstore/tests/test_upload_batch.py`

- [ ] **Step 1: Write the failing tests**

```python
def test_upload_command_uses_browser_runner_when_mode_is_browser(self):
    runner = RecordingBrowserRunner()
    client = FakeClient()
    exit_code = run_cli(
        [
            "upload",
            str(self.workbook_path),
            "--mode",
            "browser",
            "--capabilities-cache",
            str(self.cache_path),
        ],
        client=client,
        browser_runner=runner,
    )
    self.assertEqual(exit_code, 0)
    self.assertEqual(len(runner.calls), 1)
    self.assertEqual(runner.calls[0]["release_key"], "stable")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_upload_batch -v`
Expected: FAIL because `upload_batch` has no browser mode integration

- [ ] **Step 3: Write minimal implementation**

```python
# upload_batch integration sketch
parser_upload.add_argument("--mode", choices=("auto", "api", "browser"), default="auto")

effective_mode = decide_execution_mode(release=release_record, cli_mode=args.mode)
if effective_mode == "browser":
    result = browser_runner.submit_release_group(
        app=app,
        release=release_record,
        packages=package_group,
        targets=target_group,
        artifact_dir=artifact_dir,
    )
else:
    result = submit_grouped_release(...)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_upload_batch -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add appstore/upload_batch.py appstore/submission.py appstore/appstore_client.py appstore/tests/test_upload_batch.py
git commit -m "feat: route appstore uploads through hybrid execution"
```

### Task 5: Add reports, artifacts, and documentation updates

**Files:**
- Modify: `appstore/README.md`
- Modify: `appstore/examples/generate_template.py`
- Modify: `appstore/tests/test_examples.py`
- Modify: `appstore/upload_batch.py`

- [ ] **Step 1: Write the failing tests**

```python
def test_generate_template_includes_execution_mode_column(self):
    workbook = load_workbook(self.generated_template)
    release_headers = [cell.value for cell in workbook["releases"][1]]
    self.assertIn("execution_mode", release_headers)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python3 -m unittest appstore.tests.test_examples -v`
Expected: FAIL because generated templates do not include browser execution metadata

- [ ] **Step 3: Write minimal implementation**

```python
# release sheet headers
[
    "enabled",
    "app_key",
    "release_key",
    "release_name",
    "execution_mode",
    "region",
    "note",
]
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python3 -m unittest appstore.tests.test_examples -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add appstore/README.md appstore/examples/generate_template.py appstore/tests/test_examples.py appstore/upload_batch.py
git commit -m "docs: describe hybrid appstore upload workflow"
```

### Task 6: Full verification

**Files:**
- Modify only if verification reveals defects

- [ ] **Step 1: Run focused unit suites**

Run:

```bash
python3 -m unittest appstore.tests.test_platform_policy -v
python3 -m unittest appstore.tests.test_browser_session -v
python3 -m unittest appstore.tests.test_browser_submission -v
python3 -m unittest appstore.tests.test_upload_batch -v
python3 -m unittest appstore.tests.test_examples -v
```

Expected: PASS

- [ ] **Step 2: Run the full appstore suite**

Run:

```bash
python3 -m unittest discover appstore/tests -v
```

Expected: PASS

- [ ] **Step 3: Run a dry-run CLI verification**

Run:

```bash
python3 -m appstore.upload_batch validate appstore/examples/template.xlsx
python3 -m appstore.upload_batch upload appstore/examples/template.xlsx --mode browser --dry-run
```

Expected:

- validation exits `0`
- dry-run exits `0`
- report files are written

- [ ] **Step 4: Commit final fixups if needed**

```bash
git add appstore
git commit -m "test: verify hybrid appstore upload workflow"
```
