mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user