fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
628 lines
26 KiB
Python
628 lines
26 KiB
Python
"""Repository for the ``artifacts`` / ``artifact_versions`` tables.
|
|
|
|
Append-only artifact store. An ``artifacts`` row is the stable identity; each
|
|
edit appends an immutable ``artifact_versions`` row and atomically bumps
|
|
``artifacts.current_version`` (mirroring ``workflows.increment_graph_version``).
|
|
|
|
Authz is parent-derived: reads are not gated on ``user_id`` — callers resolve
|
|
access via the artifact's ``conversation_id`` / ``workflow_run_id`` parent.
|
|
``user_id`` is carried for ownership / quota accounting only. Pass-by-reference:
|
|
only metadata + the ``BaseStorage`` key are stored here, never binary bytes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy import Connection, text
|
|
|
|
from application.storage.db.base_repository import row_to_dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _artifact_to_dict(row: Any) -> dict:
|
|
"""row_to_dict for an ``artifacts`` row (``metadata`` key preserved)."""
|
|
return row_to_dict(row)
|
|
|
|
|
|
def _version_to_dict(row: Any) -> dict:
|
|
"""row_to_dict for an ``artifact_versions`` row (``spec``/``produced_by`` keys preserved)."""
|
|
return row_to_dict(row)
|
|
|
|
|
|
class ArtifactsRepository:
|
|
"""CRUD for artifact identities and their append-only versions."""
|
|
|
|
def __init__(self, conn: Connection) -> None:
|
|
"""Bind the repository to an open SQLAlchemy connection."""
|
|
self._conn = conn
|
|
|
|
def create_artifact(
|
|
self,
|
|
user_id: str,
|
|
kind: str,
|
|
*,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
team_id: Optional[str] = None,
|
|
message_id: Optional[str] = None,
|
|
title: Optional[str] = None,
|
|
metadata: Any = None,
|
|
mime_type: Optional[str] = None,
|
|
filename: Optional[str] = None,
|
|
storage_path: Optional[str] = None,
|
|
size: Optional[int] = None,
|
|
sha256: Optional[str] = None,
|
|
spec: Any = None,
|
|
preview_text: Optional[str] = None,
|
|
produced_by: Any = None,
|
|
) -> dict:
|
|
"""Create the identity row and its version 1 atomically; return the artifact dict.
|
|
|
|
When a parent is given, a STABLE per-parent ``ref_seq`` (max existing + 1) is
|
|
computed BEFORE the insert and stored into ``metadata`` so the short ``A{n}`` ref
|
|
the model receives survives later deletions. See ``next_ref_seq`` for the (benign)
|
|
concurrent-create dup caveat.
|
|
"""
|
|
metadata_payload = metadata
|
|
if conversation_id is not None or workflow_run_id is not None:
|
|
ref_seq = self.next_ref_seq(
|
|
conversation_id=conversation_id, workflow_run_id=workflow_run_id
|
|
)
|
|
merged = dict(metadata) if isinstance(metadata, dict) else {}
|
|
merged["ref_seq"] = ref_seq
|
|
metadata_payload = merged
|
|
artifact = self._conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO artifacts (
|
|
user_id, conversation_id, workflow_run_id, team_id,
|
|
message_id, kind, title, metadata, current_version
|
|
)
|
|
VALUES (
|
|
:user_id,
|
|
CAST(:conversation_id AS uuid),
|
|
CAST(:workflow_run_id AS uuid),
|
|
CAST(:team_id AS uuid),
|
|
CAST(:message_id AS uuid),
|
|
:kind, :title, CAST(:metadata AS jsonb), 1
|
|
)
|
|
RETURNING *
|
|
"""
|
|
),
|
|
{
|
|
"user_id": user_id,
|
|
"conversation_id": conversation_id,
|
|
"workflow_run_id": workflow_run_id,
|
|
"team_id": team_id,
|
|
"message_id": message_id,
|
|
"kind": kind,
|
|
"title": title,
|
|
"metadata": json.dumps(metadata_payload) if metadata_payload is not None else None,
|
|
},
|
|
).fetchone()
|
|
artifact_dict = _artifact_to_dict(artifact)
|
|
self._insert_version(
|
|
artifact_id=artifact_dict["id"],
|
|
version=1,
|
|
mime_type=mime_type,
|
|
filename=filename,
|
|
storage_path=storage_path,
|
|
size=size,
|
|
sha256=sha256,
|
|
spec=spec,
|
|
preview_text=preview_text,
|
|
produced_by=produced_by,
|
|
)
|
|
return artifact_dict
|
|
|
|
def get_artifact(self, artifact_id: str) -> Optional[dict]:
|
|
"""Fetch one artifact by id, unscoped (prefer ``get_artifact_in_parent`` at request boundaries)."""
|
|
result = self._conn.execute(
|
|
text("SELECT * FROM artifacts WHERE id = CAST(:id AS uuid)"),
|
|
{"id": artifact_id},
|
|
)
|
|
row = result.fetchone()
|
|
return _artifact_to_dict(row) if row is not None else None
|
|
|
|
def get_artifact_in_parent(
|
|
self,
|
|
artifact_id: str,
|
|
*,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
) -> Optional[dict]:
|
|
"""Fetch an artifact only if it belongs to the given parent; the safe gate for request handlers."""
|
|
if conversation_id is None and workflow_run_id is None:
|
|
raise ValueError("get_artifact_in_parent requires conversation_id or workflow_run_id")
|
|
clauses = ["id = CAST(:id AS uuid)"]
|
|
params: dict[str, Any] = {"id": artifact_id}
|
|
if conversation_id is not None:
|
|
clauses.append("conversation_id = CAST(:conversation_id AS uuid)")
|
|
params["conversation_id"] = conversation_id
|
|
if workflow_run_id is not None:
|
|
clauses.append("workflow_run_id = CAST(:workflow_run_id AS uuid)")
|
|
params["workflow_run_id"] = workflow_run_id
|
|
result = self._conn.execute(
|
|
text(f"SELECT * FROM artifacts WHERE {' AND '.join(clauses)}"),
|
|
params,
|
|
)
|
|
row = result.fetchone()
|
|
return _artifact_to_dict(row) if row is not None else None
|
|
|
|
def find_bridged_attachment(
|
|
self,
|
|
attachment_id: str,
|
|
*,
|
|
conversation_id: str,
|
|
) -> Optional[dict]:
|
|
"""Return the conversation artifact already bridged from ``attachment_id``, or None (idempotency gate)."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT a.* FROM artifacts a "
|
|
"JOIN artifact_versions v "
|
|
" ON v.artifact_id = a.id AND v.version = a.current_version "
|
|
"WHERE a.conversation_id = CAST(:conversation_id AS uuid) "
|
|
" AND v.produced_by ->> 'attachment_id' = :attachment_id "
|
|
"ORDER BY a.created_at ASC, a.id ASC LIMIT 1"
|
|
),
|
|
{"conversation_id": conversation_id, "attachment_id": str(attachment_id)},
|
|
)
|
|
row = result.fetchone()
|
|
return _artifact_to_dict(row) if row is not None else None
|
|
|
|
def list_artifacts(
|
|
self,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
) -> list[dict]:
|
|
"""List artifacts filtered by parent / owner (newest first); at least one filter is required."""
|
|
if conversation_id is None and workflow_run_id is None and user_id is None:
|
|
raise ValueError("list_artifacts requires at least one of conversation_id, workflow_run_id, user_id")
|
|
clauses: list[str] = []
|
|
params: dict[str, Any] = {}
|
|
if conversation_id is not None:
|
|
clauses.append("conversation_id = CAST(:conversation_id AS uuid)")
|
|
params["conversation_id"] = conversation_id
|
|
if workflow_run_id is not None:
|
|
clauses.append("workflow_run_id = CAST(:workflow_run_id AS uuid)")
|
|
params["workflow_run_id"] = workflow_run_id
|
|
if user_id is not None:
|
|
clauses.append("user_id = :user_id")
|
|
params["user_id"] = user_id
|
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
result = self._conn.execute(
|
|
text(f"SELECT * FROM artifacts {where} ORDER BY created_at DESC"),
|
|
params,
|
|
)
|
|
return [_artifact_to_dict(r) for r in result.fetchall()]
|
|
|
|
def list_artifacts_for_agent(
|
|
self,
|
|
agent_id: str,
|
|
user_id: str,
|
|
conversation_id: Optional[str] = None,
|
|
) -> list[dict]:
|
|
"""List an agent's artifacts (owner-scoped), optionally narrowed to one conversation.
|
|
|
|
Scopes a per-agent api-key's artifact visibility to the conversations that
|
|
agent produced, so the key cannot enumerate the owner's whole corpus. When
|
|
``conversation_id`` is given, the SQL narrows to that single conversation
|
|
(the per-visitor bearer capability for a public widget key).
|
|
"""
|
|
clauses = ["c.agent_id = CAST(:agent_id AS uuid)", "a.user_id = :user_id"]
|
|
params: dict[str, Any] = {"agent_id": str(agent_id), "user_id": user_id}
|
|
if conversation_id is not None:
|
|
clauses.append("a.conversation_id = CAST(:conversation_id AS uuid)")
|
|
params["conversation_id"] = conversation_id
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT a.* FROM artifacts a "
|
|
"JOIN conversations c ON a.conversation_id = c.id "
|
|
f"WHERE {' AND '.join(clauses)} "
|
|
"ORDER BY a.created_at DESC, a.id DESC"
|
|
),
|
|
params,
|
|
)
|
|
return [_artifact_to_dict(r) for r in result.fetchall()]
|
|
|
|
def artifact_in_agent_scope(self, artifact_id: str, agent_id: str) -> bool:
|
|
"""True if ``artifact_id``'s parent conversation belongs to ``agent_id``."""
|
|
clauses = [
|
|
"a.id = CAST(:id AS uuid)",
|
|
"c.agent_id = CAST(:agent_id AS uuid)",
|
|
]
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT 1 FROM artifacts a "
|
|
"JOIN conversations c ON a.conversation_id = c.id "
|
|
f"WHERE {' AND '.join(clauses)} "
|
|
"LIMIT 1"
|
|
),
|
|
{"id": artifact_id, "agent_id": str(agent_id)},
|
|
)
|
|
return result.fetchone() is not None
|
|
|
|
def position_in_parent(
|
|
self,
|
|
artifact_id: str,
|
|
*,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
) -> int:
|
|
"""Return the 1-based position of an artifact within its parent (by created_at, id tie-break); 0 if absent."""
|
|
if conversation_id is None and workflow_run_id is None:
|
|
raise ValueError("position_in_parent requires conversation_id or workflow_run_id")
|
|
outer, params = self._parent_clauses(conversation_id, workflow_run_id, alias="a")
|
|
inner, _ = self._parent_clauses(conversation_id, workflow_run_id, alias="t")
|
|
params["id"] = artifact_id
|
|
# The inner SELECT applies the same parent scope, so an artifact in a
|
|
# different parent yields no anchor row -> count() is 0 (not in this parent).
|
|
row = self._conn.execute(
|
|
text(
|
|
f"SELECT count(*) FROM artifacts a "
|
|
f"WHERE {' AND '.join(outer)} AND (a.created_at, a.id) <= ("
|
|
f" SELECT t.created_at, t.id FROM artifacts t "
|
|
f" WHERE {' AND '.join(inner)} AND t.id = CAST(:id AS uuid)"
|
|
f")"
|
|
),
|
|
params,
|
|
).fetchone()
|
|
return int(row[0]) if row is not None else 0
|
|
|
|
def artifact_id_at_position(
|
|
self,
|
|
n: int,
|
|
*,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Return the id of the n-th artifact (1-based, created_at asc, id tie-break) in a parent, or None."""
|
|
if conversation_id is None and workflow_run_id is None:
|
|
raise ValueError("artifact_id_at_position requires conversation_id or workflow_run_id")
|
|
if not isinstance(n, int) or n < 1:
|
|
return None
|
|
clauses, params = self._parent_clauses(conversation_id, workflow_run_id)
|
|
params["offset"] = n - 1
|
|
row = self._conn.execute(
|
|
text(
|
|
f"SELECT id FROM artifacts WHERE {' AND '.join(clauses)} "
|
|
f"ORDER BY created_at ASC, id ASC OFFSET :offset LIMIT 1"
|
|
),
|
|
params,
|
|
).fetchone()
|
|
return str(row[0]) if row is not None else None
|
|
|
|
def next_ref_seq(
|
|
self,
|
|
*,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
) -> int:
|
|
"""Return the next stable ``ref_seq`` for a parent (max stored + 1; 1 when empty).
|
|
|
|
Caveat: two concurrent creates in one parent can read the same MAX and get a
|
|
duplicate ``ref_seq`` (creates within a parent are normally serial from one agent
|
|
turn); a dup resolves to the earliest — acceptable, and strictly better than the
|
|
re-point bug. A DB unique index is a possible future hardening (no migration here).
|
|
"""
|
|
if conversation_id is None and workflow_run_id is None:
|
|
raise ValueError("next_ref_seq requires conversation_id or workflow_run_id")
|
|
clauses, params = self._parent_clauses(conversation_id, workflow_run_id)
|
|
# Guard the cast: legacy rows have no ``ref_seq`` (NULL, ignored), and the regex
|
|
# match keeps any non-numeric value from erroring the cast.
|
|
row = self._conn.execute(
|
|
text(
|
|
"SELECT COALESCE(MAX(CASE WHEN metadata ->> 'ref_seq' ~ '^[0-9]+$' "
|
|
"THEN (metadata ->> 'ref_seq')::int END), 0) + 1 "
|
|
f"FROM artifacts WHERE {' AND '.join(clauses)}"
|
|
),
|
|
params,
|
|
).fetchone()
|
|
return int(row[0]) if row is not None else 1
|
|
|
|
def resolve_id_by_ref_seq(
|
|
self,
|
|
seq: int,
|
|
*,
|
|
conversation_id: Optional[str] = None,
|
|
workflow_run_id: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Return the id of the artifact whose stored ``ref_seq`` == ``seq`` in a parent, or None.
|
|
|
|
Earliest ``created_at`` wins on the rare concurrent-create tie (see ``next_ref_seq``).
|
|
"""
|
|
if conversation_id is None and workflow_run_id is None:
|
|
raise ValueError("resolve_id_by_ref_seq requires conversation_id or workflow_run_id")
|
|
if not isinstance(seq, int) or seq < 1:
|
|
return None
|
|
clauses, params = self._parent_clauses(conversation_id, workflow_run_id)
|
|
params["seq"] = str(seq)
|
|
row = self._conn.execute(
|
|
text(
|
|
f"SELECT id FROM artifacts WHERE {' AND '.join(clauses)} "
|
|
"AND metadata ->> 'ref_seq' = :seq "
|
|
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
|
),
|
|
params,
|
|
).fetchone()
|
|
return str(row[0]) if row is not None else None
|
|
|
|
@staticmethod
|
|
def _parent_clauses(
|
|
conversation_id: Optional[str], workflow_run_id: Optional[str], alias: str = ""
|
|
) -> tuple[list[str], dict[str, Any]]:
|
|
"""Build the parent-scope WHERE clauses + params (optionally column-aliased) for the position helpers."""
|
|
prefix = f"{alias}." if alias else ""
|
|
clauses: list[str] = []
|
|
params: dict[str, Any] = {}
|
|
if conversation_id is not None:
|
|
clauses.append(f"{prefix}conversation_id = CAST(:conversation_id AS uuid)")
|
|
params["conversation_id"] = conversation_id
|
|
if workflow_run_id is not None:
|
|
clauses.append(f"{prefix}workflow_run_id = CAST(:workflow_run_id AS uuid)")
|
|
params["workflow_run_id"] = workflow_run_id
|
|
return clauses, params
|
|
|
|
def count_for_user(self, user_id: str) -> int:
|
|
"""Return how many artifacts ``user_id`` currently owns (quota accounting)."""
|
|
row = self._conn.execute(
|
|
text("SELECT count(*) FROM artifacts WHERE user_id = :user_id"),
|
|
{"user_id": user_id},
|
|
).fetchone()
|
|
return int(row[0]) if row is not None else 0
|
|
|
|
def total_bytes_for_user(self, user_id: str) -> int:
|
|
"""Return the stored byte size a user owns (quota accounting).
|
|
|
|
Deduplicated by ``storage_path``: ``restore`` appends a version that
|
|
re-points at an existing version's stored object (same key + size), so
|
|
summing every version row would charge the same bytes N times and inflate
|
|
usage without any new bytes being written. Each distinct stored object is
|
|
counted once; versions with no object yet (``storage_path IS NULL`` --
|
|
spec-only, whose size is normally NULL too) are counted individually since
|
|
they share no key to dedupe on.
|
|
"""
|
|
row = self._conn.execute(
|
|
text(
|
|
"SELECT COALESCE(SUM(t.size), 0) FROM ("
|
|
" SELECT DISTINCT v.storage_path, v.size "
|
|
" FROM artifact_versions v "
|
|
" JOIN artifacts a ON a.id = v.artifact_id "
|
|
" WHERE a.user_id = :user_id AND v.storage_path IS NOT NULL "
|
|
" UNION ALL "
|
|
" SELECT v.storage_path, v.size "
|
|
" FROM artifact_versions v "
|
|
" JOIN artifacts a ON a.id = v.artifact_id "
|
|
" WHERE a.user_id = :user_id AND v.storage_path IS NULL"
|
|
") t"
|
|
),
|
|
{"user_id": user_id},
|
|
).fetchone()
|
|
return int(row[0]) if row is not None else 0
|
|
|
|
def delete_artifact(self, artifact_id: str) -> list[str]:
|
|
"""Delete an artifact (+ its versions, via FK cascade); return its stored paths.
|
|
|
|
The caller deletes the returned storage objects best-effort AFTER commit;
|
|
the DB delete also frees the owner's count/byte quota.
|
|
"""
|
|
paths = [
|
|
v["storage_path"]
|
|
for v in self.list_versions(artifact_id)
|
|
if v.get("storage_path")
|
|
]
|
|
self._conn.execute(
|
|
text("DELETE FROM artifacts WHERE id = CAST(:id AS uuid)"),
|
|
{"id": artifact_id},
|
|
)
|
|
return paths
|
|
|
|
def storage_paths_for_conversation(self, conversation_id: str) -> list[str]:
|
|
"""Return every stored version path for a conversation's artifacts (for byte cleanup)."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT v.storage_path FROM artifact_versions v "
|
|
"JOIN artifacts a ON a.id = v.artifact_id "
|
|
"WHERE a.conversation_id = CAST(:cid AS uuid) "
|
|
"AND v.storage_path IS NOT NULL"
|
|
),
|
|
{"cid": conversation_id},
|
|
)
|
|
return [r[0] for r in result.fetchall()]
|
|
|
|
def storage_paths_for_user_conversations(self, user_id: str) -> list[str]:
|
|
"""Return every stored version path for artifacts under a user's conversations."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT v.storage_path FROM artifact_versions v "
|
|
"JOIN artifacts a ON a.id = v.artifact_id "
|
|
"JOIN conversations c ON c.id = a.conversation_id "
|
|
"WHERE c.user_id = :user_id AND v.storage_path IS NOT NULL"
|
|
),
|
|
{"user_id": user_id},
|
|
)
|
|
return [r[0] for r in result.fetchall()]
|
|
|
|
def delete_for_conversation(self, conversation_id: str) -> int:
|
|
"""Delete all artifacts (+ versions, via cascade) parented to a conversation.
|
|
|
|
``conversation_id`` is a bare uuid column (no FK), so the parent delete
|
|
does not cascade — callers invoke this to reclaim the rows + their quota.
|
|
"""
|
|
result = self._conn.execute(
|
|
text("DELETE FROM artifacts WHERE conversation_id = CAST(:cid AS uuid)"),
|
|
{"cid": conversation_id},
|
|
)
|
|
return result.rowcount
|
|
|
|
def delete_for_user_conversations(self, user_id: str) -> int:
|
|
"""Delete every artifact parented to one of a user's conversations (+ versions)."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"DELETE FROM artifacts WHERE conversation_id IN "
|
|
"(SELECT id FROM conversations WHERE user_id = :user_id)"
|
|
),
|
|
{"user_id": user_id},
|
|
)
|
|
return result.rowcount
|
|
|
|
def storage_paths_for_workflow(self, workflow_id: str) -> list[str]:
|
|
"""Return every stored version path for artifacts under a workflow's runs (for byte cleanup)."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT v.storage_path FROM artifact_versions v "
|
|
"JOIN artifacts a ON a.id = v.artifact_id "
|
|
"JOIN workflow_runs r ON r.id = a.workflow_run_id "
|
|
"WHERE r.workflow_id = CAST(:wid AS uuid) "
|
|
"AND v.storage_path IS NOT NULL"
|
|
),
|
|
{"wid": workflow_id},
|
|
)
|
|
return [r[0] for r in result.fetchall()]
|
|
|
|
def delete_for_workflow(self, workflow_id: str) -> int:
|
|
"""Delete all artifacts (+ versions, via cascade) parented to a workflow's runs.
|
|
|
|
``artifacts.workflow_run_id`` is a bare uuid column (no FK), so deleting the
|
|
workflow (which cascade-deletes its ``workflow_runs``) does NOT reap these
|
|
rows -- callers invoke this to reclaim the rows + their quota. Must run
|
|
BEFORE the workflow delete, while the run rows still resolve the subquery.
|
|
"""
|
|
result = self._conn.execute(
|
|
text(
|
|
"DELETE FROM artifacts WHERE workflow_run_id IN "
|
|
"(SELECT id FROM workflow_runs WHERE workflow_id = CAST(:wid AS uuid))"
|
|
),
|
|
{"wid": workflow_id},
|
|
)
|
|
return result.rowcount
|
|
|
|
@staticmethod
|
|
def reap_storage(paths: list) -> None:
|
|
"""Best-effort delete a batch of artifact byte objects; never raises."""
|
|
if not paths:
|
|
return
|
|
try:
|
|
from application.storage.storage_creator import StorageCreator
|
|
|
|
storage = StorageCreator.get_storage()
|
|
except Exception:
|
|
logger.warning("artifact cleanup: storage unavailable", exc_info=True)
|
|
return
|
|
for path in paths:
|
|
try:
|
|
storage.delete_file(path)
|
|
except Exception:
|
|
logger.warning("artifact cleanup: failed to delete bytes %s", path, exc_info=True)
|
|
|
|
def get_version(self, artifact_id: str, version: int) -> Optional[dict]:
|
|
"""Fetch a single version row by artifact id and version number."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT * FROM artifact_versions "
|
|
"WHERE artifact_id = CAST(:artifact_id AS uuid) AND version = :version"
|
|
),
|
|
{"artifact_id": artifact_id, "version": version},
|
|
)
|
|
row = result.fetchone()
|
|
return _version_to_dict(row) if row is not None else None
|
|
|
|
def list_versions(self, artifact_id: str) -> list[dict]:
|
|
"""List every version of an artifact, oldest first."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"SELECT * FROM artifact_versions "
|
|
"WHERE artifact_id = CAST(:artifact_id AS uuid) ORDER BY version ASC"
|
|
),
|
|
{"artifact_id": artifact_id},
|
|
)
|
|
return [_version_to_dict(r) for r in result.fetchall()]
|
|
|
|
def append_version(
|
|
self,
|
|
artifact_id: str,
|
|
*,
|
|
mime_type: Optional[str] = None,
|
|
filename: Optional[str] = None,
|
|
storage_path: Optional[str] = None,
|
|
size: Optional[int] = None,
|
|
sha256: Optional[str] = None,
|
|
spec: Any = None,
|
|
preview_text: Optional[str] = None,
|
|
produced_by: Any = None,
|
|
) -> dict:
|
|
"""Append a new version and atomically bump ``current_version``; return the version dict."""
|
|
new_version = self._conn.execute(
|
|
text(
|
|
"UPDATE artifacts "
|
|
"SET current_version = current_version + 1, updated_at = now() "
|
|
"WHERE id = CAST(:id AS uuid) "
|
|
"RETURNING current_version"
|
|
),
|
|
{"id": artifact_id},
|
|
).fetchone()
|
|
if new_version is None:
|
|
raise ValueError(f"artifact {artifact_id} not found")
|
|
return self._insert_version(
|
|
artifact_id=artifact_id,
|
|
version=new_version[0],
|
|
mime_type=mime_type,
|
|
filename=filename,
|
|
storage_path=storage_path,
|
|
size=size,
|
|
sha256=sha256,
|
|
spec=spec,
|
|
preview_text=preview_text,
|
|
produced_by=produced_by,
|
|
)
|
|
|
|
def _insert_version(
|
|
self,
|
|
*,
|
|
artifact_id: str,
|
|
version: int,
|
|
mime_type: Optional[str],
|
|
filename: Optional[str],
|
|
storage_path: Optional[str],
|
|
size: Optional[int],
|
|
sha256: Optional[str],
|
|
spec: Any,
|
|
preview_text: Optional[str],
|
|
produced_by: Any,
|
|
) -> dict:
|
|
"""Insert one append-only version row; UNIQUE(artifact_id, version) guards duplicates."""
|
|
result = self._conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO artifact_versions (
|
|
artifact_id, version, mime_type, filename, storage_path,
|
|
size, sha256, spec, preview_text, produced_by
|
|
)
|
|
VALUES (
|
|
CAST(:artifact_id AS uuid), :version, :mime_type, :filename,
|
|
:storage_path, :size, :sha256, CAST(:spec AS jsonb),
|
|
:preview_text, CAST(:produced_by AS jsonb)
|
|
)
|
|
RETURNING *
|
|
"""
|
|
),
|
|
{
|
|
"artifact_id": artifact_id,
|
|
"version": version,
|
|
"mime_type": mime_type,
|
|
"filename": filename,
|
|
"storage_path": storage_path,
|
|
"size": size,
|
|
"sha256": sha256,
|
|
"spec": json.dumps(spec) if spec is not None else None,
|
|
"preview_text": preview_text,
|
|
"produced_by": json.dumps(produced_by) if produced_by is not None else None,
|
|
},
|
|
)
|
|
return _version_to_dict(result.fetchone())
|