feat(playbooks): improve execution visibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funnywolf
2026-08-05 23:48:53 +08:00
co-authored by Copilot
parent 575997a950
commit be3e9b48a5
21 changed files with 537 additions and 199 deletions
@@ -1,9 +1,13 @@
import logging
from django.core.management.base import BaseCommand
from apps.agentic.runtime.monitor import run_playbook_once
from apps.agentic.services.playbooks import recover_orphaned_playbook_runs
from apps.common.worker_runner import SLEEP_WHEN_IDLE, add_worker_arguments, run_worker
DEFAULT_INTERVAL_SECONDS = 3.0
logger = logging.getLogger(__name__)
class Command(BaseCommand):
@@ -13,6 +17,9 @@ class Command(BaseCommand):
add_worker_arguments(parser, interval_help="Seconds to sleep when no playbook run is pending.")
def handle(self, *args, **options):
recovered = recover_orphaned_playbook_runs()
if recovered:
logger.warning("Recovered %d orphaned playbook run(s)", recovered)
run_worker(
self,
options=options,
+8
View File
@@ -14,6 +14,7 @@ class BasePlaybook:
NAME = ""
DESC = ""
TAGS = []
RISK_LEVEL = "Low"
PROMPT_SLUG = ""
SCRIPT_PATH = None
@@ -25,6 +26,13 @@ class BasePlaybook:
def run(self):
raise NotImplementedError
def add_run_message(self, message):
if self.playbook_run is None:
raise ValueError("Run messages require an active playbook run.")
from apps.agentic.services.playbooks import add_playbook_run_message
add_playbook_run_message(self.playbook_run, message)
@classmethod
def prompt_slug(cls):
if cls.PROMPT_SLUG:
+1 -1
View File
@@ -23,11 +23,11 @@ def run_playbook_once(*, scripts_dir=None):
try:
playbook_class = find_playbook_class(playbook_run.name, scripts_dir=scripts_dir)
result = playbook_class(playbook_run=playbook_run).run()
mark_playbook_success(playbook_run, str(result))
except Exception as exc:
mark_playbook_failed(playbook_run, exc)
return True
mark_playbook_success(playbook_run, str(result))
return True
+1
View File
@@ -38,6 +38,7 @@ def _playbook_record(definition):
"name": definition.name,
"description": getattr(definition.script_class, "DESC", ""),
"tags": tags,
"risk_level": getattr(definition.script_class, "RISK_LEVEL", "Low"),
"path": str(definition.path),
"source": _source_for_path(definition.path),
}
+105 -6
View File
@@ -1,17 +1,44 @@
import logging
import re
import uuid
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from apps.agentic.runtime.base import BasePlaybook
from apps.agentic.runtime.loader import discover_script_class, iter_overlaid_python_scripts
from apps.audit.context import suppress_audit
from apps.inbox.notifications import notify_playbook_completion
from apps.playbooks.models import Playbook, PlaybookJobStatus
from apps.playbooks.models import Playbook, PlaybookJobStatus, PlaybookRunMessage
logger = logging.getLogger(__name__)
PLAYBOOK_RISK_LEVELS = {"Low", "Medium", "High", "Critical"}
MAX_RUN_MESSAGE_LENGTH = 1000
MAX_RUN_REMARK_LENGTH = 2000
ORPHANED_RUN_REMARK = "Playbook worker stopped before completion."
_AUTHORIZATION_RE = re.compile(r"(?i)\bauthorization\b(\s*[:=]\s*)[^\r\n]+")
_SENSITIVE_ASSIGNMENT_RE = re.compile(
r"""(?ix)
\b(password|token|api[_-]?key|secret)\b
(\s*[:=]\s*)
("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)
"""
)
def _sanitize_visible_text(value, *, max_length):
text = str(value or "").strip()
text = _AUTHORIZATION_RE.sub(lambda match: f"authorization{match.group(1)}***", text)
text = _SENSITIVE_ASSIGNMENT_RE.sub(
lambda match: f"{match.group(1)}{match.group(2)}***",
text,
)
return text[:max_length]
def default_playbook_scripts_dir():
return Path(settings.BASE_DIR) / "playbooks"
@@ -65,6 +92,18 @@ def scan_playbook_definitions(*, scripts_dir=None, scripts_dirs=None):
errors.append({"path": str(path), "error": "Failed to load playbook definition."})
continue
if definition is not None:
risk_level = getattr(definition.script_class, "RISK_LEVEL", "Low")
if risk_level not in PLAYBOOK_RISK_LEVELS:
logger.error(
"Invalid playbook risk level: path=%s risk_level=%r",
path,
risk_level,
)
errors.append({
"path": str(path),
"error": f"RISK_LEVEL must be one of: {', '.join(sorted(PLAYBOOK_RISK_LEVELS))}.",
})
continue
definitions.append(definition)
return definitions, errors
@@ -81,6 +120,7 @@ def list_playbook_definitions(*, include_path=False, scripts_dir=None):
"name": item.name,
"description": getattr(item.script_class, "DESC", ""),
"tags": _normalize_tags(getattr(item.script_class, "TAGS", [])),
"risk_level": item.script_class.RISK_LEVEL,
}
if include_path:
data["path"] = str(item.path)
@@ -119,7 +159,7 @@ def claim_pending_playbook_run():
Playbook.objects
.select_for_update()
.filter(job_status=PlaybookJobStatus.PENDING)
.order_by("created_at")
.order_by("created_at", "id")
.first()
)
if playbook is None:
@@ -127,19 +167,55 @@ def claim_pending_playbook_run():
playbook.job_status = PlaybookJobStatus.RUNNING
playbook.job_id = str(uuid.uuid4())
playbook.started_at = timezone.now()
playbook.finished_at = None
playbook.remark = ""
playbook.save(update_fields=["job_status", "job_id", "remark", "updated_at"])
with suppress_audit():
playbook.save(update_fields=[
"job_status",
"job_id",
"started_at",
"finished_at",
"remark",
"updated_at",
])
return playbook
@transaction.atomic
def add_playbook_run_message(playbook, message):
if not isinstance(message, str):
raise TypeError("Run message must be a string.")
sanitized = _sanitize_visible_text(message, max_length=MAX_RUN_MESSAGE_LENGTH)
if not sanitized:
raise ValueError("Run message must not be empty.")
locked = Playbook.objects.select_for_update().get(pk=playbook.pk)
if locked.job_status != PlaybookJobStatus.RUNNING:
raise ValueError("Run messages can only be added while the playbook is Running.")
last_sequence = (
PlaybookRunMessage.objects
.filter(playbook_run=locked)
.aggregate(value=Max("sequence"))["value"]
or 0
)
return PlaybookRunMessage.objects.create(
playbook_run=locked,
sequence=last_sequence + 1,
message=sanitized,
)
@transaction.atomic
def mark_playbook_success(playbook, remark):
locked = Playbook.objects.select_for_update().get(pk=playbook.pk)
if locked.job_status != PlaybookJobStatus.RUNNING:
raise ValueError(f"Playbook must be Running before success, got {locked.job_status}")
locked.job_status = PlaybookJobStatus.SUCCESS
locked.remark = remark
locked.save(update_fields=["job_status", "remark", "updated_at"])
locked.finished_at = timezone.now()
locked.remark = _sanitize_visible_text(remark, max_length=MAX_RUN_REMARK_LENGTH)
with suppress_audit():
locked.save(update_fields=["job_status", "finished_at", "remark", "updated_at"])
notify_playbook_completion(locked)
return locked
@@ -150,8 +226,31 @@ def mark_playbook_failed(playbook, error):
if locked.job_status != PlaybookJobStatus.RUNNING:
raise ValueError(f"Playbook must be Running before failure, got {locked.job_status}")
locked.job_status = PlaybookJobStatus.FAILED
locked.finished_at = timezone.now()
logger.exception("Playbook execution failed", exc_info=error)
locked.remark = "Playbook execution failed."
locked.save(update_fields=["job_status", "remark", "updated_at"])
with suppress_audit():
locked.save(update_fields=["job_status", "finished_at", "remark", "updated_at"])
notify_playbook_completion(locked)
return locked
def recover_orphaned_playbook_runs():
with transaction.atomic():
orphaned_runs = list(
Playbook.objects
.select_for_update()
.select_related("user", "case")
.filter(job_status=PlaybookJobStatus.RUNNING)
)
finished_at = timezone.now()
for playbook in orphaned_runs:
playbook.job_status = PlaybookJobStatus.FAILED
playbook.finished_at = finished_at
playbook.remark = ORPHANED_RUN_REMARK
with suppress_audit():
playbook.save(update_fields=["job_status", "finished_at", "remark", "updated_at"])
for playbook in orphaned_runs:
notify_playbook_completion(playbook)
return len(orphaned_runs)
+14
View File
@@ -9,6 +9,10 @@ def get_current_actor():
return getattr(_state, "actor", None)
def audit_is_suppressed():
return getattr(_state, "suppressed", False)
@contextmanager
def audit_actor(actor):
previous = get_current_actor()
@@ -17,3 +21,13 @@ def audit_actor(actor):
yield
finally:
_state.actor = previous
@contextmanager
def suppress_audit():
previous = audit_is_suppressed()
_state.suppressed = True
try:
yield
finally:
_state.suppressed = previous
+7 -5
View File
@@ -4,7 +4,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de
from django.dispatch import receiver
from apps.common.models import BaseModel
from .context import get_current_actor
from .context import audit_is_suppressed, get_current_actor
from .helpers import readable_label, write_relation_event
from .models import AuditLog
@@ -77,7 +77,7 @@ def write_delete_relation_events(sender, instance):
@receiver(pre_save)
def capture_previous_state(sender, instance, **kwargs):
if not audit_model(sender) or not instance.pk:
if audit_is_suppressed() or not audit_model(sender) or not instance.pk:
instance._audit_previous = None
return
instance._audit_previous = sender.objects.filter(pk=instance.pk).first()
@@ -85,7 +85,7 @@ def capture_previous_state(sender, instance, **kwargs):
@receiver(pre_delete)
def capture_delete_relation_parents(sender, instance, **kwargs):
if not audit_model(sender):
if audit_is_suppressed() or not audit_model(sender):
return
parents = {}
for field_name in relation_fields(sender):
@@ -96,7 +96,7 @@ def capture_delete_relation_parents(sender, instance, **kwargs):
@receiver(post_save)
def log_save(sender, instance, created, **kwargs):
if not audit_model(sender):
if audit_is_suppressed() or not audit_model(sender):
return
action = "create" if created else "update"
previous = getattr(instance, "_audit_previous", None)
@@ -114,7 +114,7 @@ def log_save(sender, instance, created, **kwargs):
@receiver(post_delete)
def log_delete(sender, instance, **kwargs):
if not audit_model(sender):
if audit_is_suppressed() or not audit_model(sender):
return
AuditLog.objects.create(
content_type=ContentType.objects.get_for_model(sender),
@@ -128,6 +128,8 @@ def log_delete(sender, instance, **kwargs):
@receiver(m2m_changed)
def log_many_to_many_change(sender, instance, action, reverse, model, pk_set, **kwargs):
if audit_is_suppressed():
return
if action not in {"post_add", "post_remove", "post_clear"}:
return
if not isinstance(instance, BaseModel):
@@ -0,0 +1,41 @@
# Generated by Django 6.0.7 on 2026-08-05 15:21
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playbooks', '0002_playbook_playbook_created_job_idx'),
]
operations = [
migrations.AddField(
model_name='playbook',
name='finished_at',
field=models.DateTimeField(blank=True, help_text='Execution finish time (执行结束时间)', null=True),
),
migrations.AddField(
model_name='playbook',
name='started_at',
field=models.DateTimeField(blank=True, help_text='Execution start time (执行开始时间)', null=True),
),
migrations.CreateModel(
name='PlaybookRunMessage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('sequence', models.PositiveBigIntegerField()),
('message', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('playbook_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='run_messages', to='playbooks.playbook')),
],
options={
'db_table': 'playbook_run_messages',
'ordering': ['sequence'],
'indexes': [models.Index(fields=['playbook_run', 'sequence'], name='playbook_msg_run_seq_idx')],
'constraints': [models.UniqueConstraint(fields=('playbook_run', 'sequence'), name='playbook_msg_run_seq_uniq')],
},
),
]
+32
View File
@@ -1,3 +1,5 @@
import uuid
from django.conf import settings
from django.db import models
@@ -27,6 +29,8 @@ class Playbook(BaseModel):
help_text="Background job status (后台任务状态)",
)
job_id = models.CharField(max_length=255, blank=True, default="", help_text="Background job ID (后台任务 ID)")
started_at = models.DateTimeField(null=True, blank=True, help_text="Execution start time (执行开始时间)")
finished_at = models.DateTimeField(null=True, blank=True, help_text="Execution finish time (执行结束时间)")
remark = models.TextField(blank=True, default="", help_text="Execution remark (执行备注)")
class Meta:
@@ -41,3 +45,31 @@ class Playbook(BaseModel):
def __str__(self):
return self.name or str(self.id)
class PlaybookRunMessage(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
playbook_run = models.ForeignKey(
Playbook,
on_delete=models.CASCADE,
related_name="run_messages",
)
sequence = models.PositiveBigIntegerField()
message = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "playbook_run_messages"
ordering = ["sequence"]
constraints = [
models.UniqueConstraint(
fields=["playbook_run", "sequence"],
name="playbook_msg_run_seq_uniq",
),
]
indexes = [
models.Index(
fields=["playbook_run", "sequence"],
name="playbook_msg_run_seq_idx",
),
]
+42 -3
View File
@@ -1,6 +1,7 @@
from rest_framework import serializers
from django.utils import timezone
from .models import Playbook
from .models import Playbook, PlaybookJobStatus, PlaybookRunMessage
class PlaybookSerializer(serializers.ModelSerializer):
@@ -8,8 +9,46 @@ class PlaybookSerializer(serializers.ModelSerializer):
case_title = serializers.CharField(source="case.title", read_only=True)
case_id = serializers.UUIDField(source="case.id", read_only=True)
case_readable_id = serializers.CharField(source="case.case_id", read_only=True)
duration_seconds = serializers.SerializerMethodField()
def get_duration_seconds(self, obj):
if obj.started_at is None:
return None
if obj.finished_at is not None:
end = obj.finished_at
elif obj.job_status == PlaybookJobStatus.RUNNING:
end = timezone.now()
else:
return None
return max(0, int((end - obj.started_at).total_seconds()))
class Meta:
model = Playbook
fields = "__all__"
read_only_fields = ("id", "playbook_id", "created_at", "updated_at")
fields = (
"id",
"playbook_id",
"case",
"case_id",
"case_readable_id",
"case_title",
"name",
"user_input",
"user",
"user_username",
"job_status",
"job_id",
"started_at",
"finished_at",
"duration_seconds",
"remark",
"created_at",
"updated_at",
)
read_only_fields = fields
class PlaybookRunMessageSerializer(serializers.ModelSerializer):
class Meta:
model = PlaybookRunMessage
fields = ("id", "sequence", "message", "created_at")
read_only_fields = fields
+15 -4
View File
@@ -10,23 +10,22 @@ from rest_framework.response import Response
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
from apps.agentic.services.playbooks import create_pending_playbook_run, list_playbook_definitions
from apps.audit.context import audit_actor
from apps.audit.mixins import AuditActorMixin
from apps.cases.models import Case
from apps.common.advanced_filters import AdvancedFilterBackend
from .models import Playbook
from .serializers import PlaybookSerializer
from .serializers import PlaybookRunMessageSerializer, PlaybookSerializer
logger = logging.getLogger(__name__)
class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet):
class PlaybookViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Playbook.objects.select_related("user", "case")
serializer_class = PlaybookSerializer
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
lookup_field = "id"
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
search_fields = ("playbook_id", "name", "job_id", "user_input", "remark")
ordering_fields = ("created_at", "updated_at", "job_status")
ordering_fields = ("created_at", "updated_at", "job_status", "started_at", "finished_at")
filterset_fields = ("job_status", "case__id")
advanced_filter_fields = {
"playbook_id": "text",
@@ -37,12 +36,24 @@ class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet):
"remark": "text",
"created_at": "date",
"updated_at": "date",
"started_at": "date",
"finished_at": "date",
}
@action(detail=False, methods=["get"], url_path="definitions")
def definitions(self, request):
return Response(list_playbook_definitions())
@action(detail=True, methods=["get"], url_path="messages")
def messages(self, request, id=None):
playbook = self.get_object()
queryset = playbook.run_messages.order_by("sequence")
page = self.paginate_queryset(queryset)
if page is not None:
serializer = PlaybookRunMessageSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
return Response(PlaybookRunMessageSerializer(queryset, many=True).data)
@action(detail=False, methods=["post"], url_path="run")
def run(self, request):
name = request.data.get("name")
+3
View File
@@ -10,10 +10,13 @@ class Playbook(BasePlaybook):
def run(self):
if self.case is None:
raise ValueError("Investigation playbook requires a linked case.")
self.add_run_message(f"Starting investigation for {self.case.case_id.upper()}.")
self.add_run_message("Generating the AI investigation report.")
result = run_case_analysis(
case=self.case,
trigger="playbook",
user_input=self.user_input,
source=self.playbook_run,
)
self.add_run_message("Investigation report generated.")
return f"Investigation completed: {result.report.digest}"
+70 -162
View File
@@ -4,9 +4,9 @@ Status: Confirmed
## 1. Purpose
保留低心智成本的 Python `run()` 编程模型,同时增加可持久化的结构化执行阶段、明确时间、取消、完整重试、崩溃恢复和只读运行历史。
保留低心智成本的 Python `run()` 编程模型,同时增加可选的 UI 可见运行消息、明确时间、崩溃恢复和只读运行历史。
该设计不是工作流引擎。Stage 是观测事件,不是独立调度、恢复或重试的 Step
该设计不是工作流引擎,也不要求开发者拆分或声明执行阶段
## 2. Authoring model
@@ -20,26 +20,25 @@ class Playbook(BasePlaybook):
RISK_LEVEL = "High"
def run(self):
with self.stage("collect", "Collect endpoint context") as stage:
endpoints = collect_endpoints(self.case)
stage.summary = f"Collected {len(endpoints)} endpoint(s)."
self.add_run_message("Collecting endpoint context.")
endpoints = collect_endpoints(self.case)
self.add_run_message(f"Collected {len(endpoints)} endpoint(s).")
for endpoint in endpoints:
with self.stage("contain", f"Contain {endpoint.hostname}") as stage:
contain(endpoint)
stage.summary = "Containment request accepted."
contain(endpoint)
self.add_run_message("Containment requests submitted.")
return f"Contained {len(endpoints)} endpoint(s)."
```
规则:
- `run()` 仍是唯一执行入口。
- Stage 完全可选;现有 v0.5.2 Playbook 无需修改即可运行。
- Stage 不保存 Python 返回值或跨阶段输入
- Stage 不独立调度、不暂停、不恢复、不单步重试
- `add_run_message(message)` 完全可选;现有 v0.5.2 Playbook 无需修改即可运行。
- Run Message 只是当前 Run 的 UI 可见文本,不保存 Python 返回值或执行上下文
- 平台不捕获 `print()` 或 Python `logging` 输出;服务器日志与 UI 可见消息保持分离
- `run()` 未捕获异常导致整个 Run Failed。
- 开发者可以在 Stage 内捕获可容忍错误,并显式写安全 summary
- 开发者可以捕获可容忍错误,并通过 `add_run_message()` 写入安全说明
- Playbook 可直接使用 `httpx` 或厂商 SDK;平台不提供通用 Connector。
## 3. Explicit exclusions
@@ -47,8 +46,10 @@ class Playbook(BasePlaybook):
- 可视化/表单式编排器。
- DAG、分支、并行 Step。
- 中途人工审批;点击 Run 即授权整个 Playbook。
- Running hard cancel 或 cooperative cancel
- 单步 retry/resume。
- Pending 或 Running 取消
- 专用 Retry、retry lineage 或单步 resume。
- 结构化 Stage 或执行步骤。
- 自动捕获 `print()` 或 Python `logging`
- 源码、hash 或定义版本锁定。
- 结构化 input schema。
- HTTP/Webhook Connection profile。
@@ -72,12 +73,9 @@ class Playbook(BasePlaybook):
默认 Low。风险只用于 UI 展示,不改变权限、确认或执行流程。
Run 不保存 DESC/TAGS/RISK_LEVEL 快照历史页面按 name 解析当前定义
Definition 选择界面展示当前扫描到的 metadata。Run 不保存 metadata 快照历史列表和详情只展示 Run 已保存的 name,不解析当前定义
- 定义仍存在:展示当前 metadata
- 定义已删除或改名:只展示 Run 保存的 name,并标记 definition unavailable。
Pending/Retry 执行时始终加载当前最新 Python 代码。
Pending 执行时始终加载当前最新 Python 代码
## 5. Run model
@@ -87,12 +85,10 @@ Pending/Retry 执行时始终加载当前最新 Python 代码。
| Field | Type | Semantics |
| --- | --- | --- |
| job_status | enum | Pending/Running/Success/Failed/Cancelled |
| job_status | enum | Pending/Running/Success/Failed |
| job_id | string/UUID | 当前执行标识 |
| retry_of | nullable self FK | Failed Run 的重试来源 |
| started_at | nullable datetime | claim 成功时间 |
| finished_at | nullable datetime | terminal 时间 |
| cancelled_by | nullable FK User | Pending cancel actor |
| remark | text | 终态安全摘要 |
保留:
@@ -107,11 +103,10 @@ Pending/Retry 执行时始终加载当前最新 Python 代码。
| Current | Allowed next |
| --- | --- |
| Pending | Running, Cancelled |
| Pending | Running |
| Running | Success, Failed |
| Success | none |
| Failed | none; Retry creates new Run |
| Cancelled | none |
| Failed | none |
不允许直接修改 job_status。所有状态变化通过 domain service。
@@ -120,64 +115,43 @@ Pending/Retry 执行时始终加载当前最新 Python 代码。
- Pending 创建时 started_at/finished_at 为空。
- Pending→Running 设置 started_at。
- Running→Success/Failed 设置 finished_at。
- Pending→Cancelled 设置 finished_at,不设置 started_at。
- duration_seconds 由 started_at 和 finished_at 计算;Running 使用 now-started_at。
## 6. Stage model
## 6. Run Message model
建议模型 `PlaybookStage`
建议模型 `PlaybookRunMessage`
| Field | Type | Notes |
| --- | --- | --- |
| id | UUID | primary key |
| playbook_run | FK | CASCADE at DB level, though Run API cannot delete |
| sequence | positive bigint | per Run append order |
| key | string | developer supplied, may repeat |
| label | string | human-readable |
| status | enum | Running/Success/Failed |
| summary | text | explicit safe summary |
| error_type | string | sanitized exception class |
| error_message | text | sanitized length-limited message |
| started_at | datetime | context enter |
| finished_at | nullable datetime | context exit |
| duration_ms | nullable bigint | derived/persisted |
| message | text | UI-visible safe message |
| created_at | datetime | append time |
Constraints/indexes:
- unique `(playbook_run, sequence)`.
- index `(playbook_run, sequence)`.
- index `(playbook_run, status)`.
- key/label 长度必须有限,但 Stage 数量不限。
- message 经过长度限制。
### Stage context behavior
### `add_run_message()` behavior
进入 `with self.stage(key, label)`
调用 `self.add_run_message(message)`
1. 原子分配下一个 sequence
2. 创建 Running Stage
3. 返回可设置 `summary` 的 context object
1. 只接受非空字符串
2. 对 message 执行敏感字段过滤和长度限制
3. 原子分配当前 Run 的下一个 sequence
4. 持久化消息,供 Run 详情 UI 按 sequence 展示。
正常退出:
1. 保存显式 summary。
2. 设置 Success、finished_at、duration。
异常退出:
1. 设置 Failed。
2. 保存异常类型。
3. 保存经过敏感字段过滤和长度限制的安全错误。
4. 完整 traceback 只进入 Worker log。
5. 原异常继续抛出,使 Run Failed。
Stage key/label 可以重复,支持循环动态生成。Stage 是扁平 sequence,不支持 parent。
该方法返回 `None`,不提供 level、结构化字段、进度百分比或消息更新能力。开发者应记录少量有意义的执行信息,而不是逐条输出循环数据。
### Output safety
- 不自动 `str()` 或 JSON serialize 任意函数输出。
- 不自动保存 HTTP response、LLM output、SIEM records 或变量值。
- summary 由自定义代码显式提供。
- error_message 使用统一 sanitizer,至少屏蔽 password/token/api_key/secret/authorization 等值。
- message 由自定义代码显式提供,并视为最终用户可见内容
- message 使用统一 sanitizer,至少屏蔽 password/token/api_key/secret/authorization 等值。
- API 不返回 traceback。
## 7. Queue and Worker behavior
@@ -188,14 +162,6 @@ Stage key/label 可以重复,支持循环动态生成。Stage 是扁平 sequen
- 全局 FIFO,按 created_at/id claim。
- 不按 Case Severity 或用户优先级排序。
### Per-Case concurrency
同一 Case 最多一个 Running Run
- claim 时跳过已经存在 Running Run 的 Case。
- 同 Case 的其他 Pending 保持队列。
- 不同 Case 在未来多 Worker 实现中可并行,但 v0.6.0 不支持多 Worker。
### Duplicate launch
Run endpoint 不提供 idempotency。重复请求可以创建多条 Pending Run,这是已接受行为。
@@ -205,45 +171,13 @@ Run endpoint 不提供 idempotency。重复请求可以创建多条 Pending Run
Playbook Worker 使用 Worker Health 心跳。检测到前一实例丢失后:
- 遗留 Running Run 标记 Failed。
- 遗留 Running Stage 标记 Failed。
- remark 使用固定安全文本,说明 Worker stopped before completion。
- 不自动重置 Pending 或重跑。
- 用户检查后手动 Retry
- 用户检查后可重新发起 Run
实现可在 Worker 成功获取 singleton lease 后执行 orphan recovery。不得仅按运行时长把合法长任务判失败。
## 8. Cancellation
只有 Pending 可取消。
`POST /api/playbooks/{id}/cancel/`
- Admin/User 可取消任意 Pending Run。
- Viewer 403。
- 非 Pending 返回 409。
- 设置 Cancelled、finished_at、cancelled_by 和安全 remark。
- 写 AuditLog。
- 不发送 completion notification。
Running 无取消入口。Playbook 中每个外部调用必须自行设置超时。
## 9. Retry
`POST /api/playbooks/{id}/retry/`
- 仅 Failed Run。
- Admin/User 可重试任意 Failed Run。
- Viewer 403。
- 创建新的 Pending Run。
- `retry_of` 指向原 Run。
- 复制 case、name、user_input。
- 新 Run 的 user 是执行 Retry 的当前用户。
- 使用当前 Case 数据和最新 Playbook 代码。
- 原 Run/Stage 不修改。
- 写 retry AuditLog,关联新旧 Run。
- Retry 不受幂等保护;重复点击可能创建多个 Run。
## 10. Launch
## 8. Launch
`POST /api/playbooks/run/`
@@ -255,7 +189,7 @@ Running 无取消入口。Playbook 中每个外部调用必须自行设置超时
- 点击 Run 直接创建 Pending,不增加确认。
- risk level 只展示。
## 11. API shape
## 9. API shape
Playbook Run 资源改为 read-only
@@ -263,9 +197,7 @@ Playbook Run 资源改为 read-only
- GET retrieve。
- GET definitions。
- POST run。
- POST cancel
- POST retry。
- GET stagesdetail action 或独立 nested endpoint)。
- GET messagesdetail action 或独立 nested endpoint
禁止:
@@ -273,11 +205,11 @@ Playbook Run 资源改为 read-only
- PUT/PATCH。
- DELETE。
Stage API
Run Message API
- 只读。
- 必须按 sequence 分页。
- 支持 status 筛选可选,但不得一次返回无限 Stage
- 不得一次返回无限消息
### Run response additions
@@ -286,58 +218,40 @@ Stage API
"id": "...",
"playbook_id": "playbook_000123",
"job_status": "Running",
"retry_of": null,
"started_at": "2026-07-30T08:00:00Z",
"finished_at": null,
"duration_seconds": 42,
"current_stage": {
"sequence": 8,
"key": "contain",
"label": "Contain endpoint-7",
"status": "Running"
},
"stage_count": 8,
"definition": {
"available": true,
"risk_level": "High",
"tags": ["EDR", "Response"]
}
"duration_seconds": 42
}
```
## 12. Remark semantics
## 10. Remark semantics
| Terminal state | Remark |
| --- | --- |
| Success | `str(run() return value)`,经长度限制和安全处理 |
| Failed | 固定安全摘要,可引用失败 Stage label/sequence |
| Cancelled | 固定文本并记录取消 actor |
| Failed | 固定安全摘要 |
过程日志不得拼接到 remark。原始异常只进服务器日志。
Run Message 不得拼接到 remark。原始异常只进服务器日志。
## 13. Notifications
## 11. Notifications
遵循发起用户现有 `notify_on_playbook_completion` 偏好:
- Success 通知。
- Failed 通知。
- Cancelled 不通知。
- Stage 状态变化不通知。
- Retry 新 Run 按新发起用户偏好处理。
- Run Message 不通知。
## 14. Audit
## 12. Audit
只记录用户动作:
- launch
- cancel
- retry
Worker 自动状态变化不写全局 AuditLog,因为 Run/Stage 已是状态事实。
Worker 自动状态变化不写全局 AuditLog,因为 Run 和 Run Message 已是状态事实。
Audit metadata 不包含 user_input 全文、Stage summary 或任何 Secret。
Audit metadata 不包含 user_input 全文、Run Message 或任何 Secret。
## 15. Frontend
## 13. Frontend
### Definition selection
@@ -347,42 +261,36 @@ Audit metadata 不包含 user_input 全文、Stage summary 或任何 Secret。
### Run list/detail
- 状态、Case、发起人、时间duration、retry relation
- Pending 显示 Cancel
- Failed 显示 Retry
- Run/Stage 无 Delete/Edit。
- Stage 使用分页的扁平时间线或表格。
- 状态、Case、发起人、时间duration。
- Run/Run Message 无 Delete/Edit
- Run Message 在详情中按 sequence 分页展示
- 页面不自动轮询、不使用 WebSocket;提供 Refresh。
- definition 删除后显示 unavailable,而不是报页面错误。
## 16. Migration
## 14. Migration
- 现有四状态数据直接保留。
- 新 Cancelled 只用于 v0.6.0 后记录。
- 已有 Success/Failed Run 的 started_at 可为空,不伪造历史时间。
- 旧 Running Run 在升级后由首次 Worker recovery 处理。
- retry_of、timing、cancel actor 均 nullable。
- started_at、finished_at 均 nullable。
## 17. Acceptance criteria
## 15. Acceptance criteria
1. v0.5.2 旧 Playbook 不修改即可运行。
2. 可选 Stage 正确保存动态重复 key 和 sequence
3. Stage 异常导致 Stage/Run FailedAPI 不泄露 traceback。
4. Pending 可取消,Running 不可取消
5. Failed Retry 创建新 Run并保留原历史
6. Run/Stage 所有普通 mutation/delete 被拒绝
7. 同一 Case 不同时 Running 两个 Run
8. FIFO claim 可预测
9. Worker 崩溃后遗留 Running 标记 Failed且不自动重跑
10. Closed Case 可运行,Case Relationship 不影响运行。
11. Success/Failed 通知符合用户偏好。
12. Stage 数量大时 API 正确分页。
2. 可选 `add_run_message()` 按 sequence 保存并可在 UI 查询
3. Run 异常导致 Run FailedAPI 不泄露 traceback。
4. Run/Run Message 所有普通 mutation/delete 被拒绝
5. FIFO claim 可预测
6. Worker 崩溃后遗留 Running 标记 Failed且不自动重跑
7. Closed Case 可运行,Case Relationship 不影响运行
8. Success/Failed 通知符合用户偏好
9. Run Message 数量大时 API 正确分页
## 18. Known tradeoffs
## 16. Known tradeoffs
- 最新代码执行使 Pending Run 语义可能在排队期间变化。
- 不保存 metadata 快照,历史 risk/tags 会随定义变化
- 重复 launch/retry 可产生重复外部副作用。
- Running 不可取消。
- 动态无限 Stage 可能产生大量数据,开发者需自律;平台只通过分页保护读取
- 历史 Run 不展示 description、tags 或 risk level
- 重复 launch 可产生重复外部副作用。
- Pending 和 Running 不可取消。
- Failed Run 没有专用 Retry;用户需要重新发起
- 频繁调用 `add_run_message()` 可能产生大量数据,开发者需自律;平台只通过分页保护读取。
- 直接 httpx 调用的重试、幂等和 Secret 安全由自定义代码负责。
@@ -6,6 +6,7 @@ import type {ColumnsType} from 'antd/es/table'
import client from '../api/client'
import OverflowTags from './OverflowTags'
import {comfortableTagProps} from '../utils/tagStyles'
import {severityTag} from '../utils/recordDisplay'
type RecordRow = Record<string, unknown>
@@ -13,6 +14,7 @@ interface PlaybookDefinition {
name: string
description: string
tags: string[]
risk_level: 'Low' | 'Medium' | 'High' | 'Critical'
}
interface CasePlaybookActionProps {
@@ -156,11 +158,23 @@ function CasePlaybookRunModal({ open, caseId, onClose, onSubmitted }: CasePlaybo
return [
definition.name,
definition.description,
definition.risk_level,
...normalizeTags(definition.tags),
].some((value) => value.toLowerCase().includes(keyword))
},
render: (name: string) => <Typography.Text strong>{name}</Typography.Text>,
},
{
title: 'Risk',
dataIndex: 'risk_level',
width: 140,
filters: ['Low', 'Medium', 'High', 'Critical'].map((riskLevel) => ({
text: riskLevel,
value: riskLevel,
})),
onFilter: (filterValue, definition) => definition.risk_level === filterValue,
render: (riskLevel: string) => severityTag(riskLevel),
},
{
title: 'Tags',
dataIndex: 'tags',
@@ -256,7 +270,10 @@ function CasePlaybookRunModal({ open, caseId, onClose, onSubmitted }: CasePlaybo
<>
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>{selectedDefinition.name}</Typography.Title>
<PlaybookTags tags={selectedDefinition.tags} />
<Space wrap>
{severityTag(selectedDefinition.risk_level)}
<PlaybookTags tags={selectedDefinition.tags} />
</Space>
</div>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
{selectedDefinition.description || 'No description.'}
+37 -16
View File
@@ -2,10 +2,10 @@ import type {ReactNode} from 'react'
import type {DescriptionsProps} from 'antd'
import {Button, Descriptions} from 'antd'
import {DescriptionValue} from './DescriptionValue'
import DetailSectionDivider from './DetailSectionDivider'
import {descriptionStyles} from './descriptionValueStyles'
import {emptyValue} from '../utils/recordDisplay'
import {emptyValue, formatDateTime, formatDurationSeconds} from '../utils/recordDisplay'
import {monoTextStyle} from '../utils/typography'
import PlaybookRunMessagesView from './PlaybookRunMessagesView'
type RecordRow = Record<string, unknown>
@@ -22,19 +22,16 @@ const upperStringValue = (record: RecordRow, key: string) => {
return displayValue === '—' ? displayValue : displayValue.toUpperCase()
}
function Section({ title, items, showTitle = true }: { title?: string; items: DescriptionsProps['items']; showTitle?: boolean }) {
function SummarySection({items}: {items: DescriptionsProps['items']}) {
return (
<div style={{ marginTop: showTitle ? 16 : 0 }}>
{showTitle && title && <DetailSectionDivider title={title} />}
<Descriptions
size="small"
layout="vertical"
colon={false}
column={4}
items={items}
styles={descriptionStyles}
/>
</div>
<Descriptions
size="small"
layout="vertical"
colon={false}
column={4}
items={items}
styles={descriptionStyles}
/>
)
}
@@ -57,6 +54,7 @@ function blockItem(key: string, label: string, children: string) {
}
export default function PlaybookBasicView({ record, onOpenResource, renderStatus }: PlaybookBasicViewProps) {
const playbookRunId = String(value(record, 'id') || '')
const caseRowId = value(record, 'case_id') as string | number | null | undefined
const caseReadableId = upperStringValue(record, 'case_readable_id')
const caseLabel = caseReadableId === '—'
@@ -82,6 +80,9 @@ export default function PlaybookBasicView({ record, onOpenResource, renderStatus
item('user', 'User', stringValue(record, 'user_username')),
item('name', 'Name', stringValue(record, 'name')),
{ key: 'job-id', label: 'Job ID', children: <DescriptionValue><span style={monoTextStyle}>{stringValue(record, 'job_id')}</span></DescriptionValue> },
item('started-at', 'Started Time', formatDateTime(String(value(record, 'started_at') || ''))),
item('finished-at', 'Finished Time', formatDateTime(String(value(record, 'finished_at') || ''))),
item('duration', 'Duration', formatDurationSeconds(value(record, 'duration_seconds'))),
]
const inputItems: DescriptionsProps['items'] = [
@@ -91,8 +92,28 @@ export default function PlaybookBasicView({ record, onOpenResource, renderStatus
return (
<div style={{ padding: '20px 20px 16px', overflow: 'auto', height: '100%', boxSizing: 'border-box' }}>
<Section showTitle={false} items={summaryItems} />
<Section title="Input & Result" items={inputItems} />
<SummarySection items={summaryItems} />
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(320px, 2fr) minmax(0, 3fr)',
gap: 24,
alignItems: 'start',
marginTop: 24,
}}>
<div style={{minWidth: 0}}>
<Descriptions
size="small"
layout="vertical"
colon={false}
column={4}
items={inputItems}
styles={descriptionStyles}
/>
</div>
<div style={{minWidth: 0}}>
<PlaybookRunMessagesView playbookRunId={playbookRunId} refreshToken={record} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,121 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Alert, Empty, Table} from 'antd'
import type {ColumnsType, TablePaginationConfig} from 'antd/es/table'
import client from '../api/client'
import {formatDateTime} from '../utils/recordDisplay'
interface PlaybookRunMessage {
id: string
sequence: number
message: string
created_at: string
}
interface PaginatedMessages {
count: number
results: PlaybookRunMessage[]
}
function errorMessage(error: unknown) {
const detail = (error as {response?: {data?: {detail?: unknown}}}).response?.data?.detail
return typeof detail === 'string' ? detail : 'Failed to load run messages'
}
export default function PlaybookRunMessagesView({
playbookRunId,
refreshToken,
}: {
playbookRunId: string
refreshToken?: unknown
}) {
const [messages, setMessages] = useState<PlaybookRunMessage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [total, setTotal] = useState(0)
const requestIdRef = useRef(0)
const loadMessages = useCallback(async (nextPage: number, nextPageSize: number) => {
void refreshToken
if (!playbookRunId) return
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
setLoading(true)
setError('')
try {
const {data} = await client.get<PaginatedMessages>(
`/playbooks/${encodeURIComponent(playbookRunId)}/messages/`,
{params: {page: nextPage, page_size: nextPageSize}},
)
if (requestId !== requestIdRef.current) return
setMessages(data.results)
setTotal(data.count)
} catch (loadError) {
if (requestId !== requestIdRef.current) return
setMessages([])
setTotal(0)
setError(errorMessage(loadError))
} finally {
if (requestId === requestIdRef.current) setLoading(false)
}
}, [playbookRunId, refreshToken])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadMessages(page, pageSize)
return () => {
requestIdRef.current += 1
}
}, [loadMessages, page, pageSize])
const columns = useMemo<ColumnsType<PlaybookRunMessage>>(() => [
{
title: '#',
dataIndex: 'sequence',
width: 88,
},
{
title: 'Time',
dataIndex: 'created_at',
width: 220,
render: (value: string) => formatDateTime(value),
},
{
title: 'Message',
dataIndex: 'message',
render: (value: string) => (
<span style={{whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>{value}</span>
),
},
], [])
const handleTableChange = (pagination: TablePaginationConfig) => {
setPage(pagination.current || 1)
setPageSize(pagination.pageSize || 20)
}
return (
<div style={{minWidth: 0}}>
{error && <Alert type="error" showIcon title={error} style={{marginBottom: 12}}/>}
<Table<PlaybookRunMessage>
rowKey="id"
size="small"
columns={columns}
dataSource={messages}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (count) => `${count} message${count === 1 ? '' : 's'}`,
}}
locale={{emptyText: loading ? <span/> : <Empty description="No run messages"/>}}
scroll={{y: 320}}
/>
</div>
)
}
@@ -70,6 +70,7 @@ export default function ResourceListPage({ resourceKey, actions }: ResourceListP
}}
onOpenResource={openRelatedDetail}
refreshToken={tableRefreshToken}
readOnly={config.readOnly}
dense
fillParent
/>
+7
View File
@@ -37,6 +37,7 @@ import {
emptyValue,
emptyValueNode,
formatDateTime,
formatDurationSeconds,
knowledgeSourceTag,
productCategoryTag,
severityTag,
@@ -842,6 +843,7 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
icon: <BrainCircuit {...lucideIconProps}/>,
endpoint: '/playbooks/',
rowKey: 'id',
readOnly: true,
searchPlaceholder: 'Playbook ID, Name, Job ID, Remark',
filters: [{key: 'job_status', label: 'Status', valueType: 'select', width: L132}],
advancedFilters: [
@@ -853,6 +855,8 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
field('remark', 'Remark', 'text'),
field('created_at', 'Created Time', 'date'),
field('updated_at', 'Updated Time', 'date'),
field('started_at', 'Started Time', 'date'),
field('finished_at', 'Finished Time', 'date'),
],
columns: [
column('playbook_id', 'Playbook ID', L160, {required: true, defaultVisible: true, fixed: 'left', openRecord: true, uppercase: true}),
@@ -868,6 +872,9 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
}),
column('user_username', 'User', L160, {defaultVisible: true}),
column('job_id', 'Job ID', L240, {defaultVisible: true}),
column('started_at', 'Started Time', L160, {defaultVisible: true, sorter: true, render: date('started_at')}),
column('finished_at', 'Finished Time', L160, {defaultVisible: true, sorter: true, render: date('finished_at')}),
column('duration_seconds', 'Duration', L132, {defaultVisible: true, render: (v) => formatDurationSeconds(v)}),
column('created_at', 'Created Time', L160, {defaultVisible: true, sorter: true, render: date('created_at')}),
column('updated_at', 'Updated Time', L160, {defaultVisible: true, sorter: true, render: date('updated_at')}),
column('user_input', 'User Input', L400),
+5
View File
@@ -10,6 +10,7 @@ import IconTabLabel from '../components/IconTabLabel'
import OverflowTags from '../components/OverflowTags'
import CustomVariablesSettings from './CustomVariablesSettings'
import {comfortableTagProps} from '../utils/tagStyles'
import {severityTag} from '../utils/recordDisplay'
type SourceType = 'official' | 'custom'
type SourceFilter = 'all' | SourceType
@@ -59,6 +60,7 @@ interface PlaybookDefinition {
name: string
description: string
tags: string[]
risk_level: 'Low' | 'Medium' | 'High' | 'Critical'
source: SourceType
path: string
}
@@ -426,6 +428,7 @@ function PlaybooksTab() {
filterBySource(result?.items || [], source).filter((item) => includesSearch([
item.name,
item.description,
item.risk_level,
item.path,
...item.tags,
], search))
@@ -434,6 +437,7 @@ function PlaybooksTab() {
const columns = useMemo<ColumnsType<PlaybookDefinition>>(() => [
{ title: 'Playbook', dataIndex: 'name', width: 260, render: (name: string) => <Typography.Text strong>{name}</Typography.Text> },
{ title: 'Source', dataIndex: 'source', width: 110, render: (value: SourceType) => <SourceTag source={value} /> },
{ title: 'Risk', dataIndex: 'risk_level', width: 110, render: (value: string) => severityTag(value) },
{
title: 'Tags',
dataIndex: 'tags',
@@ -472,6 +476,7 @@ function PlaybooksTab() {
<Flex vertical gap={16} style={{ width: '100%' }}>
<Space>
<SourceTag source={selected.source} />
{severityTag(selected.risk_level)}
{(selected.tags || []).map((tag) => <Tag {...comfortableTagProps} key={tag} color={PLAYBOOK_TAG_COLORS[tag] || 'blue'}>{tag}</Tag>)}
</Space>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap' }}>{selected.description || 'No description.'}</Typography.Paragraph>
+1
View File
@@ -176,6 +176,7 @@ export interface ResourceConfig<RecordType = Record<string, unknown>> {
filters: ResourceFilterConfig[]
advancedFilters?: AdvancedFilterFieldConfig[]
editableFields?: EditableFieldConfig[]
readOnly?: boolean
basicView?: (record: RecordType, options?: {
onOpenResource?: (resourceKey: string, rowId: string | number, options?: OpenResourceOptions) => void
onChanged?: () => void