Files
turnstone/tests/test_migration_062.py
Patrick Buckley 2169559d6e feat(projects): governed project containers — memory scope, grouping, manage UI (#724)
* feat(projects): governed project containers — memory scope, grouping, manage UI

A workstream can attach to a project: a first-class, shareable resource
container that owns a `project` memory scope, groups conversations, and is
managed from the console.

Storage / migration 062: projects + project_members tables, workstreams.
project_id, and the memory type default project→general; grants
project.{create,read,write,delete} (admin-default).

Recall + writes: project memory is recalled iff the workstream is attached AND
the user has access (owner ∨ member ∨ public-for-read), resolved once at session
construction; coordinators recall it too. New saves default to the project when
attached + writable; the save and delete paths are write-gated; deleting a
project purges its scoped memory; archived projects aren't recalled.

Access = RBAC capability ∧ per-project ACL (auth.resolve_project_access, a
single-fetch resolver); visibility changes, member management, and delete are
owner-only.

API: project CRUD routes on both the server and console; project_id threaded
through workstream creation, spawn inheritance, the cluster-create proxy, the
dashboard / snapshot / coordinator row builders, and the collector deltas.

UI: a project picker with an inline "+ New project" creator in every creation
box (console launcher + standalone dialog + dashboard); group-by-project in the
rail; a project badge in the composer and on dashboard rows; a console manage
tab (list + create/edit + members shelves). The admin Memories view gains
coordinator/project scope filters and human scope labels (name, not hex). The
memory tool schema documents the project scope and the attach-aware default.

* fix(projects): client refresh hardening, creator race guard, SDK project_id

Addresses PR #724 review feedback plus two bugs found while validating it.

- projects.js refreshProjects: a non-OK status (e.g. 403 when the caller
  lacks project.read) or a network/parse error no longer blanks the cache
  or masquerades as "no projects" -- the prior cache is preserved, the
  failure is recorded (new projectsError()) and warned. Honors the
  long-standing "a transient error can't blank the rail" docstring.
- projects.js _fp: the fingerprint separators were raw control bytes,
  which made git treat the whole file as binary (no reviewable diff).
  Rewritten as escape sequences instead of raw bytes -- behavior is
  byte-identical at runtime.
- project_creator.js: createProject() could reject unhandled (authFetch
  throws on network/401; r.json() throws on a non-JSON body), leaving the
  widget stuck busy/disabled. Added a .catch, plus a generation guard so a
  create whose widget was cancelled/reopened mid-flight drops its result
  instead of selecting a project the user backed out of.
- types.ts: add project_id to CreateWorkstreamRequest / WorkstreamInfo /
  DashboardWorkstream to match the server schemas (was SDK-invisible).
- test_project_api.py: move side-effecting HTTP calls out of asserts so
  the requests run even under python -O.

* fix(projects): JSON.stringify the cache fingerprint, drop control-byte separators

_fp joined fields/rows on raw NUL/SOH bytes, which made projects.js read as binary to git. Replace with a collision-proof, escape-free JSON.stringify encoding -- same change-detection semantics, zero embedded control characters.
2026-06-26 17:24:06 -07:00

157 lines
5.4 KiB
Python

"""Tests for alembic migration 062 (Projects: containers + type project→general rename).
Drives ``command.upgrade``/``downgrade`` against an isolated SQLite database per test
(the 060/061 harness pattern), then asserts:
* the ``projects`` + ``project_members`` tables and ``workstreams.project_id`` are created;
* ``structured_memories`` rows with ``type='project'`` are relabelled ``'general'`` while
other types pass through untouched;
* ``project.{create,read,write}`` are appended to the ``builtin-admin`` role;
* ``downgrade`` drops the schema, removes the perms, and relabels ``'general'`` → ``'project'``.
"""
from __future__ import annotations
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
_MIGRATIONS_DIR = str(
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
)
def _alembic_cfg(db_path: Path) -> Config:
cfg = Config()
cfg.set_main_option("script_location", _MIGRATIONS_DIR)
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
return cfg
def _seed_memory(
conn: sa.Connection,
memory_id: str,
name: str,
mem_type: str,
scope: str = "user",
scope_id: str = "u1",
) -> None:
conn.execute(
sa.text(
"INSERT INTO structured_memories "
"(memory_id, name, type, scope, scope_id, content, created, updated) "
"VALUES (:id, :name, :type, :scope, :sid, 'c', "
"'2026-06-01T00:00:00', '2026-06-01T00:00:00')"
),
{"id": memory_id, "name": name, "type": mem_type, "scope": scope, "sid": scope_id},
)
def _admin_perms(engine: sa.Engine) -> str:
with engine.connect() as conn:
row = conn.execute(
sa.text("SELECT permissions FROM roles WHERE role_id = 'builtin-admin'")
).fetchone()
return str(row[0]) if row else ""
class TestMigration062:
def test_creates_projects_schema(self, tmp_path: Path) -> None:
db_path = tmp_path / "062-schema.db"
cfg = _alembic_cfg(db_path)
command.upgrade(cfg, "062")
engine = sa.create_engine(f"sqlite:///{db_path}")
try:
insp = sa.inspect(engine)
assert {"projects", "project_members"} <= set(insp.get_table_names())
proj_cols = {c["name"] for c in insp.get_columns("projects")}
assert {
"project_id",
"name",
"owner_id",
"visibility",
"state",
"parent_project_id",
"created",
"updated",
} <= proj_cols
member_cols = {c["name"] for c in insp.get_columns("project_members")}
assert {"project_id", "user_id", "created"} <= member_cols
assert "project_id" in {c["name"] for c in insp.get_columns("workstreams")}
finally:
engine.dispose()
def test_renames_type_project_to_general(self, tmp_path: Path) -> None:
db_path = tmp_path / "062-type.db"
cfg = _alembic_cfg(db_path)
command.upgrade(cfg, "061")
engine = sa.create_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as conn:
_seed_memory(conn, "m-proj", "a", "project")
_seed_memory(conn, "m-feed", "b", "feedback")
_seed_memory(conn, "m-user", "c", "user")
command.upgrade(cfg, "062")
with engine.connect() as conn:
rows = {
str(r[0]): str(r[1])
for r in conn.execute(
sa.text("SELECT memory_id, type FROM structured_memories")
).fetchall()
}
assert rows["m-proj"] == "general"
assert rows["m-feed"] == "feedback"
assert rows["m-user"] == "user"
finally:
engine.dispose()
def test_grants_project_perms_to_admin(self, tmp_path: Path) -> None:
db_path = tmp_path / "062-perms.db"
cfg = _alembic_cfg(db_path)
command.upgrade(cfg, "062")
engine = sa.create_engine(f"sqlite:///{db_path}")
try:
perms = _admin_perms(engine)
for perm in (
"project.create",
"project.read",
"project.write",
"project.delete",
):
assert perm in perms
finally:
engine.dispose()
def test_downgrade_reverses_everything(self, tmp_path: Path) -> None:
db_path = tmp_path / "062-down.db"
cfg = _alembic_cfg(db_path)
command.upgrade(cfg, "062")
engine = sa.create_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as conn:
_seed_memory(conn, "m-gen", "a", "general")
command.downgrade(cfg, "061")
insp = sa.inspect(engine)
tables = set(insp.get_table_names())
assert "projects" not in tables
assert "project_members" not in tables
assert "project_id" not in {c["name"] for c in insp.get_columns("workstreams")}
assert "project.create" not in _admin_perms(engine)
with engine.connect() as conn:
row = conn.execute(
sa.text("SELECT type FROM structured_memories WHERE memory_id = 'm-gen'")
).fetchone()
assert row is not None and row[0] == "project"
finally:
engine.dispose()