feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""Project lifecycle tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import (
|
||||
Agent,
|
||||
AuthProvider,
|
||||
Component,
|
||||
HandoffRequest,
|
||||
KbSource,
|
||||
McpClient,
|
||||
QaPair,
|
||||
Run,
|
||||
Secret,
|
||||
Span,
|
||||
Thread,
|
||||
Tool,
|
||||
Trace,
|
||||
Workflow,
|
||||
)
|
||||
from forge.services.projects import ProjectService
|
||||
|
||||
|
||||
async def _count(session, model, **where) -> int:
|
||||
stmt = select(func.count()).select_from(model)
|
||||
for key, value in where.items():
|
||||
stmt = stmt.where(getattr(model, key) == value)
|
||||
return int((await session.execute(stmt)).scalar_one())
|
||||
|
||||
|
||||
async def test_project_counts_are_scoped_to_project():
|
||||
tenant_id = "tenant_counts"
|
||||
async with SessionLocal() as session:
|
||||
proj = await ProjectService.create(session, tenant_id, name="Counts", slug="counts")
|
||||
other = await ProjectService.create(session, tenant_id, name="Other", slug="other")
|
||||
session.add_all([
|
||||
Workflow(tenant_id=tenant_id, project_id=proj.id, name="wf1"),
|
||||
Workflow(tenant_id=tenant_id, project_id=proj.id, name="wf2"),
|
||||
Agent(tenant_id=tenant_id, project_id=proj.id, name="a", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=proj.id, name="t1", kind="builtin", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=proj.id, name="t2", kind="builtin", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=proj.id, name="t3", kind="builtin", config={}),
|
||||
Component(tenant_id=tenant_id, project_id=proj.id, name="card"),
|
||||
KbSource(tenant_id=tenant_id, project_id=proj.id, kind="text", name="src"),
|
||||
AuthProvider(tenant_id=tenant_id, project_id=proj.id, name="auth", kind="bearer", config={}),
|
||||
HandoffRequest(tenant_id=tenant_id, project_id=proj.id, run_id="run-open", status="open"),
|
||||
HandoffRequest(tenant_id=tenant_id, project_id=proj.id, run_id="run-done", status="answered"),
|
||||
# Belongs to a different project in the same tenant - must NOT be counted for `proj`.
|
||||
Tool(tenant_id=tenant_id, project_id=other.id, name="other_tool", kind="builtin", config={}),
|
||||
Workflow(tenant_id=tenant_id, project_id=other.id, name="other_wf"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
# ProjectService.create auto-provisions the platform built-ins, so each project starts with
|
||||
# len(BUILTIN_DEFAULTS) tools before the 3 added here. Scoping still holds: `other`'s tool
|
||||
# (and its own provisioned built-ins) are not counted for `proj`.
|
||||
from forge.tools.builtin import BUILTIN_DEFAULTS
|
||||
counts = await ProjectService.counts(session, tenant_id, proj.id)
|
||||
assert counts == {
|
||||
"workflows": 2, "agents": 1, "tools": 3 + len(BUILTIN_DEFAULTS),
|
||||
"components": 1, "knowledge": 1, "auth": 1, "handoffs": 1,
|
||||
}
|
||||
# A fresh project is all zeros except the auto-provisioned platform built-ins (never None).
|
||||
empty = await ProjectService.create(session, tenant_id, name="Empty", slug="empty")
|
||||
assert await ProjectService.counts(session, tenant_id, empty.id) == {
|
||||
"workflows": 0, "agents": 0, "tools": len(BUILTIN_DEFAULTS), "components": 0, "knowledge": 0, "auth": 0,
|
||||
"handoffs": 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_delete_project_removes_project_scoped_data_and_trace_spans():
|
||||
tenant_id = "tenant_delete_project"
|
||||
deleted_threads: list[str] = []
|
||||
|
||||
class FakeCheckpointer:
|
||||
async def adelete_thread(self, thread_id: str) -> None:
|
||||
deleted_threads.append(thread_id)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
project = await ProjectService.create(session, tenant_id, name="Delete Me", slug="delete-me")
|
||||
workflow = Workflow(tenant_id=tenant_id, project_id=project.id, name="wf")
|
||||
thread = Thread(tenant_id=tenant_id, project_id=project.id, workflow_id="wf1", lg_thread_id="lg1")
|
||||
run = Run(tenant_id=tenant_id, project_id=project.id, workflow_id="wf1", thread_id="thread1")
|
||||
trace = Trace(tenant_id=tenant_id, project_id=project.id, workflow_id="wf1", run_id="run1", name="trace")
|
||||
session.add_all([workflow, thread, run, trace])
|
||||
await session.flush()
|
||||
session.add_all([
|
||||
Span(tenant_id=tenant_id, trace_id=trace.id, name="span", kind="node"),
|
||||
Agent(tenant_id=tenant_id, project_id=project.id, name="agent", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=project.id, name="tool", kind="builtin", config={}),
|
||||
AuthProvider(tenant_id=tenant_id, project_id=project.id, name="auth", kind="bearer", config={}),
|
||||
Secret(tenant_id=tenant_id, project_id=project.id, name="secret", kind="api_key", encrypted_value=b"x"),
|
||||
KbSource(tenant_id=tenant_id, project_id=project.id, kind="text", name="source"),
|
||||
QaPair(tenant_id=tenant_id, project_id=project.id, question="q", answer="a"),
|
||||
McpClient(tenant_id=tenant_id, project_id=project.id, name="mcp"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
await ProjectService.delete(session, project, checkpointer=FakeCheckpointer())
|
||||
|
||||
assert await ProjectService.get(session, tenant_id, project.id) is None
|
||||
for model in (Workflow, Thread, Run, Trace, Agent, Tool, AuthProvider, Secret, KbSource, QaPair, McpClient):
|
||||
assert await _count(session, model, project_id=project.id) == 0
|
||||
assert await _count(session, Span, trace_id=trace.id) == 0
|
||||
assert deleted_threads == ["lg1"]
|
||||
Reference in New Issue
Block a user