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,
+ '",
+ );
+ 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 =
+ '";
+ 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 +=
+ '";
+ 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 +=
+ '";
+ }
+ }
+ html +=
+ '' +
+ '' +
+ 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