Merge pull request #1674 from AnishSarkar22/feat/sandbox-artifacts
Some checks are pending
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-24.04-arm, linux/arm64, arm64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-latest, linux/amd64, amd64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (sandbox, surfsense-sandbox, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions

feat(artifacts): add sandbox-generated artifacts as first-class documents
This commit is contained in:
Anish Sarkar 2026-08-13 04:17:47 +05:30 committed by GitHub
commit 83fd345461
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
209 changed files with 11631 additions and 1971 deletions

View file

@ -6,6 +6,8 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'surfsense_backend/**'
- 'docker/opensandbox/**'
- 'docker/sandbox/**'
- '.github/workflows/backend-tests.yml'
concurrency:
@ -110,12 +112,35 @@ jobs:
working-directory: surfsense_backend
run: uv sync
- name: Build sandbox image
run: docker build -t surfsense/sandbox:dev -f docker/sandbox/Dockerfile docker/sandbox
- name: Start OpenSandbox
run: |
docker run -d --name opensandbox-server \
--add-host host.docker.internal:host-gateway \
-p 8080:8080 \
--tmpfs /data \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$GITHUB_WORKSPACE/docker/opensandbox/sandbox.toml:/etc/opensandbox/config.toml:ro" \
-e OPENSANDBOX_SERVER_API_KEY=surfsense-dev-sandbox \
opensandbox/server:v0.2.2
for attempt in {1..30}; do
if curl --fail --silent http://localhost:8080/health >/dev/null; then
exit 0
fi
sleep 2
done
docker logs opensandbox-server
exit 1
- name: Run integration tests
working-directory: surfsense_backend
env:
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense_test
SECRET_KEY: ci-test-secret-key-not-for-production
ETL_SERVICE: DOCLING
OPENSANDBOX_INTEGRATION: '1'
run: uv run pytest -m integration
test-gate:

View file

@ -11,6 +11,7 @@ on:
paths:
- 'surfsense_backend/**'
- 'surfsense_web/**'
- 'docker/sandbox/**'
workflow_dispatch:
inputs:
branch:
@ -109,13 +110,17 @@ jobs:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
image: [backend, web]
image: [backend, web, sandbox]
variant: [cpu, cuda, cuda126]
exclude:
- image: web
variant: cuda
- image: web
variant: cuda126
- image: sandbox
variant: cuda
- image: sandbox
variant: cuda126
include:
- platform: linux/amd64
suffix: amd64
@ -133,6 +138,10 @@ jobs:
context: ./surfsense_web
file: ./surfsense_web/Dockerfile
target: runner
- image: sandbox
name: surfsense-sandbox
context: ./docker/sandbox
file: ./docker/sandbox/Dockerfile
- variant: cpu
tag_suffix: ""
use_cuda: "false"
@ -244,6 +253,7 @@ jobs:
check backend-cuda
check backend-cuda126
check web-cpu
check sandbox-cpu
[ "$fail" -eq 0 ] || exit 1
create_manifest:
@ -273,6 +283,10 @@ jobs:
image: web
variant: cpu
tag_suffix: ""
- name: surfsense-sandbox
image: sandbox
variant: cpu
tag_suffix: ""
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ debug.log
references/
references
specs/
# Source/tests packages: exempt from the broad "references" scratch-folder ignore above.
!surfsense_backend/app/agents/chat/runtime/references/

View file

@ -353,15 +353,47 @@ STT_SERVICE=local/base
# GATEWAY_DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/gateway/discord/callback
# ------------------------------------------------------------------------------
# Daytona Sandbox (optional cloud code execution for the deep agent)
# Code execution sandbox (deep agent + artifact generation)
# ------------------------------------------------------------------------------
# Set DAYTONA_SANDBOX_ENABLED=TRUE and provide credentials to give the agent
# an isolated code execution environment via the Daytona cloud API.
# DAYTONA_SANDBOX_ENABLED=FALSE
# One switch turns code execution on; SANDBOX_PROVIDER decides where it runs.
# On by default: compose always runs the control plane and pulls the sandbox
# image, so there is nothing else to set.
#
# FALSE disables the feature in the application — the agent is offered no
# sandbox tools and nothing can reach a sandbox — but it is not a deployment
# switch: compose still runs opensandbox-server, which mounts the host docker
# socket. To remove that too, stop the service itself.
# SANDBOX_ENABLED=TRUE
# Idle sessions are reaped after this many seconds, and each workspace may hold
# only so many at once (over the cap the tool returns a retry-shortly error).
# SANDBOX_IDLE_TTL_SECONDS=900
# SANDBOX_MAX_SESSIONS_PER_WORKSPACE=2
# Largest file the agent may pull out of a sandbox into an artifact (30 MiB).
# ARTIFACT_MAX_FILE_BYTES=31457280
# --- Daytona (cloud provider) ---
# SANDBOX_PROVIDER=daytona
# DAYTONA_API_KEY=
# DAYTONA_API_URL=https://app.daytona.io/api
# DAYTONA_TARGET=us
# --- OpenSandbox (self-hosted provider, default) ---
# The opensandbox-server container runs the control plane and spawns sandbox
# containers on the host docker daemon. Config lives in opensandbox/sandbox.toml.
# SANDBOX_PROVIDER=opensandbox
# Shared secret: the server authenticates the backend with it. Change it for any
# deployment where the docker network is not fully trusted.
# OPENSANDBOX_API_KEY=surfsense-dev-sandbox
# host:port only — no scheme, no /v1 path. The SDK appends the version prefix.
# Containerised backend: opensandbox-server:8080. Host-run backend: localhost:8080.
# OPENSANDBOX_DOMAIN=opensandbox-server:8080
# OPENSANDBOX_PORT=8080
# OPENSANDBOX_SERVER_MEMORY_LIMIT=512m
# Tracks SURFSENSE_VERSION by default; set only to run your own sandbox image.
# SANDBOX_IMAGE=
# Creation waits on the image pull when the compose pull did not warm the cache.
# SANDBOX_REQUEST_TIMEOUT_SECONDS=120
# ------------------------------------------------------------------------------
# External API Keys (optional)
# ------------------------------------------------------------------------------

View file

@ -136,6 +136,31 @@ services:
retries: 5
start_period: 600s
# Sandbox control plane for code execution / artifact generation. It spawns
# sandbox containers on the host daemon as siblings, so it needs the socket.
# The host-run backend reaches it with OPENSANDBOX_DOMAIN=localhost:8080.
opensandbox-server:
image: opensandbox/server:v0.2.2
ports:
- "${OPENSANDBOX_PORT:-8080}:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./opensandbox/sandbox.toml:/etc/opensandbox/config.toml:ro
- opensandbox_data:/data
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- OPENSANDBOX_SERVER_API_KEY=${OPENSANDBOX_API_KEY:-surfsense-dev-sandbox}
mem_limit: ${OPENSANDBOX_SERVER_MEMORY_LIMIT:-512m}
restart: unless-stopped
# The runtime image ships neither curl nor wget; python is on PATH.
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status == 200 else 1)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# OPTIONAL — Azurite emulates Azure Blob Storage for testing the Azure
# original-file backend. The default filesystem backend needs none of this.
# To exercise it, set in surfsense_backend/.env:
@ -164,3 +189,5 @@ volumes:
name: surfsense-deps-zero-cache
azurite_data:
name: surfsense-deps-azurite
opensandbox_data:
name: surfsense-deps-opensandbox

View file

@ -90,6 +90,40 @@ services:
timeout: 5s
retries: 5
# Spawns sandbox containers on the host daemon as siblings, so it needs the
# socket. Its own config path is baked into the image's CMD.
opensandbox-server:
image: opensandbox/server:v0.2.2
ports:
- "${OPENSANDBOX_PORT:-8080}:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./opensandbox/sandbox.toml:/etc/opensandbox/config.toml:ro
- opensandbox_data:/data
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- OPENSANDBOX_SERVER_API_KEY=${OPENSANDBOX_API_KEY:-surfsense-dev-sandbox}
mem_limit: ${OPENSANDBOX_SERVER_MEMORY_LIMIT:-512m}
restart: unless-stopped
# The runtime image ships neither curl nor wget; python is on PATH.
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status == 200 else 1)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# Builds the sandbox image and exits. Sandbox containers are created by
# opensandbox-server outside compose, so nothing else would put
# surfsense-sandbox:dev on the host daemon.
sandbox-image:
build:
context: ./sandbox
image: surfsense-sandbox:dev
entrypoint: ["/bin/true"]
restart: "no"
otel-lgtm:
image: grafana/otel-lgtm:latest
ports:
@ -128,11 +162,15 @@ services:
- WHATSAPP_BRIDGE_URL=${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
- SEARXNG_URL=${SEARXNG_URL:-http://searxng:8080}
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-500}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution
# - DAYTONA_SANDBOX_ENABLED=TRUE
# Daytona uncomment with SANDBOX_PROVIDER=daytona for cloud execution
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
# - DAYTONA_API_URL=${DAYTONA_API_URL:-https://app.daytona.io/api}
# - DAYTONA_TARGET=${DAYTONA_TARGET:-us}
- SANDBOX_ENABLED=${SANDBOX_ENABLED:-TRUE}
- SANDBOX_PROVIDER=${SANDBOX_PROVIDER:-opensandbox}
- OPENSANDBOX_DOMAIN=${OPENSANDBOX_DOMAIN:-opensandbox-server:8080}
- OPENSANDBOX_API_KEY=${OPENSANDBOX_API_KEY:-surfsense-dev-sandbox}
- SANDBOX_IMAGE=${SANDBOX_IMAGE:-surfsense-sandbox:dev}
- SERVICE_ROLE=api
depends_on:
db:
@ -141,6 +179,8 @@ services:
condition: service_healthy
migrations:
condition: service_completed_successfully
opensandbox-server:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s
@ -260,6 +300,16 @@ services:
frontend:
build:
context: ../surfsense_web
target: dev
# Live source, same idea as the backend's app/ mount: edit on the host and
# `next dev` recompiles — no rebuild, no restart. Rebuild only when
# pnpm-lock.yaml changes, since node_modules comes from the image.
volumes:
- ../surfsense_web:/app
# Anonymous volumes keep the image's musl-built node_modules and the dev
# server's cache from being shadowed by the bind mount above.
- /app/node_modules
- /app/.next
ports:
- "${FRONTEND_PORT:-3000}:3000"
env_file:
@ -291,3 +341,5 @@ volumes:
name: surfsense-dev-zero-cache
whatsapp_sessions:
name: surfsense-dev-whatsapp-sessions
opensandbox_data:
name: surfsense-dev-opensandbox

View file

@ -71,6 +71,35 @@ services:
timeout: 5s
retries: 5
# Spawns sandbox containers on the host daemon as siblings, so it needs the
# socket. Its own config path is baked into the image's CMD.
opensandbox-server:
image: opensandbox/server:v0.2.2
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./opensandbox/sandbox.toml:/etc/opensandbox/config.toml:ro
- opensandbox_data:/data
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
OPENSANDBOX_SERVER_API_KEY: ${OPENSANDBOX_API_KEY:-surfsense-dev-sandbox}
mem_limit: ${OPENSANDBOX_SERVER_MEMORY_LIMIT:-512m}
restart: unless-stopped
# The runtime image ships neither curl nor wget; python is on PATH.
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status == 200 else 1)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# Never starts. Exists so `docker compose pull` warms the sandbox image, which
# opensandbox-server would otherwise pull inside the first create call.
sandbox-image:
image: ghcr.io/modsetter/surfsense-sandbox:${SURFSENSE_VERSION:-latest}
deploy:
replicas: 0
# otel-collector:
# image: otel/opentelemetry-collector-contrib:0.152.1
# profiles:
@ -149,11 +178,15 @@ services:
WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
SEARXNG_URL: ${SEARXNG_URL:-http://searxng:8080}
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-500}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution
# DAYTONA_SANDBOX_ENABLED: "TRUE"
# Daytona uncomment with SANDBOX_PROVIDER=daytona for cloud execution
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
# DAYTONA_API_URL: ${DAYTONA_API_URL:-https://app.daytona.io/api}
# DAYTONA_TARGET: ${DAYTONA_TARGET:-us}
SANDBOX_ENABLED: ${SANDBOX_ENABLED:-TRUE}
SANDBOX_PROVIDER: ${SANDBOX_PROVIDER:-opensandbox}
OPENSANDBOX_DOMAIN: ${OPENSANDBOX_DOMAIN:-opensandbox-server:8080}
OPENSANDBOX_API_KEY: ${OPENSANDBOX_API_KEY:-surfsense-dev-sandbox}
SANDBOX_IMAGE: ${SANDBOX_IMAGE:-ghcr.io/modsetter/surfsense-sandbox:${SURFSENSE_VERSION:-latest}}
SERVICE_ROLE: api
labels:
- "com.centurylinklabs.watchtower.enable=true"
@ -323,3 +356,5 @@ volumes:
name: surfsense-caddy-config
whatsapp_sessions:
name: surfsense-whatsapp-sessions
opensandbox_data:
name: surfsense-opensandbox

View file

@ -0,0 +1,68 @@
# Mounted over the image's baked-in config, which is the Kubernetes example.
# Nothing validates unknown keys: the server ignores extra tables silently, so a
# misplaced section fails as wrong behaviour at runtime, never as a startup error.
[server]
# Must stay 0.0.0.0. docker.host_ip below is only consulted when host is
# 0.0.0.0/::/empty, so narrowing this silently hands out 127.0.0.1 endpoints.
host = "0.0.0.0"
port = 8080
max_sandbox_timeout_seconds = 86400
# api_key comes from OPENSANDBOX_SERVER_API_KEY so it stays out of the repo.
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:v1.0.21"
# Top-level table, NOT [runtime.docker]: a nested one parses and is discarded,
# leaving network_mode at its "host" default, which egress rejects.
[docker]
network_mode = "bridge"
# Effectively mandatory: left unset, the server pairs its OWN container IP with
# a host-published port and hands back an endpoint nothing listens on. No env
# override exists for this key and the TOML loader does no interpolation, so
# clients that cannot resolve this name connect with use_server_proxy=True
# instead of us forking this file per deployment.
host_ip = "host.docker.internal"
# Upstream's 40000-60000 default overlaps the ephemeral range on both hosts we
# target (Linux 32768-60999, Windows/WSL dynamic from 49152 with Hyper-V holes),
# so sandbox creation fails intermittently with "address already in use". This
# band sits below both. Docker publishes these on 0.0.0.0 and execd itself is
# unauthenticated, so restrict the range at the host firewall (DOCKER-USER
# chain — plain firewalld rules sit behind docker's own iptables entries).
port_range_min = 20000
port_range_max = 30000
drop_capabilities = [
"AUDIT_WRITE",
"MKNOD",
"NET_ADMIN",
"NET_RAW",
"SYS_ADMIN",
"SYS_MODULE",
"SYS_PTRACE",
"SYS_TIME",
"SYS_TTY_CONFIG",
]
no_new_privileges = true
pids_limit = 4096
[ingress]
mode = "direct"
[egress]
image = "opensandbox/egress:v1.1.5"
# "dns" filters hostnames only, so default-deny leaks to anything addressed by
# raw IP — a sandbox could open Postgres on the compose network. "dns+nft" adds
# the packet filter that actually enforces the policy. The sidecar gets its own
# cap_add NET_ADMIN and is unaffected by drop_capabilities above.
mode = "dns+nft"
[store]
type = "sqlite"
path = "/data/opensandbox.db"
[storage]
allowed_host_paths = []

62
docker/sandbox/Dockerfile Normal file
View file

@ -0,0 +1,62 @@
FROM opensandbox/code-interpreter:v1.1.0
SHELL ["/bin/bash", "-c"]
USER root
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
fonts-dejavu-core \
fonts-liberation \
fonts-noto-cjk \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libreoffice \
pandoc \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# The entrypoint activates the interpreter named by PYTHON_VERSION and binds the
# Jupyter kernel to it. The base default is 3.14, which lacks wheels for parts of
# the stack below; pin 3.12 so the kernel is the interpreter carrying them.
ENV PYTHON_VERSION=3.12
# code-interpreter-env.sh only mutates the calling shell, so it must be sourced
# in the same layer as the install. Unqualified pip would otherwise land in the
# unrelated Ubuntu /usr/bin/python3.12 that the kernel never sees.
RUN set -euo pipefail \
&& . /opt/code-interpreter/code-interpreter-env.sh python "${PYTHON_VERSION}" \
&& python3 -m pip install --no-cache-dir --break-system-packages \
matplotlib \
openpyxl \
pandas \
pypdf \
python-pptx \
reportlab \
weasyprint \
&& python3 -c "import sys; assert sys.version_info[:2] == (3, 12), sys.version; assert sys.prefix.startswith('/opt/python/versions'), sys.prefix" \
&& python3 -c "import matplotlib, openpyxl, pandas, pypdf, pptx, reportlab, weasyprint"
# A global npm install is not on node's resolution path from /workspace, so pin
# the prefix and point NODE_PATH at it to make require('docx') work anywhere.
ENV NPM_CONFIG_PREFIX=/opt/node-global \
NODE_PATH=/opt/node-global/lib/node_modules
RUN set -euo pipefail \
&& npm install --global docx \
&& node -e "require('docx')"
# The base image also ships skills. Replace the formats SurfSense owns in one
# layer so OverlayFS records removed base-image files as deletions.
COPY skills /tmp/surfsense-skills
RUN set -euo pipefail \
&& mkdir -p /opt/skills \
&& for skill in /tmp/surfsense-skills/*; do \
name="${skill##*/}"; \
rm -rf "/opt/skills/${name}"; \
cp -a "${skill}" "/opt/skills/${name}"; \
rm -rf "/opt/skills/${name}/scripts"; \
done \
&& rm -rf /tmp/surfsense-skills
ENTRYPOINT ["/opt/code-interpreter/code-interpreter.sh"]

View file

@ -0,0 +1,54 @@
---
name: docx
description: Create polished, editable Microsoft Word documents such as reports, letters, proposals, and handbooks.
---
# DOCX
Create the requested Word document in `/workspace` with the preinstalled `docx`
npm package. Never install or download dependencies.
Use one deliverable-derived stem for the source and output, for example
`project-brief.js` and `project-brief.docx`. Write deterministic JavaScript
source so revisions change the source and regenerate the document.
## Authoring rules
- Use one page size and orientation for the document unless the user asks for a
different layout. Prefer portrait; narrow wide content instead of silently
switching sections to landscape.
- Set explicit DXA page dimensions: US Letter is 12,240 × 15,840; A4 is
11,906 × 16,838. Choose the size the user requests, or the locale-appropriate
default when they do not specify one.
- Set margins of at least 18 mm and use built-in heading levels.
- Tables need `columnWidths`, a matching `width` on every cell, and
`WidthType.DXA` for both. Include a table grid. Never use percentage widths.
- Use `ShadingType.CLEAR`, never `ShadingType.SOLID`.
- Build lists through the document's `numbering` configuration with
`LevelFormat.BULLET`; never insert literal `•` characters.
- Put `PageBreak` inside its own `Paragraph`. Use separate paragraphs instead
of newline characters for separate blocks.
- Do not add a table of contents unless the user asks. If requested, use
built-in heading levels and state that page numbers populate when Word opens
and updates the field.
- For right-aligned text on the same line, use a right tab stop rather than
`PositionalTab`, which LibreOffice renders incorrectly.
- Generate the document as one whole document. Do not assemble or save one
DOCX per intended page; Word controls pagination through reflow.
When revising, `load_artifact_source` returns the existing `document_id` and a
`source_path` with a name such as `artifact-42-project-brief.js`. Copy that
source to `project-brief.js` before editing so the `artifact-42-` prefix does not
compound, then pass the returned `document_id` to `save_artifact`. A changed
title, filename, or design is still the same artifact unless the user explicitly
asks for a separate copy.
## Verify and save
Call `verify_artifact(path="project-brief.docx")`. Warnings are advisory. If it
reports blocking findings, fix them in the JavaScript source and regenerate
once. Reverify that revision; if a blocker remains, stop and explain it instead
of entering another automatic rewrite loop. Then call `save_artifact` with the
DOCX path, JavaScript source path, and the exact `preview_path` returned by
verification. The Markdown representation must faithfully contain the
document's substantive text for accessibility and search.

View file

@ -0,0 +1,48 @@
---
name: pdf
description: Create polished PDF files for requests such as PDFs, resumes, CVs, reports, letters, one-pagers, and printable documents.
---
# PDF
Create the requested PDF in `/workspace`. Everything required is preinstalled;
never run `pip install`, `npm install`, or download dependencies.
## Choose the renderer
- Use **WeasyPrint** for typography-heavy documents authored naturally as
HTML/CSS: resumes, CVs, letters, one-pagers, and styled reports.
- Use **ReportLab** for programmatic layouts, drawing, charts, coordinates, or
documents assembled directly from Python data.
- Use A4 unless the user or source context implies US Letter. Use margins of at
least 18 mm (0.7 in), 1011 pt body text, and 1.31.5 line height.
- Available families include DejaVu, Liberation, and Noto (including CJK).
Prefer Liberation Sans/Serif for office-style documents and Noto for broad
Unicode coverage.
Write deterministic source alongside the output (`.py` or `.html`) so defects
can be fixed without rebuilding from scratch. Escape untrusted text before
placing it in HTML.
When revising, `load_artifact_source` returns the existing `document_id` and
`source_path`. Edit that source, regenerate and verify the PDF, then pass the
returned `document_id` to `save_artifact`. A changed title, filename, or design
is still the same artifact unless the user explicitly asks for a separate copy.
## Required quality gate
Use one deliverable-derived stem for the source and output, for example
`project-brief.html` and `project-brief.pdf`; the output basename is the
filename the user downloads. After generating it, call
`verify_artifact(path="project-brief.pdf")`. Warnings are advisory and do not
require regeneration. If verification reports blocking findings, fix them in
the source and regenerate once. Reverify that revision; if a blocker remains,
stop and explain it instead of entering another automatic rewrite loop. Only a
verified file can be saved.
Then call `save_artifact(path="project-brief.pdf",
source_path="project-brief.html", title="...", markdown_representation="...")`,
using the actual `.html` or `.py` source path that produced the PDF.
The Markdown representation must faithfully contain the document's substantive
text so the artifact remains accessible and searchable without parsing the PDF.

View file

@ -0,0 +1,84 @@
---
name: pptx
description: Create polished, editable PowerPoint files for slide decks, presentations, and explicit .pptx requests.
---
# PPTX
Create the requested presentation in `/workspace` with the preinstalled
`python-pptx` package. Never install or download dependencies.
Use one deliverable-derived stem for deterministic Python source and output, for
example `quarterly-review.py` and `quarterly-review.pptx`. The source must
regenerate the complete deck so later revisions edit rather than reconstruct it.
## Authoring rules
- Use a 16:9 presentation unless the user requests another aspect ratio. Set
`prs.slide_width` and `prs.slide_height` once; slide size is deck-wide.
- Use a consistent layout, palette, type scale, spacing system, and placement
grid across the deck. Prefer the default template's layouts and placeholders
over unrelated free-positioned text boxes.
- Keep at least 0.5 inches of outer margin and 0.3 inches between unrelated
content regions. Define title, subtitle, body, and footer regions once and
reuse them; title and subtitle regions must not overlap.
- Give every slide one clear purpose. Keep body copy concise: normally no more
than six bullets, with short phrases rather than paragraphs.
- Default to Arial for fit-sensitive text; LibreOffice substitutes the
metric-compatible Liberation Sans installed in the sandbox. Use Times New
Roman/Liberation Serif when a serif is appropriate, and Noto for scripts they
do not cover. If the user requires another font, leave about 10% extra width
and height because LibreOffice and PowerPoint may use different metrics.
- Use 3240 pt titles, 1824 pt subtitles, and body text of at least 18 pt. Set
`text_frame.word_wrap = True` and explicit text-frame margins; use zero
margins where text must align exactly with a nearby shape.
- Do not use PowerPoint runtime autofit (`text_frame.auto_size`) because
PowerPoint and LibreOffice can resolve it differently. For a single-style
title or subtitle, use `text_frame.fit_text(...)` with an explicit Liberation
font file so the fitted size is written into the PPTX. If a title must shrink
below 28 pt or a subtitle below 16 pt, shorten it, enlarge its region, or
split the content across slides instead of accepting the smaller text.
- Keep every shape on the slide canvas. Intentional edge bleed may cross a slide
boundary, but do not park unused shapes off-canvas or hide backup slides.
- Preserve image aspect ratios. When calling `add_picture`, derive the missing
dimension from the source image instead of setting both width and height to
unrelated values. Extended crop values are legal, but the left and right crop
fractions must sum to less than 1, as must the top and bottom fractions, so
some of the image remains visible.
- Prefer editable native text, shapes, tables, and charts. Avoid SmartArt and
elaborate gradients whose LibreOffice conversion is unreliable.
Build slides incrementally in the Python source. Before saving the PPTX, run
local assertions that name the slide and shape when they fail:
- required slide count and content are present;
- title and subtitle regions do not overlap;
- area shapes have positive width and height, while a connector may have one
zero extent but not two;
- every shape intersects the slide canvas; and
- fitted title and subtitle text remains above the minimum sizes above.
Generate the complete PPTX and pass those local checks before verification. Do
not call `verify_artifact` after each slide because each call renders and reviews
the whole draft again.
When revising, `load_artifact_source` returns the existing `document_id` and a
`source_path` with a name such as `artifact-42-quarterly-review.py`. Copy that
source to `quarterly-review.py` before editing so the `artifact-42-` prefix does
not compound, then pass the returned `document_id` to `save_artifact`. A changed
title, filename, or design is still the same artifact unless the user explicitly
asks for a separate copy.
## Verify and save
Call `verify_artifact(path="quarterly-review.pptx")`. Warnings are advisory. If
it reports blocking findings, fix all reported blockers together in the Python
source, rerun the local checks, regenerate once, and reverify. If a blocker
remains, stop and explain it rather than entering another automatic rewrite
loop.
Call `save_artifact` only when the latest verification of those exact PPTX bytes
returned `status="verified"`. Use the Python source path and exact `preview_path`
from that result. A failed verification invalidates every earlier pass: never
attempt to save afterward. The Markdown representation must faithfully contain
the deck's substantive text for accessibility and search.

View file

@ -0,0 +1,65 @@
# ADR 0003: Generated deliverables are a document type, not a second corpus
- **Status:** Accepted
- **Date:** 2026-08-13
- **Relates to:** [ADR 0001](0001-git-native-knowledge-base.md) (git is the source of truth, Postgres is derived) and [ADR 0002](0002-knowledge-core-ports-and-adapters.md) (one core, many adapters). This ADR decides where a *generated deliverable* lives inside that model.
- **Specs:** [`plans/artifacts/artifacts-overhaul.md`](../../plans/artifacts/artifacts-overhaul.md), [`plans/git-native-kb/00c-shared-contract.md`](../../plans/git-native-kb/00c-shared-contract.md) C1/C5.
---
## Context
A generated deliverable (PDF, DOCX, PPTX, XLSX, Markdown) has state the document model does not represent: an adapter `format`, rendered bytes in `primary`/`preview`/`source` roles, an optimistic `generation` for later-turn revision, a signed verification receipt, and the tool-call provenance that produced it. That part is uncontroversial — it needs its own tables.
The question is the **searchable text**. Every deliverable also has a Markdown body that must be committed to git, chunked, embedded, ranked, cited, and pruned. Two shapes were available:
1. Give the artifact domain its own body: `Artifact.search_content`, an `/artifacts/**` git root, an `ArtifactChunk` table, an artifact leg in hybrid search, and an `ARTIFACT_CHUNK` citation kind.
2. Make the body an ordinary `Document` with a new `document_type`, and keep `Artifact` as a sidecar.
Shape 1 is the natural read of "artifacts are not documents", and it is what the domain-boundary instinct produces. Following it through the whole pipeline is what changed the answer: the file is Markdown in a git tree either way, so every stage downstream of "it is text" had to be duplicated to serve a distinction that only matters at the delivery surface.
## Decision
**An artifact's searchable body is a `Document` with `document_type = ARTIFACT`. `Artifact`/`ArtifactFile` are sidecars.**
- `Document` owns title, path (`documents/Artifacts/<title>.md`), Markdown, content hash, folder, and indexing status.
- `Artifact` owns `format`, `generation`, provenance, verification metadata, and `document_id` — a non-null unique cascading key. It owns **no** title, path, body, hash, or indexing state.
- `ArtifactFile` owns one immutable blob per role. Binary bytes never enter git and never become `DocumentFile` rows.
- One projected git root, one `Chunk` table, one search leg, one citation namespace.
- `document_type` earns exactly three behaviors: the library badge, the type filter, and the editor's read-only guard. Nothing in storage, convergence, or retrieval branches on it.
## Consequences
### Positive
- **One of everything instead of two.** No artifact chunk table, root dispatcher, prune map, search leg, rank-fusion merge, citation kind, marker prefix, or chunk-context route. Every one of those was a place two implementations could drift.
- **Rename, move, and delete have one implementation.** The old shape had `Artifact.title` and a document title as candidate homes for the same string; dropping the column removed the class of bug where a rename updates one and search returns the other.
- **Type filters and `@`-mentions come free.** A deliverable is pinnable and excludable because it is a document. A parallel corpus would have needed both features reimplemented to reach parity.
- **Fair ranking without special-casing.** Artifact passages compete in the same reciprocal-rank fusion rather than being merged in as a second result list.
- **Blob purge is reachable from the document.** The document purge path collects `ArtifactFile` keys through `artifact.document_id`, so no caller has to know which kind of document it deleted to avoid leaking blobs.
### Negative / cost
- **`document_type` becomes load-bearing in convergence.** Row upsert must resolve by `path` and preserve the existing row's type; if a rebuild ever re-derives it, every deliverable silently demotes to a note and its badge, filter, and read-only guard all fail at once. This is the single most important invariant the decision creates.
- **Read-only is now an obligation, not a structural guarantee.** A separate corpus could not be opened by the document editor at all. Because the body is a document, the editor's save path must refuse `ARTIFACT` server-side — the frontend's editable-type set is a UX affordance, not the guard.
- **Corpus-wide content-hash dedup must be bypassed.** `prepare_for_indexing` drops a document whose Markdown hash already exists, which is correct for a re-synced connector page and wrong for a deliverable with its own identity, roles, and generation. `save_artifact` constructs the row directly and calls `index()`.
- **Save is no longer one transaction on non-git workspaces.** `IndexingPipelineService.index()` commits mid-flow, so an embedding failure leaves a durable artifact with a `failed` document instead of rolling the save back. Accepted deliberately: losing a verified deliverable because an embedding provider was down is worse than a retryable index.
- **The stale-generation window is now the ordinary document window.** A revision's passages trail its bytes by one convergence run, so search can quote the previous generation's text while the manifest serves the current file. The old shape hid this by gating search on `indexed_generation == generation`; that gate is gone, because it does not exist for documents and reintroducing it for one type would recreate the artifact-specific search leg this ADR removes. The panel reads the manifest, so what the user opens is always current.
- **The enum member is permanent.** PostgreSQL cannot drop an enum value, so a downgrade leaves `ARTIFACT` in `documenttype` with no rows using it.
## Alternatives considered
| Alternative | Why not |
|---|---|
| Separate artifact corpus (`/artifacts/**`, `ArtifactChunk`, artifact search leg, `ARTIFACT_CHUNK`) | Duplicates the entire text pipeline to preserve a distinction that only matters at the delivery surface, and still has to reimplement type filters and mentions to reach parity. |
| `document_metadata` flag instead of an enum value | A discriminator that drives filters and a badge needs to be queryable and indexable. A JSON flag is neither, and it leaves the read-only guard depending on a field any writer can drop. |
| Keep `Artifact.title` alongside `Document.title` | Two homes for one string. The only way a rename cannot desynchronize them is for one of them not to exist. |
| Hide deliverables in a system folder | The folder is visible and normal. Hiding it would mean a second listing path and a document the user can find by search but not by browsing. |
## Obligations
1. Convergence and projection preserve an existing row's `document_type`; `NOTE` is invented only for a git file no row claims.
2. `save_document` refuses `document_type = ARTIFACT`. Rename, move, and delete stay legal.
3. `save_artifact` does not route through connector preparation.
4. `save_artifact` withholds the knowledge-store path marker until projection lands, so a rebuild cannot read an uncommitted deliverable as an orphan to prune.
5. Blob purge resolves artifact roles through `artifact.document_id`.

