Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions app/workspace/bff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Read-only Workspace BFF projections.

The v1 BFF is deliberately narrower than the legacy workspace compatibility API:
no command, persistence, or approval identifiers cross this boundary.
"""
from __future__ import annotations

import base64
import hashlib
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any


class BFFUnavailableError(RuntimeError):
"""The projection cannot be read safely from the current local database."""


class BFFNotFoundError(LookupError):
"""The opaque public reference does not identify a supported object."""


@dataclass(frozen=True)
class ActivityPage:
items: list[dict[str, Any]]
next_cursor: str | None


def public_ref(kind: str, value: str) -> str:
"""Return a stable opaque reference without exposing persistence identifiers.

This is an object reference, not an authorization credential. Local loopback
isolation remains the authorization boundary.
"""
material = f"archeaxis-workspace-bff-v1\0{kind}\0{value}".encode()
return f"wr1_{hashlib.sha256(material).hexdigest()[:32]}"


def _cursor_encode(updated_at: str, reference: str) -> str:
raw = f"{updated_at}\0{reference}".encode()
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _cursor_decode(cursor: str) -> tuple[str, str]:
try:
padded = cursor + "=" * (-len(cursor) % 4)
updated_at, reference = base64.urlsafe_b64decode(padded).decode("utf-8").split("\0", 1)
except (ValueError, UnicodeDecodeError, base64.binascii.Error) as exc:
raise ValueError("invalid activity cursor") from exc
if not updated_at or not reference:
raise ValueError("invalid activity cursor")
return updated_at, reference


def _connection(db_path: str | Path) -> sqlite3.Connection:
connection = sqlite3.connect(Path(db_path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout=30000")
connection.execute("PRAGMA query_only=ON")
return connection


def _require_table(connection: sqlite3.Connection, table: str) -> None:
exists = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
if exists is None:
raise BFFUnavailableError(f"workspace projection table unavailable: {table}")


def _activity_rows(connection: sqlite3.Connection) -> list[dict[str, Any]]:
_require_table(connection, "workspace_jobs_v1")
_require_table(connection, "research_packages_v1")
rows: list[dict[str, Any]] = []
for row in connection.execute(
"SELECT job_id, state, updated_at FROM workspace_jobs_v1"
).fetchall():
reference = public_ref("job", str(row["job_id"]))
rows.append(
{
"public_ref": reference,
"kind": "job",
"label": "资料导入",
"state": str(row["state"]),
"updated_at": str(row["updated_at"]),
}
)
for row in connection.execute(
"SELECT canonical_url, status, created_at FROM research_packages_v1"
).fetchall():
reference = public_ref("source", str(row["canonical_url"]))
rows.append(
{
"public_ref": reference,
"kind": "source",
"label": "研究资料",
"state": str(row["status"]),
"updated_at": str(row["created_at"]),
}
)
return sorted(
rows,
key=lambda item: (item["updated_at"], item["public_ref"]),
reverse=True,
)


def activity(*, db_path: str | Path, limit: int = 20, cursor: str | None = None) -> dict[str, Any]:
if not 1 <= limit <= 50:
raise ValueError("limit must be between 1 and 50")
with _connection(db_path) as connection:
rows = _activity_rows(connection)
if cursor:
cursor_time, cursor_ref = _cursor_decode(cursor)
rows = [
item
for item in rows
if (item["updated_at"], item["public_ref"]) < (cursor_time, cursor_ref)
]
page = rows[:limit]
next_cursor = None
if len(rows) > limit:
tail = page[-1]
next_cursor = _cursor_encode(tail["updated_at"], tail["public_ref"])
return {"schema_version": "v1", "items": page, "next_cursor": next_cursor}


def _object_from_row(kind: str, row: sqlite3.Row) -> dict[str, Any]:
if kind == "job":
value = str(row["job_id"])
return {
"schema_version": "v1",
"kind": "job",
"public_ref": public_ref(kind, value),
"label": "资料导入",
"state": str(row["state"]),
"updated_at": str(row["updated_at"]),
}
value = str(row["canonical_url"])
return {
"schema_version": "v1",
"kind": "source",
"public_ref": public_ref(kind, value),
"label": "研究资料",
"source": value,
"state": str(row["status"]),
"updated_at": str(row["created_at"]),
}


def object_by_ref(*, db_path: str | Path, reference: str) -> dict[str, Any]:
if not reference.startswith("wr1_") or len(reference) != 36:
raise BFFNotFoundError("workspace object was not found")
with _connection(db_path) as connection:
_require_table(connection, "workspace_jobs_v1")
for row in connection.execute(
"SELECT job_id, state, updated_at FROM workspace_jobs_v1"
).fetchall():
if public_ref("job", str(row["job_id"])) == reference:
return _object_from_row("job", row)
_require_table(connection, "research_packages_v1")
for row in connection.execute(
"SELECT canonical_url, status, created_at FROM research_packages_v1"
).fetchall():
if public_ref("source", str(row["canonical_url"])) == reference:
return _object_from_row("source", row)
raise BFFNotFoundError("workspace object was not found")


def home(*, db_path: str | Path) -> dict[str, Any]:
from app.workspace.service import workspace_status

status = workspace_status(db_path=db_path)
recent = activity(db_path=db_path, limit=5)["items"]
return {
"schema_version": "v1",
"observed_at": status["observed_at"],
"release": status["release"],
"components": status["components"],
"counts": status["counts"],
"capabilities": status["capabilities"],
"recent_activity": recent,
}
37 changes: 36 additions & 1 deletion app/workspace/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError

from app.workspace import service
from app.workspace import bff, service
from app.workspace.bff import BFFNotFoundError, BFFUnavailableError
from shared.storage import DB_PATH

WORKSPACE_PREFIX = "/" + "workspace"
Expand Down Expand Up @@ -191,6 +192,40 @@ def workspace_status(request: Request) -> dict[str, object]:
return _command_error(lambda: service.workspace_status(db_path=DB_PATH))


def _bff_error(action):
try:
return action()
except BFFNotFoundError as exc:
raise HTTPException(status_code=404, detail="workspace object was not found") from exc
except (BFFUnavailableError, RuntimeError) as exc:
raise HTTPException(status_code=503, detail="workspace projection is unavailable") from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc


@router.get("/api/v1/home")
def workspace_bff_home(request: Request) -> dict[str, object]:
"""Read-only v1 home projection; persistence IDs never cross this boundary."""
_local_principal(request)
return _bff_error(lambda: bff.home(db_path=DB_PATH))


@router.get("/api/v1/activity")
def workspace_bff_activity(
request: Request, limit: int = 20, cursor: str | None = None
) -> dict[str, object]:
"""Stable cursor-paginated activity projection."""
_local_principal(request)
return _bff_error(lambda: bff.activity(db_path=DB_PATH, limit=limit, cursor=cursor))


@router.get("/api/v1/objects/{public_ref}")
def workspace_bff_object(public_ref: str, request: Request) -> dict[str, object]:
"""Resolve only opaque public references to read-only object DTOs."""
_local_principal(request)
return _bff_error(lambda: bff.object_by_ref(db_path=DB_PATH, reference=public_ref))


@router.get("/api/_desktop/ready")
def desktop_readiness(request: Request) -> dict[str, str]:
launch_token = os.getenv("COGNITIVE_DESKTOP_LAUNCH_TOKEN", "")
Expand Down
113 changes: 113 additions & 0 deletions docs/contracts/WORKSPACE_BFF_V1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Workspace BFF v1 Contract

Status: **BE01 implementation candidate — read-only contract**
Owner: Cognitive-OS workspace boundary
Scope: local loopback product shell only

## 1. Boundary

The v1 BFF is a server-owned projection boundary for the product shell. It is not a
second persistence API and it is not an authorization credential.

- Base path: `/workspace/api/v1`
- Transport: same-origin loopback HTTP only
- Methods in this contract: `GET` only
- No API key, JWT, approval reason, retry command, cancellation command, or manual
action is required by the local product shell
- Existing `/workspace/api/*` endpoints remain a compatibility surface and are not
part of this contract. UI migration must not silently mix the two surfaces.

## 2. Privacy and identity rules

Responses MUST NOT contain `command_id`, `job_id`, `package_id`, `artifact_id`,
`unit_id`, database paths, backup paths, SQL/table names, credentials, or raw
persistence payloads.

Object identity is represented by `public_ref`:

- format: `wr1_` plus 32 lowercase hexadecimal characters;
- stable for the same object key and contract version;
- does not encode a persistence identifier;
- is not an authorization token; loopback and same-origin checks remain mandatory;
- an unknown or malformed reference returns the same 404 object-not-found shape.

## 3. DTOs

### `GET /workspace/api/v1/home`

```json
{
"schema_version": "v1",
"observed_at": "2026-08-01T00:00:00Z",
"release": { "version": "0.4.1", "status": "..." },
"components": { "api": "available", "database": "available" },
"counts": {},
"capabilities": {},
"recent_activity": [Activity]
}
```

The existing truthful aggregate status is reused; no fabricated progress or ETA is
allowed. If its projection cannot be read, the endpoint returns HTTP 503 with
`{"detail":"workspace projection is unavailable"}`.

### `GET /workspace/api/v1/activity`

Parameters:

- `limit`: integer `1..50`, default `20`;
- `cursor`: opaque cursor returned by the previous page.

```json
{
"schema_version": "v1",
"items": [Activity],
"next_cursor": "..."
}
```

`items` are stably ordered by `updated_at` descending, then `public_ref`
descending. Invalid cursors return HTTP 422. There is no offset pagination.

`Activity`:

```json
{
"public_ref": "wr1_...",
"kind": "job|source",
"label": "资料导入|研究资料",
"state": "candidate|queued|succeeded|...",
"updated_at": "2026-08-01T00:00:00Z"
}
```

### `GET /workspace/api/v1/objects/{public_ref}`

Supported kinds in this first slice are `job` and `source`. A successful object
response contains only the corresponding public DTO. Unknown, expired, malformed,
or unsupported references return HTTP 404; the response must not reveal which
lookup failed.

## 4. Failure semantics

| Condition | HTTP | Meaning |
|---|---:|---|
| malformed limit/cursor | 422 | caller input invalid |
| object is unknown | 404 | no object is revealed |
| projection table/data unavailable | 503 | retry/readiness issue, no partial fake data |
| non-loopback, cross-site, or wrong-origin request | 403 | local boundary rejected |

## 5. Action boundary

The v1 BFF has no write routes. Approval, retry, delivery, learning, practice,
Runtime promotion, intake, and cancellation remain outside BE01 and must not be
triggered by a GET or hidden browser-side side effect.

## 6. Acceptance gates

- API route inventory shows only GET operations under `/workspace/api/v1`.
- Integration tests prove public-ref mapping, cursor pagination, 404/503 behavior,
and absence of internal identifiers.
- `git diff --check`, Ruff, and targeted pytest pass.
- A later UI01 task may consume this contract only after deep-link and route
migration tests are added in its own branch/PR.
Loading
Loading