Skip to content

Commit aeb3a95

Browse files
committed
feat(assets): add 'used_by' attribute to assets for tracking workspace workflows
1 parent c8b60a0 commit aeb3a95

12 files changed

Lines changed: 265 additions & 20 deletions

File tree

‎docs/SERVER.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,11 @@ The editor's forms come from these; they are just as usable from scripts:
575575
delete can actually reach is the one with `writable: true`. `shadowed`
576576
lists the entries a nearer library hides: same shape as an `assets` entry
577577
but without `url` (that URL would serve the shadowing file, not this one),
578-
plus `shadowed_by` naming the origin that won. The workflow and prompt
578+
plus `shadowed_by` naming the origin that won. An `assets` entry carries
579+
`used_by` when any of the workspace's own (writable) workflows names it in
580+
an `asset:` string, variable defaults included - a reference a run passes
581+
as an argument is not seen; the web UI shows a workspace only the shared
582+
assets its workflows use. The workflow and prompt
579583
listings carry the same `libraries` and `shadowed` (`[{name, origin,
580584
shadowed_by}]`); a filter narrows `shadowed` with the entries. Upload, keep
581585
and delete answer 409 `This workspace has no asset library` when there is

‎dw/server/api_models.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,10 @@ class AssetFile(ApiModel):
869869
writable: bool
870870
url: str
871871
absolute_url: str = sometimes()
872+
used_by: list[str] = sometimes(
873+
"This workspace's own workflows that name it in an `asset:` reference, "
874+
"when any do"
875+
)
872876

873877

874878
class ShadowedAsset(ApiModel):