View file

@ -0,0 +1,212 @@
# Artifacts Overhaul — Authoritative Architecture
**Status:** Sandbox generation, backend verification, PDF, DOCX, and PPTX are implemented. The persistence, indexing, and search model in sections 1, 3, 4, and 5 is under implementation. Phase 5 (full XLSX skill, verification adapter, and viewer) and phase 6 (legacy report/Typst demolition) remain planned.
**Scope:** Generated non-media deliverables. Media generation remains on its existing pipelines.
**Shape:** [ADR 0003](../../docs/adr/0003-artifacts-as-documents.md) records why a deliverable's body is a document type rather than a second corpus, and the obligations that creates.
This document describes the intended architecture. The phase documents record delivery scope and must not override these contracts.
## 1. Domain boundary
An artifact is a document plus the things a document has no concept of: rendered bytes in one or more roles, an adapter format, an optimistic revision counter, a verification receipt, and the tool-call provenance that produced it. It is not a second corpus.
- `Document` with `document_type = ARTIFACT` owns the artifact's searchable Markdown, title, stable Git path, folder placement, content hash, indexing status, and chunks. It is an ordinary row in the ordinary corpus.
- `Artifact` owns what the document model does not model: adapter `format`, `generation`, workspace/thread/user provenance, the tool-call ids that wrote it, verification metadata, and timestamps. `artifact.document_id` is a non-null unique foreign key with `ON DELETE CASCADE`.
- `ArtifactFile` owns one immutable blob for role `primary`, `preview`, or `source`. `(artifact_id, role)` is unique.
- `Chunk` is the only passage table. There is no artifact chunk table, no artifact embedding column, and no artifact search index.
Single ownership is the point. Title, path, body, and indexing state exist once — on the document — so a rename is one write with one outcome instead of two rows that can disagree. Format, generation, roles, and receipts exist once, on the artifact, because no plain document needs them. Type is the only discriminator: it selects a badge, participates in the type filter, and gates the editor's read-only guard. Nothing in storage, indexing, retrieval, or citation branches on it.
## 2. Frozen save and manifest contracts
### 2.1 `save_artifact`
Create omits both revision fields. Revision requires both `artifact_id` and `expected_generation`.
```json
{
"status": "saved",
"artifact_id": 123,
"generation": 2,
"title": "Indian History — Overview",
"files": [
{
"file_id": 456,
"role": "primary",
"filename": "indian-history.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"size_bytes": 48213
},
{
"file_id": 457,
"role": "preview",
"filename": "indian-history.pdf",
"mime_type": "application/pdf",
"size_bytes": 91240
}
]
}
```
- Markdown artifacts have no blob rows; the document's Markdown is their complete deliverable and download source.
- Binary artifacts require a primary and persisted generation source; a verification-produced preview is included when the adapter has a rendered policy.
- Source files never appear in tool results, manifests, immutable file routes, or user downloads.
- A revision locks the row, compares `expected_generation`, and increments `generation`. A stale writer fails with an instruction to load the source again. A failed revision leaves the current generation intact.
- `load_artifact_source(artifact_id)` returns the stored source and current generation. The next save must pass both identity and generation.
- Failures return a visible failed tool result. There is no end-of-turn persistence promise for metadata or blobs.
### 2.2 Manifest
`GET /api/v1/workspaces/{workspace_id}/artifacts/{artifact_id}/manifest` returns:
```json
{
"artifact_id": 123,
"document_id": 4821,
"title": "Indian History — Overview",
"format": "docx",
"generation": 2,
"markdown_representation": "Searchable representation…",
"files": [
{
"file_id": 456,
"role": "primary",
"filename": "indian-history.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"size_bytes": 48213,
"content_url": "/api/v1/workspaces/7/artifacts/123/files/456/content"
}
],
"updated_at": "…"
}
```
Title and `markdown_representation` are read from the joined document, which is their only home. The frontend derives text/file rendering from the manifest: no files means read-only Markdown; otherwise the primary MIME selects the viewer. There is no artifact branch in document `editor-content`.
## 3. Persistence and blob lifecycle
Artifact blob keys are owned by the artifact domain:
```
artifacts/{workspace_id}/{artifact_id}/{role}/{uuid}{extension}
```
The artifact storage service reuses configured local/Azure backend interfaces, not document-file models or document key helpers. Binary bytes never enter Git and never become `DocumentFile` rows.
- File rows are immutable. Revision stages new blobs, replaces current role rows transactionally, then best-effort purges superseded blobs after commit.
- Rollback best-effort deletes newly staged blobs.
- Deletion is the document deletion path. The route marks the document `deleting`; the purge task records the Git removal before the row disappears, then cascades chunks, the artifact, and its file rows, and purges every reachable blob. Marking first drops the artifact out of search immediately, and removing the file before the row is the only safe order — a committed file that outlives its row is read back on the next rebuild as a document nobody asked for.
- Blob purge covers artifact roles as well as document files: the purge query collects `DocumentFile` keys for the document and `ArtifactFile` keys through `artifact.document_id`, so no reachable blob depends on the caller knowing which kind of document it deleted.
- A blob deletion failure leaves an unreachable blob and a warning; deletion still proceeds. Blob storage cannot enlist in the database transaction, so repair is operational rather than rollback.
- Per-file size limits apply independently to primary, preview, and source.
- Only PDF may use inline `Content-Disposition`; all other bytes are attachments. Immutable file routes use checksum ETags and private immutable caching. The stable current download uses `private, no-store`.
- Markdown downloads are generated from the document's current Markdown.
## 4. Git integration and indexing
Git has one projected root:
```
/documents/** -> Document + Chunk
```
An artifact create allocates `/documents/Artifacts/<normalized title>.md` through the shared path allocator, so it obeys the same filename rules and collision suffixes as any document. The path is authored once; revisions and retitles reuse it. A user who renames or moves the file relocates it through the ordinary document move, which preserves the document id and therefore the artifact. The path stores only the searchable Markdown; binary artifact bytes remain in blob storage.
The `Artifacts/` folder is a normal visible folder. Artifacts appear in the document list with an artifact badge, are filterable by type, and are `@`-mentionable, because they are documents.
### Git-backed workspaces
`save_artifact` durably commits the document row, the artifact row, and the blobs inside the tool call, and writes the Markdown into the turn's private working copy. It joins the same one-commit-per-turn revision as document changes.
Commit-time projection and convergence treat the file as what it is — a document. Row upsert resolves by `path`, finds the row the save already created, and never re-derives `document_type`, so the type chosen at save survives every projection and every full rebuild. A Markdown file authored directly in Git under `/documents` with no row claiming it is a note; nothing can promote a file to an artifact, because only `save_artifact` creates the sidecar.
The save deliberately does not write the knowledge-store path marker. The marker means "the indexer owns this file", and a marker on a document whose file has not been committed yet reads to a full rebuild as an orphan to prune. Projection writes the marker once the revision lands.
### Non-git workspaces
`save_artifact` indexes the Markdown through `IndexingPipelineService.index()` inside the save. It constructs the document row directly rather than going through connector preparation, whose corpus-wide content-hash dedup would silently discard a second artifact whose Markdown matches an existing document — correct for a re-synced Notion page, wrong for a deliverable with its own identity, roles, and generation.
An indexing failure records a failed document status and leaves the artifact and its blobs intact; the document reindex path retries it. The artifact is not searchable until that succeeds, and it is never destroyed by an embedding outage.
Both paths reuse the existing chunker, embedding cache, embedding batch, and reconciler. Unchanged passages retain ids and embeddings across revisions where reconciliation permits.
## 5. Search and citations
Knowledge-base search is one hybrid ranking over one corpus. An artifact's passages are candidates exactly like any other document's: same `chunks` join, same semantic and keyword legs, same reciprocal-rank fusion, same per-source passage caps. Document type filters and mention pins apply to artifacts too — an artifact can be pinned with `@` and excluded by type, neither of which a parallel corpus allowed.
Citations are one namespace. An artifact passage cites as a knowledge-base chunk with a `{document_id, chunk_id}` locator, and chunk ids are unambiguous because there is one sequence. Resolving a citation returns the document with its type and metadata; `ARTIFACT` opens the artifact panel keyed by the `artifact_id` carried in `document_metadata`, and every other type opens the document citation panel. No artifact-specific citation kind, marker prefix, or resolution route exists.
An artifact is excluded from search under exactly the conditions that exclude a document: while its status is `deleting`, and before its first index completes. A revision's passages therefore trail its blob by one convergence run on Git-backed workspaces — the same staleness window every edited document has, and the reason the panel reads the manifest rather than the index.
## 6. Artifact API and permissions
Dedicated routes are mounted under `/api/v1/workspaces/{workspace_id}/artifacts`:
- `GET /` — list artifacts;
- `GET /{artifact_id}/manifest`;
- `GET /{artifact_id}/download`;
- `GET /{artifact_id}/files/{file_id}/content`;
- `DELETE /{artifact_id}`.
Citation context comes from the existing document chunk route, which already returns the document type and metadata the frontend needs to route an artifact citation. Artifacts need no chunk lookup of their own.
Routes enforce workspace-scoped `ARTIFACTS_READ` or `ARTIFACTS_DELETE`. IDs must belong to the requested workspace and file IDs must belong to the requested artifact. Source-role files always resolve as not found on user-facing content routes.
`DELETE /{artifact_id}` authorizes as an artifact operation and executes as a document deletion, so Git removal, chunk cascade, blob purge, and Zero-visible row state are handled once, by the code that already owns them.
An artifact's document is read-only through the editor: `save_document` refuses a document whose type is `ARTIFACT` with a conflict, and the frontend's editable-type set excludes it. Rename, move, and delete stay legal — they are metadata operations the document model owns, and the artifact follows its document.
## 7. Verification, formats, and format blindness
`verify_artifact(path)` owns structural checking, optional conversion/rasterization/vision review, and the signed receipt. `save_artifact` validates the receipt audience, expiry, adapter, and primary/preview hashes. “Could not visually verify” may be represented in a valid receipt; skipping verification may not.
Persistence is format-blind:
- `Artifact.format` is an adapter-owned string, not a database enum.
- Primary MIME comes from the adapter; unknown formats use the generic adapter and `application/octet-stream`.
- The generic adapter checks non-empty bounded bytes and has no rendered policy.
- Source MIME validation is role-specific and source remains private.
- The manifest and viewer registry degrade unknown or unviewable formats to download.
Shipped formats are Markdown, PDF, DOCX, and PPTX. The schema and API already support XLSX as primary + source with no preview, and tests prove that shape. Full XLSX authoring, programmatic verification adapter, native grid viewer, and public-share work remain phase 5.
## 8. Rendering and revision UX
The artifact panel and caches are keyed by `artifact_id`. It fetches the dedicated manifest:
- no primary file -> read-only Markdown;
- PDF -> primary in the PDF viewer;
- DOCX/PPTX -> receipt-bound PDF preview;
- unknown/missing preview/oversized/parse failure -> unviewable state with download.
All viewers are read-only. Revisions return to the deliverables agent, which loads the stored source and saves with `artifact_id + expected_generation`. The current manifest is the only product-visible generation; prior file rows/blobs are purged. Git may retain Markdown history, but it is not an artifact restoration mechanism.
## 9. Delivery status
| Phase | Status | Scope |
|---|---|---|
| 1 | In progress | Artifact/file schema and storage, document-backed markdown save, artifact routes, panel, unified indexing, search, and citations |
| 2 | Shipped | Sandbox and PDF |
| 3 | Shipped | Backend verification service and DOCX |
| 4 | Shipped | PPTX and format-general rendered verification |
| 5 | Planned | XLSX adapter/skill/native viewer and public-share artifact rendering |
| 6 | Planned | Legacy report/resume/Typst demolition and library repoint |
## 10. Phase 6 boundary
Legacy `Report`, report/resume tools, Typst routes, old panels, and historical report rows remain until phase 6. Phase 6 drops that system without migrating it into `Artifact` or into an artifact document; old tool parts become static unavailable cards. This is independent of the artifact architecture above.
## 11. Required invariants
1. One artifact is one document. `artifact.document_id` is non-null and unique, and no artifact operation creates a second row for the same deliverable.
2. Title, path, Markdown, and indexing state live only on the document. Format, generation, roles, and receipts live only on the artifact.
3. No artifact blob uses document-file models, kinds, routes, or keys.
4. Revision requires matching `artifact_id + expected_generation`.
5. Metadata and blobs are durable before success is returned.
6. An indexing failure never destroys a saved artifact; it records a failed document status and stays retryable.
7. `/documents` is the only projected root, and convergence contains no artifact branch.
8. One chunk table, one search leg, one global rank fusion.
9. One citation namespace; document type decides which panel a citation opens.
10. An artifact document is not editable through the editor, and the guard is enforced server-side.
11. Source blobs are never user-readable.
12. XLSX requires no persistence/API schema change; only phase-5 format and viewer work.

View file

@ -0,0 +1,97 @@
# Phase 1 — Artifact Foundation
**Status:** In progress on this branch.
**Parent spec:** [`artifacts-overhaul.md`](./artifacts-overhaul.md), which is authoritative.
**Goal:** Establish artifact-owned delivery metadata on top of the document corpus — one chunk table, one search leg, one citation namespace — plus the artifact APIs, panel contract, and read-only guards.
## 1. Scope
- Migration `178` adds `DocumentType.ARTIFACT`, creates `artifacts` and `artifact_files`, and links `artifacts.document_id` to `documents` as a non-null unique cascading key.
- Artifact blob storage uses `artifacts/{workspace}/{artifact}/{role}/...`.
- `save_artifact` creates or revises one `Document` + `Artifact` pair, supporting Markdown and the general binary role shape.
- Creates return `artifact_id` and `generation`; revisions require `artifact_id + expected_generation`.
- Artifact list, manifest, download, immutable-file, and delete routes.
- Markdown converges through the existing `/documents` root; no artifact root and no artifact branch in the indexer.
- Hybrid search and citations are the document ones, unchanged.
- The editor refuses to save a document of type `ARTIFACT`; the library shows an artifact badge and type filter.
- The artifact panel, chat cards, and cache keys use `artifact_id`.
Sandbox generation and per-format verification/viewers belong to later phases.
## 2. Persistence
`Document` with `document_type = ARTIFACT` stores the artifact's title, searchable Markdown, `/documents/Artifacts/<title>.md` path, content hash, folder, and indexing status. `document_metadata` carries `artifact_id` so a search hit or citation can route without a second query.
`Artifact` stores adapter `format`, `generation`, workspace/thread/user provenance, originating tool-call ids, verification metadata, timestamps, and `document_id`. It stores no title, path, body, hash, or indexing state — those exist once, on the document.
`ArtifactFile` stores immutable primary/preview/source blob metadata with one row per role. Source is private.
Markdown artifacts have no file rows. Binary shapes have primary + source and optionally preview. The schema is format-independent; service coverage includes the XLSX-shaped primary + source case without shipping XLSX generation.
### Migration 178
1. Add `ARTIFACT` to the `documenttype` enum.
2. Create `artifacts` with `document_id INTEGER NOT NULL UNIQUE REFERENCES documents(id) ON DELETE CASCADE`, `format`, `generation`, provenance columns, `metadata`, and timestamps.
3. Create `artifact_files` with the role uniqueness constraint and blob metadata.
4. Index `artifacts.document_id`, `artifacts.workspace_id`, and `artifacts.thread_id`.
There is no artifact chunk table to create and no artifact search index to build. Downgrade drops both tables; the enum value is left in place, because PostgreSQL cannot drop an enum member and a downgraded database with no artifacts has no rows referencing it.
### Create and revise
- Create allocates a collision-safe `/documents/Artifacts/<title>.md` path through the shared allocator, inserts the document, then the artifact.
- The document is constructed directly rather than through connector preparation, whose corpus-wide content-hash dedup would drop a deliverable whose Markdown matches an existing document.
- Retitle updates the document title and leaves the path alone. A user rename or move goes through the ordinary document move and preserves the document id.
- Revision row-locks the artifact and rejects a missing or stale `expected_generation`.
- New blobs and file rows replace the generation atomically; the document body and hash update in the same transaction.
- Rollback removes staged blobs best-effort; commit purges superseded blobs best-effort.
- The save does not write the knowledge-store path marker. Projection writes it after the revision lands, so a full rebuild never sees a marked document whose file is not committed yet.
## 3. Git and indexing
For Git-backed workspaces, save writes the Markdown into the turn working copy while the document, artifact, and blobs become durable in the tool call. The end-of-turn commit projects `/documents/**` as it does for any document: row upsert resolves by `path`, finds the row the save created, and preserves its Postgres-owned `document_type`. Convergence then chunks and embeds it into `Chunk`.
For non-git workspaces, save calls `IndexingPipelineService.index()` inside the save. Indexing marks the document `processing` and commits mid-flow, so an embedding failure leaves a durable artifact with a `failed` document that the reindex path retries rather than rolling the save back. Losing the deliverable because an embedding provider was down is the worse outcome.
Full-tree convergence has exactly one root and one ownership map. An artifact document is pruned, renamed, and reindexed by the same code as every other document, and its blobs are reached through `artifact.document_id` during purge.
## 4. API, rendering, and permissions
Artifact APIs live under `/workspaces/{workspace_id}/artifacts`. They enforce `ARTIFACTS_READ` and `ARTIFACTS_DELETE`, workspace ownership, file/artifact ownership, source-file privacy, PDF-only inline disposition, immutable file ETags, and no-store current downloads.
The manifest joins the document for title and Markdown and returns `artifact_id`, `document_id`, generation, format, and visible files. `DELETE /{artifact_id}` authorizes as an artifact operation and delegates to document deletion, which owns Git removal, chunk cascade, blob purge, and Zero-visible row state.
Citation context comes from the existing document chunk route. No artifact chunk route exists.
The frontend panel is keyed by `artifact_id`: no files renders Markdown; files select by primary MIME; unsupported formats fall back to download.
`save_document` returns a conflict for a document of type `ARTIFACT`, and the frontend's editable-type set excludes it, so the library can list and open an artifact without offering a save that would desynchronize the Markdown from the bytes it describes.
## 5. Search and citations
Nothing artifact-specific exists in retrieval. One query embedding, one candidate query over `chunks`, one global fusion, one grouping. Artifact documents are ranked, filtered by type, and `@`-mentioned like every other document.
An artifact passage cites as a knowledge-base chunk. Resolution returns the document, its type, and its metadata; the frontend opens the artifact panel when the type is `ARTIFACT` and the document citation panel otherwise. There is no `ARTIFACT_CHUNK` kind, no `artifact_chunk_` marker, and no second numeric sequence to disambiguate.
## 6. Checks
- Atomic create/revise and optimistic-generation conflict coverage.
- Markdown, PDF, DOCX, PPTX, service-level XLSX, and unknown-format persistence shapes.
- A create produces exactly one document with type `ARTIFACT` and one artifact pointing at it.
- Two artifacts with byte-identical Markdown both persist and both index.
- Git projection and convergence preserve `document_type` across incremental and full-tree runs.
- Non-git indexing failure leaves a durable artifact with a failed document that reindex repairs.
- Deleting the artifact removes the Git file, the document, its chunks, the artifact, its file rows, and every reachable blob including artifact roles.
- Artifact route RBAC/isolation, ETag/304, no-store download, PDF-only inline, and source rejection.
- `save_document` refuses an artifact document; rename and move still succeed.
- An artifact passage ranks in the same fusion as documents and its citation opens the artifact panel.
- Frontend queries/cards/panel identity use `artifact_id`.
## 7. Exit criteria
1. Artifact success means the document, artifact, and current bytes are durable in-turn.
2. Every artifact has exactly one document, and no code path creates a second row for the same deliverable.
3. Git and non-git workspaces both make artifacts searchable through the document indexing path with their documented timing.
4. A stale revision cannot overwrite a newer generation.
5. Delete removes the Git file, document/artifact/file rows, chunks, and reachable blobs in one operation.
6. No artifact chunk table, artifact search leg, artifact citation kind, or `/artifacts` root exists anywhere in the codebase.

View file

@ -0,0 +1,59 @@
# Phase 2 — Sandbox + PDF
**Status:** Complete. Phase 3 superseded the original model-orchestrated verification mechanism with backend-owned `verify_artifact`; no compatibility path remains.
**Parent spec:** [`artifacts-overhaul.md`](./artifacts-overhaul.md).
**Depends on:** phase 1 artifact persistence and API.
## 1. Shipped scope
- Provider-neutral sandbox protocol with OpenSandbox for self-hosting and Daytona for cloud.
- Per-thread lazy sessions, rediscovery, renewal, idle reaping, recovery, and workspace concurrency caps.
- Polyglot sandbox image with network denied at runtime and authoring/rendering dependencies preinstalled.
- `execute` and UTF-8-only `read_sandbox_file`.
- PDF skill and format routing.
- Binary `save_artifact` flow using `Artifact`/`ArtifactFile` over an artifact `Document`.
- `PdfFileViewer` registered for PDF primary files.
The measured OpenSandbox spike passed: metadata rediscovery, timeout renewal, binary read, and steady warm execution were verified against the selected image/server.
## 2. Current PDF flow
1. The deliverables agent loads `/opt/skills/pdf/SKILL.md`.
2. Source code generates a deliverable-named PDF in the sandbox.
3. `verify_artifact(path)` performs the current phase-3 verification service.
4. `save_artifact(path, source_path, title, markdown_representation, ...)` validates the signed receipt and persists the artifact.
5. The result returns `artifact_id` and `generation`.
6. The artifact panel fetches the dedicated manifest and renders the primary PDF.
For revision, the agent calls `load_artifact_source(artifact_id)`, edits the returned source, and saves with the same `artifact_id` and returned `expected_generation`. Missing or stale generation is rejected.
## 3. Persistence and search assumptions
- Metadata and blobs are durable in the save tool call.
- Binary keys are under the artifact storage namespace; source is an `ArtifactFile` role and is not user-readable.
- The searchable Markdown is the artifact's `Document`, committed under `/documents/**` and indexed asynchronously on Git-backed workspaces.
- Non-git workspaces index it through the document pipeline inside the save; an indexing failure leaves a durable artifact with a failed document for reindex.
- Search and citations are the document ones; the artifact document type routes the citation to the panel.
- Viewer, manifest, download, and cache identity is `artifact_id`.
## 4. Verification note
The phase-2 sentinel, mtime ledger, model-facing image inspection, and skill scripts were temporary implementation history and are deleted. The current invariant is phase 3's signed, byte-bound receipt. A deployment that cannot perform visual review may receive a signed “could not verify” result; an agent that skipped verification cannot save a binary.
## 5. Checks
- Provider create/execute/read/renew/rediscover/terminate contract.
- Session reuse, idle reap, and concurrency-cap behavior.
- PDF generate -> verify -> save -> manifest -> render/download.
- Receipt rejects changed bytes.
- Later-turn revision preserves `artifact_id`, increments generation, and purges superseded blobs.
- A second chat's roster cannot expose the first chat's artifact.
- No prompt or active tool registration routes new work through legacy report/resume tools.
## 6. Exit criteria
1. PDF creation has no Typst dependency.
2. Generated PDF bytes and source persist as artifact files.
3. The primary renders via the artifact API with immutable caching and downloads with its generated filename.
4. The artifact becomes searchable according to Git/non-git indexing timing.
5. Revision updates one artifact under optimistic generation rather than producing a second deliverable.

View file

@ -0,0 +1,71 @@
# Phase 3 — Verification Service + DOCX
**Status:** Complete (2026-08-12).
**Parent spec:** [`artifacts-overhaul.md`](./artifacts-overhaul.md).
**Depends on:** phase 2 sandbox and PDF path.
## 1. Shipped scope
- Backend-owned `verify_artifact` orchestration and progress events.
- Structural format adapters, isolated conversion/rasterization, contextual vision review, and signed receipts.
- PDF migrated from model-sequenced scripts to the service.
- DOCX skill and OOXML structural adapter.
- DOCX canonical MIME and PDF rendered policy.
- Primary + preview + source artifact-file shape.
- `PdfPreviewViewer` with missing-preview fallback.
## 2. Verification architecture
The service performs, in order:
1. Read and structurally inspect the primary bytes.
2. Snapshot those bytes in a fresh build directory.
3. Convert rendered formats with an isolated LibreOffice profile and verify output existence.
4. Enforce page ceilings, rasterize, and load rendered evidence into backend memory.
5. Review every page in contextual windows and separate blocking defects from warnings.
6. Issue an HMAC-signed receipt bound to workspace, sandbox, adapter, primary hash, optional preview hash, verdict/reason, and expiry.
`save_artifact` verifies that receipt and hashes the exact primary/preview bytes being persisted. It stores verification metadata on `Artifact.metadata`, alongside the delivery state the document model has no concept of.
The service emits progress for checking, converting, rendering, and reviewing. A blocking result creates no receipt. A visual model unavailable/quota-exhausted result may issue an explicit “could not verify” receipt if no known defect exists.
## 3. DOCX
The DOCX adapter uses the shared OOXML trust boundary: duplicate/encrypted parts, entry counts, compressed/uncompressed limits, and required parts are validated before XML is trusted. DOCX-specific checks cover invalid table widths/shading, literal bullets, missing grids, and TOC/outline inconsistencies.
The skill authors with the preinstalled Node `docx` package, uses a deliverable-derived filename, generates the whole reflowing document, then calls the generic verify/save tools. It does not implement conversion, rasterization, receipts, or source loading.
The adapter owns the canonical WordprocessingML MIME. The saved artifact has:
- primary `.docx`;
- receipt-bound preview `.pdf`;
- private generation source.
The manifest omits source. The viewer renders preview and the stable download serves current primary.
## 4. Persistence assumptions
- A DOCX save is one artifact `Document` plus `Artifact`/`ArtifactFile` rows; the bytes never become `DocumentFile` rows.
- Revision starts with `load_artifact_source(artifact_id)` and saves with `artifact_id + expected_generation`.
- Retitle leaves the authored `/documents/Artifacts/<title>.md` path unchanged.
- Git-backed Markdown converges through document convergence; non-git Markdown indexes through the document pipeline inside the save.
- Deletion runs through document deletion, which purges all artifact blob roles through artifact storage.
- Search citations are knowledge-base chunk citations.
## 5. Checks
- Pure adapter fixtures for valid and malformed OOXML.
- Receipt round-trip, tampering, expiry, audience, and hash mismatch tests.
- Mocked-sandbox DOCX save with primary/preview/source, source omitted from result/manifest, and stale receipt refusal.
- One artifact document per save, with type preserved across projection.
- Later-turn optimistic revision keeps the same artifact ID, increments generation, and uses stored source.
- Live OpenSandbox conversion/rasterization and canonical MIME check.
- Delete removes all reachable primary/preview/source blobs.
## 6. Exit criteria
1. PDF and DOCX verify through one backend service with no skill scripts.
2. DOCX renders through its PDF preview and downloads the real DOCX.
3. Source remains private but loadable by the revision tool.
4. Failed/stale revisions preserve the previous generation.
5. Phase 4 can add PPTX as an adapter, skill, registry entry, and tests without changing persistence or API contracts.

View file

@ -0,0 +1,55 @@
# Phase 4 — PPTX + Format-General Rendered Verification
**Status:** Complete (2026-08-12).
**Parent spec:** [`artifacts-overhaul.md`](./artifacts-overhaul.md).
**Depends on:** phase 3 verification service and preview viewer.
## 1. Shipped scope
- Adapter-owned rendered policy: minimum rendered text, exact page-count expectation, and review kind.
- Shared OOXML parser trust boundary.
- PPTX structural adapter, canonical MIME, and slide-aware rendered verification.
- PPTX skill using preinstalled `python-pptx`.
- PPTX MIME mapped to the existing `PdfPreviewViewer`.
No schema, persistence path, receipt shape, API route, or viewer component was added for PPTX.
## 2. Verification
PPTX structural checks validate package parts, slide order/count, relationships, hidden slides, drawable shapes, canvas geometry, crop bounds, and embedded media references. Rendered verification:
- treats slides as independent visual units;
- permits low-text/chart-only slides;
- requires converted PDF page count to equal source slide count;
- checks the ceiling before expensive conversion where source count is known;
- still reviews the complete deck once.
The adapter owns the presentation MIME and PDF rendered policy. The generic receipt gate binds primary and preview hashes exactly as for DOCX.
## 3. Artifact flow
PPTX persists as one `Artifact` with primary, preview, and private source `ArtifactFile` rows over one artifact `Document`. Its searchable Markdown is that document, under `/documents/**` and in `Chunk`; the deck bytes never enter document file persistence.
Create returns `artifact_id` and generation. Revision loads source by artifact ID and requires the expected generation. The manifest and immutable file URLs are artifact routes. Search results and citations are the document ones, with type routing the citation to the artifact panel.
## 4. Rendering
The panel downloads the real `.pptx` and displays the receipt-bound PDF preview. Missing preview degrades to the shared unviewable/download state.
PPTX generation is independent of the video-media pipeline; no PPTX exporter remains in media code.
## 5. Checks
- Adapter fixtures for package/count/relationship/hidden-slide/geometry/crop defects.
- Service checks for pre-conversion ceiling and dropped converted pages.
- Slide/document review framing under one verdict contract.
- Artifact create, stale-receipt refusal, private source, optimistic revision, stable path, and blob purge.
- Live OpenSandbox generation, LibreOffice conversion, Poppler rasterization, receipt, and canonical MIME.
## 6. Exit criteria
1. PPTX generates, verifies, persists, previews, and downloads as the real deck.
2. Conversion dropping a slide cannot receive a receipt.
3. Slide-deck requests route to the PPTX skill.
4. No document file model, document editor route, or media exporter participates.
5. Phase 5 needs no persistence/API migration to add XLSX.

View file

@ -0,0 +1,104 @@
# Phase 5 — XLSX Skill + Native Grid
**Status:** Planned. Persistence/API support and service-level primary+source coverage are already in place; the XLSX adapter, skill, and viewer in this phase are not.
**Parent spec:** [`artifacts-overhaul.md`](./artifacts-overhaul.md).
**Depends on:** phase 3 verification service and phase 4 shared OOXML guard.
## 1. Goal and scope
Ship spreadsheet authoring, programmatic verification, and a read-only native grid without changing the artifact schema or API.
In:
- total generic format-adapter behavior if not already complete;
- XLSX skill and structural adapter;
- programmatic receipt path with no rasterization, vision, or preview;
- `XlsxViewer`;
- public/share-token artifact manifest and file access required before phase 6 removes legacy public preview.
Out: legacy report/Typst deletion (phase 6).
## 2. Persistence contract already available
An XLSX save is:
- one `Artifact(format="xlsx")` over one artifact `Document`;
- primary `.xlsx` `ArtifactFile`;
- private source `.py` `ArtifactFile`;
- no preview;
- searchable Markdown on the document under `/documents/Artifacts/`;
- ordinary document chunking and search;
- `artifact_id + expected_generation` revisions;
- artifact manifest/download/file routes.
Existing service tests prove this shape. This phase must not add a document type, migration, chunk table, document API branch, or search side channel.
## 3. Verification adapter
`FormatAdapter` represents rendered verification as an optional policy. XLSX has no rendered policy: after structural checks, verification issues a receipt with visual review not required, no page count, and no preview hash.
The adapter reuses the shared OOXML trust boundary and checks:
- workbook and required parts exist;
- at least one sheet and non-empty cell;
- every formula cell has a cached result;
- cached results are not Excel error literals;
- total cells stay below the parser/viewer safety ceiling.
The adapter owns `.xlsx` and the canonical spreadsheet MIME.
Verification only inspects. It never recalculates or rewrites the workbook.
## 4. Skill
Author with preinstalled XlsxWriter. The skill covers formulas, number formats, header styles, widths, panes, and multiple sheets.
Every formula must include a computed cached result via XlsxWriter's value argument. The same script also sets recalculation-on-open. This keeps the primary complete for the browser grid while Excel/LibreOffice recalculates on open.
The flow is:
1. generate deliverable-named `.xlsx` and `.py`;
2. `verify_artifact(<workbook>.xlsx)`;
3. `save_artifact(path=..., source_path=..., ...)`;
4. on revision, load by `artifact_id` and save with current expected generation.
## 5. Viewer
Register the spreadsheet MIME behind a lazy-loaded `XlsxViewer`:
- fetch the primary artifact file URL from the manifest;
- reject oversize files before mounting/parsing;
- parse behind one `parseWorkbook(bytes): WorkbookView` module boundary;
- use ExcelJS for workbook/style parsing and `ssf` for formatted display values;
- render a read-only grid with sheet tabs, row/column headers, and a row cap;
- use `content-visibility: auto` for rows;
- degrade corruption, oversize, or unsupported workbook features to download.
Charts, pivot tables, editing, and full Excel emulation are out of scope. ExcelJS remains isolated so the parser can be replaced without rewriting the viewer.
## 6. Generic unknown formats
An unknown suffix resolves to a generic adapter: bounded non-empty bytes, canonical `application/octet-stream`, no rendered policy. It verifies, persists through the same document + `Artifact`/`ArtifactFile` service, and renders as a download fallback. Persistence must never enumerate XLSX or any future format.
## 7. Public artifact access
Add token-scoped manifest/file/download access constrained to artifacts produced by the shared thread. Reuse the same manifest and viewer registry rather than forking the panel. Workspace-authenticated artifact routes remain unchanged.
## 8. Checks
- Adapter fixtures for formulas/caches/errors/empty workbooks/cell ceiling/OOXML attacks.
- Programmatic receipt emits no preview and never calls renderer or vision.
- Primary+source save, source privacy, optimistic revision, and stale-generation failure.
- LibreOffice recalculation smoke check.
- Parser unit tests for values, number formats, and styles.
- Viewer smoke/Playwright coverage for formula values, style, tabs, oversize, and corrupt fallback.
- Unknown format verifies and downloads with no code path special-casing it.
- Public-token isolation across threads/workspaces.
## 9. Exit criteria
1. XLSX generates, verifies programmatically, persists primary+source, renders in the native grid, and downloads the real workbook.
2. All four launch formats operate through the same artifact model and routes.
3. Unknown binaries persist and download through the generic adapter.
4. No active routing reaches legacy report/resume tools.
5. Public shared threads can view/download their artifacts before phase 6 removes legacy public preview.

