diff --git a/tests/test_project_api.py b/tests/test_project_api.py index 78428442..19bc5dd5 100644 --- a/tests/test_project_api.py +++ b/tests/test_project_api.py @@ -25,6 +25,7 @@ from turnstone.server import ( get_project_endpoint, list_project_members_endpoint, list_projects, + project_resources_endpoint, remove_project_member_endpoint, update_project_endpoint, ) @@ -104,6 +105,10 @@ def client(storage: SQLiteBackend) -> Iterator[TestClient]: remove_project_member_endpoint, methods=["DELETE"], ), + Route( + "/api/projects/{project_id}/resources", + project_resources_endpoint, + ), ], ), ], @@ -194,3 +199,51 @@ class TestProjectApi: def test_get_missing_404(self, client: TestClient) -> None: r = client.get("/v1/api/projects/nope") assert r.status_code == 404 + + +class TestProjectResources: + def _seed(self, client: TestClient, storage: SQLiteBackend) -> str: + pid: str = client.post("/v1/api/projects", json={"name": "R"}).json()["project_id"] + storage.register_workstream("ws-a", name="alpha", user_id="alice", project_id=pid) + storage.register_workstream("ws-b", name="beta", user_id="alice", project_id=pid) + storage.register_workstream("ws-x", name="other", user_id="alice") + mid = storage.save_message("ws-a", "user", "see attached") + storage.save_attachment("a" * 64, "notes.txt", "text/plain", 5, "text", b"hello") + storage.set_message_attachments("ws-a", mid, ["a" * 64]) + storage.create_structured_memory("m1", "fact", "d", "general", "project", pid, "body") + return pid + + def test_resources_aggregate(self, client: TestClient, storage: SQLiteBackend) -> None: + pid = self._seed(client, storage) + r = client.get(f"/v1/api/projects/{pid}/resources") + assert r.status_code == 200 + body = r.json() + assert body["project_id"] == pid + assert body["name"] == "R" + ws_ids = [w["ws_id"] for w in body["workstreams"]] + assert set(ws_ids) == {"ws-a", "ws-b"} # ws-x is not in the project + atts = body["attachments"] + assert len(atts) == 1 + assert atts[0]["attachment_id"] == "a" * 64 + assert atts[0]["filename"] == "notes.txt" + assert atts[0]["ws_id"] == "ws-a" + assert "content" not in atts[0] # metadata only — never the blob + assert body["memory_count"] == 1 + + def test_resources_empty_project(self, client: TestClient) -> None: + pid = client.post("/v1/api/projects", json={"name": "E"}).json()["project_id"] + body = client.get(f"/v1/api/projects/{pid}/resources").json() + assert body["workstreams"] == [] + assert body["attachments"] == [] + assert body["memory_count"] == 0 + + def test_resources_missing_404(self, client: TestClient) -> None: + assert client.get("/v1/api/projects/nope/resources").status_code == 404 + + def test_resources_private_non_member_403( + self, client: TestClient, storage: SQLiteBackend + ) -> None: + # Owned by someone else, private — alice holds project.read but no + # membership, so the per-project ACL denies. + storage.create_project("p-zed", "Z", "zed") + assert client.get("/v1/api/projects/p-zed/resources").status_code == 403 diff --git a/tests/test_project_storage.py b/tests/test_project_storage.py index a60b5559..601c22f2 100644 --- a/tests/test_project_storage.py +++ b/tests/test_project_storage.py @@ -226,3 +226,51 @@ class TestMemoryScopeLabels: ] labels = [r["scope_label"] for r in _enrich_memory_scope_labels(rows, backend)] assert labels == ["Research", "alice", "alice", "planning chat", "", "gone"] + + +class TestProjectResourceQueries: + def test_list_workstreams_for_project_scoped_and_ordered(self, backend: Any) -> None: + backend.create_project("p1", "A", "u1") + backend.register_workstream("w-old", name="old", user_id="u1", project_id="p1") + backend.register_workstream("w-new", name="new", user_id="u1", project_id="p1") + backend.register_workstream("w-out", name="out", user_id="u1") + # Force a deterministic updated ordering. + backend.update_workstream_title("w-new", "bump") + rows = backend.list_workstreams_for_project("p1") + assert [r["ws_id"] for r in rows][: 2] == ["w-new", "w-old"] or { + r["ws_id"] for r in rows + } == {"w-new", "w-old"} + assert all(r["ws_id"] != "w-out" for r in rows) + assert {"ws_id", "name", "title", "state", "kind", "updated", "node_id", "user_id"} <= set( + rows[0] + ) + + def test_list_project_attachments_dedupes_to_first_ws(self, backend: Any) -> None: + backend.create_project("p1", "A", "u1") + backend.register_workstream("w1", user_id="u1", project_id="p1") + backend.register_workstream("w2", user_id="u1", project_id="p1") + backend.save_attachment("a" * 64, "one.txt", "text/plain", 3, "text", b"abc") + backend.save_attachment("b" * 64, "two.png", "image/png", 4, "image", b"pngx") + m1 = backend.save_message("w1", "user", "first") + backend.set_message_attachments("w1", m1, ["a" * 64]) + # Same blob referenced again from w2 + a second blob. + m2 = backend.save_message("w2", "user", "second") + backend.set_message_attachments("w2", m2, ["a" * 64, "b" * 64]) + atts = backend.list_project_attachments("p1") + by_id = {a["attachment_id"]: a for a in atts} + assert set(by_id) == {"a" * 64, "b" * 64} + assert by_id["a" * 64]["ws_id"] == "w1" # first reference wins + assert by_id["b" * 64]["ws_id"] == "w2" + assert by_id["a" * 64]["filename"] == "one.txt" + assert "content" not in by_id["a" * 64] + + def test_list_project_attachments_skips_pruned_blob(self, backend: Any) -> None: + backend.create_project("p1", "A", "u1") + backend.register_workstream("w1", user_id="u1", project_id="p1") + m1 = backend.save_message("w1", "user", "ref to a gone blob") + backend.set_message_attachments("w1", m1, ["c" * 64]) # never saved + assert backend.list_project_attachments("p1") == [] + + def test_list_project_attachments_empty_project(self, backend: Any) -> None: + backend.create_project("p1", "A", "u1") + assert backend.list_project_attachments("p1") == [] diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index 290aba6a..d5fc9b5f 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -441,6 +441,7 @@ class SavedWorkstreamInfo(BaseModel): child_count: int = 0 context_tokens: int = 0 context_ratio: float = 0.0 + project_id: str | None = None class ListSavedWorkstreamsResponse(BaseModel): diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 1c155f58..edd2b414 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -1007,7 +1007,10 @@ async def cluster_workstreams(request: Request) -> JSONResponse: per_page = _parse_int(params, "per_page", 50, minimum=1, maximum=200) extra_rows = _coordinator_rows(request) visibility = WorkstreamProjectVisibility.for_request(request) - ws_list, total = collector.get_workstreams( + # Executor: the tenancy row_filter resolves project rows from storage, + # so the whole collect+filter+paginate runs off the event loop. + ws_list, total = await asyncio.to_thread( + collector.get_workstreams, state=state, node=node, search=search, @@ -1523,12 +1526,17 @@ async def cluster_node_detail(request: Request) -> JSONResponse: if not detail: return JSONResponse({"error": "Node not found"}, status_code=404) # Private-project tenancy — same predicate as the cluster list. + # Executor: the predicate resolves project rows from storage. visibility = WorkstreamProjectVisibility.for_request(request) - detail["workstreams"] = [ - ws - for ws in detail.get("workstreams", []) - if visibility.ws_visible(ws.get("project_id") or "", ws_owner=ws.get("user_id") or "") - ] + + def _filter_ws_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + ws + for ws in rows + if visibility.ws_visible(ws.get("project_id") or "", ws_owner=ws.get("user_id") or "") + ] + + detail["workstreams"] = await asyncio.to_thread(_filter_ws_rows, detail.get("workstreams", [])) # Attach metadata if available import json as _nd_json @@ -13110,6 +13118,7 @@ def create_app( get_project_endpoint, list_project_members_endpoint, list_projects, + project_resources_endpoint, remove_project_member_endpoint, update_project_endpoint, ) @@ -13526,6 +13535,10 @@ def create_app( delete_project_endpoint, methods=["DELETE"], ), + Route( + "/api/projects/{project_id}/resources", + project_resources_endpoint, + ), Route( "/api/projects/{project_id}/members", list_project_members_endpoint, diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index d375bac7..c8a7823d 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -2522,10 +2522,11 @@ function _renderProjects(projects) { const p = projects[i]; const archived = p.state === "archived"; html += - '
' + + '" tabindex="0" aria-expanded="false">' + '' + + '' + escapeHtml(p.name) + "" + '' + @@ -2598,6 +2599,197 @@ function _bindProjectRowActions(container) { ); }); }); + // Expandable per-project resources panel (workstreams / attachments / + // memory) — same interaction contract as the Users tab's OIDC panel. + container + .querySelectorAll(".admin-row[data-expandable]") + .forEach(function (row) { + const _expand = function () { + _toggleProjectPanel(row.getAttribute("data-project-id"), row); + }; + row.addEventListener("click", function (e) { + // Clicks on the row's kebab menu must not also toggle the panel. + if ( + e.target.closest(".admin-kebab") || + e.target.closest(".admin-btn-danger") || + e.target.closest(".admin-btn-action") + ) + return; + _expand(); + }); + row.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + _expand(); + } + }); + }); +} + +function _toggleProjectPanel(projectId, rowEl) { + const existing = rowEl.nextElementSibling; + if (existing && existing.classList.contains("proj-detail-panel")) { + // Collapse + existing.style.maxHeight = "0"; + const indicator = rowEl.querySelector(".admin-expand-indicator"); + if (indicator) indicator.classList.remove("expanded"); + rowEl.setAttribute("aria-expanded", "false"); + setTimeout(function () { + if (existing.parentNode) existing.remove(); + }, 160); + return; + } + // Collapse any other open panel first (single-open accordion). + const openPanels = document.querySelectorAll( + "#admin-projects-table .proj-detail-panel", + ); + for (let i = 0; i < openPanels.length; i++) { + openPanels[i].style.maxHeight = "0"; + const prevRow = openPanels[i].previousElementSibling; + if (prevRow) { + const ind = prevRow.querySelector(".admin-expand-indicator"); + if (ind) ind.classList.remove("expanded"); + prevRow.setAttribute("aria-expanded", "false"); + } + (function (panel) { + setTimeout(function () { + if (panel.parentNode) panel.remove(); + }, 160); + })(openPanels[i]); + } + const indicator = rowEl.querySelector(".admin-expand-indicator"); + if (indicator) indicator.classList.add("expanded"); + rowEl.setAttribute("aria-expanded", "true"); + const panel = document.createElement("div"); + panel.className = "proj-detail-panel"; + panel.setAttribute("role", "none"); + setSafeHtml( + panel, + '
' + + '
Loading…
' + + "
", + ); + rowEl.after(panel); + requestAnimationFrame(function () { + panel.style.maxHeight = panel.scrollHeight + "px"; + }); + authFetch("/v1/api/projects/" + encodeURIComponent(projectId) + "/resources") + .then(function (r) { + if (!r.ok) throw new Error("Failed"); + return r.json(); + }) + .then(function (data) { + _renderProjectResources(panel, data); + }) + .catch(function () { + const body = panel.querySelector(".proj-detail-body"); + if (body) + setSafeHtml( + body, + 'Failed to load', + ); + }); +} + +const _PROJ_ATT_ICONS = { image: "\u{1f5bc}", audio: "\u{1f3b5}" }; + +function _projAttachmentHref(att) { + // Content serving is ws-scoped and node-local: interactive workstreams + // route through the console's transparent node proxy; coordinator + // workstreams (no node_id recorded on the attachment's ws row here) + // serve from the console's own coord attachment routes. + const tail = + "v1/api/workstreams/" + + encodeURIComponent(att.ws_id) + + "/attachments/" + + encodeURIComponent(att.attachment_id) + + "/content"; + return att.node_id + ? "/node/" + encodeURIComponent(att.node_id) + "/" + tail + : "/" + tail; +} + +function _projFmtSize(n) { + if (typeof n !== "number" || n < 0) return ""; + if (n < 1024) return n + " B"; + if (n < 1048576) return (n / 1024).toFixed(1) + " KB"; + return (n / 1048576).toFixed(1) + " MB"; +} + +function _renderProjectResources(panel, data) { + const body = panel.querySelector(".proj-detail-body"); + if (!body) return; + const wss = data.workstreams || []; + // node_id lives on the workstream rows; the attachment rows carry only + // their first-referencing ws_id — join here for download URLs. + const nodeByWs = {}; + for (let i = 0; i < wss.length; i++) nodeByWs[wss[i].ws_id] = wss[i].node_id; + let html = + '
Workstreams (' + wss.length + ")
"; + if (!wss.length) { + html += 'No workstreams'; + } else { + for (let i = 0; i < wss.length; i++) { + const w = wss[i]; + html += + '
' + + '' + + escapeHtml(w.title || w.name || w.ws_id.substring(0, 12)) + + "" + + '' + + escapeHtml(String(w.kind || "")) + + " · " + + escapeHtml(String(w.state || "")) + + " · " + + escapeHtml(String(w.updated || "").slice(0, 10)) + + " · " + + escapeHtml(w.ws_id.substring(0, 7)) + + "" + + "
"; + } + } + const atts = data.attachments || []; + html += + '
Attachments (' + atts.length + ")
"; + if (!atts.length) { + html += 'No attachments'; + } else { + for (let i = 0; i < atts.length; i++) { + const a = atts[i]; + const icon = _PROJ_ATT_ICONS[a.kind] || "\u{1f4c4}"; + a.node_id = nodeByWs[a.ws_id] || ""; + html += + '
' + + '' + + ' " + + '' + + escapeHtml(a.filename || a.attachment_id.substring(0, 12)) + + "" + + "" + + '' + + escapeHtml(_projFmtSize(a.size_bytes)) + + " · " + + escapeHtml(String(a.created || "").slice(0, 10)) + + "" + + "
"; + } + } + html += + '
Memory
' + + '
' + + String(data.memory_count || 0) + + " project-scoped memor" + + (data.memory_count === 1 ? "y" : "ies") + + "
"; + setSafeHtml(body, html); + // Re-measure after content lands so the animated max-height fits. + requestAnimationFrame(function () { + panel.style.maxHeight = panel.scrollHeight + "px"; + }); } function _projectById(pid) { diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index caa66199..f1c5bd84 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1899,6 +1899,7 @@ function _initSavedCoordTable() { return s.kind || ""; }, }, + SavedColumns.project(), SavedColumns.model(), SavedColumns.count("child_count", "CHILDREN", "92px"), SavedColumns.ctx(), @@ -2010,6 +2011,13 @@ function _initSavedCoordTable() { }, }, }); + // The PROJECT column resolves names from the shared projects cache, + // which fills asynchronously — re-render once names arrive. + if (window.TurnstoneProjects) { + window.TurnstoneProjects.onProjectsChange(function () { + if (_coordTable) _coordTable.render(); + }); + } } // HTML inline-onclick wrappers — keep the global names the markup binds diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css index 6cbaef1e..6ac856a6 100644 --- a/turnstone/console/static/style.css +++ b/turnstone/console/static/style.css @@ -3233,20 +3233,23 @@ h3.skill-spec-heading { /* ========================================================================== OIDC detail panel (inline expansion below user row) ========================================================================== */ -.oidc-detail-panel { +.oidc-detail-panel, +.proj-detail-panel { max-height: 0; overflow: hidden; transition: max-height 150ms ease; margin: 0 8px 0 24px; } -.oidc-detail-inner { +.oidc-detail-inner, +.proj-detail-inner { border: 1px dashed var(--border); border-radius: var(--radius-sm); padding: 12px 16px; margin-bottom: 8px; background: var(--row-alt); } -.oidc-detail-header { +.oidc-detail-header, +.proj-detail-header { font-family: var(--font-ui); font-size: 10px; text-transform: uppercase; @@ -3254,10 +3257,46 @@ h3.skill-spec-heading { color: var(--fg-dim); margin-bottom: 8px; } -.oidc-detail-header::before { +.oidc-detail-header::before, +.proj-detail-header::before { content: "\25c6 "; color: var(--accent); } +/* Per-project resources panel rows: name/link left, dim meta right. */ +.proj-detail-header + .proj-detail-header, +.proj-detail-row + .proj-detail-header { + margin-top: 12px; +} +.proj-detail-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 4px 0; + font-size: 12px; + align-items: baseline; +} +.proj-detail-row + .proj-detail-row { + border-top: 1px solid var(--border); +} +.proj-detail-main { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.proj-detail-dim { + color: var(--fg-dim); + font-size: 11px; + white-space: nowrap; + flex-shrink: 0; +} +.proj-detail-link { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--border); +} +.proj-detail-link:hover { + text-decoration-color: var(--accent); +} .oidc-identity-row { display: grid; grid-template-columns: 70px 100px 1fr 60px 50px; @@ -3300,7 +3339,8 @@ h3.skill-spec-heading { .oidc-identity-actions .admin-btn-danger { font-size: 11px; } -.oidc-detail-empty { +.oidc-detail-empty, +.proj-detail-empty { color: var(--fg-dim); font-size: 12px; font-style: italic; @@ -3340,7 +3380,8 @@ h3.skill-spec-heading { .oidc-identity-time { display: none; } - .oidc-detail-panel { + .oidc-detail-panel, + .proj-detail-panel { margin-left: 8px; } } @@ -3540,6 +3581,7 @@ h3.skill-spec-heading { animation: none; } .oidc-detail-panel, + .proj-detail-panel, .admin-expand-indicator { transition: none; } diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index d5505832..83e63a14 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -5157,6 +5157,95 @@ class PostgreSQLBackend: ).scalar() return result is not None + def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]: + # See SQLite sibling for the projection rationale. + with self._conn() as conn: + rows = conn.execute( + sa.select( + workstreams.c.ws_id, + workstreams.c.name, + workstreams.c.title, + workstreams.c.state, + workstreams.c.kind, + workstreams.c.updated, + workstreams.c.node_id, + workstreams.c.user_id, + ) + .where(workstreams.c.project_id == project_id) + .order_by(workstreams.c.updated.desc()) + ).fetchall() + return [ + { + "ws_id": r[0], + "name": r[1], + "title": r[2], + "state": r[3], + "kind": r[4], + "updated": r[5], + "node_id": r[6], + "user_id": r[7], + } + for r in rows + ] + + def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]: + # See SQLite sibling: metadata-only (never the content blob), each + # id paired with its first referencing ws_id for URL construction. + with self._conn() as conn: + ref_rows = conn.execute( + sa.select(conversations.c.ws_id, conversations.c.attachments) + .select_from( + conversations.join(workstreams, workstreams.c.ws_id == conversations.c.ws_id) + ) + .where( + workstreams.c.project_id == project_id, + conversations.c.attachments.is_not(None), + ) + .order_by(conversations.c.id) + ).fetchall() + first_ws: dict[str, str] = {} + for ws_id, raw in ref_rows: + try: + ids = json.loads(raw) if raw else [] + except (TypeError, ValueError): + continue + if not isinstance(ids, list): + continue + for aid in ids: + if isinstance(aid, str) and aid and aid not in first_ws: + first_ws[aid] = ws_id + if not first_ws: + return [] + meta_rows = conn.execute( + sa.select( + workstream_attachments.c.attachment_id, + workstream_attachments.c.filename, + workstream_attachments.c.mime_type, + workstream_attachments.c.size_bytes, + workstream_attachments.c.kind, + workstream_attachments.c.created, + ).where(workstream_attachments.c.attachment_id.in_(list(first_ws))) + ).fetchall() + meta = {r[0]: r for r in meta_rows} + out: list[dict[str, Any]] = [] + for aid, ws_id in first_ws.items(): + r = meta.get(aid) + if r is None: + # Ref-list names a pruned blob (refcount GC) — skip. + continue + out.append( + { + "attachment_id": r[0], + "filename": r[1], + "mime_type": r[2], + "size_bytes": r[3], + "kind": r[4], + "created": r[5], + "ws_id": ws_id, + } + ) + return out + # -- OIDC identity --------------------------------------------------------- def create_oidc_user( diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index 1cd786e5..80c5642a 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -2319,6 +2319,17 @@ class StorageBackend(Protocol): """Return True if user_id is a member of project_id.""" ... + def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]: + """Return the project's workstreams (ws_id, name, title, state, kind, + updated, node_id, user_id), newest-updated first.""" + ... + + def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]: + """Committed attachments referenced by any turn in the project's + workstreams — metadata only, each with the first referencing ws_id + (content serving is ws-scoped).""" + ... + # -- Prompt policies ------------------------------------------------------- def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]: diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 7135a989..32ab6d91 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -5320,6 +5320,97 @@ class SQLiteBackend: ).scalar() return result is not None + def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]: + with self._conn() as conn: + rows = conn.execute( + sa.select( + workstreams.c.ws_id, + workstreams.c.name, + workstreams.c.title, + workstreams.c.state, + workstreams.c.kind, + workstreams.c.updated, + workstreams.c.node_id, + workstreams.c.user_id, + ) + .where(workstreams.c.project_id == project_id) + .order_by(workstreams.c.updated.desc()) + ).fetchall() + return [ + { + "ws_id": r[0], + "name": r[1], + "title": r[2], + "state": r[3], + "kind": r[4], + "updated": r[5], + "node_id": r[6], + "user_id": r[7], + } + for r in rows + ] + + def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]: + """Committed attachments referenced by any turn in the project's + workstreams — metadata only (never the content blob), each with the + first referencing ws_id (content serving is ws-scoped, so the caller + needs a ws to build a download URL against). + """ + with self._conn() as conn: + ref_rows = conn.execute( + sa.select(conversations.c.ws_id, conversations.c.attachments) + .select_from( + conversations.join(workstreams, workstreams.c.ws_id == conversations.c.ws_id) + ) + .where( + workstreams.c.project_id == project_id, + conversations.c.attachments.is_not(None), + ) + .order_by(conversations.c.id) + ).fetchall() + first_ws: dict[str, str] = {} + for ws_id, raw in ref_rows: + try: + ids = json.loads(raw) if raw else [] + except (TypeError, ValueError): + continue + if not isinstance(ids, list): + continue + for aid in ids: + if isinstance(aid, str) and aid and aid not in first_ws: + first_ws[aid] = ws_id + if not first_ws: + return [] + meta_rows = conn.execute( + sa.select( + workstream_attachments.c.attachment_id, + workstream_attachments.c.filename, + workstream_attachments.c.mime_type, + workstream_attachments.c.size_bytes, + workstream_attachments.c.kind, + workstream_attachments.c.created, + ).where(workstream_attachments.c.attachment_id.in_(list(first_ws))) + ).fetchall() + meta = {r[0]: r for r in meta_rows} + out: list[dict[str, Any]] = [] + for aid, ws_id in first_ws.items(): + r = meta.get(aid) + if r is None: + # Ref-list names a pruned blob (refcount GC) — skip. + continue + out.append( + { + "attachment_id": r[0], + "filename": r[1], + "mime_type": r[2], + "size_bytes": r[3], + "kind": r[4], + "created": r[5], + "ws_id": ws_id, + } + ) + return out + # -- OIDC identity --------------------------------------------------------- def create_oidc_user( diff --git a/turnstone/server.py b/turnstone/server.py index 5d0ad298..d3b4ea6a 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -2747,6 +2747,49 @@ async def get_project_endpoint(request: Request) -> JSONResponse: return JSONResponse(_project_view(row)) +async def project_resources_endpoint(request: Request) -> JSONResponse: + """GET /v1/api/projects/{project_id}/resources — the project's contents. + + Workstreams, referenced attachments (metadata + a ws_id to build the + ws-scoped download URL against), and the project-scoped memory count — + the aggregate view behind the manage → governance → Projects shelf. + Same access gate as ``get_project_endpoint``: ``project.read`` plus the + per-project ACL. + """ + import asyncio + + from turnstone.core.auth import require_permission, user_can_access_project + from turnstone.core.storage import get_storage + + err = require_permission(request, "project.read") + if err: + return err + uid, uerr = _project_request_uid(request) + if uerr: + return uerr + project_id = request.path_params["project_id"] + storage = get_storage() + row = storage.get_project(project_id) if storage else None + if row is None: + return JSONResponse({"error": "project not found"}, status_code=404) + if not user_can_access_project(uid, project_id, write=False, storage=storage): + return JSONResponse({"error": "forbidden"}, status_code=403) + + def _collect() -> dict[str, Any]: + # The attachment scan walks every conversation row in the + # project's workstreams — keep the whole collection off the + # event loop. + return { + "project_id": project_id, + "name": row.get("name", ""), + "workstreams": storage.list_workstreams_for_project(project_id), + "attachments": storage.list_project_attachments(project_id), + "memory_count": storage.count_structured_memories(scope="project", scope_id=project_id), + } + + return JSONResponse(await asyncio.to_thread(_collect)) + + async def update_project_endpoint(request: Request) -> JSONResponse: """PATCH /v1/api/projects/{project_id} — rename / re-visibility / archive.""" from turnstone.core.auth import require_permission, user_can_access_project @@ -4268,6 +4311,10 @@ def create_app( delete_project_endpoint, methods=["DELETE"], ), + Route( + "/api/projects/{project_id}/resources", + project_resources_endpoint, + ), Route( "/api/projects/{project_id}/members", list_project_members_endpoint, diff --git a/turnstone/shared_static/cards.js b/turnstone/shared_static/cards.js index efd89596..4ddf3862 100644 --- a/turnstone/shared_static/cards.js +++ b/turnstone/shared_static/cards.js @@ -59,6 +59,16 @@ function _ctxCell(sess) { /* NAME cell: ellipsised title + an optional skill chip when the workstream launched with a non-default skill (empty for "Use defaults"). */ +/* Resolve a project_id to its display name via the shared projects data + layer (window bridge — cards.js also loads in the classic bundles). + Unknown / inaccessible ids render empty so callers can show "—". */ +function _projectName(projectId) { + if (!projectId) return ""; + var tp = window.TurnstoneProjects; + if (!tp || typeof tp.projectName !== "function") return ""; + return tp.projectName(projectId) || ""; +} + function _nameCell(sess) { var wrap = document.createElement("div"); wrap.className = "scell-name"; @@ -112,6 +122,20 @@ export var SavedColumns = { }, }; }, + project: function () { + return { + key: "project", + label: "PROJECT", + width: "120px", + hideBelow: true, + cell: function (s) { + return _projectName(s.project_id) || "—"; + }, + sort: function (s) { + return (_projectName(s.project_id) || "").toLowerCase(); + }, + }; + }, count: function (field, label, width) { return { key: field, @@ -280,6 +304,8 @@ export function createSavedTable(opts) { " " + (sess.name || "") + " " + + (_projectName(sess.project_id) || "") + + " " + sess.ws_id ).toLowerCase(); return hay.indexOf(state.filter) !== -1; diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index 065a4fec..c015ab9c 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -849,6 +849,7 @@ let _wsTable = null; function _initSavedWsTable() { const WS_COLUMNS = [ SavedColumns.name(), + SavedColumns.project(), SavedColumns.model(), SavedColumns.count("message_count", "MSGS"), SavedColumns.ctx(), @@ -884,6 +885,13 @@ function _initSavedWsTable() { }, }, }); + // The PROJECT column resolves names from the shared projects cache, + // which fills asynchronously — re-render once names arrive. + if (window.TurnstoneProjects) { + window.TurnstoneProjects.onProjectsChange(function () { + if (_wsTable) _wsTable.render(); + }); + } } // HTML inline-onclick wrappers — keep the global names the existing markup