‎dw/server/app.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def _store_serving(
155155
# One index per distinct listing, per app (see deps.ceiling_index)
156156
state.ceiling_indexes = {}
157157
state.catalog_listings = {}
158+
# Which workspace workflows name each asset (see deps.asset_users)
159+
state.asset_users = {}
158160

159161

160162
def _mount_mcp(app, mcp_asgi):

‎dw/server/deps.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
from fastapi import HTTPException, Request
1414

15-
from ..security import SecurityError
15+
from .. import references
16+
from ..realize import strings_with_prefix
17+
from ..security import SecurityError, validate_asset_reference
1618
from ..vram_inheritance import build_index, inherited_differences, template_identity
1719
from .observed_cost import declared_drivers
1820
from ..library import ASSETS_KIND, LORAS_KIND, PROMPTS_KIND, library_path
@@ -171,6 +173,36 @@ def ceiling_index(state, ws):
171173
return index
172174

173175

176+
def asset_users(state, ws):
177+
"""Which of this workspace's own workflows name each asset: `{asset name:
178+
[workflow name, ...]}`, over the writable libraries of its search path -
179+
the population the Workflows page lists, not the read-only examples.
180+
181+
Read off every `asset:` string a definition holds, variable defaults
182+
included, the way an export collects them (`dw/server/exports.py`). A
183+
reference the definition builds at run time, or one a run passes as an
184+
argument, is not seen. Cached per catalog listing, as `ceiling_index` is.
185+
"""
186+
catalog = _catalog_listing(state, ws)
187+
cached = state.asset_users.get(ws.name)
188+
if cached and cached[0] is catalog:
189+
return cached[1]
190+
users = {}
191+
for name, definition, writable in catalog:
192+
if not writable:
193+
continue
194+
for reference in strings_with_prefix(definition, references.ASSET):
195+
try:
196+
asset = validate_asset_reference(
197+
references.ref_name(references.ASSET, reference).strip()
198+
)
199+
except SecurityError:
200+
continue
201+
users.setdefault(asset, []).append(name)
202+
state.asset_users[ws.name] = (catalog, users)
203+
return users
204+
205+
174206
def _pipeline_steps(definition):
175207
return sum(
176208
1

‎dw/server/routes/assets.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ...workspace import Workspace, forget_workspace_usage
3939
from ..deps import (
4040
asset_library_missing,
41+
asset_users,
4142
selected_workspace,
4243
writable_asset_directory,
4344
)
@@ -219,6 +220,10 @@ def list_assets(
219220
`prefix` keeps the assets (and shadowed names) whose name starts with
220221
it; `limit` then keeps the newest that many. `total` is what matched
221222
before the cut, so a caller can tell a bounded answer from a complete one.
223+
224+
`used_by` names the workspace's own workflows that reference an asset
225+
(`asset_users`) - what lets a workspace show the shared assets it
226+
actually uses rather than the whole shared library.
222227
"""
223228
library = workspace_asset_library(request.app.state, ws)
224229
if not library.roots():
@@ -267,6 +272,7 @@ def summary(name, root):
267272
"writable": root.writable,
268273
}
269274

275+
users = asset_users(request.app.state, ws)
270276
assets = []
271277
for name, root in winners.items():
272278
asset_path = f"/inputs/{quote(name)}"
@@ -276,6 +282,8 @@ def summary(name, root):
276282
absolute_url = absolute_served_url(asset_path, ws)
277283
if absolute_url is not None:
278284
asset_entry["absolute_url"] = absolute_url
285+
if name in users:
286+
asset_entry["used_by"] = users[name]
279287
assets.append(asset_entry)
280288
assets.sort(key=lambda entry: entry["mtime"], reverse=True)
281289
if prefix:

‎tests/test_server_library_path.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,58 @@ def test_limit_and_prefix_cut_server_side_and_total_counts_the_match(self, serve
287287
assert only["shadowed"] == []
288288

289289

290+
class TestAnAssetSaysWhichWorkspaceWorkflowsUseIt:
291+
"""`used_by` names the workspace's own workflows that reference an asset,
292+
so the workspace's Assets page can show the shared assets it uses
293+
without listing the whole shared library. An example's references do
294+
not count: an example is not the workspace's."""
295+
296+
@pytest.fixture
297+
def server(self, make_server):
298+
server = make_server()
299+
for name in ("cast/priya.png", "unused.png", "example-only.png"):
300+
path = os.path.join(server.workspace.common_assets, name)
301+
os.makedirs(os.path.dirname(path), exist_ok=True)
302+
with open(path, "wb") as file:
303+
file.write(b"x")
304+
for directory, name, reference in (
305+
(server.workspace.workflows, "portrait", "asset:cast/priya.png"),
306+
(server.workspace.workflows, "sequel", "asset:cast/priya.png"),
307+
(str(server.checkout / "workflows"), "example", "asset:example-only.png"),
308+
):
309+
workflow = valid_workflow()
310+
workflow["variables"]["image"] = reference
311+
with open(os.path.join(directory, f"{name}.json"), "w") as file:
312+
json.dump(workflow, file)
313+
return server
314+
315+
def used_by(self, server):
316+
assets = server.client.get("/api/assets").json()["assets"]
317+
return {a["name"]: a.get("used_by") for a in assets}
318+
319+
def test_a_referenced_asset_names_its_workflows(self, server):
320+
assert self.used_by(server)["cast/priya.png"] == ["portrait", "sequel"]
321+
322+
def test_an_unreferenced_asset_carries_no_used_by(self, server):
323+
assert self.used_by(server)["unused.png"] is None
324+
325+
def test_an_examples_reference_does_not_count(self, server):
326+
assert self.used_by(server)["example-only.png"] is None
327+
328+
def test_a_saved_edit_is_seen_on_the_next_listing(self, server):
329+
workflow = valid_workflow()
330+
workflow["variables"]["image"] = "asset:unused.png"
331+
path = os.path.join(server.workspace.workflows, "portrait.json")
332+
with open(path, "w") as file:
333+
json.dump(workflow, file)
334+
# A later mtime, so the catalog cache sees the change
335+
stat = os.stat(path)
336+
os.utime(path, (stat.st_atime, stat.st_mtime + 5))
337+
used = self.used_by(server)
338+
assert used["unused.png"] == ["portrait"]
339+
assert used["cast/priya.png"] == ["sequel"]
340+
341+
290342
class TestWorkspaceRegistryNamesTheServersPrompts:
291343
def test_a_named_workspace_reports_the_servers_prompt_dir(
292344
self, make_server, tmp_path

‎ui/src/lib/assets/AssetDetail.svelte‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import CopyButton from '../CopyButton.svelte'
44
import type { AssetFile } from '../types'
55
import { formatBytes, formatMtime } from '../format'
6+
import { wsHref } from '../routes'
7+
import { workspace } from '../workspace.svelte'
68
79
// The popout for the one asset picked: its reference to copy, the file,
810
// and a delete unless a read-only library brought it
@@ -61,6 +63,15 @@
6163
aria-label="close details"><X size={14} /></button
6264
>
6365
</div>
66+
{#if asset.used_by?.length}
67+
<p class="usedby muted">
68+
used by
69+
{#each asset.used_by as name, i (name)}{i ? ', ' : ''}<a
70+
href={wsHref(workspace.current, 'workflows', ...name.split('/'))}
71+
>{name}</a
72+
>{/each}
73+
</p>
74+
{/if}
6475
<div class="body">
6576
{#if asset.kind === 'image'}
6677
<img src={asset.url} alt={asset.name} />
@@ -76,6 +87,10 @@
7687
</div>
7788

7889
<style>
90+
.usedby {
91+
margin: 0 0 var(--space-2);
92+
font-size: var(--t-sm);
93+
}
7994
/* The gallery's popout: it rides the bottom of the viewport while the
8095
grid scrolls behind it. Sitting at the end of the document instead
8196
would put it off screen for any click above the fold */

‎ui/src/lib/assets/LibraryRow.svelte‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
<span class="muted" title="read-only: this server cannot write to it"
3838
>read-only</span
3939
>
40-
{:else if section.origin === 'common'}
40+
{:else if section.origin === 'common' && shared}
41+
<!-- Only on the Shared assets page: a workspace's page shows just the
42+
shared assets its workflows use, so an upload there would vanish
43+
from the section it was made in -->
4144
<button
4245
class="withicon"
43-
class:quiet={!shared}
4446
onclick={() => onupload('shared')}
4547
disabled={busy}
4648
title="lands in the shared library - visible from every workspace under this root and cannot be moved afterwards"

‎ui/src/lib/generated/api-schema.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export interface paths {
2525
* `prefix` keeps the assets (and shadowed names) whose name starts with
2626
* it; `limit` then keeps the newest that many. `total` is what matched
2727
* before the cut, so a caller can tell a bounded answer from a complete one.
28+
*
29+
* `used_by` names the workspace's own workflows that reference an asset
30+
* (`asset_users`) - what lets a workspace show the shared assets it
31+
* actually uses rather than the whole shared library.
2832
*/
2933
get: operations["list_assets_api_assets_get"];
3034
put?: never;
@@ -1802,6 +1806,11 @@ export interface components {
18021806
size: number;
18031807
/** Url */
18041808
url: string;
1809+
/**
1810+
* Used By
1811+
* @description This workspace's own workflows that name it in an `asset:` reference, when any do
1812+
*/
1813+
used_by?: string[];
18051814
/** Writable */
18061815
writable: boolean;
18071816
};

‎ui/src/lib/generated/openapi.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@
159159
"title": "Url",
160160
"type": "string"
161161
},
162+
"used_by": {
163+
"description": "This workspace's own workflows that name it in an `asset:` reference, when any do",
164+
"items": {
165+
"type": "string"
166+
},
167+
"title": "Used By",
168+
"type": "array"
169+
},
162170
"writable": {
163171
"title": "Writable",
164172
"type": "boolean"
@@ -4696,7 +4704,7 @@
46964704
"paths": {
46974705
"/api/assets": {
46984706
"get": {
4699-
"description": "The asset library: the input media an 'asset:' reference names.\n\nReported by reference rather than by path - 'asset:uploads/x.png' is\nwhat a workflow argument carries, and a client that only ever sees\nreferences cannot accidentally write a path that means something\nelse on another machine. Empty, not an error, on a server with no\nlibrary configured: nothing is wrong, there is just nowhere for an\nasset to be.\n\n`prefix` keeps the assets (and shadowed names) whose name starts with\nit; `limit` then keeps the newest that many. `total` is what matched\nbefore the cut, so a caller can tell a bounded answer from a complete one.",
4707+
"description": "The asset library: the input media an 'asset:' reference names.\n\nReported by reference rather than by path - 'asset:uploads/x.png' is\nwhat a workflow argument carries, and a client that only ever sees\nreferences cannot accidentally write a path that means something\nelse on another machine. Empty, not an error, on a server with no\nlibrary configured: nothing is wrong, there is just nowhere for an\nasset to be.\n\n`prefix` keeps the assets (and shadowed names) whose name starts with\nit; `limit` then keeps the newest that many. `total` is what matched\nbefore the cut, so a caller can tell a bounded answer from a complete one.\n\n`used_by` names the workspace's own workflows that reference an asset\n(`asset_users`) - what lets a workspace show the shared assets it\nactually uses rather than the whole shared library.",
47004708
"operationId": "list_assets_api_assets_get",
47014709
"parameters": [
47024710
{

0 commit comments

Comments
 (0)