Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
8386889
feat(array): add sandbox snapshots
joshsny Sep 30, 2025
bf71939
Merge branch 'master' into array/sandbox-snapshots
joshsny Sep 30, 2025
4c5fe76
make integration nullable
joshsny Sep 30, 2025
2065db5
Merge branch 'array/sandbox-snapshots' of https://github.com/posthog/…
joshsny Sep 30, 2025
bb8a824
fix lint issues
joshsny Sep 30, 2025
66c2e92
(wip) add sandbox related activities
joshsny Sep 30, 2025
509e4e4
wip splitting out repo setup in temporal
joshsny Sep 30, 2025
a471b21
wip moving to sandbox flow
joshsny Sep 30, 2025
76b0b0a
wip - new temporal workflow
joshsny Oct 1, 2025
5c0c756
wip testing temporal flow
joshsny Oct 1, 2025
029bba8
wip activities tests
joshsny Oct 1, 2025
7546364
wip get sandbox for setup tests
joshsny Oct 1, 2025
000b55e
tests for get sandbox for setup
joshsny Oct 1, 2025
990fcf7
add default ttl, fix clone repo output, setup repo activity tests
joshsny Oct 1, 2025
dacaaa5
add tests for setup repository
joshsny Oct 1, 2025
1a68c7e
add more tests, add snapshot deletion functionality
joshsny Oct 1, 2025
844f118
add tests for cleanup and task execution
joshsny Oct 1, 2025
d02efa2
wip tests for creating sandbox from a snapshot
joshsny Oct 2, 2025
da488d5
add some workflow tests, update output of workflow
joshsny Oct 2, 2025
392be71
add support for sandbox environment flow based off a falg
joshsny Oct 2, 2025
2b282ef
add asyncify util for sync code in temporal, use it in some activities
joshsny Oct 2, 2025
5725649
add sandbox metadata
joshsny Oct 2, 2025
39a442b
add task for fetching latest github token and injecting
joshsny Oct 2, 2025
3037fea
add steps for injecting and cleaning up personal api key, fix issues …
joshsny Oct 3, 2025
ca85a5d
update tests for injecting api key
joshsny Oct 3, 2025
97fe254
update conftest
joshsny Oct 3, 2025
2ab71f9
update workflow tests, update test snapshots
joshsny Oct 3, 2025
b2bc69f
drop sandbox agent tests as they are covered elsewhere
joshsny Oct 3, 2025
3cf3c48
full workflow
joshsny Oct 3, 2025
b6cb346
Merge branch 'master' into array/sandbox-snapshot-activities
joshsny Oct 4, 2025
2bdd4b1
add better error handling and observability
joshsny Oct 4, 2025
6fdc34c
more activity logging
joshsny Oct 4, 2025
8c28c0e
merge migrations
joshsny Oct 4, 2025
e5d4eec
update exceptions to be non retriable for fatal errors, add track wo…
joshsny Oct 4, 2025
62ddb7e
drop index migration
joshsny Oct 4, 2025
bd756ef
update tests to use accurate errors
joshsny Oct 4, 2025
5a817a2
add temporal tests
joshsny Oct 4, 2025
4c07fc4
type fixes
joshsny Oct 4, 2025
7ef6322
fix test errors
joshsny Oct 4, 2025
22bc092
type fixes, test cleanup
joshsny Oct 4, 2025
d9bbdf5
feat: add created_by to task
joshsny Oct 4, 2025
ba9b2da
please ci
joshsny Oct 4, 2025
3557103
Merge branch 'master' into array/sandbox-snapshot-activities
joshsny Oct 4, 2025
f0a948e
suggested fixes
joshsny Oct 7, 2025
a3d7b08
fix task details
joshsny Oct 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions posthog/temporal/common/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,40 @@
import inspect
from collections.abc import Callable
from datetime import datetime
from functools import wraps
from typing import ParamSpec, TypeVar

