From 060e49f4076a51bd98d11b0ecfba8e4fb5eb1759 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:54:37 -0400 Subject: [PATCH 01/16] Add artifacts and artifact_versions tables with org RLS policies --- server/utils/db/db_utils.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/utils/db/db_utils.py b/server/utils/db/db_utils.py index e1c80b36b..3fd91855b 100644 --- a/server/utils/db/db_utils.py +++ b/server/utils/db/db_utils.py @@ -1238,6 +1238,38 @@ def initialize_tables(): CREATE UNIQUE INDEX IF NOT EXISTS postmortems_incident_id_unique ON postmortems(incident_id); CREATE INDEX IF NOT EXISTS idx_postmortems_user_id ON postmortems(user_id); """, + "artifacts": """ + CREATE TABLE IF NOT EXISTS artifacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + last_edited_by VARCHAR(20) NOT NULL DEFAULT 'agent', + current_version_id UUID, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_org_title ON artifacts(org_id, title); + """, + "artifact_versions": """ + CREATE TABLE IF NOT EXISTS artifact_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + artifact_id UUID NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE, + org_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + version_number INTEGER NOT NULL DEFAULT 1, + source VARCHAR(50) NOT NULL DEFAULT 'agent', + generation_session_id VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_artifact_versions_artifact + ON artifact_versions(artifact_id, version_number DESC); + CREATE INDEX IF NOT EXISTS idx_artifact_versions_org ON artifact_versions(org_id); + """, "incident_lifecycle_events": """ CREATE TABLE IF NOT EXISTS incident_lifecycle_events ( id SERIAL PRIMARY KEY, @@ -1413,6 +1445,8 @@ def initialize_tables(): rls_tables.append("actions") rls_tables.append("action_runs") rls_tables.append("postmortem_versions") + rls_tables.append("artifacts") + rls_tables.append("artifact_versions") # Migration: Add rca_celery_task_id column to incidents table if it doesn't exist From b959c0eca5f5728e0d953151a47472f7dfc9162f Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:54:37 -0400 Subject: [PATCH 02/16] Add shared artifact store helper for title-based upsert and versioning --- server/services/artifacts/__init__.py | 0 server/services/artifacts/store.py | 87 +++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 server/services/artifacts/__init__.py create mode 100644 server/services/artifacts/store.py diff --git a/server/services/artifacts/__init__.py b/server/services/artifacts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/services/artifacts/store.py b/server/services/artifacts/store.py new file mode 100644 index 000000000..e7295c7b0 --- /dev/null +++ b/server/services/artifacts/store.py @@ -0,0 +1,87 @@ +"""Shared artifact persistence helpers. + +Flask-free so the agent tool (title-based) and the REST routes (id + title) +share identical upsert + versioning logic, avoiding version-bump drift between +the two write paths. Every function operates on a caller-supplied cursor — the +caller owns the connection, RLS context, and commit/rollback. +""" + +from typing import Optional, Tuple + + +def create_version( + cursor, + artifact_id: str, + org_id: str, + user_id: str, + content: str, + *, + source: str, + session_id: Optional[str] = None, + set_current: bool = True, +) -> int: + """Insert a new version row for an artifact and return its number. + + The next version number is computed inline via a subquery (MAX+1) at insert + time, matching the postmortem versioning pattern. When set_current=True + (default), also advances the artifact's current_version_id pointer. + """ + cursor.execute( + """INSERT INTO artifact_versions + (artifact_id, org_id, user_id, content, version_number, source, generation_session_id) + VALUES (%s, %s, %s, %s, + (SELECT COALESCE(MAX(version_number), 0) + 1 + FROM artifact_versions WHERE artifact_id = %s), + %s, %s) + RETURNING id, version_number""", + (artifact_id, org_id, user_id, content, artifact_id, source, session_id), + ) + row = cursor.fetchone() + version_id, version_number = row[0], row[1] + if set_current: + cursor.execute( + "UPDATE artifacts SET current_version_id = %s WHERE id = %s", + (str(version_id), artifact_id), + ) + return version_number + + +def upsert_artifact_by_title( + cursor, + org_id: str, + user_id: str, + title: str, + content: str, + *, + source: str, + session_id: Optional[str] = None, +) -> Tuple[str, int]: + """Create or replace an artifact addressed by (org_id, title) and version it. + + Relies on the unique index idx_artifacts_org_title for an atomic upsert. + last_edited_by is derived from source: a 'manual' write came from a human + (the UI), anything else from the agent. Returns (artifact_id, version_number). + """ + last_edited_by = "user" if source == "manual" else "agent" + + cursor.execute( + """INSERT INTO artifacts (org_id, user_id, title, content, last_edited_by, updated_at) + VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (org_id, title) + DO UPDATE SET content = EXCLUDED.content, + user_id = EXCLUDED.user_id, + last_edited_by = EXCLUDED.last_edited_by, + updated_at = CURRENT_TIMESTAMP + RETURNING id""", + (org_id, user_id, title, content, last_edited_by), + ) + row = cursor.fetchone() + if not row: + raise RuntimeError("Artifact upsert failed — access denied or conflict.") + artifact_id = str(row[0]) + + version_number = create_version( + cursor, artifact_id, org_id, user_id, content, + source=source, session_id=session_id, set_current=True, + ) + return artifact_id, version_number From c8b75f9c334971ccc68a92262342ba55cb23a759 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:54:37 -0400 Subject: [PATCH 03/16] Add artifact REST CRUD, version history, and restore routes --- server/main_compute.py | 3 + server/routes/artifact_routes.py | 348 +++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 server/routes/artifact_routes.py diff --git a/server/main_compute.py b/server/main_compute.py index e049f3beb..6c721cf48 100644 --- a/server/main_compute.py +++ b/server/main_compute.py @@ -513,6 +513,9 @@ def enforce_user_org_binding(): from routes.postmortem_routes import postmortem_bp app.register_blueprint(postmortem_bp) +from routes.artifact_routes import artifact_bp +app.register_blueprint(artifact_bp) + # --- SRE Metrics Routes --- from routes.metrics_routes import metrics_bp app.register_blueprint(metrics_bp) diff --git a/server/routes/artifact_routes.py b/server/routes/artifact_routes.py new file mode 100644 index 000000000..bc1bf2a8e --- /dev/null +++ b/server/routes/artifact_routes.py @@ -0,0 +1,348 @@ +"""API routes for artifact CRUD, version history, and restore. + +Artifacts are persistent markdown documents Aurora maintains over time (living +findings lists, cost reports, runbooks). The agent writes them by title via +artifact_tool; this blueprint backs the Monitor → Artifacts UI and the MCP +docs tools. Both write paths share services.artifacts.store so versioning +never drifts between them. +""" + +import logging +from datetime import timezone +from functools import wraps +from typing import Optional +from uuid import UUID + +import psycopg2.errors +from flask import Blueprint, jsonify, request + +from routes.audit_routes import record_audit_event +from services.artifacts.store import create_version, upsert_artifact_by_title +from utils.auth.rbac_decorators import require_permission +from utils.auth.stateless_auth import get_org_id_from_request, set_rls_context +from utils.db.connection_pool import db_pool + +logger = logging.getLogger(__name__) + +artifact_bp = Blueprint("artifact", __name__) +_LOG_PREFIX = "[Artifact]" +_MAX_CONTENT = 100000 +_MAX_TITLE = 500 + + +def _validate_uuid(value: str) -> bool: + try: + UUID(value) + return True + except (ValueError, TypeError): + return False + + +def _format_timestamp(ts) -> Optional[str]: + if not ts: + return None + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts.isoformat() + + +def _serialize_artifact(row, *, include_content: bool) -> dict: + """Build a camelCase artifact dict from a row of + (id, title, content, last_edited_by, created_at, updated_at, version_number). + """ + data = { + "id": str(row[0]), + "title": row[1], + "lastEditedBy": row[3], + "createdAt": _format_timestamp(row[4]), + "updatedAt": _format_timestamp(row[5]), + "version": row[6] or 0, + } + if include_content: + data["content"] = row[2] or "" + return data + + +def with_artifact(fn): + """Validate artifact_id, resolve org_id, open DB, set RLS, confirm the + artifact exists. Injects org_id, conn, cursor as keyword args. 404 if absent. + """ + @wraps(fn) + def wrapper(user_id, artifact_id, *args, **kwargs): + if not _validate_uuid(artifact_id): + return jsonify({"error": "Invalid artifact ID"}), 400 + + org_id = get_org_id_from_request() + + try: + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cursor: + if set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX) is None: + # Org couldn't be resolved → RLS unset → every query would + # default-deny. Surface that rather than a misleading 404. + return jsonify({"error": "Unable to resolve organization context"}), 500 + + cursor.execute( + "SELECT id FROM artifacts WHERE id = %s AND org_id = %s", + (artifact_id, org_id), + ) + if not cursor.fetchone(): + return jsonify({"error": "Artifact not found"}), 404 + + return fn( + user_id, artifact_id, *args, + org_id=org_id, conn=conn, cursor=cursor, **kwargs, + ) + except Exception as e: + logger.error("%s %s failed for artifact %s: %s", _LOG_PREFIX, fn.__name__, artifact_id, e) + return jsonify({"error": f"Failed to {fn.__name__.replace('_', ' ')}"}), 500 + + return wrapper + + +@artifact_bp.route("/api/artifacts", methods=["GET"]) +@require_permission("artifacts", "read") +def list_or_get_artifacts(user_id): + """List artifacts (no ?title) or fetch one by exact title (?title=).""" + org_id = get_org_id_from_request() + title = request.args.get("title") + + try: + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cursor: + set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX) + + if title: + cursor.execute( + """SELECT a.id, a.title, a.content, a.last_edited_by, + a.created_at, a.updated_at, COALESCE(v.version_number, 0) + FROM artifacts a + LEFT JOIN artifact_versions v ON a.current_version_id = v.id + WHERE a.org_id = %s AND a.title = %s""", + (org_id, title.strip()), + ) + row = cursor.fetchone() + if not row: + return jsonify({"error": "Artifact not found"}), 404 + return jsonify({"artifact": _serialize_artifact(row, include_content=True)}) + + cursor.execute( + """SELECT a.id, a.title, a.content, a.last_edited_by, + a.created_at, a.updated_at, COALESCE(v.version_number, 0) + FROM artifacts a + LEFT JOIN artifact_versions v ON a.current_version_id = v.id + WHERE a.org_id = %s + ORDER BY a.updated_at DESC""", + (org_id,), + ) + rows = cursor.fetchall() + + artifacts = [_serialize_artifact(r, include_content=False) for r in rows] + return jsonify({"artifacts": artifacts}) + + except Exception as e: + logger.error("%s Failed to list artifacts for user %s: %s", _LOG_PREFIX, user_id, e) + return jsonify({"error": "Failed to fetch artifacts"}), 500 + + +@artifact_bp.route("/api/artifacts", methods=["POST"]) +@require_permission("artifacts", "write") +def create_artifact(user_id): + """Create (or replace by title) an artifact from the UI / MCP. Marked as a + user edit so the agent treats it as human-authored.""" + data = request.get_json(force=True, silent=True) or {} + + title = (data.get("title") or "").strip() + content = data.get("content") + if not title: + return jsonify({"error": "Title is required"}), 400 + if len(title) > _MAX_TITLE: + return jsonify({"error": f"Title exceeds maximum length of {_MAX_TITLE} characters"}), 400 + if not isinstance(content, str) or not content.strip(): + return jsonify({"error": "Content is required"}), 400 + if len(content) > _MAX_CONTENT: + return jsonify({"error": f"Content exceeds maximum length of {_MAX_CONTENT} characters"}), 400 + + org_id = get_org_id_from_request() + + try: + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cursor: + set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX) + artifact_id, version = upsert_artifact_by_title( + cursor, org_id, user_id, title, content, source="manual", + ) + conn.commit() + except Exception as e: + logger.error("%s Failed to create artifact for user %s: %s", _LOG_PREFIX, user_id, e) + return jsonify({"error": "Failed to create artifact"}), 500 + + record_audit_event(org_id, user_id, "create_artifact", "artifact", artifact_id, + {"title": title}, request) + return jsonify({"id": artifact_id, "version": version}), 201 + + +@artifact_bp.route("/api/artifacts/", methods=["GET"]) +@require_permission("artifacts", "read") +@with_artifact +def get_artifact(user_id, artifact_id, *, org_id, conn, cursor, **kwargs): + cursor.execute( + """SELECT a.id, a.title, a.content, a.last_edited_by, + a.created_at, a.updated_at, COALESCE(v.version_number, 0) + FROM artifacts a + LEFT JOIN artifact_versions v ON a.current_version_id = v.id + WHERE a.id = %s AND a.org_id = %s""", + (artifact_id, org_id), + ) + row = cursor.fetchone() + if not row: + return jsonify({"error": "Artifact not found"}), 404 + return jsonify({"artifact": _serialize_artifact(row, include_content=True)}) + + +@artifact_bp.route("/api/artifacts/", methods=["PATCH"]) +@require_permission("artifacts", "write") +@with_artifact +def update_artifact(user_id, artifact_id, *, org_id, conn, cursor, **kwargs): + data = request.get_json(force=True, silent=True) or {} + + content = data.get("content") + if not isinstance(content, str) or not content.strip(): + return jsonify({"error": "Content is required"}), 400 + if len(content) > _MAX_CONTENT: + return jsonify({"error": f"Content exceeds maximum length of {_MAX_CONTENT} characters"}), 400 + + new_title = data.get("title") + new_title = new_title.strip() if isinstance(new_title, str) and new_title.strip() else None + if new_title and len(new_title) > _MAX_TITLE: + return jsonify({"error": f"Title exceeds maximum length of {_MAX_TITLE} characters"}), 400 + + # No pre-edit snapshot needed: the prior content is already the current + # version row, so create_version below records this edit as the new + # current version and the previous one remains restorable. + try: + cursor.execute( + """UPDATE artifacts + SET content = %s, + title = COALESCE(%s, title), + last_edited_by = 'user', + user_id = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (content, new_title, user_id, artifact_id), + ) + except psycopg2.errors.UniqueViolation: + conn.rollback() + return jsonify({"error": "An artifact with that title already exists"}), 409 + + # New current version reflecting the manual edit. + version = create_version(cursor, artifact_id, org_id, user_id, content, source="manual") + conn.commit() + + record_audit_event(org_id, user_id, "update_artifact", "artifact", artifact_id, {}, request) + return jsonify({"success": True, "version": version}) + + +@artifact_bp.route("/api/artifacts/", methods=["DELETE"]) +@require_permission("artifacts", "write") +@with_artifact +def delete_artifact(user_id, artifact_id, *, org_id, conn, cursor, **kwargs): + # Versions cascade via the artifact_versions FK (ON DELETE CASCADE). + cursor.execute("DELETE FROM artifacts WHERE id = %s AND org_id = %s", (artifact_id, org_id)) + conn.commit() + + record_audit_event(org_id, user_id, "delete_artifact", "artifact", artifact_id, {}, request) + return jsonify({"success": True}) + + +@artifact_bp.route("/api/artifacts//versions", methods=["GET"]) +@require_permission("artifacts", "read") +@with_artifact +def list_artifact_versions(user_id, artifact_id, *, org_id, conn, cursor, **kwargs): + cursor.execute( + """SELECT v.id, v.version_number, v.source, v.user_id, v.created_at, + v.generation_session_id, a.current_version_id + FROM artifact_versions v + JOIN artifacts a ON v.artifact_id = a.id + WHERE a.id = %s AND a.org_id = %s + ORDER BY v.version_number DESC""", + (artifact_id, org_id), + ) + rows = cursor.fetchall() + + current_version_id = str(rows[0][6]) if rows and rows[0][6] else None + versions = [ + { + "id": str(row[0]), + "versionNumber": row[1], + "source": row[2], + "userId": row[3], + "createdAt": _format_timestamp(row[4]), + "generationSessionId": str(row[5]) if row[5] else None, + } + for row in rows + ] + return jsonify({"versions": versions, "currentVersionId": current_version_id}) + + +@artifact_bp.route("/api/artifacts//versions/", methods=["GET"]) +@require_permission("artifacts", "read") +@with_artifact +def get_artifact_version(user_id, artifact_id, version_id, *, org_id, conn, cursor, **kwargs): + if not _validate_uuid(version_id): + return jsonify({"error": "Invalid version ID"}), 400 + + cursor.execute( + """SELECT v.id, v.version_number, v.source, v.user_id, v.content, v.created_at + FROM artifact_versions v + JOIN artifacts a ON v.artifact_id = a.id + WHERE v.id = %s AND a.id = %s AND a.org_id = %s""", + (version_id, artifact_id, org_id), + ) + row = cursor.fetchone() + if not row: + return jsonify({"error": "Version not found"}), 404 + + return jsonify({ + "version": { + "id": str(row[0]), + "versionNumber": row[1], + "source": row[2], + "userId": row[3], + "content": row[4], + "createdAt": _format_timestamp(row[5]), + } + }) + + +@artifact_bp.route("/api/artifacts//versions//restore", methods=["POST"]) +@require_permission("artifacts", "write") +@with_artifact +def restore_artifact_version(user_id, artifact_id, version_id, *, org_id, conn, cursor, **kwargs): + if not _validate_uuid(version_id): + return jsonify({"error": "Invalid version ID"}), 400 + + cursor.execute( + """SELECT v.content + FROM artifact_versions v + JOIN artifacts a ON v.artifact_id = a.id + WHERE v.id = %s AND a.id = %s AND a.org_id = %s""", + (version_id, artifact_id, org_id), + ) + row = cursor.fetchone() + if not row: + return jsonify({"error": "Version not found"}), 404 + + restored_content = row[0] + cursor.execute( + """UPDATE artifacts + SET content = %s, current_version_id = %s, + last_edited_by = 'user', updated_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (restored_content, version_id, artifact_id), + ) + conn.commit() + + record_audit_event(org_id, user_id, "restore_artifact_version", "artifact", artifact_id, + {"version_id": version_id}, request) + return jsonify({"success": True, "content": restored_content}) From 78d84d1ac444f8c07087e0f4e2d94b8cd10bb5f1 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:54:37 -0400 Subject: [PATCH 04/16] Add artifacts RBAC read and write policies --- server/utils/auth/enforcer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/utils/auth/enforcer.py b/server/utils/auth/enforcer.py index 8c6ab1318..ab3a63a1e 100644 --- a/server/utils/auth/enforcer.py +++ b/server/utils/auth/enforcer.py @@ -32,6 +32,7 @@ # --- viewer permissions (read-only) --- ("viewer", "*", "incidents", "read"), ("viewer", "*", "postmortems", "read"), + ("viewer", "*", "artifacts", "read"), ("viewer", "*", "dashboards", "read"), ("viewer", "*", "connectors", "read"), ("viewer", "*", "chat", "read"), @@ -50,6 +51,7 @@ ("editor", "*", "connectors", "write"), ("editor", "*", "incidents", "write"), ("editor", "*", "postmortems", "write"), + ("editor", "*", "artifacts", "write"), ("editor", "*", "knowledge_base", "write"), ("editor", "*", "ssh_keys", "write"), ("editor", "*", "vms", "write"), From 662049460f4631a93bdfeae884ffda4ef82ecdbe Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:55:18 -0400 Subject: [PATCH 05/16] Add artifact list/read/write agent tools to the cloud toolbelt --- .../chat/backend/agent/tools/artifact_tool.py | 177 ++++++++++++++++++ .../chat/backend/agent/tools/cloud_tools.py | 58 ++++++ 2 files changed, 235 insertions(+) create mode 100644 server/chat/backend/agent/tools/artifact_tool.py diff --git a/server/chat/backend/agent/tools/artifact_tool.py b/server/chat/backend/agent/tools/artifact_tool.py new file mode 100644 index 000000000..2c5c6f5ee --- /dev/null +++ b/server/chat/backend/agent/tools/artifact_tool.py @@ -0,0 +1,177 @@ +""" +Artifact Tools + +Agent-callable tools for listing, reading, and writing persistent markdown +documents (artifacts) that Aurora maintains over time. Available to every agent +surface (chat, scheduled Actions, background RCA, MCP) via get_cloud_tools(). + +Title-based — no UUIDs are ever exposed to the LLM. Each function resolves the +caller's org via set_rls_context() so writes are scoped to the right tenant even +outside a Flask request context (e.g. Celery action runs). +""" + +import json +import logging + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +_MAX_CONTENT = 100000 +_MAX_TITLE = 500 + + +class ListArtifactsArgs(BaseModel): + """No args -- lists every artifact in the caller's workspace.""" + pass + + +class ReadArtifactArgs(BaseModel): + title: str = Field(description="The exact title of the artifact to read") + + +class WriteArtifactArgs(BaseModel): + title: str = Field(description="The exact title of the artifact to create or update") + content: str = Field(description="The full markdown content of the document") + + +def list_artifacts(user_id: str | None = None, **kwargs) -> str: + """List artifact metadata (title, version, last editor, updated time) for the org.""" + if not user_id: + return json.dumps({"error": "No user context available."}) + + try: + from utils.db.connection_pool import db_pool + from utils.auth.stateless_auth import set_rls_context + + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cursor: + org_id = set_rls_context(cursor, conn, user_id, log_prefix="[ArtifactTool:list]") + if not org_id: + return json.dumps({"error": "No organization context available."}) + + cursor.execute( + """SELECT a.title, a.last_edited_by, a.updated_at, + COALESCE(v.version_number, 0) + FROM artifacts a + LEFT JOIN artifact_versions v ON a.current_version_id = v.id + WHERE a.org_id = %s + ORDER BY a.updated_at DESC""", + (org_id,), + ) + rows = cursor.fetchall() + + artifacts = [ + { + "title": row[0], + "last_edited_by": row[1], + "updated_at": row[2].isoformat() if row[2] else None, + "version": row[3], + } + for row in rows + ] + return json.dumps({"status": "ok", "artifacts": artifacts}) + + except Exception: + logger.exception("[ArtifactTool] Failed to list artifacts") + return json.dumps({"error": "Failed to list artifacts."}) + + +def read_artifact(title: str, user_id: str | None = None, **kwargs) -> str: + """Read one artifact's full markdown by exact title, or report it doesn't exist.""" + if not user_id: + return json.dumps({"error": "No user context available."}) + + if not title or not title.strip(): + return json.dumps({"error": "title is required."}) + + title = title.strip() + + try: + from utils.db.connection_pool import db_pool + from utils.auth.stateless_auth import set_rls_context + + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cursor: + org_id = set_rls_context(cursor, conn, user_id, log_prefix="[ArtifactTool:read]") + if not org_id: + return json.dumps({"error": "No organization context available."}) + + cursor.execute( + """SELECT a.content, a.last_edited_by, a.updated_at, + COALESCE(v.version_number, 0) + FROM artifacts a + LEFT JOIN artifact_versions v ON a.current_version_id = v.id + WHERE a.org_id = %s AND a.title = %s""", + (org_id, title), + ) + row = cursor.fetchone() + + if not row: + return json.dumps({ + "status": "not_found", + "message": "No artifact with that title exists.", + }) + + return json.dumps({ + "status": "ok", + "content": row[0] or "", + "last_edited_by": row[1], + "updated_at": row[2].isoformat() if row[2] else None, + "version": row[3], + }) + + except Exception: + logger.exception("[ArtifactTool] Failed to read artifact") + return json.dumps({"error": "Failed to read artifact."}) + + +def write_artifact( + title: str, + content: str, + user_id: str | None = None, + session_id: str | None = None, + **kwargs, +) -> str: + """Create or update an artifact by title, recording a new version each time.""" + if not user_id: + return json.dumps({"error": "No user context available."}) + + if not title or not title.strip(): + return json.dumps({"error": "title is required."}) + + if len(title.strip()) > _MAX_TITLE: + return json.dumps({"error": "Title exceeds maximum length (500 chars)."}) + + if not content or not content.strip(): + return json.dumps({"error": "content cannot be empty."}) + + if len(content) > _MAX_CONTENT: + return json.dumps({"error": "Content exceeds maximum length (100000 chars)."}) + + try: + from utils.db.connection_pool import db_pool + from utils.auth.stateless_auth import set_rls_context + from services.artifacts.store import upsert_artifact_by_title + + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cursor: + org_id = set_rls_context(cursor, conn, user_id, log_prefix="[ArtifactTool:write]") + if not org_id: + return json.dumps({"error": "No organization context available."}) + + _artifact_id, version = upsert_artifact_by_title( + cursor, org_id, user_id, title.strip(), content, + source="agent", session_id=session_id, + ) + conn.commit() + + return json.dumps({ + "status": "ok", + "message": f"Artifact saved (version {version}).", + "version": version, + }) + + except Exception: + logger.exception("[ArtifactTool] Failed to write artifact") + return json.dumps({"error": "Failed to write artifact."}) diff --git a/server/chat/backend/agent/tools/cloud_tools.py b/server/chat/backend/agent/tools/cloud_tools.py index dbcc0040b..c90b87e04 100644 --- a/server/chat/backend/agent/tools/cloud_tools.py +++ b/server/chat/backend/agent/tools/cloud_tools.py @@ -1371,6 +1371,18 @@ def _safe_connected(check_fn, connector_name: str) -> bool: except ImportError: logger.warning("Postmortem tools not available — import failed") + # Artifacts: persistent markdown documents Aurora maintains across runs. + # Unconditional (no connector gating) so any agent surface — chat, scheduled + # Actions, background RCA, MCP — can list/read/write them. Steering lives + # entirely in the tool descriptions below; never in a system prompt. + try: + from .artifact_tool import list_artifacts, read_artifact, write_artifact + tool_functions.append((list_artifacts, "list_artifacts")) + tool_functions.append((read_artifact, "read_artifact")) + tool_functions.append((write_artifact, "write_artifact")) + except ImportError: + logger.warning("Artifact tools not available — import failed") + # Process Aurora native tools for func, name in tool_functions: # For github_rca, inject the authoritative incident timestamp before any @@ -1588,6 +1600,52 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw): ), args_schema=SavePostmortemArgs, ) + elif name == 'list_artifacts': + from .artifact_tool import ListArtifactsArgs + tool = StructuredTool.from_function( + func=final_func, + name=name, + description=( + "List all persistent documents (artifacts) maintained for this workspace, " + "with titles, versions, and last-updated times. Call this to discover what " + "documents already exist before deciding whether to read, update, or create " + "one — especially when instructions reference maintaining/updating a document " + "but don't say it's new. Metadata only, not content." + ), + args_schema=ListArtifactsArgs, + ) + elif name == 'read_artifact': + from .artifact_tool import ReadArtifactArgs + tool = StructuredTool.from_function( + func=final_func, + name=name, + description=( + "Read the full current markdown of one artifact by exact title. Call after " + "list_artifacts to get a document's current state — e.g. to see what you " + "reported last run before producing an update, or to respect a user's edits. " + "Returns content, version, and last-updated time, or that it doesn't exist." + ), + args_schema=ReadArtifactArgs, + ) + elif name == 'write_artifact': + from .artifact_tool import WriteArtifactArgs + tool = StructuredTool.from_function( + func=final_func, + name=name, + description=( + "Create or update a persistent markdown document by title. If the title " + "exists, its content is replaced and a new version recorded; otherwise it's " + "created. Use when instructions ask you to maintain/update/record findings in " + "a document that persists across runs (a living findings list, cost report, " + "runbook) — not for one-off chat answers. ALWAYS read the existing artifact " + "first and reconcile rather than regenerate: keep items the user edited or " + "added, remove findings that are now resolved or no longer reproduce, do not " + "re-add anything the user deleted, and avoid duplicates. If the existing doc " + "was last edited by the user, treat their version as authoritative and change " + "it minimally." + ), + args_schema=WriteArtifactArgs, + ) elif name == 'list_slack_channels': from .slack_tool import ListSlackChannelsArgs tool = StructuredTool.from_function( From 7cd69d72e9d2a7315531a93aa96bbe4ca186b476 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:55:18 -0400 Subject: [PATCH 06/16] Expose artifact list/read/write as MCP dispatch tools --- server/aurora_mcp/dispatch.py | 2 ++ server/aurora_mcp/registry.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/server/aurora_mcp/dispatch.py b/server/aurora_mcp/dispatch.py index c7c71a1ed..257c7dfb3 100644 --- a/server/aurora_mcp/dispatch.py +++ b/server/aurora_mcp/dispatch.py @@ -44,6 +44,8 @@ def _arg_schema(entry) -> List[Dict[str, Any]]: out.append({"name": a, "in": "path", "required": True}) for a in entry.body_keys: out.append({"name": a, "in": "body", "required": False}) + for a in getattr(entry, "query_keys", ()): + out.append({"name": a, "in": "query", "required": False}) return out diff --git a/server/aurora_mcp/registry.py b/server/aurora_mcp/registry.py index a46e28942..71759ea89 100644 --- a/server/aurora_mcp/registry.py +++ b/server/aurora_mcp/registry.py @@ -171,6 +171,9 @@ class DispatchEntry: body_keys: Tuple[str, ...] = () # Args that must be substituted into the path (e.g. {issue_key}). path_args: Tuple[str, ...] = () + # Query-string args to advertise in the tool schema so the LLM knows to + # pass them (forwarding already routes any non-body/non-path arg to query). + query_keys: Tuple[str, ...] = () # Each entry maps a stable MCP-side name to an existing Aurora REST endpoint. @@ -524,6 +527,33 @@ class DispatchEntry: path_args=("incident_id",), body_keys=("databaseId", "titleProperty", "propertyMapping", "actionItemsDatabaseId"), ), + # ----- Artifacts (Aurora-internal — no prefix; /api/artifacts) ----- + DispatchEntry( + name="artifact_list", + description="List persistent markdown artifacts for this workspace.", + category="docs", + method="GET", + path="/api/artifacts", + enabling_skills=(), + ), + DispatchEntry( + name="artifact_read", + description="Read one artifact's full content by exact title (pass the `title` arg).", + category="docs", + method="GET", + path="/api/artifacts", + enabling_skills=(), + query_keys=("title",), + ), + DispatchEntry( + name="artifact_write", + description="Create or update a persistent markdown artifact by title.", + category="docs", + method="POST", + path="/api/artifacts", + enabling_skills=(), + body_keys=("title", "content"), + ), # ----- Incidents — Aurora-internal reads and lifecycle writes ----- DispatchEntry( name="incident_update", From af298dad3c419ecd0eff9c3d775443bd269f7ff7 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:55:18 -0400 Subject: [PATCH 07/16] Add artifacts client service and Next.js proxy routes --- client/src/app/api/artifacts/[id]/route.ts | 26 ++++ .../versions/[versionId]/restore/route.ts | 15 ++ .../[id]/versions/[versionId]/route.ts | 15 ++ .../app/api/artifacts/[id]/versions/route.ts | 10 ++ client/src/app/api/artifacts/route.ts | 13 ++ client/src/lib/services/artifacts.ts | 131 ++++++++++++++++++ 6 files changed, 210 insertions(+) create mode 100644 client/src/app/api/artifacts/[id]/route.ts create mode 100644 client/src/app/api/artifacts/[id]/versions/[versionId]/restore/route.ts create mode 100644 client/src/app/api/artifacts/[id]/versions/[versionId]/route.ts create mode 100644 client/src/app/api/artifacts/[id]/versions/route.ts create mode 100644 client/src/app/api/artifacts/route.ts create mode 100644 client/src/lib/services/artifacts.ts diff --git a/client/src/app/api/artifacts/[id]/route.ts b/client/src/app/api/artifacts/[id]/route.ts new file mode 100644 index 000000000..9e208bbf8 --- /dev/null +++ b/client/src/app/api/artifacts/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server'; +import { forwardRequest } from '@/lib/backend-proxy'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + return forwardRequest(request, 'GET', `/api/artifacts/${id}`, 'Failed to fetch artifact'); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + return forwardRequest(request, 'PATCH', `/api/artifacts/${id}`, 'Failed to update artifact'); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + return forwardRequest(request, 'DELETE', `/api/artifacts/${id}`, 'Failed to delete artifact'); +} diff --git a/client/src/app/api/artifacts/[id]/versions/[versionId]/restore/route.ts b/client/src/app/api/artifacts/[id]/versions/[versionId]/restore/route.ts new file mode 100644 index 000000000..d21e2f2b3 --- /dev/null +++ b/client/src/app/api/artifacts/[id]/versions/[versionId]/restore/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from 'next/server'; +import { forwardRequest } from '@/lib/backend-proxy'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; versionId: string }> }, +) { + const { id, versionId } = await params; + return forwardRequest( + request, + 'POST', + `/api/artifacts/${id}/versions/${versionId}/restore`, + 'Failed to restore artifact version', + ); +} diff --git a/client/src/app/api/artifacts/[id]/versions/[versionId]/route.ts b/client/src/app/api/artifacts/[id]/versions/[versionId]/route.ts new file mode 100644 index 000000000..117346e32 --- /dev/null +++ b/client/src/app/api/artifacts/[id]/versions/[versionId]/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from 'next/server'; +import { forwardRequest } from '@/lib/backend-proxy'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; versionId: string }> }, +) { + const { id, versionId } = await params; + return forwardRequest( + request, + 'GET', + `/api/artifacts/${id}/versions/${versionId}`, + 'Failed to fetch artifact version', + ); +} diff --git a/client/src/app/api/artifacts/[id]/versions/route.ts b/client/src/app/api/artifacts/[id]/versions/route.ts new file mode 100644 index 000000000..e722a3a81 --- /dev/null +++ b/client/src/app/api/artifacts/[id]/versions/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from 'next/server'; +import { forwardRequest } from '@/lib/backend-proxy'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + return forwardRequest(request, 'GET', `/api/artifacts/${id}/versions`, 'Failed to fetch artifact versions'); +} diff --git a/client/src/app/api/artifacts/route.ts b/client/src/app/api/artifacts/route.ts new file mode 100644 index 000000000..a3f58e0ab --- /dev/null +++ b/client/src/app/api/artifacts/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server'; +import { forwardRequest } from '@/lib/backend-proxy'; + +// GET /api/artifacts → list summaries +// GET /api/artifacts?title=... → single artifact by exact title +export async function GET(request: NextRequest) { + return forwardRequest(request, 'GET', '/api/artifacts', 'Failed to fetch artifacts'); +} + +// POST /api/artifacts → create (or replace by title) +export async function POST(request: NextRequest) { + return forwardRequest(request, 'POST', '/api/artifacts', 'Failed to create artifact'); +} diff --git a/client/src/lib/services/artifacts.ts b/client/src/lib/services/artifacts.ts new file mode 100644 index 000000000..3fce6d572 --- /dev/null +++ b/client/src/lib/services/artifacts.ts @@ -0,0 +1,131 @@ +'use client'; + +import { apiGet, apiPost, apiRequest, type ApiError } from '@/lib/services/api-client'; + +// ============================================================================ +// Types +// ============================================================================ + +export type ArtifactEditor = 'agent' | 'user'; + +export interface ArtifactSummary { + id: string; + title: string; + version: number; + lastEditedBy: ArtifactEditor; + updatedAt: string | null; + createdAt: string | null; +} + +export interface ArtifactData extends ArtifactSummary { + content: string; +} + +export interface ArtifactVersion { + id: string; + versionNumber: number; + source: string; + userId: string; + createdAt: string | null; + generationSessionId: string | null; +} + +export interface ArtifactVersionDetail extends ArtifactVersion { + content: string; +} + +// ============================================================================ +// Service +// ============================================================================ + +export const artifactsService = { + async listArtifacts(): Promise { + try { + const data = await apiGet<{ artifacts: ArtifactSummary[] }>('/api/artifacts'); + return data.artifacts ?? []; + } catch (error) { + console.error('Error fetching artifacts:', error); + return []; + } + }, + + async getArtifact(id: string): Promise { + try { + const data = await apiGet<{ artifact: ArtifactData }>(`/api/artifacts/${id}`); + return data.artifact ?? null; + } catch (error) { + if ((error as ApiError).status === 404) return null; + console.error('Error fetching artifact:', error); + return null; + } + }, + + async createArtifact(title: string, content: string): Promise<{ success: boolean; id?: string; error?: string }> { + try { + const data = await apiPost<{ id: string; version: number }>('/api/artifacts', { title, content }); + return { success: true, id: data.id }; + } catch (error) { + const apiErr = error as ApiError; + return { success: false, error: apiErr.message || 'Failed to create artifact' }; + } + }, + + async updateArtifact(id: string, content: string): Promise<{ success: boolean; error?: string }> { + try { + await apiRequest(`/api/artifacts/${id}`, { + method: 'PATCH', + body: JSON.stringify({ content }), + }); + return { success: true }; + } catch (error) { + const apiErr = error as ApiError; + return { success: false, error: apiErr.message || 'Failed to update artifact' }; + } + }, + + async deleteArtifact(id: string): Promise<{ success: boolean; error?: string }> { + try { + await apiRequest(`/api/artifacts/${id}`, { method: 'DELETE' }); + return { success: true }; + } catch (error) { + const apiErr = error as ApiError; + return { success: false, error: apiErr.message || 'Failed to delete artifact' }; + } + }, + + async getVersions(id: string): Promise<{ versions: ArtifactVersion[]; currentVersionId: string | null; error?: string }> { + try { + const data = await apiGet<{ versions: ArtifactVersion[]; currentVersionId: string | null }>( + `/api/artifacts/${id}/versions`, + ); + return { versions: data.versions ?? [], currentVersionId: data.currentVersionId ?? null }; + } catch (error) { + const apiErr = error as ApiError; + return { versions: [], currentVersionId: null, error: apiErr.message || 'Failed to load versions' }; + } + }, + + async getVersion(id: string, versionId: string): Promise { + try { + const data = await apiGet<{ version: ArtifactVersionDetail }>( + `/api/artifacts/${id}/versions/${versionId}`, + ); + return data.version ?? null; + } catch (error) { + console.error('Error fetching artifact version:', error); + return null; + } + }, + + async restoreVersion(id: string, versionId: string): Promise<{ success: boolean; content?: string; error?: string }> { + try { + const data = await apiPost<{ success: boolean; content: string }>( + `/api/artifacts/${id}/versions/${versionId}/restore`, + ); + return { success: true, content: data.content }; + } catch (error) { + const apiErr = error as ApiError; + return { success: false, error: apiErr.message || 'Failed to restore version' }; + } + }, +}; From 1b6b8a776f1567fdc54e621414f6f46a5f06e4f3 Mon Sep 17 00:00:00 2001 From: Siddharth Singh Date: Thu, 4 Jun 2026 17:55:18 -0400 Subject: [PATCH 08/16] Add Artifacts tab with master/detail view, edit, and version history to Monitor --- .../app/monitor/components/artifacts-tab.tsx | 472 ++++++++++++++++++ client/src/app/monitor/page.tsx | 5 +- 2 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 client/src/app/monitor/components/artifacts-tab.tsx diff --git a/client/src/app/monitor/components/artifacts-tab.tsx b/client/src/app/monitor/components/artifacts-tab.tsx new file mode 100644 index 000000000..7c5b51aac --- /dev/null +++ b/client/src/app/monitor/components/artifacts-tab.tsx @@ -0,0 +1,472 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { + BookOpen, Plus, ArrowLeft, Save, X, Trash2, History, Eye, Edit2, RotateCcw, Loader2, +} from 'lucide-react'; +import { useQuery, jsonFetcher } from '@/lib/query'; +import { postmortemMarkdownComponents } from '@/lib/markdown-components'; +import { formatTimeAgo } from '@/lib/utils/time-format'; +import { ChartPanel, EmptyState } from './charts'; +import { + artifactsService, + type ArtifactSummary, + type ArtifactData, + type ArtifactVersion, + type ArtifactEditor, +} from '@/lib/services/artifacts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function EditorBadge({ who }: { who: ArtifactEditor }) { + const isUser = who === 'user'; + return ( + + {isUser ? 'You' : 'Agent'} + + ); +} + +// --------------------------------------------------------------------------- +// List view +// --------------------------------------------------------------------------- + +export default function ArtifactsTab() { + const { data, isLoading, mutate } = useQuery<{ artifacts: ArtifactSummary[] }>( + '/api/artifacts', jsonFetcher, { staleTime: 15_000 }, + ); + const [selectedId, setSelectedId] = useState(null); + const [creating, setCreating] = useState(false); + const [confirmingId, setConfirmingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [deleteError, setDeleteError] = useState(null); + + const artifacts = data?.artifacts ?? []; + + const handleDelete = useCallback(async (id: string) => { + setDeletingId(id); + setDeleteError(null); + const result = await artifactsService.deleteArtifact(id); + setDeletingId(null); + if (!result.success) { + setDeleteError(result.error || 'Failed to delete artifact'); + return; + } + setConfirmingId(null); + mutate(); + }, [mutate]); + + if (creating) { + return ( + a.title)} + onBack={() => setCreating(false)} + onCreated={(id) => { setCreating(false); mutate(); setSelectedId(id); }} + /> + ); + } + + if (selectedId) { + return ( + { setSelectedId(null); mutate(); }} + onDeleted={() => { setSelectedId(null); mutate(); }} + /> + ); + } + + return ( + +
+ +
+ + {deleteError &&

{deleteError}

} + + {artifacts.length === 0 ? ( + + ) : ( +
+ {artifacts.map((a) => ( +
+ + +
+ {confirmingId === a.id ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Create view +// --------------------------------------------------------------------------- + +function ArtifactCreate({ existingTitles, onBack, onCreated }: { + existingTitles: string[]; + onBack: () => void; + onCreated: (id: string) => void; +}) { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSave = async () => { + if (!title.trim() || !content.trim()) return; + // POST is an upsert-by-title server-side; guard here so a human creating a + // new doc can't silently overwrite an existing one (incl. agent-maintained). + const collision = existingTitles.some((t) => t.toLowerCase() === title.trim().toLowerCase()); + if (collision) { + setError('An artifact with this title already exists — open it to edit instead.'); + return; + } + setSaving(true); + setError(null); + const result = await artifactsService.createArtifact(title.trim(), content); + setSaving(false); + if (result.success && result.id) { + onCreated(result.id); + } else { + setError(result.error || 'Failed to create artifact'); + } + }; + + return ( + +
+ + +
+ + setTitle(e.target.value)} + placeholder="Artifact title" + aria-label="Artifact title" + className="w-full mb-3 px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-500" + /> +