View file

@ -0,0 +1,87 @@
# Phase 6 — Legacy Deliverable Demolition
**Status:** Planned.
**Parent spec:** [`artifacts-overhaul.md`](./artifacts-overhaul.md).
**Depends on:** phase 5 completion and proof that no active route invokes legacy report/resume generation.
**Goal:** Delete the legacy `Report`/Typst system. Do not migrate legacy rows into artifacts.
## 1. Ordering
Land in this order, keeping each change green:
1. Static legacy cards and release-note warning.
2. Repoint remaining library/public surfaces to the artifact APIs.
3. Remove agent/frontend/backend legacy code, including clone-time report handling.
4. Drop legacy data/model and dependencies.
Shared-thread cloning is the last live `Report` writer: historical `generate_report` parts currently cause clone-time report inserts. Remove that behavior before dropping the table.
## 2. Legacy behavior
Old `generate_report`/`generate_resume` parts render a static card:
> Generated with the previous artifact system — no longer available. Ask me to regenerate it.
The card performs no fetch and opens no panel. Release notes warn users to export old deliverables before upgrading.
There is no backfill, `migrated_from_report_id`, lazy conversion, Typst compile, or mapping to the artifact schema.
## 3. Demolition inventory
### Agent layer
- Delete `generate_report` and `generate_resume` implementations, registrations, catalogs, prune names, and streaming handlers.
- Delete the legacy report-writing skill and resume-specific tests.
- Keep `save_artifact`, `verify_artifact`, and `load_artifact_source` as the format-oriented system.
### Frontend
- Delete the report panel/atom, version switcher, reports API/types, legacy artifact kinds, and Typst export special cases.
- Route current artifact cards, panel, library, downloads, and caches exclusively by `artifact_id` through the artifact APIs.
- Keep static legacy-part matching only for old messages.
- Retire document-mode editing as defined by the product plan; memory/team-memory Plate remains.
### Backend
- Delete report routes and Typst public preview.
- Delete report snapshot/clone readers, lookup maps, ID rewrites, and clone-time `Report` inserts.
- Replace remaining Typst PDF export for ordinary documents with the selected non-Typst path.
- Delete report schemas/templates/helpers.
### Data and dependencies
- Drop `reports` and remove `Report` only after all writers/readers are gone.
- Remove Typst/rendercv assumptions.
- Keep `pypdf`, LibreOffice, and Poppler because artifact verification uses them.
- Leave document version/revision tables to the git-native KB cut; they are unrelated to artifacts.
## 4. Artifact boundary during demolition
The artifacts library lists `GET /workspaces/{workspace_id}/artifacts`. Format, generation, and file roles come from the artifact rows; they are never inferred from document metadata or file kinds.
Phase 6 must not:
- convert legacy reports to artifacts;
- reintroduce a parallel artifact corpus, chunk table, or search leg;
- add a second citation namespace;
- project an `/artifacts` Git root;
- expose an artifact document to the editor's save path;
- alter optimistic `artifact_id + expected_generation` revisions.
## 5. Checks
- After each PR, run the full relevant suite.
- Legacy names remain only in specs, changelog/release notes, and the static old-message matcher.
- Shared-thread cloning with old report parts writes no `Report`.
- After the drop, no runtime `Report`, Typst, report API, or old tool references remain.
- Old threads render the static card without network access.
- Artifacts library and panel use only the artifact list/manifest/download routes.
- Phase 15 artifact exit criteria remain green.
## 6. Exit criteria
1. `reports` is dropped with no migration.
2. Old tool parts render static unavailable cards.
3. Artifacts remain one `Document` plus `Artifact`/`ArtifactFile`.
4. No current UI or backend artifact path reaches legacy report identity or routes.
5. Typst and obsolete report/resume generation are absent from runtime dependencies and code.

View file