from asgiref.sync import sync_to_async
from temporalio import workflow

P = ParamSpec("P")
T = TypeVar("T")


def asyncify(fn: Callable[P, T]) -> Callable[P, T]:
"""Decorator to convert a sync function using sync_to_async - this preserves type hints for Temporal's serialization while allowing sync Django ORM code.

This preserves type hints for Temporal's serialization while allowing
sync Django ORM code.

Usage:
@activity.defn
@asyncify
def my_activity(task_id: str) -> TaskDetails:
task = Task.objects.get(id=task_id)
return TaskDetails(...)
"""
if inspect.iscoroutinefunction(fn):
raise TypeError(
f"@asyncify should only be used on sync functions. " f"'{fn.__name__}' is already async. Remove @asyncify."
)

@wraps(fn)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
return await sync_to_async(fn)(*args, **kwargs)

return wrapper


def get_scheduled_start_time():
"""Return the start time of a workflow.
Expand Down
1 change: 1 addition & 0 deletions products/tasks/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ class AgentDefinitionViewSet(TeamAndOrgViewSetMixin, viewsets.ReadOnlyModelViewS
serializer_class = AgentDefinitionSerializer
authentication_classes = [SessionAuthentication, PersonalAPIKeyAuthentication]
queryset = None # No model queryset since we're using hardcoded agents
scope_object = "task"
posthog_feature_flag = {"tasks": ["list", "retrieve"]}

@extend_schema(
Expand Down
19 changes: 19 additions & 0 deletions products/tasks/backend/lib/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
SETUP_REPOSITORY_PROMPT = """
Your goal is to setup the repository in the current environment.

You are operating in a sandbox environment. You must install all dependencies necessary and setup the enviornment such that it is ready for executing code tasks.

CONTEXT:

CWD: {cwd}

REPOSITORY: {repository}

INSTRUCTIONS:

1. Install all dependencies necessary to run the repository
2. Run any setup scripts that are available
3. Verify the setup by running tests or build if available

