feat(projects): saved-list project column + per-project resources view

Dashboard saved-sessions lists now carry and render the workstream's
project: SavedWorkstreamInfo gains project_id (the saved projection was
extended in the visibility change), SavedColumns grows a PROJECT column
(name resolved through the shared projects data layer, searchable via
the filter haystack, re-rendered when the async project cache fills),
inserted on both the webui saved-workstreams and console saved-sessions
tables.

Manage → governance → Projects rows are now expandable (same
interaction contract as the Users tab's OIDC panel): a per-project
resources panel lists the project's workstreams (kind/state/updated),
referenced attachments (metadata + ws-scoped download link through the
console's node proxy), and the project-scoped memory count. Backed by
GET /v1/api/projects/{id}/resources (project.read + per-project ACL,
collection off the event loop) over two new storage queries —
list_workstreams_for_project (first consumer of idx_workstreams_project)
and list_project_attachments (conversation ref-list walk, metadata only,
first-referencing ws per blob, pruned blobs skipped).
This commit is contained in:
Patrick Buckley
2026-07-01 15:49:55 -07:00
parent fbfd170ca6
commit bf9299de1a
13 changed files with 643 additions and 14 deletions
+53
View File
@@ -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
+48
View File
@@ -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") == []
+1
View File
@@ -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):
+19 -6
View File
@@ -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,
+194 -2
View File
@@ -2522,10 +2522,11 @@ function _renderProjects(projects) {
const p = projects[i];
const archived = p.state === "archived";
html +=
'<div class="admin-row" role="listitem" data-project-id="' +
'<div class="admin-row" role="listitem" data-expandable data-project-id="' +
escapeHtml(p.project_id) +
'">' +
'" tabindex="0" aria-expanded="false">' +
'<span class="admin-col admin-col-username">' +
'<span class="admin-expand-indicator" aria-hidden="true">▸</span>' +
escapeHtml(p.name) +
"</span>" +
'<span class="admin-col admin-col-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,
'<div class="proj-detail-inner">' +
'<div class="proj-detail-body"><span class="proj-detail-empty">Loading…</span></div>' +
"</div>",
);
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,
'<span class="proj-detail-empty">Failed to load</span>',
);
});
}
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 =
'<div class="proj-detail-header">Workstreams (' + wss.length + ")</div>";
if (!wss.length) {
html += '<span class="proj-detail-empty">No workstreams</span>';
} else {
for (let i = 0; i < wss.length; i++) {
const w = wss[i];
html +=
'<div class="proj-detail-row">' +
'<span class="proj-detail-main">' +
escapeHtml(w.title || w.name || w.ws_id.substring(0, 12)) +
"</span>" +
'<span class="proj-detail-dim">' +
escapeHtml(String(w.kind || "")) +
" · " +
escapeHtml(String(w.state || "")) +
" · " +
escapeHtml(String(w.updated || "").slice(0, 10)) +
" · " +
escapeHtml(w.ws_id.substring(0, 7)) +
"</span>" +
"</div>";
}
}
const atts = data.attachments || [];
html +=
'<div class="proj-detail-header">Attachments (' + atts.length + ")</div>";
if (!atts.length) {
html += '<span class="proj-detail-empty">No attachments</span>';
} 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 +=
'<div class="proj-detail-row">' +
'<span class="proj-detail-main">' +
'<span aria-hidden="true">' +
icon +
"</span> " +
'<a class="proj-detail-link" target="_blank" rel="noopener" href="' +
escapeHtml(_projAttachmentHref(a)) +
'">' +
escapeHtml(a.filename || a.attachment_id.substring(0, 12)) +
"</a>" +
"</span>" +
'<span class="proj-detail-dim">' +
escapeHtml(_projFmtSize(a.size_bytes)) +
" · " +
escapeHtml(String(a.created || "").slice(0, 10)) +
"</span>" +
"</div>";
}
}
html +=
'<div class="proj-detail-header">Memory</div>' +
'<div class="proj-detail-row"><span class="proj-detail-main">' +
String(data.memory_count || 0) +
" project-scoped memor" +
(data.memory_count === 1 ? "y" : "ies") +
"</span></div>";
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) {
+8
View File
@@ -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
+48 -6
View File
@@ -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;
}
+89
View File
@@ -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(
+11
View File
@@ -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]]:
+91
View File
@@ -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(
+47
View File
@@ -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,
+26
View File
@@ -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;
+8
View File
@@ -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