Write project.json to the storage owner prefix.

update_project used meta.user_id for the path, so JWT projects that still
have user_id=local never persisted pcb_status=queued and the PCB worker
exited as draft. Point writes at the prefix used to read the project.
This commit is contained in:
2026-09-20 00:57:15 +02:00
parent 344ecb3389
commit 3e2ba269f9
3 changed files with 63 additions and 6 deletions
+19 -5
View File
@@ -201,10 +201,24 @@ def _read_meta_with_generation(
return ProjectMeta.model_validate(data), gen
def _write_meta(storage: StorageBackend, meta: ProjectMeta) -> None:
def _write_meta(
storage: StorageBackend,
meta: ProjectMeta,
*,
owner_user_id: str | None = None,
) -> None:
"""Persist meta under ``users/{owner}/projects/{id}/``.
``meta.user_id`` can lag the storage prefix (local-auth accounts that
still have ``user_id: "local"`` in JSON while files live under
``users/usr_…/``). Writes must follow the prefix used to *read* the
project, not the stale field — otherwise ``update_project(pcb_status=…)``
lands in a different tree and the PCB worker still sees ``draft``.
"""
uid = owner_user_id or meta.user_id
meta.updated = datetime.now(timezone.utc).isoformat()
storage.write_json(
_meta_key(meta.user_id, meta.id),
_meta_key(uid, meta.id),
meta.model_dump(),
)
@@ -516,7 +530,7 @@ def update_project(
meta = _read_meta(storage, user_id, project_id)
for k, v in fields.items():
setattr(meta, k, v)
_write_meta(storage, meta)
_write_meta(storage, meta, owner_user_id=user_id)
return meta
@@ -691,7 +705,7 @@ def add_collaborator(
meta = _read_meta(storage, owner_user_id, project_id)
if collaborator_user_id not in meta.collaborators:
meta.collaborators.append(collaborator_user_id)
_write_meta(storage, meta)
_write_meta(storage, meta, owner_user_id=owner_user_id)
# Write reverse reference for the collaborator
ref_key = _shared_ref_key(collaborator_user_id, project_id)
storage.write_json(ref_key, {"owner_user_id": owner_user_id})
@@ -704,7 +718,7 @@ def remove_collaborator(
"""Remove a collaborator from a project and delete the shared reference."""
meta = _read_meta(storage, owner_user_id, project_id)
meta.collaborators = [c for c in meta.collaborators if c != collaborator_user_id]
_write_meta(storage, meta)
_write_meta(storage, meta, owner_user_id=owner_user_id)
# Delete reverse reference
ref_key = _shared_ref_key(collaborator_user_id, project_id)
if storage.exists(ref_key):
@@ -28,6 +28,7 @@ export default function PcbReviewPage({
const { id } = use(params);
const [projectName, setProjectName] = useState("");
const [pcbStatus, setPcbStatus] = useState<string>("draft");
const [analysisBusy, setAnalysisBusy] = useState(false);
const [inventory, setInventory] = useState<{
nets: Array<Record<string, unknown>>;
domains: string[];
@@ -49,6 +50,7 @@ export default function PcbReviewPage({
.then((p) => {
setProjectName(p.name);
setPcbStatus(p.pcbStatus ?? "draft");
setAnalysisBusy(p.status === "running" || p.status === "queued");
setStatusLoaded(true);
})
.catch(() => setStatusLoaded(true));
@@ -198,8 +200,19 @@ export default function PcbReviewPage({
</Card>
)}
{statusLoaded && pcbStatus === "draft" && analysisBusy && (
<p className="text-sm text-muted-foreground">
Schematic review is still running. Wait for it to finish (or cancel
it), then start PCB review the two jobs cannot run at the same time.
</p>
)}
{statusLoaded && pcbStatus === "draft" && (
<Button size="sm" disabled={starting} onClick={handleStart}>
<Button
size="sm"
disabled={starting || analysisBusy}
onClick={handleStart}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Run PCB review"}
</Button>
+30
View File
@@ -181,3 +181,33 @@ def test_pcb_start_requires_board(tmp_path: Path):
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
assert resp.status_code == 400
assert "kicad_pcb" in resp.json()["detail"]
def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
"""JWT owner path with JSON still saying user_id=local (live Emmaforo)."""
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
pid = "759a4dea9612"
owner = "usr_jwt_owner"
storage.write_json(
f"users/{owner}/projects/{pid}/project.json",
{
"id": pid,
"name": "Emmaforo",
"user_id": "local",
"created": "2026-01-01T00:00:00Z",
"status": "complete",
"has_pcb": True,
"has_bom": True,
"has_netlist": True,
"pcb_status": "draft",
},
)
from backend.services import projects as proj_svc
proj_svc.update_project(storage, owner, pid, pcb_status="queued")
jwt_meta = proj_svc.get_project(storage, owner, pid)
assert jwt_meta is not None
assert jwt_meta.pcb_status == "queued"
assert proj_svc.get_project(storage, "local", pid) is None