DO NOT make any code changes to the repository. The final state of the disk of this sandbox is what will be used for subsequent tasks, so do not leave any cruft behind, and make sure the repository is in a ready to use state.
"""
68 changes: 68 additions & 0 deletions products/tasks/backend/migrations/0007_sandboxsnapshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Generated by Django 4.2.22 on 2025-09-30 13:58

import uuid

import django.db.models.deletion
import django.contrib.postgres.fields
import django.contrib.postgres.indexes
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("posthog", "0867_add_updated_at_to_feature_flags"),
("tasks", "0006_remove_workflowstage_agent_alter_task_workflow_and_more"),
]

operations = [
migrations.CreateModel(
name="SandboxSnapshot",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
(
"external_id",
models.CharField(blank=True, help_text="Snapshot ID from external provider.", max_length=255),
),
(
"repos",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=255),
default=list,
help_text="List of repositories in format 'org/repo'",
size=None,
),
),
(
"metadata",
models.JSONField(blank=True, default=dict, help_text="Additional metadata for the snapshot."),
),
(
"status",
models.CharField(
choices=[("in_progress", "In Progress"), ("complete", "Complete"), ("error", "Error")],
default="in_progress",
max_length=20,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"integration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="snapshots", to="posthog.integration"
),
),
],
options={
"db_table": "posthog_sandbox_snapshot",
"indexes": [
models.Index(
fields=["integration", "status", "-created_at"], name="posthog_san_integra_93cdea_idx"
),
models.Index(fields=["integration", "-created_at"], name="posthog_san_integra_50465d_idx"),
models.Index(fields=["status"], name="posthog_san_status_a49734_idx"),
django.contrib.postgres.indexes.GinIndex(fields=["repos"], name="posthog_san_repos_9cb7b7_gin"),
],
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 4.2.22 on 2025-09-30 14:15

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("posthog", "0867_add_updated_at_to_feature_flags"),
("tasks", "0007_sandboxsnapshot"),
]

operations = [
migrations.RemoveIndex(
model_name="sandboxsnapshot",
name="posthog_san_integra_50465d_idx",
),
migrations.RemoveIndex(
model_name="sandboxsnapshot",
name="posthog_san_status_a49734_idx",
),
migrations.RemoveIndex(
model_name="sandboxsnapshot",
name="posthog_san_repos_9cb7b7_gin",
),
migrations.AlterField(
model_name="sandboxsnapshot",
name="integration",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="snapshots",
to="posthog.integration",
),
),
]
22 changes: 22 additions & 0 deletions products/tasks/backend/migrations/0009_task_created_by.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.22 on 2025-10-02 16:50

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("tasks", "0008_remove_sandboxsnapshot_posthog_san_integra_50465d_idx_and_more"),
]

operations = [
migrations.AddField(
model_name="task",
name="created_by",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
]
2 changes: 1 addition & 1 deletion products/tasks/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0006_remove_workflowstage_agent_alter_task_workflow_and_more
0009_task_created_by
91 changes: 91 additions & 0 deletions products/tasks/backend/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from typing import Optional, cast

from django.contrib.postgres.fields import ArrayField
from django.db import models, transaction
from django.utils import timezone

Expand Down Expand Up @@ -229,6 +230,7 @@ class OriginProduct(models.TextChoices):

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE)
created_by = models.ForeignKey("posthog.User", on_delete=models.SET_NULL, null=True, blank=True)
title = models.CharField(max_length=255)
description = models.TextField()
origin_product = models.CharField(max_length=20, choices=OriginProduct.choices)
Expand Down Expand Up @@ -455,3 +457,92 @@ def progress_percentage(self):
if self.total_steps and self.total_steps > 0:
return min(100, (self.completed_steps / self.total_steps) * 100)
return 0


class SandboxSnapshot(models.Model):
"""Tracks sandbox snapshots used for sandbox environments in tasks."""

class Status(models.TextChoices):
IN_PROGRESS = "in_progress", "In Progress"
COMPLETE = "complete", "Complete"
ERROR = "error", "Error"

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

integration = models.ForeignKey(
Integration,
on_delete=models.SET_NULL,
related_name="snapshots",
null=True,
blank=True,
)

external_id = models.CharField(max_length=255, blank=True, help_text="Snapshot ID from external provider.")

repos = ArrayField(
models.CharField(max_length=255),
default=list,
help_text="List of repositories in format 'org/repo'",
)

metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata for the snapshot.")

status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.IN_PROGRESS,
)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
db_table = "posthog_sandbox_snapshot"
indexes = [
models.Index(fields=["integration", "status", "-created_at"]),
]

def __str__(self):
repo_count = len(self.repos)
return f"Snapshot {self.external_id} ({self.get_status_display()}, {repo_count} repos)"

def is_complete(self) -> bool:
return self.status == self.Status.COMPLETE

def has_repo(self, repo: str) -> bool:
repo_lower = repo.lower()
return any(r.lower() == repo_lower for r in self.repos)

def has_repos(self, repos: list[str]) -> bool:
return all(self.has_repo(repo) for repo in repos)

def update_status(self, status: Status):
self.status = status
self.save(update_fields=["status"])

@classmethod
def get_latest_snapshot_for_integration(
cls, integration_id: int, status: Status = Status.COMPLETE
) -> Optional["SandboxSnapshot"]:
return (
cls.objects.filter(
integration_id=integration_id,
status=status,
)
.order_by("-created_at")
.first()
)

@classmethod
def get_latest_snapshot_with_repos(
cls, integration_id: int, required_repos: list[str], status: Status = Status.COMPLETE
) -> Optional["SandboxSnapshot"]:
snapshots = cls.objects.filter(
integration_id=integration_id,
status=status,
).order_by("-created_at")

for snapshot in snapshots:
if snapshot.has_repos(required_repos):
return snapshot
return None
Loading
Loading