mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
2169559d6e
* 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.
157 lines
5.4 KiB
Python
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()
|