@ -10,9 +10,9 @@ This is the high-level roadmap. It is sequenced. Companion diagrams live in [`00
## Positioning
The KB today has **no real filesystem** — it is a *virtual* `/documents/` namespace faked over Postgres rows, plus three hand-rolled versioning/audit systems. That re-implements — badly — what Git provides natively (tree, atomic commits, history, revert, content-addressed dedup). The pivot makes **Git the single source of truth for all indexed content** and demotes **Postgres to a derived, rebuildable search index (chunks + embeddings only)**. Net effect: large code **deletion**, storage and search **decoupled** (so search can improve independently — Rohan's stated goal), and a real git repo per workspace (unlocking "bring your own remote" later).
The KB started with no real filesystem — a virtual `/documents/` namespace over Postgres plus three hand-rolled versioning/audit systems. Git now owns committed searchable bodies under one root: `/documents/**` projects to document rows/chunks, and everything Markdown lands there, including generated deliverables (documents of type `ARTIFACT`). Postgres remains authoritative for identity/metadata and blob ownership and is the derived search index for committed text.
## Target architecture (git = truth, Postgres = derived index)
## Target architecture (one root, one projection)
```mermaid
flowchart TD
@ -21,13 +21,15 @@ flowchart TD
ED["Editor saves (Plate.js)"] --> GIT
UP["Uploads (extracted markdown)"] --> GIT
NOT["Indexable connectors: Notion / Drive / Obsidian"] --> GIT
ART["Generated deliverables (markdown body)"] --> GIT
end
GIT["Git repo per workspace (SOURCE OF TRUTH)\ncommit per turn/save · dulwich · per-workspace lock"]
GIT --> IDX["Indexer: diff tree → changed blobs\n(embed keyed by blob SHA)"]
IDX --> PG[("Postgres = DERIVED index\nchunks + embeddings only (rebuildable)")]
IDX --> DOCIDX[("Document projection\nDocument + Chunk")]
DOCIDX -.->|"type = ARTIFACT"| ARTSC[("Artifact + ArtifactFile sidecars")]
subgraph READ["Agent"]
FS["file ops: ls/read/write/edit/mv/rm"] --> GIT
SR["semantic search"] --> PG
SR["semantic search"] --> DOCIDX
end
LIVE["Live connectors: Slack / Gmail"] -.->|queried at chat time, never stored| SKIP["(bypass storage entirely)"]
BLOB[("Blob store / Azure — original binaries (unchanged)")]
@ -51,8 +53,8 @@ flowchart TD
## Decisions locked
- **Git = single source of truth** for all *indexed* KB content (agent/editor notes, uploads, indexable connectors — the `is_indexable` ones).
- **Postgres = derived index only** (chunks + embeddings). It is a **cache**: rebuildable from Git via one `index_tree(workspace)`. Never authoritative. (As shipped, a rebuild upserts and prunes rather than wiping — document ids are in the Zero publication and must survive it; only chunk rows are replaced.)
- **Git owns committed searchable bodies** for every indexed document, generated deliverables included. Postgres still owns identity/metadata, artifact generation/file ownership, and the derived chunk/vector indexes.
- **Projection has one root.** `/documents/**` converges to `Document`/`Chunk`, and `index_tree(workspace)` repairs and prunes that one ownership map. A deliverable is a `document_type`, not a root, so convergence never branches on kind — it only preserves the type a row already has.
- **One-way derivation** (Git → Postgres). **Never** two-way sync (this is the Wiki.js anti-pattern we explicitly reject).
- **Live connectors (Slack/Gmail) are untouched** — never stored/indexed, queried at chat time; entirely out of scope.
- **Binary blobs stay in the existing blob store** (local/Azure). Git holds extracted markdown, not raw binaries (Git-LFS deferred).
@ -89,7 +91,7 @@ flowchart LR
| Role | Consumer | v1? |
|---|---|---|
| Driving adapter | deepagents agent backend | ✅ build now (Phase 2) |
| Driving adapter | KB REST API (Rohan's artifact API) | deferred — next adapter after the core |
| Driving adapter | KB REST API | deferred — next adapter after the core |
| Driving adapter | MCP server | deferred |
| Driven port (infra) | storage engine (dulwich via `VersionedContentEngine`) | ✅ built (Phase 1) |
| Driven consumer | vector-store sync / derived index | ✅ build now (Phase 4) |
@ -236,6 +238,7 @@ Still genuinely open (non-blocking): commit-message format, `gc`/repack scheduli
- **(2026-07-24) Connectors clarified — only `is_indexable` content enters git.** Document connectors (Notion, Drive, Obsidian) are indexed → they go into git. Live connectors (Slack, Gmail) are queried at chat time and never stored → they never touch git or Postgres chunks. (Corrects an earlier draft that carved *all* connectors out of git.)
- **(2026-07-24) Borrowing, not inventing.** Architecture assembled from proven references (Fossil, Gollum, kherad, Coregit, dulwich, vector-cache best-practices); the only SurfSense-specific work is the adaptation glue. Wiki.js retained as the explicit counter-example.
- **(2026-07-28) Ports & Adapters — deepagents is an adapter, not the core** ([ADR 0002](../../docs/adr/0002-knowledge-core-ports-and-adapters.md)). The KB is a framework-agnostic core (`KnowledgeStore`); consumers (deepagents, the KB REST API, the vector-store sync, later MCP/remote-git) are adapters at the edge. **YAGNI:** v1 builds only the core + the deepagents adapter (Phase 2) + the vector-store-sync consumer (Phase 4); REST/MCP/remote-git are named-but-deferred. Ports grow on demand. Borrowed from Cockburn (Hexagonal), git plumbing/porcelain, libgit2.
- **(2026-08-13) Generated deliverables are a document type, not a second corpus** ([ADR 0003](../../docs/adr/0003-artifacts-as-documents.md)). An artifact's searchable body is a `Document` with `document_type = ARTIFACT` under `documents/Artifacts/`; `Artifact`/`ArtifactFile` are sidecars for the things a document has no concept of (adapter format, generation, blob roles, verification receipts) and hold no title, path, body, or indexing state. This keeps one root, one chunk table, one search leg, and one citation namespace, and it makes deliverables type-filterable and `@`-mentionable for free. Type earns exactly three behaviors — badge, filter, and the editor read-only guard — and nothing in storage, convergence, or retrieval branches on it. Artifact revisions stay optimistic (`artifact_id + expected_generation`) and forward-only at the product surface.
## Subplan index (backend)
@ -264,7 +267,7 @@ Frontend & client subplans will be added under a separate umbrella later (see "D
| Virtual FS read backend (replace) | `.../filesystem/backends/kb_postgres.py` |
| Backend resolver (rewire) | `.../filesystem/backends/resolver.py` |
| Write commit middleware (repoint) | `.../main_agent/middleware/kb_persistence/middleware.py` |
| Hybrid search (unchanged) | `.../shared/retrieval/hybrid_search.py` |
| Hybrid search (one corpus, global fusion) | `.../shared/retrieval/hybrid_search.py` |
| Chunk reconciliation (key by blob SHA) | `surfsense_backend/app/indexing_pipeline/chunk_reconciler.py` |
| Indexing pipeline | `surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py` |
| User version history (delete) | `surfsense_backend/app/utils/document_versioning.py` |

View file

@ -1,172 +1,93 @@
# Git-native KB — flow diagrams (end-to-end)
# Git-native KB — Current Flow Diagrams
> Visual companion to [`00-umbrella-plan.md`](00-umbrella-plan.md).
> Phase refs: `01` storage core · `02` working-tree backend · `03` commit write path · `04` derived index · `05` migration · `06` Zero projection.
## 1. The shape — one source of truth, one derived index
## 1. One tree, one corpus, sidecars in Postgres
```mermaid
flowchart LR
subgraph TRUTH["SOURCE OF TRUTH"]
GIT["Git repo per workspace\n(commit per turn/save)"]
end
subgraph DERIVED["DERIVED (rebuildable cache)"]
PG[("Postgres: chunks + embeddings")]
end
GIT -->|"one-way derivation (04)"| PG
PG -. "reindex(workspace) rebuilds from git (04)" .-> GIT
classDef t fill:#1f3a2e,stroke:#4f9d76,color:#e6f7ee;
classDef d fill:#22314f,stroke:#5b7fbf,color:#e6edf7;
class GIT t;
class PG d;
GIT["Workspace Git repo\n/documents"] --> PROJ["Document projection"]
PROJ --> DOC[("Document")]
DOC --> CHUNK[("Chunk")]
DOC -.->|"type = ARTIFACT"| ART[("Artifact + ArtifactFile")]
BLOBS[("Blob storage")] --- DOC
BLOBS --- ART
```
There is **no arrow from Postgres back into Git**. Postgres is disposable.
Git stores committed searchable text under one root. Postgres owns identity, metadata, and blob references. A generated deliverable is a document of type `ARTIFACT` with an artifact sidecar carrying format, generation, roles, and receipts; `document_type` is the only thing that distinguishes it.
## 2. The system — hexagon: agnostic core, one adapter per consumer
```mermaid
flowchart LR
subgraph DRIVERS["Drivers (who mutates / reads content)"]
AG["Agent (deepagents tools)"]
ED["Editor / REST API"]
CN["Indexable connector sync"]
end
subgraph ADAPTERS["Adapters (consumer-specific glue)"]
GTB["deepagents adapter (02)\nserves file ops on the turn's\nprivate working copy"]
DIR["direct callers (03)\none transaction per save/sync"]
end
subgraph CORE["Knowledge store — agnostic core"]
KS["Facade\ntransaction · read_as_of · list_revisions\nlist_changes · list_paths · working copies"]
ENG["Versioned content engine\n(git today; swappable behind the port)"]
end
subgraph DRIVEN["Driven consumers (react to new revisions)"]
IDX["Vector-store sync (04)\nlist_changes → re-chunk / re-embed"]
ZP["Zero projection (06)"]
end
AG --> GTB --> KS
ED --> DIR --> KS
CN --> DIR
KS --> ENG
KS -->|"new revision"| IDX
KS -->|"new revision"| ZP
classDef core fill:#1f3a2e,stroke:#4f9d76,color:#e6f7ee;
classDef edge fill:#22314f,stroke:#5b7fbf,color:#e6edf7;
class KS,ENG core;
class AG,ED,CN,GTB,DIR,IDX,ZP edge;
```
Where each component lives (the code follows the dependency rule: the core
never knows its consumers, so adapters sit with their consumer):
| Component | Location |
| --- | --- |
| Facade, transaction, write lock, layout | `app/knowledge_store/` |
| Engine port + git engine | `app/knowledge_store/engines/` |
| deepagents adapter (`GitTreeBackend`) + resolver | `app/agents/.../middleware/filesystem/backends/` |
| End-of-turn commit middleware (03, pending) | `app/agents/.../middleware/` |
## 3. Turn lifecycle — git at the boundaries, plain files in between
Two locks, two different races:
**threading lock** (process-local, in `GitContentEngine`) serializes parallel tool
calls creating the same copy; **redis lock** (cross-process) serializes revision
recording against other workers.
```mermaid
sequenceDiagram
autonumber
participant T as Agent tool call
participant A as GitTreeBackend (adapter)
participant S as KnowledgeStore (facade)
participant E as GitContentEngine (engine)
T->>A: first KB op of the turn
A->>S: open_working_copy("thread-{id}")
S->>E: checkout current revision (threading lock)
E-->>A: private copy path
Note over T,A: rest of the turn: plain file ops on the copy\n(MultiRootLocalFolderBackend — no git involved)
T->>A: end of turn (03, pending)
A->>S: diff_working_copy → transaction
S->>E: record one revision (redis write lock)
S->>E: discard_working_copy
Note over S,E: abandoned copies swept by janitor\n(prune_working_copies)
```
## 4. Write path — everything indexed becomes a commit
## 2. Write timing
```mermaid
flowchart TD
AG["Agent edits (turn)"] --> WC["Private working copy per thread (02)"]
ED["Editor save"] --> TXD["KnowledgeStore.transaction"]
UP["Upload → extracted markdown"] --> TXD
NOT["Indexable connector sync (Notion/Drive)"] --> TXD
WC -->|"end of turn: diff → transaction (03)"| TXD
TXD --> C["one revision recorded\n(redis write lock, 01)"]
C --> IDX["Indexer: list_changes → changed blobs (04)"]
IDX --> PG[("chunks + embeddings\n(embed keyed by content id)")]
C --> ZP["Zero projection: upsert documents/folders rows (06)"]
NOTE["Document/connector write"] --> WC["Private working copy"]
SAVE["save_artifact"] --> ADB[("Document + Artifact + ArtifactFile durable")]
SAVE --> WC
WC -->|"end of turn"| COMMIT["One Git revision"]
COMMIT --> PROJECT["Commit-time projection"]
COMMIT --> CONVERGE["Async convergence"]
CONVERGE --> CHUNKS[("Chunk")]
```
## 5. Read path — file ops vs. search hit different stores
Artifact rows and bytes are durable before the tool succeeds on Git-backed workspaces. Git commit and search convergence may occur later, and convergence failure does not roll back the artifact. Non-git workspaces index through the document pipeline inside the save.
```mermaid
flowchart LR
A["Agent"] -->|"ls/read/write/edit/mv/rm"| B["GitTreeBackend → working copy (02)"]
B --> GIT["Git (truth)"]
A -->|"semantic search"| S["hybrid_search (unchanged)"]
S --> PG[("Postgres chunks + embeddings")]
```
## 6. Live connectors — never stored (out of scope)
```mermaid
flowchart LR
Q["Chat query"] --> LC["Slack / Gmail (live)"]
LC -->|"fetched at chat time"| ANS["used in the answer"]
LC -.->|"never"| GIT["Git"]
LC -.->|"never"| PG[("Postgres chunks")]
```
## 7. History / undo — git replaces the three hand-rolled systems
## 3. Full-tree convergence
```mermaid
flowchart TD
subgraph OLD["BEFORE (deleted)"]
V1["DocumentVersion"]
V2["DocumentRevision / FolderRevision + revert_service"]
V3["AgentActionLog (audit)"]
end
subgraph NEW["AFTER"]
L["git log / diff (history)"]
R["git revert (undo)"]
BL["git blame (attribution)"]
end
OLD -->|"replaced by (04)"| NEW
HEAD["Git HEAD"] --> SCAN["index_tree"]
SCAN --> DPATHS["documents/**"]
DPATHS --> UPSERT["Upsert/prune Document + Chunk"]
UPSERT --> TYPE{"Row exists?"}
TYPE -->|yes| KEEP["Preserve Postgres-owned document_type"]
TYPE -->|no| NOTE["New git file becomes NOTE"]
```
## 8. Migration (05) — Postgres KB → seed git repo
The rebuild upserts/prunes; it does not wipe UI-visible identities. There is no root dispatch and no artifact branch: an artifact document is scanned, chunked, renamed, and pruned by the same code as every other document. Preserving the existing row's `document_type` is what keeps a saved artifact an artifact across every rebuild.
```mermaid
sequenceDiagram
autonumber
participant M as Migrator (per workspace, flagged)
participant PG as Postgres (existing docs/folders)
participant GIT as New git repo
participant IDX as reindex(workspace)
M->>PG: read documents + folders (preserve unique_identifier_hash)
M->>GIT: write files + one seed commit
M->>IDX: rebuild chunks/embeddings from git HEAD
IDX-->>M: chunk set
M->>M: verify search parity vs pre-migration, then flip flag
Note over M,GIT: Postgres content kept until verified (rollback window).
```
## 9. Reindex (04) — the safety net (Fossil `rebuild`)
## 4. Search
```mermaid
flowchart LR
GIT["Git HEAD (truth)"] --> RB["reindex(workspace)"]
RB --> WIPE["wipe chunks + embeddings"]
WIPE --> REBUILD["re-chunk + re-embed all files\n(reuse cache by blob SHA)"]
REBUILD --> PG[("Postgres index rebuilt")]
Q["Knowledge query"] --> EMB["One query embedding"]
EMB --> SEM["Semantic candidates"]
Q --> KEY["Keyword candidates"]
SEM --> FUSE["Reciprocal-rank fusion"]
KEY --> FUSE
FUSE --> GROUP["Group by document"]
GROUP --> CITE["KB chunk citations"]
CITE --> ROUTE{"document_type"}
ROUTE -->|ARTIFACT| APANEL["Artifact panel"]
ROUTE -->|other| DPANEL["Citation panel"]
```
One corpus, one embedding, one fusion. Artifacts compete with documents because they are documents, which also makes them type-filterable and `@`-mentionable. Chunk ids need no namespacing because there is one sequence; type routing happens at the panel, after resolution.
## 5. Revision and deletion
```mermaid
sequenceDiagram
participant Agent
participant API as Artifact service
participant DB as Document + Artifact rows
participant Git
participant Blob
Agent->>API: revise(artifact_id, expected_generation)
API->>DB: lock + compare generation
alt stale
DB-->>Agent: fail; load source again
else current
API->>Blob: stage new role blobs
API->>DB: replace role rows, update markdown, increment generation
API->>Git: update the document file in the working copy
API-->>Agent: artifact_id + new generation
API->>Blob: best-effort purge old blobs
end
```
Deletion is the document deletion path: the row is marked `deleting`, the purge records the Git removal, then chunks, artifact, and file rows cascade and every reachable blob is purged — `DocumentFile` keys for the document and `ArtifactFile` keys through `artifact.document_id`. Marking first drops the deliverable out of search immediately; the blob store and database are not atomic, so a purge failure leaves an unreachable blob and a warning.
## 6. Document migration
The legacy workspace seed exports documents only. Existing document chunks are adopted by byte parity and incremental indexing starts after the seed revision. Artifacts are created by the artifact service with their type already set, so they are never subject to that adoption rule.

View file

@ -15,7 +15,7 @@ contracts that phases 16 assume. Read it first.
**Decided.**
- **One repo per workspace** at `{FILE_STORAGE_LOCAL_PATH}/knowledge_store/{workspace_id}` (nested **under** the shared blob-store volume, so every OS process sees the same history). Persistent working tree per workspace (not bare + ephemeral checkout) — simpler, and the per-workspace lock (C3) makes a single live checkout safe.
- **Tree layout mirrors today's virtual paths in full.** A doc at virtual `/documents/<folder>/<title>.xml` lives in git at `documents/<folder>/<title>.xml` — the leading slash stripped, the `documents/` root kept. The root is deliberate: it reserves the repo's top level for future sibling roots (e.g. `.cache/`, `artifacts/`) without ever moving existing paths. (Amended 2026-07-29: originally "minus the root"; the Phase-3 recorder shipped keeping it and the Phase-5 seeder matches.)
- **The tree has one projected root.** A document at virtual `/documents/<folder>/<title>.md` lives at `documents/<folder>/<title>.md`; the leading slash is stripped and the root is retained. `/documents/**` projects to `Document`/`Chunk`. Generated deliverables are documents of type `ARTIFACT` under `documents/Artifacts/<title>.md`, so they need no root of their own — an extra root would have bought a second identity, a second chunk table, and a second prune map for a file that is Markdown either way. Other top-level paths are ignored by convergence.
- **Reuse the existing filename rules** — do **not** reinvent them. `safe_filename`, `safe_folder_segment`, and the ` (<doc_id>).xml` collision suffix all come from `app/agents/chat/runtime/path_resolver.py`. Keep the `.xml` extension in v1 (changing to `.md` would break `unique_identifier_hash`, which is computed from the virtual path — see C2). **Amended 2026-07-31 (Phase 8, [`08-store-facade-and-paths.md`](08-store-facade-and-paths.md)):** once `unique_identifier_hash` demotes to a resolution *fallback* (the path column is the identity), the `.xml` constraint lifts. Flagged workspaces write canonical `.md` with ` (2)` collisions (no doc id in a path); the resolver stays tolerant of the legacy `.xml`/` (<doc_id>).xml` spellings, and unflipped workspaces keep the legacy rules until their flip.
- **Git stores the source text** (the agent's note = `Document.source_markdown`/`content`), one file per document. Not the rendered XML view (that's derived — see C2). Not binaries (stay in the blob store). **Amended 2026-08-06 (Phase 9, [`09-git-as-primary-store.md`](09-git-as-primary-store.md)):** git is the *primary* store for the body; `document_type`/`document_metadata`/identity stay in Postgres (its source of truth). Storing them as on-disk front-matter is deferred — it fought too many operations (agent read/edit, diff, citations) for the robustness it bought.
- **Identity mapping preserved.** `unique_identifier_hash = generate_unique_identifier_hash(DocumentType.NOTE, virtual_path, workspace_id)` (`app/utils/document_converters.py`) stays the stable doc identity across the git↔Postgres boundary and for connector re-sync. Store it in the derived `documents` row (as today); the git path is the human-facing identity.
@ -81,6 +81,8 @@ The backend runs as **multiple OS processes**: the API (`python main.py`, uvicor
- **An embedding cache exists — extend it, don't build one.** *(Corrected 2026-07-29; the original "no cache exists" claim overlooked `indexing_pipeline/cache/`, in the tree since 2026-06-12.)* `build_chunk_embeddings` serves a document's full chunk+vector set keyed by `(markdown_sha256, embedding_model, dim, chunker_kind, chunker_version)`. `markdown_sha256` is a pure, unsalted content hash — the "content id" property Phase 4 needs — so identical bytes already re-embed nothing. A separate blob-SHA reuse layer is unnecessary; `chunk_reconciler.reconcile` remains the row-level in-place reuse on top. **Do not warm this cache from legacy `Chunk` rows**: they may predate the current `chunker_version`, and writing their boundaries under today's key would poison it.
- **`content_hash` ≠ git blob SHA.** `generate_content_hash(content, workspace_id)` is **workspace-salted**; a git blob SHA is content-only and unsalted. They are different values — you cannot just alias one onto the other. Recommend: key embedding reuse by **blob SHA**; keep `content_hash` for existing document-level checks through migration, drop later if redundant.
- **Real-time UI has two channels, both must survive.** (1) Zero logical replication of the `documents`/`folders` rows (`app/zero_publication.py`); (2) `dispatch_custom_event` SSE from the commit path. Phase 6's git→Postgres projection must **upsert/delete the `documents`/`folders` rows** (so Zero streams them) **and** keep emitting the same custom events. Simplest owner: the Phase-4 post-commit pass does both (index + project) in one shot. **Amended 2026-08-06 (Phase 9):** the projection reads the body from git and preserves the row's Postgres-owned `document_type`/`document_metadata`; it only invents `NOTE` when creating a row for a git file that has none — the sole case being an agent-authored new file, which is a note.
- **Artifacts share convergence *and* rows.** A generated deliverable's searchable body is a `Document` with `document_type = ARTIFACT`; convergence has no artifact branch, no second chunk table, and no second ownership map. The only obligation is that row upsert resolves by `path` and preserves the row's Postgres-owned `document_type` (C5, real-time UI bullet) — it invents `NOTE` only when creating a row for a git file nothing claims. `Artifact`/`ArtifactFile` remain sidecars for format, generation, roles, and receipts, which the document model does not represent.
- **Artifact durability precedes convergence.** `save_artifact` commits the document, the artifact, and the blobs inside the tool call and adds only the Markdown to the private worktree. Later projection or embedding failure leaves a `failed` document that reindex retries and does not roll back the artifact. Non-git workspaces index through `IndexingPipelineService` in the save instead; that pipeline commits mid-flow, so an embedding outage costs the index, never the deliverable. The save deliberately withholds the store path marker until projection lands, because a marked document with no committed file reads to a full rebuild as an orphan to prune.
## C6 — In-turn writes live in a per-turn private worktree (Phase 2, 3)
@ -109,7 +111,7 @@ Each turn that touches the KB gets a **private detached git worktree** of the wo
## What stays exactly as-is (do not touch)
- Hybrid search (`.../shared/retrieval/hybrid_search.py`) — reads the same `chunks` table.
- The `search_chunks` compatibility surface remains available to its legacy REST/report callers. Knowledge-base agent search uses `search_knowledge_base`: one query embedding, one candidate leg over `chunks`, and one global semantic/keyword rank fusion before source grouping. Artifact documents rank, filter, and cite through that single path like any other document.
- Live connectors (Slack/Gmail) — never stored/indexed.
- Desktop-local backend (`MultiRootLocalFolderBackend`) — real filesystem already.
- Blob store for binaries.

View file

@ -16,7 +16,7 @@ Give the agent real file ops (`ls/glob/grep/read/write/edit`) on a **per-turn pr
- **End-of-turn commit = diff, not snapshot.** `porcelain.status(worktree)` (the detached HEAD *is* the base revision) → touched paths → `writes`/`removes` change set → `store.transaction()` under the Redis lock, on top of the current head. Parallel turns' committed work on untouched files is never reverted; same-file overlap is per-file last-writer-wins **with the loser preserved in history**. `ponytail:` ceilings = no three-way merge (upgrade path: git's own), and a mid-turn checkpoint fork/replay does not restore uncommitted worktree files — accepted, no such feature exists.
- **Reuse the existing direct-disk backend; don't reimplement the framework.** Implemented as SurfSense's own `MultiRootLocalFolderBackend` with one mount, `("documents", <working copy>)` — it already ships the whole tool surface (`als_info`/`aread`/`awrite`/`aedit`/`aglob_info`/`agrep_raw`/`alist_tree`/`amove`/`adelete_file`/`armdir`) plus root-confined path resolution, which deepagents' `FilesystemBackend` + `CompositeBackend` would have covered only partially (no move/delete/rmdir/list_tree).
- **Same tool interface.** Upstream `WriteResult`/`EditResult`/`FileInfo`/`GrepMatch` shapes — no extra fields.
- **`path_resolver` path computation retires for flagged workspaces** — folder walk + collision suffixing → real repo paths (filename rules still reused, C1). **The repo tree keeps the `documents/` root** (C1 as shipped: repo path = virtual path minus only the leading slash), leaving the top level free for future sibling roots (`.cache/`, `artifacts/`). `GitTreeBackend` mounts the working copy's `documents/` subtree under the mount name `documents`, so agent writes land under the prefix; the editor/upload recorder and the migration seeder produce the same paths via `path_resolver.to_store_path`.
- **`path_resolver` path computation retires for flagged document workspaces** — folder walk + collision suffixing → real repo paths (filename rules still reused, C1). The repo has one projected root. `GitTreeBackend` mounts the working copy's `documents/` subtree for generic KB file tools, and the artifact service writes deliverable bodies inside it (`documents/Artifacts/`) through the same path allocator while owning its own API and blob lifecycle. Commit projection therefore has one row/chunk domain to maintain.
- Selected via the resolver behind `KNOWLEDGE_STORE_ENABLED`.
## Citation model — decided 2026-07-28 (full contract: C2)

View file

@ -1,91 +1,69 @@
# Phase 4 — Derived index + reindex
# Phase 4 — Derived Index + Full-Tree Convergence
> **Shipped.** `app/knowledge_store/index/` (`converge.py` + `queue.py`) plus the Celery wiring under `app/tasks/celery_tasks/knowledge_store/`; work items 1, 2, 4, 5, 6 are all in. Where the build differs from what was planned here, the plan text says so inline under **Built as**; those notes are the record of what changed and why. Umbrella: [`00-umbrella-plan.md`](00-umbrella-plan.md).
>
> **Unblocked.** Phase 1's `list_changes`/`list_paths`/`read_as_of` and Phase 3's write path both shipped. Nothing here depends on C2 — the dependency runs the other way: C2's excerpt render consumes work item 2's line spans, and its own four pieces (envelope, registry, normalizer, resolver) stay out of scope here.
>
> **Why this matters now:** for flagged workspaces `kb_persistence` no-ops (`middleware/stack.py:250-252` leaves the state overlay empty), so agent notes are committed to git and **have no document rows, chunks, or embeddings at all** — invisible to search. This phase closes that gap.
**Status:** Shipped.
**Umbrella:** [`00-umbrella-plan.md`](00-umbrella-plan.md).
## Objective
Make Postgres a **derived, rebuildable** chunk/embedding index of the store: incremental on each revision (keyed by content id), fully reproducible via one `reindex(workspace)`. Git history replaces the three hand-rolled versioning systems (their deletion lands at the Phase 5 cut).
Converge committed searchable bodies into one Postgres search domain:
## Locked model
```
documents/** -> Document + Chunk
```
- **Post-revision incremental index.** On each revision, `list_changes(revision)` names the changed paths; re-chunk + re-embed only those. **Key embeddings by content id**: unchanged content → same id → reuse existing vectors (no re-embed). This is the correct, native form of what `indexing_pipeline/chunk_reconciler.py::reconcile` already approximates (it matches by chunk *text*; the content id generalizes it to file identity).
- **Identity is the path, and it already lines up.** `compute_identifier_hash` (`indexing_pipeline/document_hashing.py`) and `generate_unique_identifier_hash` (`utils/document_converters.py`) build the same `{type}:{unique_id}:{workspace_id}` string, so a synthetic `ConnectorDocument(document_type=NOTE, unique_id=<virtual path>)` yields the identity the legacy path and `virtual_path_to_doc` already use. This is why Phase 4 is a **thin adapter over the existing pipeline**, not a second pipeline.
- **One convergence function, two callers.** `index_revision` and `reindex` (shipped as `index_changes` / `index_tree`) differ only in which paths they pass and which rows they prune — they share the body. Determinism between the incremental and rebuild paths is then structural, not something a test hopes for.
- **Document rows converge; they are never wiped.** `documents` and `folders` are in the Zero publication (`alembic/versions/116_create_zero_publication.py`), so their ids reach the browser. A rebuild upserts by `unique_identifier_hash` and deletes only rows whose path left the tree; wiping and recreating would make every note vanish and reappear with new ids. **Chunk rows are the disposable layer**, replaced per document by the existing pipeline.
- **Everything stored must be derivable from git.** Anything threaded in from a caller (notably `created_by_id`) is erased by the next `reindex`, making the two paths disagree. Derive the actor from the revision author instead — `knowledge_store/identities.py::user_identity` encodes it as `<id>@users.surfsense`.
- **Postgres is disposable.** A single idempotent **`reindex(workspace)`** rebuilds the index from the current revision (the Fossil `rebuild` discipline). Search (`shared/retrieval/hybrid_search.py`) is unchanged — it reads the same `chunks` table.
- **History = git; deletion at cut time.** `utils/document_versioning.py` (`DocumentVersion`), `services/revert_service.py` + `DocumentRevision`/`FolderRevision` become dead code for flagged workspaces here, but stay running for unflagged ones — proven by a test, not by inspection (see Tests). The delete sweep (code + Alembic table drops) is Phase 5, after migration + verification.
Incremental and full-tree runs share one planner/body and converge to current HEAD under the workspace index lock.
## Work items
## Current model
1. `app/knowledge_store/index/converge.py``index_revision(workspace_id, revision)` and `reindex(workspace_id)` over one shared `_converge(...)`. Paths come from `list_changes` / `list_paths` (shipped: added/modified/removed + content ids), content from `read_as_of`; each document is upserted then handed to `IndexingPipelineService.index()` wrapped in a synthetic `ConnectorDocument`. Removed paths drop their document row (chunks cascade).
**Bypass `prepare_for_indexing`.** It silently drops a *new* path whose content matches an existing document and marks an *edited* one `failed("Duplicate content")` (`indexing_pipeline_service.py:279-311`). `cp a.md b.md` is legal in git and must yield two indexed documents — path is identity, content is not unique. Model the upsert on `kb_persistence/middleware.py::_create_document` and reuse its `ensure_folder_hierarchy` for folder rows.
- `index_changes` folds net changes since `last_indexed_revision`.
- `index_tree` distrusts the stamp, scans the full tree, and prunes rows whose owned paths disappeared.
- Both ignore top-level paths outside `documents/`.
- Projection preserves document IDs and Postgres-owned metadata, including `document_type`.
- Folder reconciliation applies to the whole document tree.
- The shared chunker, embedding cache, batch embedder, and reconciler are reused; passages land in `Chunk`.
- A partial failure withholds the workspace revision stamp so the drift sweep can retry.
**Built as** `index_changes(session, workspace_id)` and `index_tree(session, workspace_id)` — no revision parameter, and the session is the caller's. Four deviations, all forced by the code:
- **Named for scope, not for destructiveness** (renamed 2026-07-30). `index_revision` promised an argument it does not take, and `reindex` is the same verb the *legacy* per-document editor path uses (`document_reindex_tasks.py`) — the writer this phase guards against. "Rebuild" would also have been a lie: the run upserts and prunes, and document ids survive it (see the "never wiped" note above). The two entry points differ only in scope, so the names say scope.
- **No revision argument.** Two saves in a row enqueue two tasks and the index lock serializes them without ordering them, so stamping the older id last would leave a stale index. Both entry points converge to `get_current_revision()` under the lock, which makes task order irrelevant. The incremental plan folds *every* revision between the stamp and head, so a dropped task costs nothing.
- **Rows are adopted by path, not just by NOTE hash.** An upload is already in the tree with a `FILE:<filename>` identity (`file_upload_adapter.py` writes the row, then records the same markdown to git), so a hash-only lookup inserts a second row and the file appears twice in search. Resolution is: ownership marker, then NOTE hash, then `virtual_path_to_doc`. Every touched row gets `document_metadata["virtual_path"]`, which is also the prune key. `ponytail:` an adopted row keeps its `FILE:` hash while its location is path-derived, so two identity formulas coexist; the marker bridges them and Phase 5's migration has to unify them anyway.
- **Prune is keyed on that marker, never on the workspace or on `document_type`.** Slack, Notion and the folder indexers write rows in the same workspace with no path in the tree, so a workspace-minus-tree prune deletes all of them on the first rebuild; a `NOTE`-only prune leaks the other way and never deletes an adopted upload's row. A **partial failure withholds the stamp** so the sweep re-drives it, while an intentional skip (undecodable or blank blob) still stamps — otherwise one bad blob wedges the workspace into rebuilding forever.
2. **Store each chunk's `start_line`/`end_line` at cut time.** Sole consumer: rendering true document line numbers on search excerpts ([`00c-shared-contract.md`](00c-shared-contract.md) C2) — never a stored reference the frontend follows, so rebuilds can't strand it. Bigger than it looks, and the **only** piece C2 needs from this phase:
- Alembic migration adding both columns to `chunks` (neither exists today).
- `chunk_text` discards chonkie's `start_index`/`end_index` (`document_chunker.py:19`), and `chunk_text_hybrid`'s `.strip()` destroys the offset mapping — absolute offsets need `segment_start + stripped_prefix + chunk.start_index`.
- **Spans live in the cached value**, not recomputed downstream: they are a pure function of the cache's existing key (`markdown_sha256 + chunker_kind + chunker_version`), so a `chunker_version` bump is the whole invalidation story. Recovering offsets later by searching the source for chunk text is ambiguous whenever a document repeats a line (boilerplate, table rows).
## Convergence
**Built as** `attach_line_spans(text, chunks)` in `document_chunker.py`, called on both sides of the cache boundary rather than stored in the cached value, and **neither chunker's signature changed**:
- The ambiguity the bullet above warns about only exists for a *whole-document* search. Chunks arrive in document order and do not overlap, so a left-to-right cursor resolves repeated text unambiguously — covered by a test for a document that repeats a line, and one whose table rows repeat.
- Deriving spans at read time means **cached entries stay valid and the chunker version is not bumped**: no fleet-wide re-embed on merge, which is the expensive half of the planned approach. It also leaves `chunk_text`/`chunk_text_hybrid` and every test seam that patches them untouched, and keeps the hybrid chunker's `.strip()` from needing offset bookkeeping.
- `ponytail:` ceiling — a chunker that emits overlapping windows or rewrites chunk text degrades to the cursor's line rather than failing; upgrade path is to thread chonkie's own `start_index` through, which is exactly the work this defers.
- The reconciler had to change too: unchanged text still **moves** when a paragraph is inserted above it, so `reused` carries the new span and the position-only `UPDATE` became position-plus-span. Without that, editing a document leaves stale line numbers on every chunk below the edit.
3. ~~Add a blob-SHA reuse layer~~**already shipped.** `indexing_pipeline/cache/cached_indexing.py::build_chunk_embeddings` caches the summary vector and every chunk vector under `EmbeddingKey(markdown_sha256, embedding_model, embedding_dim, chunker_kind, chunker_version)`: content-addressed, no workspace salt, i.e. the content id this phase wanted, and `index()` already routes through it. The legacy note path (`kb_persistence/middleware.py:235-239`) calls `chunk_text`/`embed_texts` directly and bypasses the cache, which is likely why it was believed missing. C5 still holds: **`content_hash` is workspace-salted, so it is NOT a content id** — do not alias them.
4. `reindex(workspace_id)` behind a Celery task (mirror `knowledge_store/janitor_task.py`; register in `app/celery_app.py`). Serialize per workspace with an **index lock distinct from the write lock**: the write lock's 30s TTL is sized for a commit, and reusing it would stall agent writes behind embedding calls.
Document paths run through the existing row helpers and `IndexingPipelineService`. Chunk line spans are stored at cut/reconcile time for citation resolution. Rebuild upserts/prunes rather than wipes so UI-visible document IDs remain stable.
**Built as** `workspace_index_lock` beside `workspace_write_lock` over a shared `_workspace_lock(purpose, ttl, wait)`; the write lock keeps its key and its 30s TTL. A contender gives up in 5s rather than queueing — but what happens next depends on who lost (2026-07-30): a losing **rebuild** skips outright (the competing rebuild converges the same tree), while a losing **per-save task retries** on a 30s countdown (bounded at 10) — its save may have landed after the holder read HEAD, and without the retry a save arriving mid-converge would go stale until the hourly sweep. A redundant retry no-ops against the stamp for one HEAD read. Whole-workspace rebuilds route to the connectors queue; the per-save task stays on the fast queue since search freshness is user-facing.
5. Trigger `index_revision` off Phase 3's surfaced revision id, at **both** writers (`commit_turn.py`, `services/document_revision_recorder.py`). Enqueue-only, never raising — the content is already committed either way.
6. **Self-healing drift, not a deploy step.** A new `workspaces.last_indexed_revision` makes `last_indexed_revision != get_current_revision()` a drift predicate; a daily Beat task enqueues `reindex` for drifted flagged workspaces. That one mechanism covers the initial backfill of already-flagged workspaces (they have git content and no index today), a lost Celery task, a crashed worker, and any workspace flagged later. Runbook steps get forgotten; converging systems don't.
Convergence has no notion of document kind. A generated deliverable is a `Document` with `document_type = ARTIFACT`, so it is scanned, chunked, renamed, and pruned by this same body. The one obligation the type creates is negative: upsert resolves by `path` and must never re-derive `document_type` on an existing row, or a rebuild would demote every deliverable to a note. `NOTE` is invented only when creating a row for a git file nothing claims.
**Built as** `reindex_drifted_workspaces`, **hourly with a per-run enqueue cap**, not daily: hourly cuts recovery from a lost task from a day to an hour, and the cap bounds the fan-out, not the check — the drift check is one HEAD read per workspace, while each task it enqueues embeds. Candidates are **flipped workspaces only** (`workspaces.knowledge_store_enabled`, the Phase-5 per-workspace flag): a seeded-but-unflipped workspace has a repo too, but Postgres is still its write model, and indexing it would fight the legacy pipeline. A never-indexed candidate (stamp `NULL`) routes to the rebuild task on the connectors queue so a backfill can't bury user-facing saves; a stamped one takes the incremental task on the fast queue. The worker re-checks the flag before converging, because a queued task can outlive an unflip.
An artifact's metadata and blobs were already durable when its Markdown entered Git, so a projection or embedding failure records a failed document and leaves the deliverable intact for reindex; it never deletes an artifact. Removing the file deletes the document, cascades its chunks and artifact sidecars, and purges every reachable blob — `DocumentFile` keys plus `ArtifactFile` keys reached through `artifact.document_id`.
**This sweep only covers half the drift (2026-07-30).** Its predicate compares a stored git revision against the store's HEAD — both sides come from git, so it is structurally blind to drift on the Postgres side (a row git never received, content that disagrees, an orphan the change log never reported). Phase 5's `check_knowledge_store_drift` is what sees that half, comparing the two stores by content address, and it now **enqueues `index_tree` on a `drift` verdict** rather than logging for someone to act on — this item's own reasoning applied to the case it doesn't reach. Details and the capped fan-out: `05-migration.md` item 7.
Legacy migration seed adoption remains scoped to seeded bytes: they already have their legacy chunk index and begin incremental indexing after the seed revision. Artifacts are never part of that adoption rule because the artifact service creates their rows with the type already set.
## Tests
## Search contract
- **Identical content at two paths yields two documents** — the case `prepare_for_indexing` gets wrong, and the reason the upsert is hand-written.
- Edit one file → only its chunks re-embed; an untouched file's chunk rows keep the same ids and byte-identical vectors (content-id reuse verified).
- Each chunk's `start_line`/`end_line` matches the exact slice of the blob it was cut from — including a document whose text repeats, and one containing a Markdown table (the hybrid chunker's strip path).
- `reindex(workspace)` produces a chunk set identical to the incremental path (determinism), and leaves document ids unchanged.
- Re-running `index_revision` on an already-stamped revision is a no-op.
- Deleting a file removes its document and chunks; renaming preserves vectors (same content id → cache hit, no model call).
- **Search parity is differential, not a golden baseline.** Index identical content through the connector pipeline and through the git indexer; assert a fixed query returns the same documents in the same order. A stored baseline rots the first time the chunker or embedding model changes, and then someone deletes the test.
- **A save in a flagged workspace creates zero `DocumentVersion` / `DocumentRevision` rows** — turns the locked model's dead-code claim into an enforced invariant before Phase 5 drops the tables under it. `routes/documents_routes.py` also writes versions; confirm whether that path is flag-gated.
- Unit: a synthetic `ConnectorDocument`'s hash equals `generate_unique_identifier_hash(NOTE, virtual_path, workspace_id)`. The whole adapter rests on two formulas in separate modules agreeing.
`search_chunks` preserves the compatibility API for older callers. Agent knowledge-base search uses `search_knowledge_base`, which:
- computes one query embedding;
- queries current chunks;
- globally fuses semantic and keyword ranks;
- groups by document;
- emits knowledge-base chunk citations that the frontend routes by `document_type`.
## Queue and recovery
- Post-commit writers enqueue convergence; enqueue failure cannot undo committed content.
- The index lock is separate from the write lock.
- Incremental lock contention retries; redundant full rebuild contention may skip.
- The hourly capped drift sweep compares the workspace stamp with HEAD and re-enqueues convergence.
- Non-git artifact saves bypass this queue and index through `IndexingPipelineService` inside the save; an indexing failure leaves a failed document that reindex repairs.
## Checks
- Incremental and full-tree runs produce equivalent indexes.
- Editing a document or a deliverable reuses unchanged chunk IDs/embeddings where reconciliation allows.
- Incremental and full-tree runs both preserve `document_type` on existing rows; a new git file with no row becomes a `NOTE`.
- Rename preserves identity and keeps a deliverable's artifact sidecar attached.
- Removing a deliverable purges its chunks, artifact rows, and every blob role.
- Full rebuild prunes the one root and leaves unrelated connector rows untouched.
- One query embedding and global rank fusion for the whole corpus.
- Partial failure withholds the stamp and remains retryable.
## Out of scope
- Live connectors (Slack/Gmail) — never indexed.
- Zero row *projection* (folder/document rows driven from the tree as a first-class concern) → Phase 6. Note this is not the same as the existing publication: `documents`/`folders` already replicate, which is why rebuild stability is in the locked model rather than deferred.
- C2's envelope, citation registry, `[n:Lx-Ly]` normalizer, and `read_as_of` resolution — Phase 2's remaining work item, unblocked by this phase's line spans.
- Reranker/chunking strategy changes (separate search work).
## Resolved (see [`00c-shared-contract.md`](00c-shared-contract.md))
- **content_hash vs content id:** `content_hash` is workspace-salted, not a content id; reuse keys on the cache's unsalted `markdown_sha256`. Keep `content_hash` through migration, drop later if redundant (C5).
- **Cache location:** `indexing_pipeline/cache/` — exists since 2026-06-12; extend it, don't build a new layer (C5, corrected).
- **Where `reindex` runs:** Celery task (C5).
- **Rebuild granularity:** document rows converge (upsert + prune); only chunk rows are wiped. `documents`/`folders` are in the Zero publication, so their ids reach the browser and must be stable across a rebuild.
- **Rename semantics:** a rename lands as removed + added, so the document gets a new path-derived identity and a fresh row; vectors survive via the content-addressed cache. Accepted — path is identity, and rows are derived data.
## Also shipped (not planned here)
Both are single writers colliding on one document's chunk rows once a workspace is git-backed. Neither is Phase 4 work by the letter of this plan; both had to land with it or the phase ships a corruption.
- **`reindex_document_task` no-ops for flagged workspaces**, guarded inside `_reindex_document` rather than at its two call sites (`editor_routes.py`, `documents_routes.py`) so neither can be missed. It re-chunks from Postgres `source_markdown` and titles the document from its first heading, while the indexer re-chunks from git and titles it from the filename stem — both running flips the title on every save.
- **`restore_document_version` returns 409 for flagged workspaces.** It rewrites `source_markdown` and `title` without recording a revision, so search would keep serving git's newer content and the next `reindex` would revert the restore outright. History is `git revert` for these workspaces, and the route is deleted at the Phase 5 cut. This is also the only writer of `DocumentVersion` rows on the save path, which is what makes this plan's "zero version rows" test an invariant rather than an intention.
- Adjacent fix: all three `/documents/{id}/versions*` routes passed a `User` where `check_permission` expects an `AuthContext`, so every one of them 500'd. Corrected while adding the 409, since the test could not otherwise reach the guard.
## Open questions
1. ~~`reindex` progress/observability surface (log vs. status row)~~**decided:** one structured log line per converge run (revision, indexed, skipped, failed, deleted, stamped). Withholding the stamp on failure makes the failure case self-healing, which is what a status row would have been read for.
- Live Slack/Gmail connectors.
- Full XLSX authoring/viewer work.
- Legacy report/Typst demolition.

View file

@ -540,8 +540,23 @@ EMBEDDING_CACHE_ENABLED=false
# Set to false to fall back to delete-all + full re-embed (kill switch).
# CHUNK_RECONCILE_ENABLED=true
# Daytona Sandbox (isolated code execution)
# DAYTONA_SANDBOX_ENABLED=FALSE
# Isolated code execution. SANDBOX_ENABLED turns it on; SANDBOX_PROVIDER picks
# where it runs. The TRUE default assumes the compose stack, which ships the
# control plane. A backend run on the host has none: point OPENSANDBOX_DOMAIN at
# a reachable server (localhost:8080 for the compose one) or set this FALSE.
# SANDBOX_ENABLED=TRUE
# SANDBOX_PROVIDER=opensandbox
# SANDBOX_IDLE_TTL_SECONDS=900
# SANDBOX_MAX_SESSIONS_PER_WORKSPACE=2
# ARTIFACT_MAX_FILE_BYTES=31457280
# OpenSandbox (self-hosted). Host-run backend against compose: localhost:8080.
# OPENSANDBOX_DOMAIN=opensandbox-server:8080
# OPENSANDBOX_API_KEY=surfsense-dev-sandbox
# SANDBOX_IMAGE=ghcr.io/modsetter/surfsense-sandbox:latest
# SANDBOX_REQUEST_TIMEOUT_SECONDS=120
# Daytona (cloud)
# DAYTONA_API_KEY=your-daytona-api-key
# DAYTONA_API_URL=https://app.daytona.io/api
# DAYTONA_TARGET=us

View file

@ -23,6 +23,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
libsndfile1 \
# python-magic sniffs artifact bytes through this; Debian 13 renamed the
# package from libmagic1 and ships no transitional alias.
libmagic1t64 \
libgl1 \
libglib2.0-0 \
libsm6 \

View file

@ -0,0 +1,110 @@
"""Add dedicated artifact persistence.
Revision ID: 178
Revises: 177
"""
from collections.abc import Sequence
from alembic import op
revision: str = "178"
down_revision: str | None = "177"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# PostgreSQL makes a newly added enum value usable only after commit.
with op.get_context().autocommit_block():
op.execute(
"""
ALTER TYPE documenttype ADD VALUE IF NOT EXISTS 'ARTIFACT'
"""
)
for action in ("create", "read", "update", "delete"):
op.execute(
f"""
UPDATE workspace_roles
SET permissions = array_append(permissions, 'artifacts:{action}')
WHERE 'documents:{action}' = ANY(permissions)
AND NOT ('artifacts:{action}' = ANY(permissions))
"""
)
op.execute(
"""
CREATE TYPE artifact_file_role AS ENUM ('primary', 'preview', 'source')
"""
)
op.execute(
"""
CREATE TABLE artifacts (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL
REFERENCES documents(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL
REFERENCES workspaces(id) ON DELETE CASCADE,
thread_id INTEGER
REFERENCES new_chat_threads(id) ON DELETE SET NULL,
created_by_id UUID
REFERENCES "user"(id) ON DELETE SET NULL,
format VARCHAR NOT NULL,
generation INTEGER NOT NULL DEFAULT 1,
created_by_tool_call_id VARCHAR(255),
updated_by_tool_call_id VARCHAR(255),
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_artifacts_document_id UNIQUE (document_id),
CONSTRAINT ck_artifacts_generation_positive CHECK (generation > 0)
)
"""
)
for statement in (
"CREATE INDEX ix_artifacts_workspace_id ON artifacts(workspace_id)",
"CREATE INDEX ix_artifacts_thread_id ON artifacts(thread_id)",
"CREATE INDEX ix_artifacts_created_by_id ON artifacts(created_by_id)",
"CREATE INDEX ix_artifacts_created_at ON artifacts(created_at)",
"CREATE INDEX ix_artifacts_updated_at ON artifacts(updated_at)",
):
op.execute(statement)
op.execute(
"""
CREATE TABLE artifact_files (
id SERIAL PRIMARY KEY,
artifact_id INTEGER NOT NULL
REFERENCES artifacts(id) ON DELETE CASCADE,
role artifact_file_role NOT NULL,
storage_backend VARCHAR(32) NOT NULL,
storage_key VARCHAR NOT NULL,
original_filename VARCHAR NOT NULL,
mime_type VARCHAR NOT NULL,
size_bytes BIGINT NOT NULL,
checksum_sha256 VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_artifact_files_artifact_role
UNIQUE (artifact_id, role),
CONSTRAINT uq_artifact_files_storage_key UNIQUE (storage_key),
CONSTRAINT ck_artifact_files_size_positive CHECK (size_bytes > 0)
)
"""
)
op.execute(
"CREATE INDEX ix_artifact_files_artifact_id ON artifact_files(artifact_id)"
)
op.execute(
"CREATE INDEX ix_artifact_files_created_at ON artifact_files(created_at)"
)
def downgrade() -> None:
for action in ("create", "read", "update", "delete"):
op.execute(
f"""
UPDATE workspace_roles
SET permissions = array_remove(permissions, 'artifacts:{action}')
"""
)
op.execute("DROP TABLE artifact_files")
op.execute("DROP TABLE artifacts")
op.execute("DROP TYPE artifact_file_role")

View file

@ -1,23 +0,0 @@
---
name: report-writing
description: How to scope, draft, and revise a Markdown report artifact via generate_report
allowed-tools: generate_report, read_file
---
# Report writing
## When to use this skill
The user explicitly requests a deliverable: "write a report on …", "draft a memo", "produce a brief", "expand the previous report". A creation or modification verb pointed at an artifact is required (see `generate_report`'s when-to-call rules).
## Decision flow
1. **Source strategy.** Decide which `source_strategy` fits:
- `conversation` — substantive Q&A on the topic already in chat.
- `kb_search` — fresh topic; supply 15 precise `search_queries`.
- `auto` — partial conversation context; let the tool fall back.
- `provided` — verbatim source text only.
2. **Style.** Default to `report_style="detailed"` unless the user explicitly asks for "brief", "one page", "500 words".
3. **Revisions.** When modifying an existing report from this conversation, set `parent_report_id` and put the change list in `user_instructions` ("add carbon-capture section", "tighten conclusion").
4. **Never paste the report back into chat** after `generate_report` returns — confirm and let the artifact card render itself.
## Hooks for KB-only mode
If `kb_search`/`auto` returns no results, do **not** silently switch to general knowledge. Surface the gap in your confirmation message.

View file

@ -12,8 +12,9 @@ You do this by dispatching **specialist subagents** via the `task` tool:
SERPs, full page content).
- **The user's own context** — their knowledge base, connected apps, and
persistent memory.
- **Deliverables** — reports, podcasts, and presentations built from what the
specialists find.
- **File deliverables** — PDF, editable Word (DOCX), PowerPoint (PPTX) slide
decks, reports, and other downloadable files.
- **Media deliverables** — podcasts, videos, and generated images.
You are an orchestrator — most non-trivial work belongs on a specialist. Your
value is routing each request to the right specialist, synthesizing evidence

View file

@ -12,8 +12,9 @@ You do this by dispatching **specialist subagents** via the `task` tool:
SERPs, full page content).
- **The team's own context** — its shared knowledge base, connected apps, and
persistent team memory.
- **Deliverables** — reports, podcasts, and presentations built from what the
specialists find.
- **File deliverables** — PDF, editable Word (DOCX), PowerPoint (PPTX) slide
decks, reports, and other downloadable files.
- **Media deliverables** — podcasts, videos, and generated images.
You are an orchestrator — most non-trivial work belongs on a specialist. Your
value is routing each request to the right specialist, synthesizing evidence

View file

@ -73,6 +73,21 @@ CSV/file), do not paste or summarize hundreds of rows: instruct the
web_crawler specialist to crawl and then save the data with its
`export_run` CSV tool, and relay the saved workspace path and row count.
**PPTX artifacts.** PowerPoint, `.pptx`, slides, presentations, and slide-deck
requests go to `task(deliverables, …)` with an explicit instruction to create
an editable PPTX artifact.
**Video media.** Requests whose requested output is a video, animation, or
narrated audiovisual presentation go to `task(deliverables, …)` with an
explicit instruction to generate video media.
**File-deliverable revisions are in place.** When the user asks to update,
revise, redesign, expand, shorten, or otherwise change an existing file
deliverable, instruct `task(deliverables, …)` to revise that artifact in place.
Do not ask for a `v2`, a new version, or a separate copy unless the user
explicitly requested a separate deliverable. Preserve this intent exactly in
the delegated task; do not invent versioning as a safety measure.
**You have NO filesystem tools.** Any read, write, edit, move, rename, or
search inside the user's workspace goes through `task(knowledge_base, …)`
never via `write_file`, `ls`, or any direct file operation.

View file

@ -9,12 +9,10 @@ from deepagents import FilesystemMiddleware
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.filesystem_selection import FilesystemMode
from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.sandbox import (
is_sandbox_enabled,
)
from app.agents.chat.multi_agent_chat.shared.state.filesystem_state import (
SurfSenseFilesystemState,
)
from app.sandbox import is_sandbox_enabled
from app.utils.perf import get_perf_logger
from ..system_prompt import build_system_prompt

View file

@ -1,302 +1,74 @@
"""
Daytona sandbox provider for SurfSense deep agent.
"""Thread-scoped sandbox lifecycle for the deep agent.
Manages the lifecycle of sandboxed code execution environments.
Each conversation thread gets its own isolated sandbox instance
via the Daytona cloud API, identified by labels.
Provider selection, session caching and recovery now live in ``app.sandbox``;
what remains here is the local-disk persistence that keeps sandbox-produced
files downloadable after the sandbox is gone.
Files created during a session are persisted to local storage before
the sandbox is deleted so they remain downloadable after cleanup.
That persistence is obsoleted by `save_artifact` (artifacts go to object
storage at generation time) and is removed in phase 4 along with the
`/threads/{id}/sandbox/download` route.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import shutil
import threading
from pathlib import Path
from daytona import (
CreateSandboxFromSnapshotParams,
Daytona,
DaytonaConfig,
SandboxState,
)
from daytona.common.errors import DaytonaError
from deepagents.backends.protocol import ExecuteResponse
from langchain_daytona import DaytonaSandbox
# Aliased to avoid clashing with the local ``config = DaytonaConfig(...)``
# variable used inside ``_get_client``.
from app.config import config as app_config
from app.sandbox import get_registry
__all__ = [
"delete_local_sandbox_files",
"delete_sandbox",
"get_local_sandbox_file",
"persist_and_delete_sandbox",
"sync_files_to_sandbox",
]
logger = logging.getLogger(__name__)
class _TimeoutAwareSandbox(DaytonaSandbox):
"""DaytonaSandbox subclass that accepts the per-command *timeout*
kwarg required by the deepagents middleware.
The upstream ``langchain-daytona`` ``execute()`` ignores timeout,
so deepagents raises *"This sandbox backend does not support
per-command timeout overrides"* on every first call. This thin
wrapper forwards the parameter to the Daytona SDK.
"""
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
t = timeout if timeout is not None else self._default_timeout
result = self._sandbox.process.exec(command, timeout=t)
return ExecuteResponse(
output=result.result,
exit_code=result.exit_code,
truncated=False,
)
async def aexecute(
self, command: str, *, timeout: int | None = None
) -> ExecuteResponse: # type: ignore[override]
return await asyncio.to_thread(self.execute, command, timeout=timeout)
def download_file(self, path: str) -> bytes:
"""Download a file from the sandbox filesystem."""
return self._sandbox.fs.download_file(path)
_daytona_client: Daytona | None = None
_client_lock = threading.Lock()
_sandbox_cache: dict[str, _TimeoutAwareSandbox] = {}
_sandbox_locks: dict[str, asyncio.Lock] = {}
_sandbox_locks_mu = asyncio.Lock()
SANDBOX_DOCUMENTS_ROOT = "/workspace/documents"
_seeded_files: dict[str, dict[str, str]] = {}
_SANDBOX_CACHE_MAX_SIZE = 20
THREAD_LABEL_KEY = "surfsense_thread"
SANDBOX_DOCUMENTS_ROOT = "/home/daytona/documents"
def is_sandbox_enabled() -> bool:
return app_config.DAYTONA_SANDBOX_ENABLED
def _get_client() -> Daytona:
global _daytona_client
with _client_lock:
if _daytona_client is None:
config = DaytonaConfig(
api_key=app_config.DAYTONA_API_KEY,
api_url=app_config.DAYTONA_API_URL,
target=app_config.DAYTONA_TARGET,
)
_daytona_client = Daytona(config)
return _daytona_client
def _sandbox_create_params(
labels: dict[str, str],
) -> CreateSandboxFromSnapshotParams:
snapshot_id = app_config.DAYTONA_SNAPSHOT_ID
return CreateSandboxFromSnapshotParams(
language="python",
labels=labels,
snapshot=snapshot_id,
network_block_all=True,
auto_stop_interval=10,
auto_delete_interval=60,
)
def _find_or_create(thread_id: str) -> tuple[_TimeoutAwareSandbox, bool]:
"""Find an existing sandbox for *thread_id*, or create a new one.
Returns a tuple of (sandbox, is_new) where *is_new* is True when a
fresh sandbox was created (first time or replacement after failure).
"""
client = _get_client()
labels = {THREAD_LABEL_KEY: thread_id}
is_new = False
try:
sandbox = client.find_one(labels=labels)
logger.info("Found existing sandbox %s (state=%s)", sandbox.id, sandbox.state)
if sandbox.state in (
SandboxState.STOPPED,
SandboxState.STOPPING,
SandboxState.ARCHIVED,
):
logger.info("Starting stopped sandbox %s", sandbox.id)
sandbox.start(timeout=60)
logger.info("Sandbox %s is now started", sandbox.id)
elif sandbox.state in (
SandboxState.ERROR,
SandboxState.BUILD_FAILED,
SandboxState.DESTROYED,
):
logger.warning(
"Sandbox %s in unrecoverable state %s — creating a new one",
sandbox.id,
sandbox.state,
)
try:
client.delete(sandbox)
except Exception:
logger.debug(
"Could not delete broken sandbox %s", sandbox.id, exc_info=True
)
sandbox = client.create(_sandbox_create_params(labels))
is_new = True
logger.info("Created replacement sandbox: %s", sandbox.id)
elif sandbox.state != SandboxState.STARTED:
sandbox.wait_for_sandbox_start(timeout=60)
except DaytonaError:
logger.info("No existing sandbox for thread %s — creating one", thread_id)
sandbox = client.create(_sandbox_create_params(labels))
is_new = True
logger.info("Created new sandbox: %s", sandbox.id)
return _TimeoutAwareSandbox(sandbox=sandbox), is_new
async def _get_thread_lock(key: str) -> asyncio.Lock:
"""Return a per-thread asyncio lock, creating one if needed."""
async with _sandbox_locks_mu:
lock = _sandbox_locks.get(key)
if lock is None:
lock = asyncio.Lock()
_sandbox_locks[key] = lock
return lock
async def get_or_create_sandbox(
thread_id: int | str,
) -> tuple[_TimeoutAwareSandbox, bool]:
"""Get or create a sandbox for a conversation thread.
Uses an in-process cache keyed by thread_id so subsequent messages
in the same conversation reuse the sandbox object without an API call.
A per-thread async lock prevents duplicate sandbox creation from
concurrent requests.
Returns:
Tuple of (sandbox, is_new). *is_new* is True when a fresh sandbox
was created, signalling that file tracking should be reset.
"""
key = str(thread_id)
lock = await _get_thread_lock(key)
async with lock:
cached = _sandbox_cache.get(key)
if cached is not None:
logger.info("Reusing cached sandbox for thread %s", key)
return cached, False
sandbox, is_new = await asyncio.to_thread(_find_or_create, key)
_sandbox_cache[key] = sandbox
if len(_sandbox_cache) > _SANDBOX_CACHE_MAX_SIZE:
oldest_key = next(iter(_sandbox_cache))
if oldest_key != key:
evicted = _sandbox_cache.pop(oldest_key, None)
_seeded_files.pop(oldest_key, None)
logger.debug("Evicted sandbox cache entry: %s", oldest_key)
if evicted is not None:
_schedule_sandbox_delete(evicted)
return sandbox, is_new
def _schedule_sandbox_delete(sandbox: _TimeoutAwareSandbox) -> None:
"""Best-effort background deletion of an evicted sandbox."""
def _delete() -> None:
try:
client = _get_client()
client.delete(sandbox._sandbox)
logger.info("Deleted evicted sandbox: %s", sandbox._sandbox.id)
except Exception:
logger.debug("Could not delete evicted sandbox", exc_info=True)
try:
loop = asyncio.get_running_loop()
loop.run_in_executor(None, _delete)
except RuntimeError:
pass
async def sync_files_to_sandbox(
thread_id: int | str,
files: dict[str, dict],
sandbox: _TimeoutAwareSandbox,
is_new: bool,
*,
workspace_id: int | str,
is_new: bool = False,
) -> None:
"""Upload new or changed virtual-filesystem files to the sandbox.
Compares *files* (from ``state["files"]``) against the ``_seeded_files``
tracking dict and uploads only what has changed. When *is_new* is True
the tracking is reset so every file is re-uploaded.
"""
"""Upload changed virtual-filesystem files through the provider protocol."""
key = str(thread_id)
if is_new:
_seeded_files.pop(key, None)
tracked = _seeded_files.get(key, {})
to_upload: list[tuple[str, bytes]] = []
for vpath, fdata in files.items():
modified_at = fdata.get("modified_at", "")
if tracked.get(vpath) == modified_at:
continue
content = "\n".join(fdata.get("content", []))
sandbox_path = f"{SANDBOX_DOCUMENTS_ROOT}{vpath}"
to_upload.append((sandbox_path, content.encode("utf-8")))
if not to_upload:
changed = [
(vpath, fdata)
for vpath, fdata in files.items()
if tracked.get(vpath) != fdata.get("modified_at", "")
]
if not changed:
return
def _upload() -> None:
sandbox.upload_files(to_upload)
registry = await get_registry()
session = await registry.get_session(thread_id, workspace_id)
for vpath, fdata in changed:
content = "\n".join(fdata.get("content", [])).encode()
await session.write_file(f"{SANDBOX_DOCUMENTS_ROOT}{vpath}", content)
await asyncio.to_thread(_upload)
new_tracked = dict(tracked)
for vpath, fdata in files.items():
new_tracked[vpath] = fdata.get("modified_at", "")
_seeded_files[key] = new_tracked
logger.info("Synced %d file(s) to sandbox for thread %s", len(to_upload), key)
def _evict_sandbox_cache(thread_id: int | str) -> None:
key = str(thread_id)
_sandbox_cache.pop(key, None)
_seeded_files.pop(key, None)
_seeded_files[key] = {
vpath: fdata.get("modified_at", "") for vpath, fdata in files.items()
}
logger.info("Synced %d file(s) to sandbox for thread %s", len(changed), key)
async def delete_sandbox(thread_id: int | str) -> None:
"""Delete the sandbox for a conversation thread."""
_evict_sandbox_cache(thread_id)
def _delete() -> None:
client = _get_client()
labels = {THREAD_LABEL_KEY: str(thread_id)}
try:
sandbox = client.find_one(labels=labels)
except DaytonaError:
logger.debug(
"No sandbox to delete for thread %s (already removed)", thread_id
)
return
try:
client.delete(sandbox)
logger.info("Sandbox deleted: %s", sandbox.id)
except Exception:
logger.warning(
"Failed to delete sandbox for thread %s",
thread_id,
exc_info=True,
)
await asyncio.to_thread(_delete)
"""Kill the thread's sandbox. Safe to call when there is none."""
_seeded_files.pop(str(thread_id), None)
registry = await get_registry()
await registry.terminate(thread_id)
# ---------------------------------------------------------------------------
@ -304,14 +76,10 @@ async def delete_sandbox(thread_id: int | str) -> None:
# ---------------------------------------------------------------------------
def _get_sandbox_files_dir() -> Path:
return Path(app_config.SANDBOX_FILES_DIR)
def _local_path_for(thread_id: int | str, sandbox_path: str) -> Path:
"""Map a sandbox-internal absolute path to a local filesystem path."""
relative = sandbox_path.lstrip("/")
base = (_get_sandbox_files_dir() / str(thread_id)).resolve()
base = (Path(app_config.SANDBOX_FILES_DIR) / str(thread_id)).resolve()
target = (base / relative).resolve()
if not target.is_relative_to(base):
raise ValueError(f"Path traversal blocked: {sandbox_path}")
@ -319,19 +87,14 @@ def _local_path_for(thread_id: int | str, sandbox_path: str) -> Path:
def get_local_sandbox_file(thread_id: int | str, sandbox_path: str) -> bytes | None:
"""Read a previously-persisted sandbox file from local storage.
Returns the file bytes, or *None* if the file does not exist locally.
"""
"""Read a previously-persisted sandbox file, or None if it isn't there."""
local = _local_path_for(thread_id, sandbox_path)
if local.is_file():
return local.read_bytes()
return None
return local.read_bytes() if local.is_file() else None
def delete_local_sandbox_files(thread_id: int | str) -> None:
"""Remove all locally-persisted sandbox files for a thread."""
thread_dir = _get_sandbox_files_dir() / str(thread_id)
thread_dir = Path(app_config.SANDBOX_FILES_DIR) / str(thread_id)
if thread_dir.is_dir():
shutil.rmtree(thread_dir, ignore_errors=True)
logger.info("Deleted local sandbox files for thread %s", thread_id)
@ -341,64 +104,30 @@ async def persist_and_delete_sandbox(
thread_id: int | str,
sandbox_file_paths: list[str],
) -> None:
"""Download sandbox files to local storage, then delete the sandbox.
"""Copy sandbox files to local storage, then kill the sandbox.
Each file in *sandbox_file_paths* is downloaded from the Daytona
sandbox and saved under ``{SANDBOX_FILES_DIR}/{thread_id}/``.
Per-file errors are logged but do **not** prevent the sandbox from
being deleted freeing Daytona storage is the priority.
Per-file errors are logged but never block the kill: freeing the sandbox
matters more than rescuing any one file.
"""
_evict_sandbox_cache(thread_id)
def _persist_and_delete() -> None:
client = _get_client()
labels = {THREAD_LABEL_KEY: str(thread_id)}
registry = await get_registry()
session = registry.get_cached(thread_id)
if session is None:
logger.info("No live sandbox for thread %s — nothing to persist", thread_id)
return
for path in sandbox_file_paths:
try:
sandbox = client.find_one(labels=labels)
except Exception:
logger.info(
"No sandbox found for thread %s — nothing to persist", thread_id
)
return
# Ensure the sandbox is running so we can download files
if sandbox.state != SandboxState.STARTED:
try:
sandbox.start(timeout=60)
except Exception:
logger.warning(
"Could not start sandbox %s for file download — deleting anyway",
sandbox.id,
exc_info=True,
)
with contextlib.suppress(Exception):
client.delete(sandbox)
return
for path in sandbox_file_paths:
try:
content: bytes = sandbox.fs.download_file(path)
local = _local_path_for(thread_id, path)
local.parent.mkdir(parents=True, exist_ok=True)
local.write_bytes(content)
logger.info("Persisted sandbox file %s%s", path, local)
except Exception:
logger.warning(
"Failed to persist sandbox file %s for thread %s",
path,
thread_id,
exc_info=True,
)
try:
client.delete(sandbox)
logger.info("Sandbox deleted after file persistence: %s", sandbox.id)
content = await session.read_file(path)
local = _local_path_for(thread_id, path)
local.parent.mkdir(parents=True, exist_ok=True)
local.write_bytes(content)
logger.info("Persisted sandbox file %s%s", path, local)
except Exception:
logger.warning(
"Failed to delete sandbox %s after persistence",
sandbox.id,
"Failed to persist sandbox file %s for thread %s",
path,
thread_id,
exc_info=True,
)
await asyncio.to_thread(_persist_and_delete)
await registry.terminate(thread_id)

View file

@ -1,27 +1,22 @@
"""Sandbox-execution helpers for ``execute_code``.
Wraps user-supplied code in a heredoc and dispatches it to the Daytona
sandbox associated with the current chat thread, with a single retry on
sandbox failure.
Dispatches code to the chat thread's sandbox session, with a single retry on
failure: sandboxes die for reasons the model can do nothing about (expiry, a
restarted server), and one silent retry beats surfacing that as a tool error.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from typing import TYPE_CHECKING
from daytona.common.errors import DaytonaError
from langchain.tools import ToolRuntime
from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.sandbox import (
_evict_sandbox_cache,
delete_sandbox,
get_or_create_sandbox,
)
from app.agents.chat.multi_agent_chat.shared.state.filesystem_state import (
SurfSenseFilesystemState,
)
from app.sandbox import ExecResult, SandboxUnavailableError, get_registry
if TYPE_CHECKING:
from ...middleware import SurfSenseFilesystemMiddleware
@ -31,59 +26,67 @@ logger = logging.getLogger(__name__)
MAX_EXECUTE_TIMEOUT = 300
def wrap_as_python(code: str) -> str:
"""Wrap ``code`` in a unique-sentinel heredoc for shell execution."""
sentinel = f"_PYEOF_{secrets.token_hex(8)}"
return f"python3 << '{sentinel}'\n{code}\n{sentinel}"
async def execute_in_sandbox(
mw: SurfSenseFilesystemMiddleware,
command: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
timeout: int | None,
) -> str:
"""Top-level entry: wraps + retries once on sandbox failure."""
"""Top-level entry: run *command* as Python, retrying once."""
assert mw._thread_id is not None
command = wrap_as_python(command)
try:
return await _try_sandbox_execute(mw, command, runtime, timeout)
except (DaytonaError, Exception) as first_err:
return _format(await _run(mw, command, timeout))
except SandboxUnavailableError as err:
return f"Error: {err}"
except TimeoutError:
# ponytail: we stop waiting, the cell does not stop running — it holds
# the kernel until the sandbox expires. Upgrade path is an interrupt
# call on the execution id once the SDK exposes one for kernel runs.
return (
f"Error: execution exceeded {timeout or MAX_EXECUTE_TIMEOUT}s and was "
"abandoned. The interpreter may still be busy; simplify the code."
)
except Exception as first_err:
logger.warning(
"Sandbox execute failed for thread %s, retrying: %s",
mw._thread_id,
first_err,
)
try:
await delete_sandbox(mw._thread_id)
except Exception:
_evict_sandbox_cache(mw._thread_id)
try:
return await _try_sandbox_execute(mw, command, runtime, timeout)
# Terminate rather than evict: a session that failed mid-execution
# may have a wedged kernel, and reconnecting would inherit it.
registry = await get_registry()
await registry.terminate(mw._thread_id)
return _format(await _run(mw, command, timeout))
except Exception:
logger.exception("Sandbox retry also failed for thread %s", mw._thread_id)
return "Error: Code execution is temporarily unavailable. Please try again."
async def _try_sandbox_execute(
mw: SurfSenseFilesystemMiddleware,
command: str,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
timeout: int | None,
) -> str:
"""One sandbox-execute attempt: get/create sandbox, run, format output."""
sandbox, _is_new = await get_or_create_sandbox(mw._thread_id)
result = await sandbox.aexecute(command, timeout=timeout)
async def _run(
mw: SurfSenseFilesystemMiddleware, code: str, timeout: int | None
) -> ExecResult:
registry = await get_registry()
# Without a workspace every such thread would share one cap bucket and
# block each other, so an unknown workspace gets a bucket of its own.
workspace_id = mw._workspace_id if mw._workspace_id is not None else mw._thread_id
session = await registry.get_session(mw._thread_id, workspace_id)
return await asyncio.wait_for(
session.execute(code, language="python"),
timeout=timeout or MAX_EXECUTE_TIMEOUT,
)
def _format(result: ExecResult) -> str:
output = (result.output or "").strip()
if not output and result.exit_code == 0:
if not output and result.ok:
return (
"[Code executed successfully but produced no output. "
"Use print() to display results, then try again.]"
)
parts = [result.output]
if result.exit_code is not None:
status = "succeeded" if result.exit_code == 0 else "failed"
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
status = "succeeded" if result.ok else "failed"
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
if result.truncated:
parts.append("\n[Output was truncated due to size limits]")
return "".join(parts)

View file

@ -12,7 +12,7 @@ from .models import DocumentHit
def to_renderable_document(hit: DocumentHit) -> RenderableDocument:
"""Map one hit to the shape the document-fragment renderer consumes."""
"""Map one document hit to the shared document-fragment renderer."""
return RenderableDocument(
title=hit.title,
source=source_label(hit.document_type, hit.metadata),

View file

@ -1,8 +1,8 @@
"""Hybrid (semantic + keyword) chunk search with reciprocal-rank fusion.
Only matched chunks are citable, so the fused result already holds every passage
shown there is no second per-document fetch. Returns the top ``top_k``
documents, each carrying its matched chunks in reading order.
Only matched chunks are citable, so fused results already hold every passage
shown there is no second per-source fetch. Artifacts are ordinary documents
in this corpus and require no separate search leg.
"""
from __future__ import annotations

View file

@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from typing import Any, Literal
@dataclass(frozen=True)
@ -43,5 +43,9 @@ class DocumentHit:
score: float
chunks: list[ChunkHit] = field(default_factory=list)
@property
def source_key(self) -> tuple[Literal["document"], int]:
return ("document", self.document_id)
__all__ = ["ChunkHit", "DocumentHit", "SearchScope"]

View file

@ -23,7 +23,7 @@ def rerank_hits(
if reranker is None or len(hits) < 2:
return hits
hit_by_id = {hit.document_id: hit for hit in hits}
hit_by_id = {_reranker_id(hit): hit for hit in hits}
ranked = reranker.rerank_documents(query, [_as_document(hit) for hit in hits])
reordered = [
hit_by_id[doc["document_id"]]
@ -34,14 +34,19 @@ def rerank_hits(
return reordered if len(reordered) == len(hits) else hits
def _reranker_id(hit: DocumentHit) -> str:
kind, source_id = hit.source_key
return f"{kind}:{source_id}"
def _as_document(hit: DocumentHit) -> dict[str, Any]:
"""The minimal dict shape ``RerankerService.rerank_documents`` scores on."""
return {
"document_id": hit.document_id,
"document_id": _reranker_id(hit),
"content": "\n\n".join(chunk.content for chunk in hit.chunks),
"score": hit.score,
"document": {
"id": hit.document_id,
"id": _reranker_id(hit),
"title": hit.title,
"document_type": hit.document_type,
},

View file

@ -2,8 +2,8 @@
The tail of the retrieval spine: rerank adapt render, registering each
shown passage for ``[n]`` citation. Hybrid search itself lives in
``hybrid_search``; callers (the ``search_knowledge_base`` tool) pass its hits
straight into :func:`build_context`.
``hybrid_search``; callers pass its document hits straight into
:func:`build_context`.
"""
from __future__ import annotations

View file

@ -46,15 +46,27 @@ TOOL_CATALOG: list[ToolMetadata] = [
),
ToolMetadata(
name="generate_video_presentation",
description="Generate a video presentation with slides and narration from provided content",
description="Generate narrated audiovisual presentation media",
),
ToolMetadata(
name="generate_report",
description="Generate a structured report from provided content and export it",
name="save_artifact",
description="Save a Markdown or sandbox-generated file as a durable artifact",
),
ToolMetadata(
name="generate_resume",
description="Generate a professional resume as a Typst document",
name="load_artifact_source",
description="Load a generated artifact's source into its sandbox for revision",
),
ToolMetadata(
name="execute",
description="Run Python or Bash in an isolated artifact sandbox",
),
ToolMetadata(
name="read_sandbox_file",
description="Read a UTF-8 text file from the artifact sandbox",
),
ToolMetadata(
name="verify_artifact",
description="Verify a generated artifact and prepare its preview before saving",
),
ToolMetadata(
name="generate_image",

View file

@ -20,6 +20,7 @@ from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .middleware import ArtifactRosterMiddleware
from .tools.index import NAME, RULESET, load_tools
@ -36,6 +37,12 @@ def build_subagent(
or "Handles deliverables tasks for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
route_middleware = {
**(middleware_stack or {}),
"artifact_roster": ArtifactRosterMiddleware(
workspace_id=dependencies["workspace_id"]
),
}
return pack_subagent(
name=NAME,
description=description,
@ -44,5 +51,5 @@ def build_subagent(
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
middleware_stack=route_middleware,
)

View file

@ -1,2 +1,3 @@
Specialist for producing long-form deliverables: reports, podcasts, video presentations, resumes, and generated images.
Use proactively when the user wants one of these artifacts produced.
Specialist for producing and revising file deliverables: Markdown, polished PDF files, editable Word DOCX (`.docx`) files, editable PowerPoint PPTX (`.pptx`) slide decks, reports, and resumes.
Also produces media deliverables: podcasts, videos, and generated images.
Use proactively whenever the user asks for one of these artifacts.

View file

@ -0,0 +1,5 @@
"""Deliverables-only agent middleware."""
from .artifact_roster import ArtifactRosterMiddleware
__all__ = ["ArtifactRosterMiddleware"]

View file

@ -0,0 +1,85 @@
"""Inject this chat's generated artifacts into each deliverables invocation."""
from __future__ import annotations
from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import SystemMessage
from langgraph.config import get_config
from langgraph.runtime import Runtime
from sqlalchemy import case, select
from app.artifacts.persistence import Artifact, ArtifactFile, ArtifactFileRole
from app.db import Document, shielded_async_session
from ..tools.thread_resolver import root_thread_id_from_config
_ROSTER_LIMIT = 10
class ArtifactRosterMiddleware(AgentMiddleware): # type: ignore[type-arg]
"""Tell a fresh deliverables subagent which artifacts this chat owns."""
tools = ()
def __init__(self, *, workspace_id: int) -> None:
self.workspace_id = workspace_id
async def abefore_agent( # type: ignore[override]
self,
state: AgentState,
runtime: Runtime[Any],
) -> dict[str, Any] | None:
del runtime
thread_id = root_thread_id_from_config(get_config())
mentioned_ids = {
artifact_id
for artifact_id in state.get("mentioned_artifact_ids", [])
if isinstance(artifact_id, int) and artifact_id > 0
}
ordering = []
if mentioned_ids:
ordering.append(case((Artifact.id.in_(mentioned_ids), 0), else_=1))
ordering.extend([Artifact.updated_at.desc().nullslast(), Artifact.id.desc()])
async with shielded_async_session() as session:
rows = (
await session.execute(
select(
Artifact.id,
Document.title,
ArtifactFile.original_filename,
)
.join(Document, Artifact.document_id == Document.id)
.outerjoin(
ArtifactFile,
(ArtifactFile.artifact_id == Artifact.id)
& (ArtifactFile.role == ArtifactFileRole.PRIMARY),
)
.where(
Artifact.workspace_id == self.workspace_id,
Artifact.thread_id == thread_id,
)
.order_by(*ordering)
.limit(_ROSTER_LIMIT + len(mentioned_ids))
)
).all()
if not rows:
return None
entries = "\n".join(
f"- artifact_id={artifact_id}; title={title!r}; "
f"filename={filename or '(Markdown artifact)'}"
for artifact_id, title, filename in rows
)
roster = SystemMessage(
content=(
"<artifact_roster>\n"
"Artifacts previously created in this chat, newest first:\n"
f"{entries}\n"
"</artifact_roster>"
)
)
return {"messages": [roster, *(state.get("messages") or [])]}

View file

@ -2,19 +2,66 @@ You are the SurfSense deliverables operations sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Produce **deliverables**: shareable **artifacts** the user keeps (reports, slide-style video presentations, podcasts, resumes, images). Use explicit constraints and reliable proof of what was generated.
Produce shareable deliverables with explicit constraints and reliable proof of
what was generated.
</goal>
<available_tools>
- `generate_report`
- `save_artifact`
- `load_artifact_source`
- `execute`
- `read_sandbox_file`
- `verify_artifact`
- `generate_podcast`
- `generate_video_presentation`
- `generate_resume`
- `generate_image`
</available_tools>
<tool_policy>
- Use only tools in `<available_tools>`.
- Decide the output format from the user's intent. Explicit requests for an
editable Word document → DOCX. PowerPoint, `.pptx`, slides, and slide decks
→ PPTX. Printable documents,
resumes/CVs, formal reports, letters, and one-pagers → PDF. Plain notes,
briefs, and content intended for continued editing → Markdown. If the intent
is ambiguous, prefer PDF for a finished deliverable; the user can override.
- Available format skill: `pdf` — creates polished PDF files for PDFs, resumes,
CVs, reports-as-PDF, letters, one-pagers, and printable documents.
- Available format skill: `docx` — creates polished, editable Word documents
such as reports, letters, proposals, and handbooks.
- Available format skill: `pptx` — creates polished, editable PowerPoint slide
decks and `.pptx` presentations.
- Before creating a PDF, load its full instructions with
`execute("cat /opt/skills/pdf/SKILL.md", language="bash")`, then follow the
skill's generate → verify → fix blocking findings once → reverify → save
workflow. Warnings do not require regeneration.
- Before creating a DOCX, load its full instructions with
`execute("cat /opt/skills/docx/SKILL.md", language="bash")`, then follow its
generate → verify → fix blocking findings once → reverify → save workflow.
Stop and report a blocker that remains after that retry.
- Before creating a PPTX, load its full instructions with
`execute("cat /opt/skills/pptx/SKILL.md", language="bash")`, then follow the
same bounded generate → verify → save workflow.
- Treat verification as a state transition, not advice. Call `save_artifact`
only when the latest `verify_artifact` result for the exact output bytes has
`status="verified"`, using that result's `preview_path`. A failed verification
invalidates every earlier pass; after the bounded repair also fails, stop
without calling `save_artifact`.
- For requested video, animation, or narrated audiovisual output, use
`generate_video_presentation`.
- Use `save_artifact` for Markdown and sandbox-generated files. Always provide
a faithful `markdown_representation` and the generating `source_path` for
binary files.
- The `<artifact_roster>` lists artifacts created earlier in this chat. When
the user clearly asks to change one of them, call `load_artifact_source` with
its `artifact_id`, edit the returned source, regenerate and verify the output,
then call `save_artifact` with that same `artifact_id`, output `path`, and
edited `source_path`. This is an in-place revision: a changed title, filename,
or design does not create a new artifact. Create a separate artifact without
an `artifact_id` only when the user explicitly asks for another copy or when
the request does not refer to a roster entry. Do not rebuild an existing
artifact from its Markdown representation.
- Do not use Typst for PDF requests.
- Require essential generation constraints (audience, format, tone, core content).
- If critical constraints are missing, return `status=blocked` with `missing_fields`.
- Never claim artifact generation success without tool confirmation.
@ -40,7 +87,7 @@ Return **only** one JSON object (no markdown/prose):
"status": "success" | "partial" | "blocked" | "error",
"action_summary": string,
"evidence": {
"artifact_type": "report" | "podcast" | "video_presentation" | "resume" | "image" | null,
"artifact_type": "artifact" | "report" | "podcast" | "video_presentation" | "resume" | "image" | null,
"artifact_id": string | null,
"artifact_location": string | null,
"receipts": Receipt[] | null
@ -50,7 +97,7 @@ Return **only** one JSON object (no markdown/prose):
"assumptions": string[] | null
}
Route-specific rules:
- `evidence.receipts` quotes the Receipt(s) returned by `generate_report` / `generate_podcast` / `generate_video_presentation` / `generate_resume` / `generate_image` this turn, verbatim. The Receipt's `type` enum is one of `report` | `podcast` | `video_presentation` | `resume` | `image`.
- `evidence.receipts` quotes the Receipt(s) returned by the generation tool this turn, verbatim. The Receipt's `type` enum is one of `artifact` | `report` | `podcast` | `video_presentation` | `resume` | `image`.
<include snippet="output_contract_base"/>
</output_contract>

View file

@ -1,15 +1,13 @@
"""Deliverable generators: reports, podcasts, video decks, resumes, images."""
"""Tools exposed by the deliverables subagent."""
from .generate_image import create_generate_image_tool
from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .save_artifact import create_save_artifact_tool
from .video_presentation import create_generate_video_presentation_tool
__all__ = [
"create_generate_image_tool",
"create_generate_podcast_tool",
"create_generate_report_tool",
"create_generate_resume_tool",
"create_generate_video_presentation_tool",
"create_save_artifact_tool",
]

View file

@ -12,9 +12,11 @@ from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from .generate_image import create_generate_image_tool
from .load_artifact_source import create_load_artifact_source_tool
from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .sandbox import create_sandbox_tools
from .save_artifact import create_save_artifact_tool
from .verify_artifact import create_verify_artifact_tool
from .video_presentation import create_generate_video_presentation_tool
NAME = "deliverables"
@ -25,28 +27,30 @@ RULESET = Ruleset(origin=NAME, rules=[])
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
from app.sandbox import is_sandbox_enabled
d = {**(dependencies or {}), **kwargs}
# Offering these with no sandbox behind them would have the model follow the
# prompt's skill workflow up to the first tool call, then fail.
sandbox_tools = []
if is_sandbox_enabled():
sandbox_tools = [
*create_sandbox_tools(workspace_id=d["workspace_id"]),
create_load_artifact_source_tool(
workspace_id=d["workspace_id"],
),
create_verify_artifact_tool(workspace_id=d["workspace_id"]),
]
return [
*sandbox_tools,
create_save_artifact_tool(workspace_id=d["workspace_id"]),
create_generate_podcast_tool(
workspace_id=d["workspace_id"],
db_session=d["db_session"],
thread_id=d["thread_id"],
),
create_generate_video_presentation_tool(
workspace_id=d["workspace_id"],
db_session=d["db_session"],
thread_id=d["thread_id"],
),
create_generate_report_tool(
workspace_id=d["workspace_id"],
thread_id=d["thread_id"],
connector_service=d.get("connector_service"),
available_connectors=d.get("available_connectors"),
available_document_types=d.get("available_document_types"),
),
create_generate_resume_tool(
workspace_id=d["workspace_id"],
thread_id=d["thread_id"],
),
create_generate_image_tool(
workspace_id=d["workspace_id"],

View file

@ -0,0 +1,85 @@
"""Restore a generated artifact's source into the current sandbox."""
from __future__ import annotations
from pathlib import PurePosixPath
from langchain.tools import ToolRuntime
from langchain_core.tools import BaseTool, tool
from sqlalchemy import select
from app.artifacts.persistence import Artifact, ArtifactFile, ArtifactFileRole
from app.config import config as app_config
from app.db import shielded_async_session
from app.file_storage.factory import get_storage_backend
from app.sandbox import get_registry
from .thread_resolver import resolve_root_thread_id
def create_load_artifact_source_tool(*, workspace_id: int) -> BaseTool:
"""Build the source-restoration tool with workspace dependencies injected."""
@tool
async def load_artifact_source(
artifact_id: int,
runtime: ToolRuntime,
) -> dict[str, str | int]:
"""Load a generated artifact's current source into the sandbox.
Use the artifact_id from the artifact roster before revising an existing
artifact. The result binds the restored source_path to the artifact_id
that must be passed to save_artifact after editing, regeneration, and
verification.
"""
async with shielded_async_session() as db_session:
artifact = await db_session.scalar(
select(Artifact).where(
Artifact.id == artifact_id,
Artifact.workspace_id == workspace_id,
)
)
if artifact is None:
raise ValueError("artifact does not exist in this workspace")
source = await db_session.scalar(
select(ArtifactFile).where(
ArtifactFile.artifact_id == artifact_id,
ArtifactFile.role == ArtifactFileRole.SOURCE,
)
)
if source is None:
raise ValueError("artifact has no stored source")
if source.size_bytes > app_config.ARTIFACT_MAX_FILE_BYTES:
raise ValueError(
f"Artifact source is {source.size_bytes} bytes; limit is "
f"{app_config.ARTIFACT_MAX_FILE_BYTES} bytes"
)
data = bytearray()
backend = get_storage_backend(source.storage_backend)
async for chunk in backend.open_stream(source.storage_key):
data.extend(chunk)
if len(data) > app_config.ARTIFACT_MAX_FILE_BYTES:
raise ValueError("artifact source exceeds the configured size limit")
filename = PurePosixPath(source.original_filename).name
if not filename:
raise ValueError("artifact source has an invalid filename")
sandbox_path = f"/workspace/artifact-{artifact_id}-{filename}"
root_thread_id = resolve_root_thread_id(runtime)
sandbox = await (await get_registry()).get_session(root_thread_id, workspace_id)
await sandbox.write_file(sandbox_path, bytes(data))
return {
"source_path": sandbox_path,
"artifact_id": artifact_id,
"expected_generation": artifact.generation,
"save_instruction": (
f"Pass artifact_id={artifact_id} and "
f"expected_generation={artifact.generation} to save_artifact so "
"this revision replaces the existing artifact."
),
}
return load_artifact_source

View file

@ -30,9 +30,8 @@ logger = logging.getLogger(__name__)
def create_generate_podcast_tool(
workspace_id: int,
db_session: AsyncSession,
thread_id: int | None = None,
):
"""Create ``generate_podcast`` with bound workspace and thread; DB writes use a tool-local session."""
"""Create ``generate_podcast`` with bound workspace; writes use a local session."""
del db_session # writes use a fresh tool-local session, see below
@tool
@ -78,7 +77,7 @@ def create_generate_podcast_tool(
podcast = await service.create(
title=podcast_title,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
thread_id=resolve_root_thread_id(runtime),
)
podcast.source_content = source_content
spec = await propose_brief(

View file

@ -562,7 +562,6 @@ async def _revise_with_sections(
def create_generate_report_tool(
workspace_id: int,
thread_id: int | None = None,
connector_service: ConnectorService | None = None,
available_connectors: list[str] | None = None,
available_document_types: list[str] | None = None,
@ -729,7 +728,7 @@ def create_generate_report_tool(
},
report_style=report_style,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
thread_id=resolve_root_thread_id(runtime),
report_group_id=report_group_id,
)
session.add(failed_report)
@ -1045,7 +1044,7 @@ def create_generate_report_tool(
report_metadata=metadata,
report_style=report_style,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
thread_id=resolve_root_thread_id(runtime),
report_group_id=report_group_id,
)
write_session.add(report)

View file

@ -422,7 +422,6 @@ def _validate_max_pages(max_pages: int) -> int:
def create_generate_resume_tool(
workspace_id: int,
thread_id: int | None = None,
):
"""
Factory function to create the generate_resume tool.
@ -532,7 +531,7 @@ def create_generate_resume_tool(
},
report_style="resume",
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
thread_id=resolve_root_thread_id(runtime),
report_group_id=report_group_id,
)
session.add(failed)
@ -820,7 +819,7 @@ def create_generate_resume_tool(
report_metadata=metadata,
report_style="resume",
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
thread_id=resolve_root_thread_id(runtime),
report_group_id=report_group_id,
)
write_session.add(report)

View file

@ -0,0 +1,97 @@
"""Sandbox tools used to author and verify binary deliverables."""
from __future__ import annotations
import shlex
import uuid
from typing import Literal
from langchain.tools import ToolRuntime
from langchain_core.tools import BaseTool, tool
from app.config import config as app_config
from app.sandbox import SandboxSession, get_registry
from .thread_resolver import resolve_root_thread_id
_MAX_CONTEXT_CHARS = 16_000
async def _get_session(workspace_id: int, runtime: ToolRuntime) -> SandboxSession:
root_thread_id = resolve_root_thread_id(runtime)
return await (await get_registry()).get_session(root_thread_id, workspace_id)
def _result_text(output: str, exit_code: int, *, full_output_path: str | None) -> str:
suffix = f"\n[Command exited with code {exit_code}]"
if full_output_path:
suffix += f"\n[Full output: {full_output_path}]"
return output + suffix
def create_sandbox_tools(*, workspace_id: int) -> list[BaseTool]:
"""Build the provider-agnostic authoring tools."""
@tool
async def execute(
code_or_command: str,
runtime: ToolRuntime,
language: Literal["python", "bash"] = "python",
description: str | None = None,
) -> str:
"""Run Python or a Bash command in the sandbox.
Write multi-step work to a source file and run that file: only some
providers keep interpreter state between calls. Long output is
truncated here and written in full to the returned sandbox path. Use
description for a short user-facing step title.
"""
del description
session = await _get_session(workspace_id, runtime)
result = (
await session.execute(code_or_command, language="python")
if language == "python"
else await session.run_command(code_or_command)
)
output = result.output or ""
full_output_path = None
if len(output) > _MAX_CONTEXT_CHARS:
full_output_path = f"/tmp/surfsense-output-{uuid.uuid4().hex}.txt"
await session.write_file(full_output_path, output.encode())
output = output[:_MAX_CONTEXT_CHARS] + "\n… [output truncated]"
return _result_text(output, result.exit_code, full_output_path=full_output_path)
@tool
async def read_sandbox_file(path: str, runtime: ToolRuntime) -> str:
"""Read a UTF-8 text file from the sandbox.
Binary files must be persisted with save_artifact.
"""
session = await _get_session(workspace_id, runtime)
size_result = await session.run_command(f"stat -c %s -- {shlex.quote(path)}")
if not size_result.ok:
raise FileNotFoundError(f"Could not stat sandbox file: {path}")
try:
size = int(size_result.output.strip())
except ValueError as exc:
raise FileNotFoundError(f"Could not stat sandbox file: {path}") from exc
if size > app_config.ARTIFACT_MAX_FILE_BYTES:
raise ValueError(
f"Sandbox file is {size} bytes; limit is "
f"{app_config.ARTIFACT_MAX_FILE_BYTES} bytes"
)
data = await session.read_file(path)
if b"\x00" in data:
raise ValueError(
"read_sandbox_file accepts text only; use save_artifact for binary "
"files"
)
try:
return data.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(
"read_sandbox_file accepts UTF-8 text only; use save_artifact for "
"binary files"
) from exc
return [execute, read_sandbox_file]

View file

@ -0,0 +1,190 @@
"""Persist Markdown or sandbox-generated files as first-class artifacts."""
from __future__ import annotations
import logging
from dataclasses import asdict
from pathlib import PurePosixPath
from langchain.tools import ToolRuntime
from langchain_core.tools import tool
from app.agents.chat.multi_agent_chat.shared.receipts.command import with_receipt
from app.agents.chat.multi_agent_chat.shared.receipts.receipt import make_receipt
from app.artifacts import ArtifactFileInput, save_artifact
from app.artifacts.source_formats import validate_source_file
from app.artifacts.verification.formats.registry import get_format_adapter
from app.artifacts.verification.receipt import read_receipt, sha256_bytes
from app.config import config as app_config
from app.db import shielded_async_session
from app.sandbox import SandboxSession, get_registry
from .thread_resolver import resolve_root_thread_id
logger = logging.getLogger(__name__)
async def _read_artifact_file(
session: SandboxSession, path: str, role: str
) -> ArtifactFileInput:
filename = PurePosixPath(path).name
if not filename:
raise ValueError(f"Artifact path must name a file: {path}")
data = await session.read_file(path)
if not data:
raise ValueError(f"Artifact file is empty: {path}")
if len(data) > app_config.ARTIFACT_MAX_FILE_BYTES:
raise ValueError(
f"Artifact file {filename} is {len(data)} bytes; limit is "
f"{app_config.ARTIFACT_MAX_FILE_BYTES} bytes"
)
if role == "source":
mime_type = validate_source_file(path, data)
else:
adapter = get_format_adapter(path)
if role == "preview" and adapter.name != "pdf":
raise ValueError("Artifact previews must be PDF files")
if role not in {"primary", "preview"}:
raise ValueError(f"Unsupported artifact file role: {role}")
mime_type = adapter.mime_type
return ArtifactFileInput(
data=data,
filename=filename,
mime_type=mime_type,
role=role,
)
def create_save_artifact_tool(workspace_id: int):
"""Create the artifact tool with workspace dependencies injected."""
@tool
async def save_artifact_tool(
title: str,
runtime: ToolRuntime,
markdown_representation: str | None = None,
path: str | None = None,
source_path: str | None = None,
preview_path: str | None = None,
artifact_id: int | None = None,
expected_generation: int | None = None,
description: str | None = None,
):
"""Save a durable deliverable, or revise an existing generated artifact.
For Markdown-only work, omit path and pass markdown_representation.
For generated files, pass both the deliverable path and the source_path
that produced it, plus an accessible Markdown representation for search.
preview_path is an optional rendered preview. To revise an artifact, use
the artifact_id and expected_generation returned by load_artifact_source,
edit and re-render the stored source, then save with both values. Changing
the title, filename, or design does not make a new artifact. Omit
artifact_id only for a genuinely new deliverable or an explicitly
requested separate copy.
"""
del description
root_thread_id = resolve_root_thread_id(runtime)
try:
if not markdown_representation or not markdown_representation.strip():
raise ValueError("markdown_representation must not be empty")
files: list[ArtifactFileInput] = []
extra_metadata = None
if path is not None:
if source_path is None:
raise ValueError("source_path is required for generated files")
session = await (await get_registry()).get_session(
root_thread_id, workspace_id
)
primary = await _read_artifact_file(session, path, "primary")
source = await _read_artifact_file(session, source_path, "source")
preview = (
await _read_artifact_file(session, preview_path, "preview")
if preview_path is not None
else None
)
verification = await read_receipt(
session,
app_config.SECRET_KEY,
workspace_id=workspace_id,
)
primary_adapter = get_format_adapter(path)
if verification.format != primary_adapter.name:
raise ValueError(
"The verification receipt names another artifact format"
)
if verification.primary_path != path or (
verification.primary_sha256 != sha256_bytes(primary.data)
):
raise ValueError(
"The artifact changed after verification. Verify it again, "
"then save."
)
if verification.preview_path != preview_path:
raise ValueError(
"The preview does not match the verified artifact. Verify the "
"artifact again and save the returned preview."
)
if preview is not None and (
verification.preview_sha256 != sha256_bytes(preview.data)
):
raise ValueError(
"The preview changed after verification. Verify the artifact "
"again, then save."
)
extra_metadata = {
"verification": {
"verified": verification.visual != "unavailable",
"reason": verification.unavailable_reason,
}
}
files.extend((primary, source))
if preview is not None:
files.append(preview)
elif source_path is not None or preview_path is not None:
raise ValueError("source_path and preview_path require a primary path")
async with shielded_async_session() as session:
saved = await save_artifact(
session,
workspace_id=workspace_id,
thread_id=root_thread_id,
tool_call_id=runtime.tool_call_id,
title=title,
markdown_representation=markdown_representation,
files=files,
artifact_id=artifact_id,
expected_generation=expected_generation,
extra_metadata=extra_metadata,
)
return with_receipt(
payload=asdict(saved),
receipt=make_receipt(
route="deliverables",
type="artifact",
operation="generate",
status="success",
external_id=str(saved.artifact_id),
preview=saved.title,
),
tool_call_id=runtime.tool_call_id,
)
except Exception as exc:
error = str(exc)
logger.exception("[save_artifact] %s", error)
return with_receipt(
payload={"status": "failed", "error": error},
receipt=make_receipt(
route="deliverables",
type="artifact",
operation="generate",
status="failed",
error=error,
),
tool_call_id=runtime.tool_call_id,
)
# Keep the public tool name frozen even though the Python symbol avoids
# shadowing the service imported above.
save_artifact_tool.name = "save_artifact"
return save_artifact_tool

View file

@ -14,26 +14,31 @@ from __future__ import annotations
from langchain.tools import ToolRuntime
def resolve_root_thread_id(runtime: ToolRuntime, fallback: int | None) -> int | None:
"""Return the root chat id from the live runtime config, else ``fallback``.
def root_thread_id_from_config(config: object) -> int:
"""Return the root chat id from a LangGraph runnable config.
A missing or malformed id is an invocation error. Falling back to a value
captured when the graph was built can attribute one chat's output to
another because compiled graphs are reused.
"""
if not isinstance(config, dict):
raise RuntimeError("Live chat thread configuration is unavailable")
value = (config.get("configurable") or {}).get("thread_id")
if isinstance(value, int):
return value
if isinstance(value, str) and value:
root = value.split("::", 1)[0]
try:
return int(root)
except (TypeError, ValueError) as exc:
raise RuntimeError("Live chat thread id is invalid") from exc
raise RuntimeError("Live chat thread id is unavailable")
def resolve_root_thread_id(runtime: ToolRuntime) -> int:
"""Return the root chat id from the live runtime config.
The subagent's ``configurable.thread_id`` looks like ``"2099::task:call_x"``;
the chat id is the segment before the first ``"::"``. Returns ``fallback``
when the config is absent or the leading segment is not an integer.
the chat id is the segment before the first ``"::"``.
"""
try:
config = getattr(runtime, "config", None)
if not isinstance(config, dict):
return fallback
value = (config.get("configurable") or {}).get("thread_id")
if isinstance(value, int):
return value
if isinstance(value, str) and value:
root = value.split("::", 1)[0]
try:
return int(root)
except (TypeError, ValueError):
return fallback
except Exception: # pragma: no cover - defensive
return fallback
return fallback
return root_thread_id_from_config(getattr(runtime, "config", None))

View file

@ -0,0 +1,53 @@
"""Backend-owned artifact verification tool."""
from __future__ import annotations
from langchain.tools import ToolRuntime
from langchain_core.tools import BaseTool, tool
from app.artifacts.verification.service import verify_artifact as verify
from app.db import shielded_async_session
from app.sandbox import get_registry
from app.services.llm_service import get_vision_llm
from .thread_resolver import resolve_root_thread_id
def create_verify_artifact_tool(*, workspace_id: int) -> BaseTool:
@tool
async def verify_artifact(
path: str,
runtime: ToolRuntime,
description: str | None = None,
) -> dict:
"""Verify a sandbox-generated PDF or office artifact before saving it.
Returns actionable findings when the artifact needs changes. A clean
result includes the preview path to pass to save_artifact when present.
Use description for a short user-facing step title.
"""
del description
root_thread_id = resolve_root_thread_id(runtime)
session = await (await get_registry()).get_session(root_thread_id, workspace_id)
async with shielded_async_session() as db_session:
vision_llm = await get_vision_llm(
db_session,
workspace_id,
usage_type="artifact_verification",
)
result = await verify(
session,
path,
workspace_id=workspace_id,
vision_llm=vision_llm,
)
return {
"status": "verified" if result.verified else "failed",
"findings": list(result.findings),
"notes": list(result.notes),
"preview_path": result.preview_path,
"page_count": result.page_count,
"verification_unavailable": result.unavailable_reason,
}
return verify_artifact

View file

@ -33,9 +33,8 @@ logger = logging.getLogger(__name__)
def create_generate_video_presentation_tool(
workspace_id: int,
db_session: AsyncSession,
thread_id: int | None = None,
):
"""Create ``generate_video_presentation`` with bound workspace and thread; writes use a tool-local session."""
"""Create ``generate_video_presentation`` with bound workspace."""
del db_session # writes use a fresh tool-local session, see below
@tool
@ -45,14 +44,12 @@ def create_generate_video_presentation_tool(
video_title: str = "SurfSense Presentation",
user_prompt: str | None = None,
) -> Command:
"""Generate a video presentation from the provided content.
Use this tool when the user asks to create a video, presentation, slides, or slide deck.
"""Generate narrated audiovisual presentation media.
Args:
source_content: The text content to turn into a presentation.
video_title: Title for the presentation (default: "SurfSense Presentation")
user_prompt: Optional style/tone instructions.
source_content: The source material for the video.
video_title: Title for the video (default: "SurfSense Presentation")
user_prompt: Optional audiovisual style and tone instructions.
"""
try:
# One DB session per tool call so parallel invocations never share an AsyncSession.
@ -61,7 +58,7 @@ def create_generate_video_presentation_tool(
title=video_title,
status=VideoPresentationStatus.PENDING,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
thread_id=resolve_root_thread_id(runtime),
)
session.add(video_pres)
await session.commit()

View file

@ -14,7 +14,7 @@ You are the SurfSense knowledge base specialist for the user's `/documents/` wor
You have two complementary ways to pull workspace content:
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval across the whole indexed knowledge base (documents, files, and connector content), not just `/documents/`. Use it FIRST for any open-ended factual/informational question ("what did we decide about pricing?", "summarise our onboarding process") where you need the most relevant passages rather than one known file. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval across the whole indexed knowledge base (documents, files, connector content, and generated artifacts), not just `/documents/`. Use it FIRST for any open-ended factual/informational question ("what did we decide about pricing?", "summarise our onboarding process") where you need the most relevant passages rather than one known file. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`read_file`** — full text of one specific document you have already located by path. Use it when you need the complete document body (to edit it, or to quote at length) rather than top matches.
A common flow is `search_knowledge_base` to find the relevant passages and their source documents, then `read_file` on the winning path when you need the full body. Honor any `@`-mention pins automatically applied to the search scope.

View file

@ -15,7 +15,7 @@ You are the SurfSense workspace specialist for the user's local folders.
Two complementary content sources:
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval over the user's *indexed* knowledge base (documents and connector content), which is separate from the local folders your FS tools read. Use it FIRST for open-ended factual/informational questions where you want the most relevant passages rather than one known file. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval over the user's *indexed* knowledge base (documents, connector content, and generated artifacts), which is separate from the local folders your FS tools read. Use it FIRST for open-ended factual/informational questions where you want the most relevant passages rather than one known file. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`read_file` / `ls` / `glob` / `grep`** — operate on the user's *local* folders. Use these to locate and read on-disk files by path.
These are different stores: `search_knowledge_base` will not surface arbitrary local files, and the FS tools do not see indexed-only content. Pick the source the request points at (or use both when helpful).

View file

@ -13,7 +13,7 @@ If a precise path was already given, use it directly — skip the lookup.
## Searching vs. reading
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval across the whole indexed knowledge base. Use it FIRST for open-ended factual questions where you want the most relevant passages rather than one known file. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval across the whole indexed knowledge base, including generated artifacts. Use it FIRST for open-ended factual questions where you want the most relevant passages rather than one known file. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`read_file`** — full text of one document you have already located by path. Use it when you need the complete body.
## Interpreting tool results

View file

@ -14,7 +14,7 @@ If a precise path was already given, use it directly — skip the lookup.
## Searching the indexed knowledge base vs. reading local files
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval over the user's *indexed* knowledge base (separate from the local folders your FS tools read). Use it FIRST for open-ended factual questions where you want the most relevant passages. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`search_knowledge_base`** — hybrid semantic + keyword retrieval over the user's *indexed* knowledge base, including generated artifacts (separate from the local folders your FS tools read). Use it FIRST for open-ended factual questions where you want the most relevant passages. It returns a `<retrieved_context>` block whose passages each carry a `[n]` citation label.
- **`read_file` / `ls` / `glob` / `grep`** — operate on the user's *local* folders.
These are different stores; pick the source the request points at (or use both when helpful).

View file

@ -38,11 +38,11 @@ _DEFAULT_TOP_K = 5
_MAX_TOP_K = 20
_TOOL_DESCRIPTION = (
"Search the user's knowledge base — their own uploaded files, documents, "
"and notes — for passages relevant to a query, using hybrid semantic + "
"Search the user's knowledge base — uploaded files, documents, notes, and "
"generated artifacts — for passages relevant to a query, using hybrid semantic + "
"keyword retrieval.\n\n"
"Use this FIRST to ground any factual or informational answer about the "
"user's personal files and notes. It returns a <retrieved_context> block: "
"user's workspace content. It returns a <retrieved_context> block: "
"each matched passage is labelled [n]. Cite a passage by writing that [n] "
"after the statement it supports.\n\n"
"This searches only the user's stored files and notes — live data in "
@ -156,7 +156,7 @@ def create_search_knowledge_base_tool(
rendered = build_context(cleaned_query, hits, registry)
_perf_log.info(
"[search_knowledge_base] tool query=%r docs=%d in %.3fs",
"[search_knowledge_base] tool query=%r sources=%d in %.3fs",
cleaned_query[:60],
len(hits),
time.perf_counter() - t0,

View file

@ -913,6 +913,7 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"], # Allows all methods
allow_headers=["*"], # Allows all headers
expose_headers=["Content-Disposition"],
# Cache CORS preflight (OPTIONS) responses for 24h. Browsers clamp:
# Chrome/Edge cap at 7200s, Firefox honours up to 86400s. Setting the
# higher value lets each browser cache for as long as it allows. This

View file

@ -0,0 +1,5 @@
"""Generated artifact persistence."""
from .service import ArtifactFileInput, ArtifactSaved, save_artifact
__all__ = ["ArtifactFileInput", "ArtifactSaved", "save_artifact"]

View file

@ -0,0 +1,23 @@
"""Object-key construction for immutable artifact blobs."""
from __future__ import annotations
import os
import uuid
from app.artifacts.persistence import ArtifactFileRole
def build_artifact_file_key(
*,
workspace_id: int,
artifact_id: int,
role: ArtifactFileRole,
filename: str,
) -> str:
"""Return ``artifacts/{workspace}/{artifact}/{role}/{uuid}{extension}``."""
extension = os.path.splitext(filename)[1].lower()
return (
f"artifacts/{workspace_id}/{artifact_id}/{role.value}/"
f"{uuid.uuid4().hex}{extension}"
)

View file

@ -0,0 +1,6 @@
"""Artifact persistence models and enums."""
from .enums import ArtifactFileRole
from .models import Artifact, ArtifactFile
__all__ = ["Artifact", "ArtifactFile", "ArtifactFileRole"]

View file

@ -0,0 +1,9 @@
"""Artifact persistence enums."""
from enum import StrEnum
class ArtifactFileRole(StrEnum):
PRIMARY = "primary"
PREVIEW = "preview"
SOURCE = "source"

View file

@ -0,0 +1,113 @@
"""Artifact sidecar and immutable artifact-file models."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
Enum as SQLAlchemyEnum,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID
from sqlalchemy.orm import relationship
from app.db import BaseModel, TimestampMixin
from .enums import ArtifactFileRole
class Artifact(BaseModel, TimestampMixin):
__tablename__ = "artifacts"
__table_args__ = (
UniqueConstraint("document_id", name="uq_artifacts_document_id"),
CheckConstraint("generation > 0", name="ck_artifacts_generation_positive"),
)
document_id = Column(
Integer,
ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
)
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
thread_id = Column(
Integer,
ForeignKey("new_chat_threads.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_by_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
format = Column(String, nullable=False)
generation = Column(Integer, nullable=False, default=1, server_default="1")
created_by_tool_call_id = Column(String(255), nullable=True)
updated_by_tool_call_id = Column(String(255), nullable=True)
artifact_metadata = Column("metadata", JSONB, nullable=True)
updated_at = Column(
TIMESTAMP(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
index=True,
)
document = relationship("Document", back_populates="artifact")
workspace = relationship("Workspace")
thread = relationship("NewChatThread")
created_by = relationship("User")
files = relationship(
"ArtifactFile",
back_populates="artifact",
cascade="all, delete-orphan",
passive_deletes=True,
)
class ArtifactFile(BaseModel, TimestampMixin):
"""Immutable metadata for one durable artifact blob."""
__tablename__ = "artifact_files"
__table_args__ = (
UniqueConstraint("artifact_id", "role", name="uq_artifact_files_artifact_role"),
UniqueConstraint("storage_key", name="uq_artifact_files_storage_key"),
CheckConstraint("size_bytes > 0", name="ck_artifact_files_size_positive"),
)
artifact_id = Column(
Integer,
ForeignKey("artifacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role = Column(
SQLAlchemyEnum(
ArtifactFileRole,
name="artifact_file_role",
values_callable=lambda enum: [item.value for item in enum],
),
nullable=False,
)
storage_backend = Column(String(32), nullable=False)
storage_key = Column(String, nullable=False)
original_filename = Column(String, nullable=False)
mime_type = Column(String, nullable=False)
size_bytes = Column(BigInteger, nullable=False)
checksum_sha256 = Column(String(64), nullable=False)
artifact = relationship("Artifact", back_populates="files")

View file

@ -0,0 +1,363 @@
"""Transactional write-through persistence for generated artifacts."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.artifacts.persistence import Artifact, ArtifactFile, ArtifactFileRole
from app.artifacts.storage import store_artifact_file
from app.db import Document, DocumentStatus, DocumentType, Workspace
from app.file_storage.factory import get_storage_backend
from app.indexing_pipeline.connector_document import ConnectorDocument
from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService
from app.knowledge_store import KnowledgeStore
from app.knowledge_store.paths import allocate_path
from app.knowledge_store.settings import knowledge_store_enabled_for
from app.utils.document_converters import (
generate_content_hash,
generate_unique_identifier_hash,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ArtifactFileInput:
data: bytes
filename: str
mime_type: str
role: str = "primary"
@dataclass(frozen=True)
class ArtifactSavedFile:
file_id: int
role: str
filename: str
mime_type: str
size_bytes: int
@dataclass(frozen=True)
class ArtifactSaved:
status: str
artifact_id: int
generation: int
title: str
files: list[ArtifactSavedFile]
def _validated_files(
files: list[ArtifactFileInput],
) -> list[tuple[ArtifactFileInput, ArtifactFileRole]]:
try:
validated = [(file, ArtifactFileRole(file.role)) for file in files]
except ValueError:
raise ValueError(
"artifact file role must be 'primary', 'preview', or 'source'"
) from None
roles = [role for _, role in validated]
if len(roles) != len(set(roles)):
raise ValueError("an artifact may contain at most one file per role")
return validated
def _validate_files(files: list[ArtifactFileInput]) -> None:
"""Compatibility validation seam used by focused unit tests."""
_validated_files(files)
def _artifact_format(files: list[tuple[ArtifactFileInput, ArtifactFileRole]]) -> str:
primary = next(
(file for file, role in files if role is ArtifactFileRole.PRIMARY), None
)
if primary is None:
return "markdown"
suffix = Path(primary.filename).suffix.lower().lstrip(".")
return suffix or primary.mime_type.split("/", 1)[-1]
async def _working_copy_paths(root: Path) -> set[str]:
def collect() -> set[str]:
documents = root / "documents"
if not documents.exists():
return set()
return {
"/" + path.relative_to(root).as_posix()
for path in documents.rglob("*")
if path.is_file()
}
return await asyncio.to_thread(collect)
async def _allocate_artifact_path(
session: AsyncSession,
*,
workspace_id: int,
title: str,
working_copy_root: Path | None,
) -> str:
if working_copy_root is not None:
taken = await _working_copy_paths(working_copy_root)
else:
paths = await session.scalars(
select(Document.path).where(
Document.workspace_id == workspace_id,
Document.path.is_not(None),
)
)
taken = set(paths)
return allocate_path(
name=title,
folder_parts=(),
taken=taken,
).virtual_path
async def _write_working_copy(
root: Path, path: str, markdown: str
) -> tuple[Path, bytes | None]:
target = root / path.removeprefix("/")
def write() -> tuple[Path, bytes | None]:
previous = target.read_bytes() if target.exists() else None
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(markdown, encoding="utf-8")
return target, previous
return await asyncio.to_thread(write)
async def _restore_working_copy(target: Path, previous: bytes | None) -> None:
def restore() -> None:
if previous is None:
target.unlink(missing_ok=True)
else:
target.write_bytes(previous)
await asyncio.to_thread(restore)
async def _delete_blobs_best_effort(blob_refs: list[tuple[str, str]]) -> None:
for storage_backend, storage_key in blob_refs:
try:
await get_storage_backend(storage_backend).delete(storage_key)
except Exception:
logger.warning(
"Failed to delete artifact blob %s",
storage_key,
exc_info=True,
)
async def save_artifact(
session: AsyncSession,
*,
workspace_id: int,
thread_id: int | None,
tool_call_id: str,
title: str,
markdown_representation: str,
files: list[ArtifactFileInput],
artifact_id: int | None = None,
expected_generation: int | None = None,
extra_metadata: dict[str, Any] | None = None,
) -> ArtifactSaved:
"""Create or revise an artifact atomically and return its stable identity."""
title = title.strip()
if not title:
raise ValueError("artifact title must not be empty")
if not markdown_representation.strip():
raise ValueError("artifact content must not be empty")
validated_files = _validated_files(files)
working_copy_root: Path | None = None
if await knowledge_store_enabled_for(workspace_id):
working_copy_root = (
await KnowledgeStore.for_workspace(workspace_id).open_turn_copy(thread_id)
).path
old_files: list[ArtifactFile] = []
now = datetime.now(UTC)
if artifact_id is None:
if expected_generation is not None:
raise ValueError(
"expected_generation is only valid when revising an artifact"
)
path = await _allocate_artifact_path(
session,
workspace_id=workspace_id,
title=title,
working_copy_root=working_copy_root,
)
created_by_id = await session.scalar(
select(Workspace.user_id).where(Workspace.id == workspace_id)
)
if created_by_id is None:
raise ValueError("workspace does not exist")
document = Document(
title=title,
document_type=DocumentType.ARTIFACT,
document_metadata={},
path=path,
content=markdown_representation,
source_markdown=markdown_representation,
content_hash=generate_content_hash(markdown_representation, workspace_id),
unique_identifier_hash=generate_unique_identifier_hash(
DocumentType.NOTE, path, workspace_id
),
workspace_id=workspace_id,
folder_id=None,
created_by_id=created_by_id,
status=DocumentStatus.pending(),
updated_at=now,
)
session.add(document)
await session.flush()
artifact = Artifact(
document_id=document.id,
workspace_id=workspace_id,
thread_id=thread_id,
created_by_id=created_by_id,
format=_artifact_format(validated_files),
generation=1,
created_by_tool_call_id=tool_call_id,
updated_by_tool_call_id=tool_call_id,
artifact_metadata=extra_metadata,
updated_at=now,
)
session.add(artifact)
await session.flush()
else:
artifact = await session.scalar(
select(Artifact)
.options(
selectinload(Artifact.document),
selectinload(Artifact.files),
)
.where(
Artifact.id == artifact_id,
Artifact.workspace_id == workspace_id,
)
.with_for_update()
)
if artifact is None:
raise ValueError("artifact does not exist in this workspace")
if expected_generation is None:
raise ValueError(
"expected_generation is required when revising an artifact"
)
if artifact.generation != expected_generation:
raise ValueError(
"artifact was revised by another operation; load its source again"
)
document = artifact.document
old_files = list(artifact.files)
artifact.format = _artifact_format(validated_files)
artifact.generation += 1
artifact.updated_by_tool_call_id = tool_call_id
artifact.artifact_metadata = {
**(artifact.artifact_metadata or {}),
**(extra_metadata or {}),
}
artifact.updated_at = now
document.title = title
document.content = markdown_representation
document.source_markdown = markdown_representation
document.content_hash = generate_content_hash(
markdown_representation, workspace_id
)
document.document_metadata = {
**(document.document_metadata or {}),
"artifact_id": artifact.id,
}
document.status = DocumentStatus.pending()
document.updated_at = now
if old_files:
await session.execute(
delete(ArtifactFile).where(
ArtifactFile.id.in_([file.id for file in old_files])
)
)
document.document_metadata = {
**(document.document_metadata or {}),
"artifact_id": artifact.id,
}
old_blob_refs = [
(file.storage_backend, file.storage_key) for file in old_files
]
backend = get_storage_backend()
new_records: list[ArtifactFile] = []
new_blob_refs: list[tuple[str, str]] = []
working_copy_state: tuple[Path, bytes | None] | None = None
try:
for file, role in validated_files:
record = await store_artifact_file(
session,
artifact_id=artifact.id,
workspace_id=workspace_id,
role=role,
data=file.data,
filename=file.filename,
mime_type=file.mime_type,
backend=backend,
)
new_records.append(record)
new_blob_refs.append((record.storage_backend, record.storage_key))
await session.flush()
saved_result = ArtifactSaved(
status="saved",
artifact_id=artifact.id,
generation=artifact.generation,
title=document.title,
files=[
ArtifactSavedFile(
file_id=record.id,
role=record.role.value,
filename=record.original_filename,
mime_type=record.mime_type or "application/octet-stream",
size_bytes=record.size_bytes,
)
for record in new_records
if record.role is not ArtifactFileRole.SOURCE
],
)
if working_copy_root is not None:
working_copy_state = await _write_working_copy(
working_copy_root, document.path, markdown_representation
)
else:
connector_document = ConnectorDocument(
title=document.title,
source_markdown=markdown_representation,
unique_id=document.path,
document_type=DocumentType.ARTIFACT,
workspace_id=workspace_id,
metadata=document.document_metadata or {},
created_by_id=str(document.created_by_id),
folder_id=document.folder_id,
)
await IndexingPipelineService(session).index(document, connector_document)
await session.commit()
except Exception:
await session.rollback()
if working_copy_state is not None:
await _restore_working_copy(*working_copy_state)
await _delete_blobs_best_effort(new_blob_refs)
raise
await _delete_blobs_best_effort(old_blob_refs)
return saved_result

View file

@ -0,0 +1,33 @@
"""Deterministic policies for stored artifact-generation source files."""
from __future__ import annotations
from pathlib import PurePosixPath
from types import MappingProxyType
SOURCE_MIME_TYPES = MappingProxyType(
{
".html": "text/html",
".js": "text/javascript",
".py": "text/x-python",
}
)
def validate_source_file(path: str, data: bytes) -> str:
"""Validate executable source as UTF-8 text and return its canonical MIME."""
suffix = PurePosixPath(path).suffix.lower()
try:
mime_type = SOURCE_MIME_TYPES[suffix]
except KeyError:
raise ValueError(
f"Unsupported artifact source type: {suffix or 'file without an extension'}"
) from None
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
raise ValueError("Artifact source must be valid UTF-8 text") from None
if "\x00" in text:
raise ValueError("Artifact source must not contain NUL bytes")
return mime_type

View file

@ -0,0 +1,55 @@
"""Store and retrieve artifact blobs through the shared file backend."""
from __future__ import annotations
import hashlib
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
from app.artifacts.keys import build_artifact_file_key
from app.artifacts.persistence import ArtifactFile, ArtifactFileRole
from app.file_storage.backends.base import StorageBackend
from app.file_storage.factory import get_storage_backend
async def store_artifact_file(
session: AsyncSession,
*,
artifact_id: int,
workspace_id: int,
role: ArtifactFileRole,
data: bytes,
filename: str,
mime_type: str,
backend: StorageBackend | None = None,
) -> ArtifactFile:
"""Write immutable bytes and add their metadata row to ``session``."""
backend = backend or get_storage_backend()
storage_key = build_artifact_file_key(
workspace_id=workspace_id,
artifact_id=artifact_id,
role=role,
filename=filename,
)
await backend.put(storage_key, data, content_type=mime_type)
record = ArtifactFile(
artifact_id=artifact_id,
role=role,
storage_backend=backend.backend_name,
storage_key=storage_key,
original_filename=filename,
mime_type=mime_type,
size_bytes=len(data),
checksum_sha256=hashlib.sha256(data).hexdigest(),
)
session.add(record)
return record
def open_artifact_file_stream(
record: ArtifactFile, *, backend: StorageBackend | None = None
) -> AsyncIterator[bytes]:
backend = backend or get_storage_backend(record.storage_backend)
return backend.open_stream(record.storage_key)

View file

@ -0,0 +1 @@
"""Backend-owned artifact verification."""

View file

@ -0,0 +1 @@
"""Format-specific structural checks."""

View file

@ -0,0 +1,34 @@
"""Shared format-adapter contract."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
DEFAULT_RENDERED_MIN_CHARS = 20
ReviewKind = Literal["document", "slides"]
@dataclass(frozen=True, slots=True)
class StructuralCheckResult:
findings: tuple[str, ...]
page_count: int | None = None
notes: tuple[str, ...] = ()
@property
def clean(self) -> bool:
return not self.findings
@dataclass(frozen=True, slots=True)
class FormatAdapter:
name: str
suffix: str
mime_type: str
convert_to_pdf: bool
check: Callable[[bytes], StructuralCheckResult]
rendered_min_chars: int = DEFAULT_RENDERED_MIN_CHARS
expects_exact_page_count: bool = False
review_kind: ReviewKind = "document"

View file

@ -0,0 +1,116 @@
"""Structural checks over DOCX OOXML bytes."""
from __future__ import annotations
from xml.etree import ElementTree
from .base import StructuralCheckResult
from .ooxml import OoxmlDefect, open_ooxml
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
W = f"{{{W_NS}}}"
REQUIRED_PARTS = frozenset({"[Content_Types].xml", "_rels/.rels", "word/document.xml"})
MIN_MARGIN_TWIPS = 720
MAX_MARGIN_TWIPS = 2880
MAX_DOCUMENT_XML_BYTES = 10 * 1024 * 1024
def _attribute(element: ElementTree.Element, name: str) -> str | None:
return element.get(f"{W}{name}")
def _toc_present(root: ElementTree.Element) -> bool:
instructions = [element.text or "" for element in root.iter(f"{W}instrText")]
instructions.extend(
_attribute(element, "instr") or "" for element in root.iter(f"{W}fldSimple")
)
return any(
instruction.strip().upper().startswith("TOC") for instruction in instructions
)
def check_docx(data: bytes) -> StructuralCheckResult:
findings: list[str] = []
notes: list[str] = []
if not data:
return StructuralCheckResult(("DOCX is empty",))
try:
with open_ooxml(
data,
format_name="DOCX",
required_parts=REQUIRED_PARTS,
part_limits={"word/document.xml": MAX_DOCUMENT_XML_BYTES},
) as archive:
root = ElementTree.fromstring(archive.read("word/document.xml"))
except OoxmlDefect as exc:
return StructuralCheckResult((str(exc),))
except ElementTree.ParseError:
return StructuralCheckResult(("DOCX is not valid OOXML",))
body = root.find(f"{W}body")
text = "".join(element.text or "" for element in root.iter(f"{W}t")).strip()
if body is None or not text:
findings.append("DOCX body contains no text")
sections = list(root.iter(f"{W}sectPr"))
if not sections:
findings.append("DOCX has no section page setup")
for index, section in enumerate(sections, start=1):
margins = section.find(f"{W}pgMar")
if margins is None:
findings.append(f"section {index} has no page margins")
continue
for side in ("top", "right", "bottom", "left"):
raw = _attribute(margins, side)
try:
value = int(raw) if raw is not None else 0
except ValueError:
value = 0
if not MIN_MARGIN_TWIPS <= value <= MAX_MARGIN_TWIPS:
findings.append(
f"section {index} has an unsafe {side} margin ({raw or 'missing'} twips)"
)
for width in root.iter(f"{W}tblW"):
if _attribute(width, "type") == "pct":
findings.append("table uses percentage width; use DXA widths")
for cell in root.iter(f"{W}tc"):
width = cell.find(f"{W}tcPr/{W}tcW")
if width is None:
findings.append("table cell is missing a positive DXA width")
continue
width_type = _attribute(width, "type")
raw_width = _attribute(width, "w")
try:
numeric_width = int(raw_width) if raw_width is not None else 0
except ValueError:
numeric_width = 0
if width_type != "dxa" or numeric_width <= 0:
findings.append("table cell is missing a positive DXA width")
for table in root.iter(f"{W}tbl"):
grid = table.find(f"{W}tblGrid")
if grid is None or not list(grid.findall(f"{W}gridCol")):
findings.append("table is missing w:tblGrid column widths")
if any(_attribute(shading, "val") == "solid" for shading in root.iter(f"{W}shd")):
findings.append("shading uses solid; use clear shading")
for paragraph in root.iter(f"{W}p"):
paragraph_text = "".join(
element.text or "" for element in paragraph.iter(f"{W}t")
)
if (
paragraph_text.lstrip().startswith("")
and paragraph.find(f"{W}pPr/{W}numPr") is None
):
findings.append("paragraph uses a literal bullet instead of numbering")
if _toc_present(root):
notes.append(
"The document contains a TOC field; page numbers populate when Word "
"opens and updates fields."
)
return StructuralCheckResult(tuple(findings), notes=tuple(notes))

View file

@ -0,0 +1,62 @@
"""Bounded access to untrusted OOXML ZIP packages."""
from __future__ import annotations
from collections.abc import Iterator, Mapping, Set
from contextlib import contextmanager
from io import BytesIO
from zipfile import BadZipFile, ZipFile
MAX_ZIP_ENTRIES = 10_000
MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024
class OoxmlDefect(ValueError):
"""A stable, user-actionable OOXML structural finding."""
@contextmanager
def open_ooxml(
data: bytes,
*,
format_name: str,
required_parts: Set[str],
part_limits: Mapping[str, int] | None = None,
) -> Iterator[ZipFile]:
"""Validate package-level limits before yielding an OOXML archive."""
try:
with ZipFile(BytesIO(data)) as archive:
entries = archive.infolist()
entry_names = [entry.filename for entry in entries]
names = set(entry_names)
if len(entries) > MAX_ZIP_ENTRIES:
raise OoxmlDefect(
f"{format_name} contains more than {MAX_ZIP_ENTRIES} ZIP entries"
)
if len(names) != len(entry_names):
raise OoxmlDefect(
f"{format_name} contains duplicate OOXML parts"
)
missing = sorted(required_parts - names)
if missing:
raise OoxmlDefect(
f"{format_name} is missing required parts: {', '.join(missing)}"
)
if any(entry.flag_bits & 1 for entry in entries):
raise OoxmlDefect(f"{format_name} contains encrypted ZIP entries")
if sum(entry.file_size for entry in entries) > MAX_UNCOMPRESSED_BYTES:
raise OoxmlDefect(
f"{format_name} uncompressed content exceeds "
f"{MAX_UNCOMPRESSED_BYTES} bytes"
)
for part, limit in (part_limits or {}).items():
if archive.getinfo(part).file_size > limit:
label = part.rsplit("/", 1)[-1].replace(".xml", " XML")
raise OoxmlDefect(
f"{format_name} {label} exceeds {limit} bytes"
)
yield archive
except OoxmlDefect:
raise
except (BadZipFile, KeyError, OSError, RuntimeError):
raise OoxmlDefect(f"{format_name} is not valid OOXML") from None

View file

@ -0,0 +1,96 @@
"""Measure PDF defects that do not require visual inspection."""
from __future__ import annotations
from io import BytesIO
from typing import Any
from pypdf import PdfReader
from .base import StructuralCheckResult
DEFAULT_MIN_CHARS = 20
def _object(value: Any) -> Any:
return value.get_object() if hasattr(value, "get_object") else value
def _font_is_embedded(font: Any) -> bool:
font = _object(font)
descendants = _object(font.get("/DescendantFonts", []))
candidates = [_object(item) for item in descendants] or [font]
for candidate in candidates:
descriptor = _object(candidate.get("/FontDescriptor"))
if descriptor and any(
descriptor.get(key) is not None
for key in ("/FontFile", "/FontFile2", "/FontFile3")
):
return True
return False
def _unembedded_fonts(page: Any) -> list[str]:
"""Return unembedded fonts that actually draw text."""
unembedded: dict[str, None] = {}
def visitor(
text: str,
_current_matrix: list[float],
_text_matrix: list[float],
font: Any,
_font_size: float,
) -> None:
if not text.strip():
return
if font is not None and not _font_is_embedded(font):
name = _object(font).get("/BaseFont", "unnamed font")
unembedded.setdefault(str(name), None)
page.extract_text(visitor_text=visitor)
return list(unembedded)
def check_pdf(
data: bytes,
*,
expected_pages: int | None = None,
min_chars: int = DEFAULT_MIN_CHARS,
) -> StructuralCheckResult:
"""Check PDF bytes and return all structural findings."""
if not data:
return StructuralCheckResult(page_count=0, findings=("PDF is empty",))
try:
reader = PdfReader(BytesIO(data))
page_count = len(reader.pages)
except Exception as exc:
return StructuralCheckResult(
page_count=0,
findings=(f"PDF could not be parsed: {exc}",),
)
if page_count == 0:
return StructuralCheckResult(page_count=0, findings=("PDF has no pages",))
findings: list[str] = []
if expected_pages is not None and page_count != expected_pages:
findings.append(f"expected {expected_pages} page(s), found {page_count}")
for number, page in enumerate(reader.pages, start=1):
try:
text = "".join((page.extract_text() or "").split())
unembedded = _unembedded_fonts(page)
except Exception as exc:
findings.append(f"page {number} could not be inspected: {exc}")
continue
if len(text) < min_chars:
findings.append(
f"page {number} is blank or near-blank "
f"({len(text)} non-whitespace characters)"
)
if unembedded:
findings.append(
f"page {number} draws text in unembedded fonts: {', '.join(unembedded)}"
)
return StructuralCheckResult(page_count=page_count, findings=tuple(findings))

View file

@ -0,0 +1,259 @@
"""Structural checks over PPTX OOXML bytes."""
from __future__ import annotations
import posixpath
from xml.etree import ElementTree
from zipfile import ZipFile
from .base import StructuralCheckResult
from .ooxml import OoxmlDefect, open_ooxml
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
P = f"{{{P_NS}}}"
A = f"{{{A_NS}}}"
R = f"{{{R_NS}}}"
REL = f"{{{REL_NS}}}"
PRESENTATION_PART = "ppt/presentation.xml"
PRESENTATION_RELS_PART = "ppt/_rels/presentation.xml.rels"
REQUIRED_PARTS = frozenset(
{
"[Content_Types].xml",
"_rels/.rels",
PRESENTATION_PART,
PRESENTATION_RELS_PART,
}
)
MAX_XML_PART_BYTES = 10 * 1024 * 1024
DRAWABLE_SHAPES = frozenset(
{"sp", "pic", "graphicFrame", "cxnSp", "grpSp", "contentPart"}
)
def _part_target(source_part: str, target: str) -> str:
return posixpath.normpath(posixpath.join(posixpath.dirname(source_part), target))
def _relationships(
archive: ZipFile, rels_part: str, *, source_part: str
) -> dict[str, tuple[str, str, bool]]:
if rels_part not in archive.namelist():
return {}
if archive.getinfo(rels_part).file_size > MAX_XML_PART_BYTES:
raise OoxmlDefect(f"PPTX {rels_part} exceeds {MAX_XML_PART_BYTES} bytes")
root = ElementTree.fromstring(archive.read(rels_part))
relationships: dict[str, tuple[str, str, bool]] = {}
for relationship in root.findall(f"{REL}Relationship"):
rel_id = relationship.get("Id")
target = relationship.get("Target")
if not rel_id or not target:
continue
relationships[rel_id] = (
_part_target(source_part, target),
relationship.get("Type", ""),
relationship.get("TargetMode") == "External",
)
return relationships
def _integer(element: ElementTree.Element, attribute: str) -> int | None:
raw = element.get(attribute)
try:
return int(raw) if raw is not None else None
except ValueError:
return None
def _check_shape_geometry(
shape: ElementTree.Element,
*,
shape_kind: str,
slide_number: int,
slide_width: int,
slide_height: int,
findings: list[str],
) -> None:
xfrm = shape.find(f".//{A}xfrm")
if xfrm is None:
xfrm = shape.find(f".//{P}xfrm")
if xfrm is None:
return
offset = xfrm.find(f"{A}off")
extent = xfrm.find(f"{A}ext")
if offset is None or extent is None:
return
x = _integer(offset, "x")
y = _integer(offset, "y")
width = _integer(extent, "cx")
height = _integer(extent, "cy")
if None in {x, y, width, height}:
findings.append(f"slide {slide_number} has invalid shape geometry")
return
assert x is not None and y is not None and width is not None and height is not None
invalid_extent = (
width < 0
or height < 0
or (width == 0 and height == 0)
or (shape_kind != "cxnSp" and (width == 0 or height == 0))
)
if invalid_extent:
findings.append(f"slide {slide_number} has a shape with non-positive extent")
return
# ponytail: only wholly off-canvas shapes block; partial bleed is intentional
# in many decks, and shapes touching the canvas boundary remain visible.
if x > slide_width or y > slide_height or x + width < 0 or y + height < 0:
findings.append(f"slide {slide_number} has a shape entirely off the canvas")
def _check_slide(
archive: ZipFile,
slide_part: str,
*,
slide_number: int,
slide_width: int,
slide_height: int,
findings: list[str],
) -> None:
if archive.getinfo(slide_part).file_size > MAX_XML_PART_BYTES:
raise OoxmlDefect(f"PPTX {slide_part} exceeds {MAX_XML_PART_BYTES} bytes")
slide = ElementTree.fromstring(archive.read(slide_part))
if slide.get("show") == "0":
findings.append(
f"slide {slide_number} is hidden; delete it so every delivered slide "
"is rendered and verified"
)
shape_tree = slide.find(f"{P}cSld/{P}spTree")
shapes = (
[
child
for child in shape_tree
if child.tag.rsplit("}", 1)[-1] in DRAWABLE_SHAPES
]
if shape_tree is not None
else []
)
if not shapes:
findings.append(f"slide {slide_number} contains no drawable shapes")
for shape in shapes:
_check_shape_geometry(
shape,
shape_kind=shape.tag.rsplit("}", 1)[-1],
slide_number=slide_number,
slide_width=slide_width,
slide_height=slide_height,
findings=findings,
)
for crop in slide.iter(f"{A}srcRect"):
values = {
side: _integer(crop, side) if crop.get(side) is not None else 0
for side in ("l", "t", "r", "b")
}
if any(value is None for value in values.values()):
findings.append(f"slide {slide_number} has an invalid picture crop")
elif (
values["l"] + values["r"] >= 100_000 or values["t"] + values["b"] >= 100_000
):
findings.append(
f"slide {slide_number} has a picture crop that removes the entire image"
)
rels_part = (
f"{posixpath.dirname(slide_part)}/_rels/{posixpath.basename(slide_part)}.rels"
)
relationships = _relationships(archive, rels_part, source_part=slide_part)
for blip in slide.iter(f"{A}blip"):
rel_id = blip.get(f"{R}embed")
if rel_id is None:
continue
relationship = relationships.get(rel_id)
if (
relationship is None
or relationship[2]
or not relationship[1].endswith("/image")
or relationship[0] not in archive.namelist()
):
findings.append(f"slide {slide_number} references missing embedded media")
def check_pptx(data: bytes) -> StructuralCheckResult:
"""Check a presentation package without attempting rendered text layout."""
if not data:
return StructuralCheckResult(("PPTX is empty",), page_count=0)
findings: list[str] = []
page_count = 0
try:
with open_ooxml(
data,
format_name="PPTX",
required_parts=REQUIRED_PARTS,
part_limits={
PRESENTATION_PART: MAX_XML_PART_BYTES,
PRESENTATION_RELS_PART: MAX_XML_PART_BYTES,
},
) as archive:
presentation = ElementTree.fromstring(archive.read(PRESENTATION_PART))
slide_size = presentation.find(f"{P}sldSz")
slide_width = _integer(slide_size, "cx") if slide_size is not None else None
slide_height = (
_integer(slide_size, "cy") if slide_size is not None else None
)
if (
slide_width is None
or slide_height is None
or slide_width <= 0
or slide_height <= 0
):
return StructuralCheckResult(
("PPTX has no positive presentation-wide slide size",),
page_count=0,
)
slide_list = presentation.find(f"{P}sldIdLst")
slide_ids = (
list(slide_list.findall(f"{P}sldId")) if slide_list is not None else []
)
page_count = len(slide_ids)
if not slide_ids:
return StructuralCheckResult(("PPTX contains no slides",), page_count=0)
relationships = _relationships(
archive,
PRESENTATION_RELS_PART,
source_part=PRESENTATION_PART,
)
for slide_number, slide_id in enumerate(slide_ids, start=1):
rel_id = slide_id.get(f"{R}id")
relationship = relationships.get(rel_id or "")
if (
relationship is None
or relationship[2]
or not relationship[1].endswith("/slide")
or relationship[0] not in archive.namelist()
):
findings.append(
f"slide {slide_number} points to a missing slide part"
)
continue
_check_slide(
archive,
relationship[0],
slide_number=slide_number,
slide_width=slide_width,
slide_height=slide_height,
findings=findings,
)
except OoxmlDefect as exc:
return StructuralCheckResult((str(exc),), page_count=page_count)
except ElementTree.ParseError:
return StructuralCheckResult(
("PPTX is not valid OOXML",), page_count=page_count
)
return StructuralCheckResult(tuple(findings), page_count=page_count)

View file

@ -0,0 +1,53 @@
"""Installed artifact-verification format adapters."""
from __future__ import annotations
from pathlib import PurePosixPath
from .base import FormatAdapter
from .docx import check_docx
from .pdf import check_pdf
from .pptx import check_pptx
PDF_MIME = "application/pdf"
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
PPTX_MIME = (
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
)
_ADAPTERS = {
".pdf": FormatAdapter(
name="pdf",
suffix=".pdf",
mime_type=PDF_MIME,
convert_to_pdf=False,
check=check_pdf,
),
".docx": FormatAdapter(
name="docx",
suffix=".docx",
mime_type=DOCX_MIME,
convert_to_pdf=True,
check=check_docx,
),
".pptx": FormatAdapter(
name="pptx",
suffix=".pptx",
mime_type=PPTX_MIME,
convert_to_pdf=True,
check=check_pptx,
rendered_min_chars=0,
expects_exact_page_count=True,
review_kind="slides",
),
}
def get_format_adapter(path: str) -> FormatAdapter:
suffix = PurePosixPath(path).suffix.lower()
try:
return _ADAPTERS[suffix]
except KeyError:
raise ValueError(
f"Artifact verification does not support {suffix or 'this file'}"
) from None

View file

@ -0,0 +1,104 @@
"""Content-bound, signed verification receipts."""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from typing import Literal
from pydantic import BaseModel, ConfigDict, ValidationError
from app.sandbox import SandboxSession
RECEIPT_PATH = "/tmp/.surfsense-artifact-verification.json"
RECEIPT_MAX_AGE_SECONDS = 15 * 60
class VerificationReceipt(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
workspace_id: int
session_id: str
format: str
primary_path: str
primary_sha256: str
preview_path: str | None = None
preview_sha256: str | None = None
page_count: int | None = None
visual: Literal["clean", "unavailable", "not_required"]
unavailable_reason: str | None = None
issued_at: int
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _payload_bytes(receipt: VerificationReceipt) -> bytes:
return json.dumps(
receipt.model_dump(mode="json"),
sort_keys=True,
separators=(",", ":"),
).encode()
def _signature(receipt: VerificationReceipt, secret_key: str) -> str:
if not secret_key:
raise ValueError("SECRET_KEY is required for artifact verification")
return hmac.new(
secret_key.encode(), _payload_bytes(receipt), hashlib.sha256
).hexdigest()
async def write_receipt(
session: SandboxSession,
receipt: VerificationReceipt,
secret_key: str,
) -> None:
envelope = {
"payload": receipt.model_dump(mode="json"),
"signature": _signature(receipt, secret_key),
}
await session.write_file(
RECEIPT_PATH,
json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode(),
)
async def read_receipt(
session: SandboxSession,
secret_key: str,
*,
workspace_id: int,
now: int | None = None,
) -> VerificationReceipt:
try:
data = await session.read_file(RECEIPT_PATH)
except (FileNotFoundError, KeyError):
raise ValueError("Artifact has not been verified") from None
if not data:
raise ValueError("Artifact has not been verified")
try:
envelope = json.loads(data.decode())
if not isinstance(envelope, dict) or set(envelope) != {"payload", "signature"}:
raise ValueError
receipt = VerificationReceipt.model_validate(envelope["payload"])
signature = envelope["signature"]
except (KeyError, TypeError, UnicodeDecodeError, ValidationError, ValueError):
raise ValueError("Artifact verification receipt is unreadable") from None
if not isinstance(signature, str) or not hmac.compare_digest(
signature, _signature(receipt, secret_key)
):
raise ValueError("Artifact verification receipt has an invalid signature")
if receipt.workspace_id != workspace_id or receipt.session_id != session.session_id:
raise ValueError(
"Artifact verification receipt belongs to another workspace or sandbox"
)
age = (int(time.time()) if now is None else now) - receipt.issued_at
if age < 0 or age > RECEIPT_MAX_AGE_SECONDS:
raise ValueError("Artifact verification receipt has expired")
return receipt

View file

@ -0,0 +1,106 @@
"""Render artifact pages inside an existing sandbox session."""
from __future__ import annotations
import shlex
import uuid
from dataclasses import dataclass
from pathlib import PurePosixPath
from app.sandbox import SandboxSession
@dataclass(frozen=True, slots=True)
class PreparedPdf:
build_dir: str
source_path: str
pdf_path: str
async def _run(session: SandboxSession, command: str, *, step: str) -> str:
result = await session.run_command(command)
if not result.ok:
detail = result.output.strip() or f"exit code {result.exit_code}"
raise RuntimeError(f"{step} failed: {detail}")
return result.output.strip()
async def prepare_pdf(
session: SandboxSession,
primary_path: str,
primary_data: bytes,
*,
convert_to_pdf: bool,
) -> PreparedPdf:
"""Create a fresh build directory and convert the primary when needed."""
build_id = uuid.uuid4().hex
build_dir = f"/tmp/surfsense-verify-{build_id}"
quoted_build = shlex.quote(build_dir)
await _run(
session,
f"mkdir -p -- {quoted_build}",
step="creating verification build directory",
)
suffix = PurePosixPath(primary_path).suffix.lower()
source_path = f"{build_dir}/primary{suffix}"
await session.write_file(source_path, primary_data)
if convert_to_pdf:
pdf_path = f"{build_dir}/primary.pdf"
profile = f"/tmp/surfsense-soffice-{build_id}"
await _run(
session,
" ".join(
(
"soffice",
"--headless",
f"-env:UserInstallation={shlex.quote(f'file://{profile}')}",
"--convert-to pdf",
f"--outdir {quoted_build}",
shlex.quote(source_path),
)
),
step="converting artifact to PDF",
)
await _run(
session,
f"test -s {shlex.quote(pdf_path)}",
step="checking converted PDF",
)
else:
pdf_path = source_path
await _run(
session,
f"test -s {shlex.quote(pdf_path)}",
step="checking PDF",
)
return PreparedPdf(
build_dir=build_dir,
source_path=source_path,
pdf_path=pdf_path,
)
async def rasterize_pdf(
session: SandboxSession, prepared: PreparedPdf
) -> tuple[str, ...]:
"""Rasterize every page after the service has enforced its page ceiling."""
page_prefix = f"{prepared.build_dir}/page"
await _run(
session,
f"pdftoppm -jpeg -r 100 {shlex.quote(prepared.pdf_path)} "
f"{shlex.quote(page_prefix)}",
step="rendering PDF pages",
)
quoted_build = shlex.quote(prepared.build_dir)
pages = await _run(
session,
f"set -- {quoted_build}/page-*.jpg; "
'[ -f "$1" ] || exit 1; printf \'%s\\n\' "$@" | sort -V',
step="finding rendered pages",
)
page_paths = tuple(line for line in pages.splitlines() if line)
if not page_paths:
raise RuntimeError("rendering PDF pages produced no images")
return page_paths

View file

@ -0,0 +1,263 @@
"""Orchestrate structural, rendered, and visual artifact verification."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any
from langchain_core.callbacks import dispatch_custom_event
from app.config import config as app_config
from app.sandbox import SandboxSession
from .formats.pdf import check_pdf
from .formats.registry import get_format_adapter
from .receipt import (
RECEIPT_PATH,
VerificationReceipt,
sha256_bytes,
write_receipt,
)
from .render import prepare_pdf, rasterize_pdf
from .vision import review_pages
logger = logging.getLogger(__name__)
ARTIFACT_MAX_VERIFY_PAGES = 40
@dataclass(frozen=True, slots=True)
class VerificationResult:
verified: bool
findings: tuple[str, ...]
notes: tuple[str, ...] = ()
preview_path: str | None = None
page_count: int | None = None
unavailable_reason: str | None = None
def _progress(phase: str, message: str, **details: int) -> None:
try:
dispatch_custom_event(
"verification_progress",
{"phase": phase, "message": message, **details},
)
except Exception:
# Unit tests and non-graph callers have no callback context.
logger.debug("verification progress dispatch skipped", exc_info=True)
async def verify_artifact(
session: SandboxSession,
primary_path: str,
*,
workspace_id: int,
vision_llm: Any | None,
secret_key: str | None = None,
) -> VerificationResult:
"""Verify one artifact and issue a signed receipt only when it may be saved."""
signing_key = secret_key if secret_key is not None else app_config.SECRET_KEY
if not signing_key:
raise ValueError("SECRET_KEY is required for artifact verification")
# Invalidate any earlier pass before starting this attempt. A failed rerun
# must not leave a still-usable receipt for the same bytes.
await session.write_file(RECEIPT_PATH, b"")
try:
return await _verify_artifact(
session,
primary_path,
workspace_id=workspace_id,
vision_llm=vision_llm,
signing_key=signing_key,
)
except Exception as exc:
logger.warning("Artifact verification failed: %s", exc, exc_info=True)
return VerificationResult(
verified=False,
findings=(f"Artifact verification failed: {exc}",),
)
async def _verify_artifact(
session: SandboxSession,
primary_path: str,
*,
workspace_id: int,
vision_llm: Any | None,
signing_key: str,
) -> VerificationResult:
adapter = get_format_adapter(primary_path)
_progress("checking", "Checking document structure")
primary_data = await session.read_file(primary_path)
if len(primary_data) > app_config.ARTIFACT_MAX_FILE_BYTES:
return VerificationResult(
verified=False,
findings=(
f"Artifact is {len(primary_data)} bytes; limit is "
f"{app_config.ARTIFACT_MAX_FILE_BYTES} bytes",
),
)
structural = adapter.check(primary_data)
if not structural.clean:
return VerificationResult(
verified=False,
findings=structural.findings,
notes=structural.notes,
page_count=structural.page_count,
)
if (
structural.page_count is not None
and structural.page_count > ARTIFACT_MAX_VERIFY_PAGES
):
return VerificationResult(
verified=False,
findings=(
f"Document has {structural.page_count} pages; verification supports "
f"at most {ARTIFACT_MAX_VERIFY_PAGES}",
),
notes=structural.notes,
page_count=structural.page_count,
)
_progress(
"converting" if adapter.convert_to_pdf else "preparing",
"Converting document to PDF"
if adapter.convert_to_pdf
else "Preparing PDF for review",
)
prepared = await prepare_pdf(
session,
primary_path,
primary_data,
convert_to_pdf=adapter.convert_to_pdf,
)
preview_data = (
await session.read_file(prepared.pdf_path)
if adapter.convert_to_pdf
else primary_data
)
rendered_pdf = (
check_pdf(
preview_data,
expected_pages=structural.page_count
if adapter.expects_exact_page_count
else None,
min_chars=adapter.rendered_min_chars,
)
if adapter.convert_to_pdf
else structural
)
if not rendered_pdf.clean:
return VerificationResult(
verified=False,
findings=rendered_pdf.findings,
notes=structural.notes,
preview_path=prepared.pdf_path if adapter.convert_to_pdf else None,
page_count=rendered_pdf.page_count,
)
page_count = rendered_pdf.page_count
if page_count is None or page_count > ARTIFACT_MAX_VERIFY_PAGES:
return VerificationResult(
verified=False,
findings=(
f"Document has {page_count or 0} pages; verification supports at most "
f"{ARTIFACT_MAX_VERIFY_PAGES}",
),
notes=structural.notes,
preview_path=prepared.pdf_path if adapter.convert_to_pdf else None,
page_count=page_count,
)
_progress("rendering", f"Rendering {page_count} page(s)", total=page_count)
page_paths = await rasterize_pdf(session, prepared)
if len(page_paths) != page_count:
return VerificationResult(
verified=False,
findings=(
f"Rendered {len(page_paths)} page image(s) for a {page_count}-page document",
),
notes=structural.notes,
preview_path=prepared.pdf_path if adapter.convert_to_pdf else None,
page_count=page_count,
)
if await session.read_file(prepared.source_path) != primary_data:
return VerificationResult(
verified=False,
findings=("The verification source changed while it was being rendered",),
notes=structural.notes,
preview_path=prepared.pdf_path if adapter.convert_to_pdf else None,
page_count=page_count,
)
if await session.read_file(prepared.pdf_path) != preview_data:
return VerificationResult(
verified=False,
findings=("The rendered preview changed during verification",),
notes=structural.notes,
preview_path=prepared.pdf_path if adapter.convert_to_pdf else None,
page_count=page_count,
)
page_images = []
for path in page_paths:
page_images.append((path, await session.read_file(path)))
notes = list(structural.notes)
unavailable_reason = None
if vision_llm is None:
unavailable_reason = "No vision-capable model is configured for this workspace"
else:
_progress("reviewing", f"Reviewing {page_count} page(s)", total=page_count)
visual = await review_pages(
vision_llm,
tuple(page_images),
review_kind=adapter.review_kind,
progress=lambda current, total: _progress(
"reviewing",
f"Inspecting page {current} of {total}",
current=current,
total=total,
),
)
notes.extend(visual.warnings)
if visual.unavailable_reason:
unavailable_reason = visual.unavailable_reason
elif not visual.clean:
return VerificationResult(
verified=False,
findings=visual.findings,
notes=tuple(notes),
preview_path=prepared.pdf_path if adapter.convert_to_pdf else None,
page_count=page_count,
)
preview_path = prepared.pdf_path if adapter.convert_to_pdf else None
receipt = VerificationReceipt(
workspace_id=workspace_id,
session_id=session.session_id,
format=adapter.name,
primary_path=primary_path,
primary_sha256=sha256_bytes(primary_data),
preview_path=preview_path,
preview_sha256=sha256_bytes(preview_data) if preview_path else None,
page_count=page_count,
visual="unavailable" if unavailable_reason else "clean",
unavailable_reason=unavailable_reason,
issued_at=int(time.time()),
)
await write_receipt(
session,
receipt,
signing_key,
)
_progress("complete", "Document verification complete")
return VerificationResult(
verified=True,
findings=(),
notes=tuple(notes),
preview_path=preview_path,
page_count=page_count,
unavailable_reason=unavailable_reason,
)

View file

@ -0,0 +1,177 @@
"""Visual artifact review over sandbox-rendered JPEG pages."""
from __future__ import annotations
import asyncio
import base64
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Any
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from app.artifacts.verification.formats.base import ReviewKind
from app.services.billable_calls import QuotaInsufficientError
from app.utils.structured_output import invoke_json
MAX_IMAGE_BYTES = 5 * 1024 * 1024
MAX_IMAGES_PER_CALL = 20
VISION_CONCURRENCY = 4
VISION_TIMEOUT_SECONDS = 120
REVIEW_FRAMINGS: dict[ReviewKind, str] = {
"document": (
"Review these consecutive pages together as one flowing document. "
"Text continuing naturally across a page boundary and unused space at the "
"end of the final page are not defects. Check for clipping, overlap, "
"unreadable text, blank or corrupt pages, missing content, and cross-page "
"layout inconsistency."
),
"slides": (
"Review these consecutive presentation slides together. Each slide is "
"self-contained: text or a list unintentionally continuing onto another "
"slide is a defect, while unused space is not. Check for clipping, overlap, "
"unreadable text, blank or corrupt slides, missing content, and consistency "
"of template, type scale, and palette across slides."
),
}
VERDICT_INSTRUCTIONS = (
"Return only JSON with `blocking_findings` and `warnings`, both arrays of "
"concise, actionable strings. A blocking finding must make the artifact "
"unusable or incomplete: clipped/overlapping or unreadable content, a "
"blank/corrupt page or slide, or missing content. Put minor contrast, "
"whitespace, alignment, and aesthetic suggestions in warnings; do not block "
"the artifact for them."
)
class VisionVerdict(BaseModel):
blocking_findings: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
@dataclass(frozen=True, slots=True)
class VisualReviewResult:
clean: bool
findings: tuple[str, ...]
warnings: tuple[str, ...] = ()
unavailable_reason: str | None = None
PageImage = tuple[str, bytes]
def _windows(images: tuple[PageImage, ...]) -> list[tuple[PageImage, ...]]:
groups: list[tuple[PageImage, ...]] = []
start = 0
while start < len(images):
groups.append(images[start : start + MAX_IMAGES_PER_CALL])
if start + MAX_IMAGES_PER_CALL >= len(images):
break
# Retain one boundary page so adjacent pages are always compared.
start += MAX_IMAGES_PER_CALL - 1
return groups
async def review_pages(
llm: Any,
page_images: tuple[PageImage, ...],
*,
review_kind: ReviewKind = "document",
progress: Callable[[int, int], None] | None = None,
) -> VisualReviewResult:
"""Review consecutive rendered windows using format-appropriate framing."""
if not page_images:
raise ValueError("Artifact verification produced no rendered pages")
semaphore = asyncio.Semaphore(VISION_CONCURRENCY)
progress_lock = asyncio.Lock()
reviewed_pages = 0
async def invoke(images: tuple[PageImage, ...]) -> VisionVerdict:
labels = ", ".join(PurePosixPath(path).name for path, _ in images)
content: list[dict[str, Any]] = [
{
"type": "text",
"text": (
f"{REVIEW_FRAMINGS[review_kind]}\n"
f"Files: {labels}\n"
f"{VERDICT_INSTRUCTIONS}"
),
}
]
for path, data in images:
if len(data) > MAX_IMAGE_BYTES:
raise ValueError(
f"Image {path} exceeds the {MAX_IMAGE_BYTES}-byte limit"
)
content.extend(
(
{"type": "text", "text": f"Filename: {path}"},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,"
+ base64.b64encode(data).decode("ascii")
},
},
)
)
async with semaphore:
return await asyncio.wait_for(
invoke_json(llm, [HumanMessage(content=content)], VisionVerdict),
timeout=VISION_TIMEOUT_SECONDS,
)
async def inspect_window(images: tuple[PageImage, ...]) -> VisionVerdict:
nonlocal reviewed_pages
try:
return await invoke(images)
finally:
if progress is not None:
async with progress_lock:
reviewed_pages += len(images) - (1 if reviewed_pages else 0)
reviewed_pages = min(reviewed_pages, len(page_images))
progress(reviewed_pages, len(page_images))
calls = [inspect_window(group) for group in _windows(page_images)]
results = await asyncio.gather(*calls, return_exceptions=True)
quota_failure = next(
(result for result in results if isinstance(result, QuotaInsufficientError)),
None,
)
findings: list[str] = []
warnings: list[str] = []
for result in results:
if isinstance(result, QuotaInsufficientError):
continue
if isinstance(result, BaseException):
findings.append(f"Visual inspection failed: {result}")
continue
findings.extend(result.blocking_findings)
warnings.extend(result.warnings)
if findings:
return VisualReviewResult(
clean=False,
findings=tuple(findings),
warnings=tuple(warnings),
)
if quota_failure is not None:
return VisualReviewResult(
clean=False,
findings=(),
warnings=tuple(warnings),
unavailable_reason=(
"Visual verification stopped because credit is insufficient: "
f"{quota_failure}"
),
)
return VisualReviewResult(
clean=True,
findings=(),
warnings=tuple(warnings),
)

View file

@ -549,15 +549,37 @@ class Config:
)
# Daytona sandbox (code execution / filesystem sandbox)
DAYTONA_SANDBOX_ENABLED = (
os.getenv("DAYTONA_SANDBOX_ENABLED", "FALSE").upper() == "TRUE"
)
DAYTONA_API_KEY = os.getenv("DAYTONA_API_KEY", "")
DAYTONA_API_URL = os.getenv("DAYTONA_API_URL", "https://app.daytona.io/api")
DAYTONA_TARGET = os.getenv("DAYTONA_TARGET", "us")
DAYTONA_SNAPSHOT_ID = os.getenv("DAYTONA_SNAPSHOT_ID") or None
SANDBOX_FILES_DIR = os.getenv("SANDBOX_FILES_DIR", "sandbox_files")
# Sandbox provider selection. A deployment choice, not a fallback chain:
# opensandbox is self-hosted, daytona is cloud.
#
# On by default because compose always runs the control plane. A backend run
# outside compose has none, so it needs OPENSANDBOX_DOMAIN pointed at a
# reachable server or SANDBOX_ENABLED=FALSE.
SANDBOX_ENABLED = os.getenv("SANDBOX_ENABLED", "TRUE").strip().upper() == "TRUE"
SANDBOX_PROVIDER = os.getenv("SANDBOX_PROVIDER", "opensandbox").lower()
OPENSANDBOX_DOMAIN = os.getenv("OPENSANDBOX_DOMAIN", "opensandbox-server:8080")
OPENSANDBOX_API_KEY = os.getenv("OPENSANDBOX_API_KEY", "")
SANDBOX_IMAGE = os.getenv(
"SANDBOX_IMAGE", "ghcr.io/modsetter/surfsense-sandbox:latest"
)
SANDBOX_IDLE_TTL_SECONDS = int(os.getenv("SANDBOX_IDLE_TTL_SECONDS", "900"))
# Creation blocks server-side while the image is pulled onto the host
# daemon, which the SDK's 30s default turns into an error rather than a slow
# first request. Compose pre-pulls; this covers hosts that did not.
SANDBOX_REQUEST_TIMEOUT_SECONDS = int(
os.getenv("SANDBOX_REQUEST_TIMEOUT_SECONDS", "120")
)
SANDBOX_MAX_SESSIONS_PER_WORKSPACE = int(
os.getenv("SANDBOX_MAX_SESSIONS_PER_WORKSPACE", "2")
)
ARTIFACT_MAX_FILE_BYTES = int(os.getenv("ARTIFACT_MAX_FILE_BYTES", "31457280"))
# Agent cache (in-process LRU+TTL cache for built agents)
AGENT_CACHE_MAXSIZE = int(os.getenv("SURFSENSE_AGENT_CACHE_MAXSIZE", "256"))
AGENT_CACHE_TTL_SECONDS = float(

View file

@ -65,6 +65,7 @@ class DocumentType(StrEnum):
CIRCLEBACK = "CIRCLEBACK"
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR"
NOTE = "NOTE"
ARTIFACT = "ARTIFACT"
DROPBOX_FILE = "DROPBOX_FILE"
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR"
@ -302,6 +303,12 @@ class Permission(StrEnum):
DOCUMENTS_UPDATE = "documents:update"
DOCUMENTS_DELETE = "documents:delete"
# Artifacts
ARTIFACTS_CREATE = "artifacts:create"
ARTIFACTS_READ = "artifacts:read"
ARTIFACTS_UPDATE = "artifacts:update"
ARTIFACTS_DELETE = "artifacts:delete"
# Chats
CHATS_CREATE = "chats:create"
CHATS_READ = "chats:read"
@ -397,6 +404,10 @@ DEFAULT_ROLE_PERMISSIONS = {
Permission.DOCUMENTS_CREATE.value,
Permission.DOCUMENTS_READ.value,
Permission.DOCUMENTS_UPDATE.value,
# Artifacts (no delete)
Permission.ARTIFACTS_CREATE.value,
Permission.ARTIFACTS_READ.value,
Permission.ARTIFACTS_UPDATE.value,
# Chats (no delete)
Permission.CHATS_CREATE.value,
Permission.CHATS_READ.value,
@ -447,6 +458,8 @@ DEFAULT_ROLE_PERMISSIONS = {
"Viewer": [
# Documents (read only)
Permission.DOCUMENTS_READ.value,
# Artifacts (read only)
Permission.ARTIFACTS_READ.value,
# Chats (read only)
Permission.CHATS_READ.value,
# Comments (can create and read, but not delete)
@ -1446,6 +1459,13 @@ class Document(BaseModel, TimestampMixin):
files = relationship(
"DocumentFile", back_populates="document", cascade="all, delete-orphan"
)
artifact = relationship(
"Artifact",
back_populates="document",
cascade="all, delete-orphan",
passive_deletes=True,
uselist=False,
)
class DocumentVersion(BaseModel, TimestampMixin):
@ -2884,6 +2904,10 @@ class ToolOutputSpill(Base, TimestampMixin):
# Register model packages that live outside this file so their classes
# are present in Base.metadata before configure_mappers() resolves any
# string-based relationship() references.
from app.artifacts.persistence import ( # noqa: E402, F401
Artifact,
ArtifactFile,
)
from app.automations.persistence import ( # noqa: E402, F401
Automation,
AutomationRun,

View file

@ -12,12 +12,13 @@ from app.file_storage.settings import (
)
@lru_cache(maxsize=1)
def get_storage_backend() -> StorageBackend:
"""Build the backend selected by ``FILE_STORAGE_BACKEND`` (lazy-imported)."""
@lru_cache(maxsize=2)
def get_storage_backend(backend_name: str | None = None) -> StorageBackend:
"""Build the selected or recorded storage backend as a singleton."""
settings = load_storage_settings()
backend_name = backend_name or settings.backend
if settings.backend == AZURE_BACKEND:
if backend_name == AZURE_BACKEND:
if not settings.azure_connection_string or not settings.azure_container:
raise ValueError(
"Azure storage requires AZURE_STORAGE_CONNECTION_STRING and "
@ -30,9 +31,9 @@ def get_storage_backend() -> StorageBackend:
container=settings.azure_container,
)
if settings.backend == LOCAL_BACKEND:
if backend_name == LOCAL_BACKEND:
from app.file_storage.backends.local import LocalFileBackend
return LocalFileBackend(settings.local_root)
raise ValueError(f"Unknown FILE_STORAGE_BACKEND: {settings.backend!r}")
raise ValueError(f"Unknown file storage backend: {backend_name!r}")

View file

@ -1,4 +1,4 @@
"""DocumentFile kinds: the original upload plus future derived artifacts."""
"""Kinds of durable files attached to knowledge-base documents."""
from __future__ import annotations

View file

@ -46,7 +46,6 @@ class DocumentFile(BaseModel, TimestampMixin):
server_default=DocumentFileKind.ORIGINAL.value,
index=True,
)
# Where the bytes live: the backend that stored them and its object key.
storage_backend = Column(String(32), nullable=False)
storage_key = Column(String, nullable=False)

View file

@ -14,6 +14,7 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.artifacts.persistence import Artifact, ArtifactFile
from app.file_storage.backends.base import StorageBackend
from app.file_storage.factory import get_storage_backend
from app.file_storage.keys import build_document_file_key
@ -95,7 +96,7 @@ def open_document_file_stream(
record: DocumentFile, *, backend: StorageBackend | None = None
) -> AsyncIterator[bytes]:
"""Open a chunked byte stream for a stored file."""
backend = backend or get_storage_backend()
backend = backend or get_storage_backend(record.storage_backend)
return backend.open_stream(record.storage_key)
@ -114,15 +115,23 @@ async def purge_document_blobs(
if not document_ids:
return
backend = backend or get_storage_backend()
result = await session.execute(
select(DocumentFile.storage_key).where(
document_files = await session.execute(
select(DocumentFile.storage_backend, DocumentFile.storage_key).where(
DocumentFile.document_id.in_(document_ids)
)
)
for storage_key in result.scalars().all():
artifact_files = await session.execute(
select(ArtifactFile.storage_backend, ArtifactFile.storage_key)
.join(Artifact, ArtifactFile.artifact_id == Artifact.id)
.where(Artifact.document_id.in_(document_ids))
)
for backend_name, storage_key in [
*document_files.all(),
*artifact_files.all(),
]:
try:
await backend.delete(storage_key)
selected_backend = backend or get_storage_backend(backend_name)
await selected_backend.delete(storage_key)
except Exception as delete_error:
logger.warning(
"Failed to delete stored blob %s: %s", storage_key, delete_error

View file

@ -171,14 +171,23 @@ async def _converge(
author_id = await revision_author_id(store, head, workspace)
for from_path, to_path in plan.renames:
follow_rename(
owned,
workspace.id,
to_virtual_path(from_path),
to_virtual_path(to_path),
)
if _is_document_store_path(from_path) and _is_document_store_path(to_path):
follow_rename(
owned,
workspace.id,
to_virtual_path(from_path),
to_virtual_path(to_path),
)
elif _is_document_store_path(from_path):
removed = await delete_row(
session, workspace.id, to_virtual_path(from_path), owned
)
outcome.deleted += 1 if removed is not None else 0
for store_path in plan.upserts:
if not _is_document_store_path(store_path):
outcome.skipped += 1
continue
virtual_path = to_virtual_path(store_path)
content = await read_indexable(store, head, store_path)
if content is None:
@ -198,14 +207,19 @@ async def _converge(
outcome.failed += 1
for store_path in plan.removals:
removed = await delete_row(
session, workspace.id, to_virtual_path(store_path), owned
)
if _is_document_store_path(store_path):
removed = await delete_row(
session, workspace.id, to_virtual_path(store_path), owned
)
else:
removed = None
outcome.deleted += 1 if removed is not None else 0
if plan.tree is not None:
live = {to_virtual_path(path) for path in plan.tree}
outcome.deleted += await prune(session, owned, live)
live_documents = {
to_virtual_path(path) for path in plan.tree if _is_document_store_path(path)
}
outcome.deleted += await prune(session, owned, live_documents)
# A failed document must not advance the marker, or the drift sweep can never
# re-drive it. An intentional skip (unreadable blob) must not block it, or one
@ -291,3 +305,7 @@ async def _index_one(
# Recorded only once index() has committed it, so every entry is a real row.
owned[virtual_path] = document
return True
def _is_document_store_path(path: str) -> bool:
return path.strip("/").startswith("documents/")

View file

@ -94,7 +94,11 @@ async def reconcile_tree_folders(
the changes is what lets the incremental and projection paths derive the same
folder rows the full rebuild does.
"""
tracked = [entry.path for entry in await store.list_paths(revision)]
tracked = [
entry.path
for entry in await store.list_paths(revision)
if entry.path.strip("/").startswith("documents/")
]
return await reconcile_folders(
session,
workspace_id=workspace_id,

View file

@ -116,7 +116,12 @@ async def _project(
author_id = await revision_author_id(store, revision, workspace)
for change in changes:
if change.kind == "renamed" and change.previous_path:
if (
change.kind == "renamed"
and change.previous_path
and _is_document_store_path(change.path)
and _is_document_store_path(change.previous_path)
):
follow_rename(
owned,
workspace.id,
@ -125,6 +130,18 @@ async def _project(
)
for change in changes:
if (
change.kind == "renamed"
and change.previous_path
and _is_document_store_path(change.previous_path)
and not _is_document_store_path(change.path)
):
previous = to_virtual_path(change.previous_path)
removed = await delete_row(session, workspace.id, previous, owned)
if removed is not None:
projection.deleted.append(_snapshot(removed, previous))
if not _is_document_store_path(change.path):
continue
virtual_path = to_virtual_path(change.path)
if change.kind == "removed":
removed = await delete_row(session, workspace.id, virtual_path, owned)
@ -179,3 +196,7 @@ def _snapshot(document, virtual_path: str) -> ProjectedDocument:
folder_id=document.folder_id,
virtual_path=virtual_path,
)
def _is_document_store_path(path: str) -> bool:
return path.strip("/").startswith("documents/")

View file

@ -56,6 +56,10 @@ async def upsert_row(
Returns the row and whether this call created it. The row is flushed before
returning because both callers need its id: the pipeline to attach chunks,
the commit path to name the document in a UI event.
``document_type`` is assigned only for a new Git-authored row. Existing rows
keep their type, so projection cannot demote an artifact or uploaded file to
a note merely because all of them live under ``/documents``.
"""
folder_parts, title = parse_documents_path(virtual_path)
if not title:
@ -96,8 +100,8 @@ async def upsert_row(
)
session.add(document)
else:
# Title is Postgres-owned: re-deriving it from the path would rename the
# note to its filename on every reindex.
# Title and type are Postgres-owned: re-deriving either from the path
# would rename a document or demote an artifact on every reindex.
document.folder_id = folder_id
document.path = virtual_path
document.source_markdown = content
@ -199,6 +203,9 @@ async def delete_row(
# arrives as a removal and an addition — while the recorder has moved the
# marker and left unique_identifier_hash, resolve's fallback, behind.
return None
from app.file_storage.service import purge_document_blobs
await purge_document_blobs(session, document_ids=[document.id])
owned.pop(virtual_path, None)
await session.delete(document)
return document
@ -213,14 +220,25 @@ async def prune(
the folder indexers) have no path in the tree at all, and a workspace-wide
prune would delete every one of them on the first rebuild.
"""
deleted = 0
for virtual_path, document in list(owned.items()):
if virtual_path in live:
continue
stale = [
(virtual_path, document)
for virtual_path, document in owned.items()
if virtual_path not in live
]
if not stale:
return 0
from app.file_storage.service import purge_document_blobs
# Blob metadata cascades with the document, so collect and purge every
# reachable document/artifact blob while those rows still exist.
await purge_document_blobs(
session, document_ids=[document.id for _, document in stale]
)
for virtual_path, document in stale:
await session.delete(document)
owned.pop(virtual_path, None)
deleted += 1
return deleted
return len(stale)
async def load_owned(session: AsyncSession, workspace_id: int) -> dict[str, Document]:

View file

@ -25,12 +25,14 @@ from .agent_revert_route import router as agent_revert_router
from .airtable_add_connector_route import (
router as airtable_add_connector_router,
)
from .artifacts_routes import router as artifacts_router
from .chat_comments_routes import router as chat_comments_router
from .circleback_webhook_route import router as circleback_webhook_router
from .clickup_add_connector_route import router as clickup_add_connector_router
from .composio_routes import router as composio_router
from .confluence_add_connector_route import router as confluence_add_connector_router
from .discord_add_connector_route import router as discord_add_connector_router
from .document_files_routes import router as document_files_router
from .documents_routes import router as documents_router
from .dropbox_add_connector_route import router as dropbox_add_connector_router
from .editor_routes import router as editor_router
@ -88,6 +90,8 @@ router.include_router(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router)
router.include_router(export_router)
router.include_router(documents_router)
router.include_router(document_files_router)
router.include_router(artifacts_router)
router.include_router(folders_router)
_gateway_enabled_dep = [Depends(require_gateway_enabled)]
router.include_router(gateway_config_router)

View file

@ -0,0 +1,291 @@
"""Workspace-scoped artifact manifests, files, downloads, and lifecycle."""
from __future__ import annotations
import io
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.artifacts.persistence import (
Artifact,
ArtifactFile,
ArtifactFileRole,
)
from app.artifacts.storage import open_artifact_file_stream
from app.auth.context import AuthContext
from app.db import Document, Permission, get_async_session
from app.users import get_auth_context
from app.utils.rbac import check_permission
from .document_files_routes import _content_disposition, _is_inline
router = APIRouter()
def _markdown_filename(title: str) -> str:
safe = "".join(
character if character.isalnum() or character in " -_" else "_"
for character in title
).strip()[:80]
return f"{safe or 'artifact'}.md"
async def _authorize_artifact(
session: AsyncSession,
auth: AuthContext,
workspace_id: int,
permission: Permission,
action: str,
) -> None:
await check_permission(
session,
auth,
workspace_id,
permission.value,
f"You don't have permission to {action} artifacts in this workspace",
)
def _visible_files(artifact: Artifact) -> list[ArtifactFile]:
return sorted(
(file for file in artifact.files if file.role is not ArtifactFileRole.SOURCE),
key=lambda item: (item.role is not ArtifactFileRole.PRIMARY, item.id),
)
def _file_manifest(
workspace_id: int, artifact_id: int, record: ArtifactFile
) -> dict[str, object]:
return {
"file_id": record.id,
"role": record.role.value,
"filename": record.original_filename,
"mime_type": record.mime_type or "application/octet-stream",
"size_bytes": record.size_bytes,
"content_url": (
f"/api/v1/workspaces/{workspace_id}/artifacts/"
f"{artifact_id}/files/{record.id}/content"
),
}
@router.get("/workspaces/{workspace_id}/artifacts")
async def list_artifacts(
workspace_id: int,
response: Response,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await _authorize_artifact(
session, auth, workspace_id, Permission.ARTIFACTS_READ, "read"
)
rows = (
await session.execute(
select(Artifact, Document)
.join(Document, Artifact.document_id == Document.id)
.where(Artifact.workspace_id == workspace_id)
.order_by(Artifact.updated_at.desc(), Artifact.id.desc())
)
).all()
response.headers["Cache-Control"] = "private, no-store"
return [
{
"artifact_id": artifact.id,
"title": document.title,
"format": artifact.format,
"generation": artifact.generation,
"indexing_status": (document.status or {}).get("state", "ready"),
"thread_id": artifact.thread_id,
"created_at": artifact.created_at.isoformat(),
"updated_at": (
artifact.updated_at.isoformat() if artifact.updated_at else None
),
}
for artifact, document in rows
]
@router.get("/workspaces/{workspace_id}/artifacts/{artifact_id}/manifest")
async def get_artifact_manifest(
workspace_id: int,
artifact_id: int,
request: Request,
response: Response,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await _authorize_artifact(
session, auth, workspace_id, Permission.ARTIFACTS_READ, "read"
)
row = (
await session.execute(
select(Artifact, Document)
.join(Document, Artifact.document_id == Document.id)
.options(selectinload(Artifact.files))
.where(Artifact.id == artifact_id, Artifact.workspace_id == workspace_id)
)
).first()
if row is None:
raise HTTPException(status_code=404, detail="Artifact not found")
artifact, document = row
etag = f'"{document.content_hash}:{artifact.generation}"'
cache_headers = {"ETag": etag, "Cache-Control": "private, no-cache"}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=cache_headers)
response.headers.update(cache_headers)
return {
"artifact_id": artifact.id,
"document_id": document.id,
"title": document.title,
"format": artifact.format,
"generation": artifact.generation,
"markdown_representation": document.source_markdown or document.content,
"files": [
_file_manifest(workspace_id, artifact.id, file)
for file in _visible_files(artifact)
],
"updated_at": (
artifact.updated_at.isoformat() if artifact.updated_at else None
),
}
@router.get("/workspaces/{workspace_id}/artifacts/{artifact_id}/download")
async def download_artifact(
workspace_id: int,
artifact_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> StreamingResponse:
await _authorize_artifact(
session, auth, workspace_id, Permission.ARTIFACTS_READ, "read"
)
row = (
await session.execute(
select(Artifact, Document)
.join(Document, Artifact.document_id == Document.id)
.options(selectinload(Artifact.files))
.where(Artifact.id == artifact_id, Artifact.workspace_id == workspace_id)
)
).first()
if row is None:
raise HTTPException(status_code=404, detail="Artifact not found")
artifact, document = row
primary = next(
(file for file in artifact.files if file.role is ArtifactFileRole.PRIMARY),
None,
)
headers = {
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
}
if primary is not None:
return StreamingResponse(
open_artifact_file_stream(primary),
media_type=primary.mime_type or "application/octet-stream",
headers={
**headers,
"Content-Disposition": _content_disposition(
primary.original_filename, inline=False
),
},
)
filename = _markdown_filename(document.title)
return StreamingResponse(
io.BytesIO((document.source_markdown or document.content).encode()),
media_type="text/markdown; charset=utf-8",
headers={
**headers,
"Content-Disposition": _content_disposition(filename, inline=False),
},
)
@router.delete("/workspaces/{workspace_id}/artifacts/{artifact_id}", status_code=204)
async def delete_artifact(
workspace_id: int,
artifact_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> Response:
await _authorize_artifact(
session, auth, workspace_id, Permission.ARTIFACTS_DELETE, "delete"
)
row = (
await session.execute(
select(Artifact, Document)
.join(Document, Artifact.document_id == Document.id)
.where(Artifact.id == artifact_id, Artifact.workspace_id == workspace_id)
)
).first()
if row is None:
raise HTTPException(status_code=404, detail="Artifact not found")
_, document = row
document.status = {"state": "deleting"}
await session.commit()
try:
from app.tasks.celery_tasks.document_tasks import delete_document_task
delete_document_task.delay(document.id)
except Exception as dispatch_error:
document.status = {"state": "ready"}
await session.commit()
raise HTTPException(
status_code=503,
detail="Failed to queue background deletion. Please try again.",
) from dispatch_error
return Response(status_code=204, headers={"Cache-Control": "private, no-store"})
@router.get(
"/workspaces/{workspace_id}/artifacts/{artifact_id}/files/{file_id}/content"
)
async def stream_artifact_file(
workspace_id: int,
artifact_id: int,
file_id: int,
request: Request,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> Response:
await _authorize_artifact(
session, auth, workspace_id, Permission.ARTIFACTS_READ, "read"
)
record = await session.scalar(
select(ArtifactFile)
.join(Artifact, ArtifactFile.artifact_id == Artifact.id)
.where(
ArtifactFile.id == file_id,
ArtifactFile.artifact_id == artifact_id,
Artifact.workspace_id == workspace_id,
ArtifactFile.role != ArtifactFileRole.SOURCE,
)
)
if record is None:
raise HTTPException(status_code=404, detail="Artifact file not found")
etag_value = record.checksum_sha256 or f"artifact-file-{record.id}"
etag = f'"{etag_value}"'
headers = {
"ETag": etag,
"Cache-Control": "private, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
mime_type = record.mime_type or "application/octet-stream"
return StreamingResponse(
open_artifact_file_stream(record),
media_type=mime_type,
headers={
**headers,
"Content-Disposition": _content_disposition(
record.original_filename, inline=_is_inline(mime_type)
),
},
)

View file

@ -0,0 +1,85 @@
"""Authenticated streaming for immutable document files."""
from __future__ import annotations
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import Permission, get_async_session
from app.file_storage.persistence.models import DocumentFile
from app.file_storage.service import open_document_file_stream
from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
def _content_disposition(filename: str, *, inline: bool) -> str:
fallback = filename.encode("ascii", "ignore").decode("ascii") or "download"
fallback = fallback.replace('"', "").replace("\r", "").replace("\n", "")
mode = "inline" if inline else "attachment"
return f"{mode}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
def _is_inline(mime_type: str) -> bool:
# Stored files are user- or agent-authored, so rendering one on our origin
# is an XSS grant. Only PDF has a sandboxed native viewer; everything else
# downloads. Widen per MIME type, by name with a consumer attached — never
# by wildcard (image/* once smuggled in scriptable SVG).
return mime_type == "application/pdf"
@router.get(
"/workspaces/{workspace_id}/documents/{document_id}/files/{file_id}/content"
)
async def stream_document_file(
workspace_id: int,
document_id: int,
file_id: int,
request: Request,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> Response:
await check_permission(
session,
auth,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this workspace",
)
record = await session.scalar(
select(DocumentFile).where(
DocumentFile.id == file_id,
DocumentFile.document_id == document_id,
DocumentFile.workspace_id == workspace_id,
)
)
if record is None:
raise HTTPException(status_code=404, detail="Document file not found")
etag = f'"{record.checksum_sha256}"'
cache_headers = {
"ETag": etag,
"Cache-Control": "private, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=cache_headers)
mime_type = record.mime_type or "application/octet-stream"
return StreamingResponse(
open_document_file_stream(record),
media_type=mime_type,
headers={
**cache_headers,
"Content-Disposition": _content_disposition(
record.original_filename,
inline=_is_inline(mime_type),
),
},
)

View file

@ -277,6 +277,12 @@ async def save_document(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
if document.document_type == DocumentType.ARTIFACT:
raise HTTPException(
status_code=409,
detail="Artifact documents are read-only; revise them through the artifact tools.",
)
source_markdown = data.get("source_markdown")
if source_markdown is None:
raise HTTPException(status_code=400, detail="source_markdown is required")

View file

@ -482,8 +482,8 @@ def _try_delete_sandbox(thread_id: int) -> None:
from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.sandbox import (
delete_local_sandbox_files,
delete_sandbox,
is_sandbox_enabled,
)
from app.sandbox import is_sandbox_enabled
if not is_sandbox_enabled():
return

View file

@ -68,6 +68,11 @@ PERMISSION_DESCRIPTIONS = {
"documents:read": "View and search documents in the workspace",
"documents:update": "Edit existing documents and their metadata",
"documents:delete": "Remove documents from the workspace",
# Artifacts
"artifacts:create": "Create generated artifacts in the workspace",
"artifacts:read": "View and download artifacts in the workspace",
"artifacts:update": "Revise existing artifacts in the workspace",
"artifacts:delete": "Remove artifacts from the workspace",
# Chats
"chats:create": "Start new AI chat conversations",
"chats:read": "View chat history and conversations",

View file

@ -208,11 +208,11 @@ async def read_reports(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
List reports the user has access to.
Filters by workspace membership.
"""
user = auth.user
try:
if workspace_id is not None:
# Verify the caller is a member of the requested workspace
@ -250,12 +250,11 @@ async def read_report(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Get a specific report by ID (metadata only, no content).
"""
try:
return await _get_report_with_access(report_id, session, user)
return await _get_report_with_access(report_id, session, auth)
except HTTPException:
raise
except SQLAlchemyError:
@ -270,12 +269,11 @@ async def read_report_content(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Get full Markdown content of a report, including version siblings.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report = await _get_report_with_access(report_id, session, auth)
versions = await _get_version_siblings(session, report)
return ReportContentRead(
@ -303,7 +301,6 @@ async def update_report_content(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Update the Markdown content of a report.
@ -311,7 +308,7 @@ async def update_report_content(
Returns the updated report content including version siblings.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report = await _get_report_with_access(report_id, session, auth)
report.content = body.content
session.add(report)
@ -345,7 +342,6 @@ async def preview_report_pdf(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Return a compiled PDF preview for Typst-based reports (resumes).
@ -353,7 +349,7 @@ async def preview_report_pdf(
on-the-fly. Only works for reports with content_type='typst'.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report = await _get_report_with_access(report_id, session, auth)
if not report.content:
raise HTTPException(
@ -401,12 +397,11 @@ async def export_report(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Export a report in the requested format.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report = await _get_report_with_access(report_id, session, auth)
if not report.content:
raise HTTPException(
@ -576,12 +571,11 @@ async def delete_report(
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
Delete a report.
"""
try:
db_report = await _get_report_with_access(report_id, session, user)
db_report = await _get_report_with_access(report_id, session, auth)
await session.delete(db_report)
await session.commit()

Some files were not shown because too many files have changed in this diff Show more