feed-the-machine 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +170 -170
- package/bin/brain.py +1340 -0
- package/bin/convert_claude_skills_to_codex.py +490 -0
- package/bin/generate-manifest.mjs +463 -463
- package/bin/harden_codex_skills.py +141 -0
- package/bin/install.mjs +491 -491
- package/bin/migrate-eng-buddy-data.py +875 -0
- package/bin/playbook_engine/__init__.py +1 -0
- package/bin/playbook_engine/conftest.py +8 -0
- package/bin/playbook_engine/extractor.py +33 -0
- package/bin/playbook_engine/manager.py +102 -0
- package/bin/playbook_engine/models.py +84 -0
- package/bin/playbook_engine/registry.py +35 -0
- package/bin/playbook_engine/test_extractor.py +72 -0
- package/bin/playbook_engine/test_integration.py +129 -0
- package/bin/playbook_engine/test_manager.py +85 -0
- package/bin/playbook_engine/test_models.py +166 -0
- package/bin/playbook_engine/test_registry.py +67 -0
- package/bin/playbook_engine/test_tracer.py +86 -0
- package/bin/playbook_engine/tracer.py +93 -0
- package/bin/tasks_db.py +456 -0
- package/docs/HOOKS.md +243 -243
- package/docs/INBOX.md +233 -233
- package/ftm/SKILL.md +125 -122
- package/ftm-audit/SKILL.md +623 -623
- package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
- package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
- package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
- package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
- package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
- package/ftm-audit/scripts/run-knip.sh +23 -23
- package/ftm-audit.yml +2 -2
- package/ftm-brainstorm/SKILL.md +1003 -498
- package/ftm-brainstorm/evals/evals.json +180 -100
- package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
- package/ftm-brainstorm/references/agent-prompts.md +552 -224
- package/ftm-brainstorm/references/plan-template.md +209 -121
- package/ftm-brainstorm.yml +2 -2
- package/ftm-browse/SKILL.md +454 -454
- package/ftm-browse/daemon/browser-manager.ts +206 -206
- package/ftm-browse/daemon/bun.lock +30 -30
- package/ftm-browse/daemon/cli.ts +347 -347
- package/ftm-browse/daemon/commands.ts +410 -410
- package/ftm-browse/daemon/main.ts +357 -357
- package/ftm-browse/daemon/package.json +17 -17
- package/ftm-browse/daemon/server.ts +189 -189
- package/ftm-browse/daemon/snapshot.ts +519 -519
- package/ftm-browse/daemon/tsconfig.json +22 -22
- package/ftm-browse.yml +4 -4
- package/ftm-capture/SKILL.md +370 -370
- package/ftm-capture.yml +4 -4
- package/ftm-codex-gate/SKILL.md +361 -361
- package/ftm-codex-gate.yml +2 -2
- package/ftm-config/SKILL.md +422 -345
- package/ftm-config.default.yml +125 -82
- package/ftm-config.yml +44 -2
- package/ftm-council/SKILL.md +416 -416
- package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
- package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
- package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
- package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
- package/ftm-council.yml +2 -2
- package/ftm-dashboard/SKILL.md +163 -163
- package/ftm-dashboard.yml +4 -4
- package/ftm-debug/SKILL.md +1037 -1037
- package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
- package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
- package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
- package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
- package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
- package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
- package/ftm-debug.yml +2 -2
- package/ftm-diagram/SKILL.md +277 -277
- package/ftm-diagram.yml +2 -2
- package/ftm-executor/SKILL.md +777 -777
- package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
- package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
- package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
- package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
- package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
- package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
- package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
- package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
- package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
- package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
- package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
- package/ftm-executor/runtime/package.json +8 -8
- package/ftm-executor.yml +2 -2
- package/ftm-git/SKILL.md +441 -441
- package/ftm-git/evals/evals.json +26 -26
- package/ftm-git/evals/promptfoo.yaml +75 -75
- package/ftm-git/hooks/post-commit-experience.sh +92 -92
- package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
- package/ftm-git/references/protocols/REMEDIATION.md +139 -139
- package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
- package/ftm-git.yml +2 -2
- package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -64
- package/ftm-inbox/backend/adapters/base.py +230 -230
- package/ftm-inbox/backend/adapters/freshservice.py +104 -104
- package/ftm-inbox/backend/adapters/gmail.py +125 -125
- package/ftm-inbox/backend/adapters/jira.py +136 -136
- package/ftm-inbox/backend/adapters/registry.py +192 -192
- package/ftm-inbox/backend/adapters/slack.py +110 -110
- package/ftm-inbox/backend/db/connection.py +54 -54
- package/ftm-inbox/backend/db/schema.py +78 -78
- package/ftm-inbox/backend/executor/__init__.py +7 -7
- package/ftm-inbox/backend/executor/engine.py +149 -149
- package/ftm-inbox/backend/executor/step_runner.py +98 -98
- package/ftm-inbox/backend/main.py +103 -103
- package/ftm-inbox/backend/models/__init__.py +1 -1
- package/ftm-inbox/backend/models/unified_task.py +36 -36
- package/ftm-inbox/backend/planner/__init__.py +6 -6
- package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/generator.py +127 -127
- package/ftm-inbox/backend/planner/schema.py +34 -34
- package/ftm-inbox/backend/requirements.txt +5 -5
- package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -186
- package/ftm-inbox/backend/routes/health.py +52 -52
- package/ftm-inbox/backend/routes/inbox.py +68 -68
- package/ftm-inbox/backend/routes/plan.py +271 -271
- package/ftm-inbox/bin/launchagent.mjs +91 -91
- package/ftm-inbox/bin/setup.mjs +188 -188
- package/ftm-inbox/bin/start.sh +10 -10
- package/ftm-inbox/bin/status.sh +17 -17
- package/ftm-inbox/bin/stop.sh +8 -8
- package/ftm-inbox/config.example.yml +55 -55
- package/ftm-inbox/package-lock.json +2898 -2898
- package/ftm-inbox/package.json +26 -26
- package/ftm-inbox/postcss.config.js +6 -6
- package/ftm-inbox/src/app.css +199 -199
- package/ftm-inbox/src/app.html +18 -18
- package/ftm-inbox/src/lib/api.ts +166 -166
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
- package/ftm-inbox/src/lib/theme.ts +47 -47
- package/ftm-inbox/src/routes/+layout.svelte +76 -76
- package/ftm-inbox/src/routes/+page.svelte +401 -401
- package/ftm-inbox/svelte.config.js +12 -12
- package/ftm-inbox/tailwind.config.ts +63 -63
- package/ftm-inbox/tsconfig.json +13 -13
- package/ftm-inbox/vite.config.ts +6 -6
- package/ftm-intent/SKILL.md +241 -241
- package/ftm-intent.yml +2 -2
- package/ftm-manifest.json +3794 -3794
- package/ftm-map/SKILL.md +291 -291
- package/ftm-map/scripts/db.py +712 -712
- package/ftm-map/scripts/index.py +415 -415
- package/ftm-map/scripts/parser.py +224 -224
- package/ftm-map/scripts/queries/go-tags.scm +20 -20
- package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
- package/ftm-map/scripts/queries/python-tags.scm +31 -31
- package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
- package/ftm-map/scripts/queries/rust-tags.scm +37 -37
- package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
- package/ftm-map/scripts/query.py +301 -301
- package/ftm-map/scripts/ranker.py +377 -377
- package/ftm-map/scripts/requirements.txt +5 -5
- package/ftm-map/scripts/setup-hooks.sh +27 -27
- package/ftm-map/scripts/setup.sh +56 -56
- package/ftm-map/scripts/test_db.py +364 -364
- package/ftm-map/scripts/test_parser.py +174 -174
- package/ftm-map/scripts/test_query.py +183 -183
- package/ftm-map/scripts/test_ranker.py +199 -199
- package/ftm-map/scripts/views.py +591 -591
- package/ftm-map.yml +2 -2
- package/ftm-mind/SKILL.md +201 -1943
- package/ftm-mind/evals/promptfoo.yaml +142 -142
- package/ftm-mind/references/blackboard-protocol.md +110 -0
- package/ftm-mind/references/blackboard-schema.md +328 -328
- package/ftm-mind/references/complexity-guide.md +110 -110
- package/ftm-mind/references/complexity-sizing.md +138 -0
- package/ftm-mind/references/decide-act-protocol.md +172 -0
- package/ftm-mind/references/direct-execution.md +51 -0
- package/ftm-mind/references/environment-discovery.md +77 -0
- package/ftm-mind/references/event-registry.md +319 -319
- package/ftm-mind/references/mcp-inventory.md +300 -296
- package/ftm-mind/references/ops-routing.md +47 -0
- package/ftm-mind/references/orient-protocol.md +234 -0
- package/ftm-mind/references/personality.md +40 -0
- package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
- package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
- package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
- package/ftm-mind/references/reflexion-protocol.md +249 -249
- package/ftm-mind/references/routing/SCENARIOS.md +22 -22
- package/ftm-mind/references/routing-scenarios.md +35 -35
- package/ftm-mind.yml +2 -2
- package/ftm-ops.yml +4 -0
- package/ftm-pause/SKILL.md +395 -395
- package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
- package/ftm-pause/references/protocols/VALIDATION.md +80 -80
- package/ftm-pause.yml +2 -2
- package/ftm-researcher/SKILL.md +275 -275
- package/ftm-researcher/evals/agent-diversity.yaml +17 -17
- package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
- package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
- package/ftm-researcher/references/adaptive-search.md +116 -116
- package/ftm-researcher/references/agent-prompts.md +193 -193
- package/ftm-researcher/references/council-integration.md +193 -193
- package/ftm-researcher/references/output-format.md +203 -203
- package/ftm-researcher/references/synthesis-pipeline.md +165 -165
- package/ftm-researcher/scripts/score_credibility.py +234 -234
- package/ftm-researcher/scripts/validate_research.py +92 -92
- package/ftm-researcher.yml +2 -2
- package/ftm-resume/SKILL.md +518 -518
- package/ftm-resume/references/protocols/VALIDATION.md +172 -172
- package/ftm-resume.yml +2 -2
- package/ftm-retro/SKILL.md +380 -380
- package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
- package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
- package/ftm-retro.yml +2 -2
- package/ftm-routine/SKILL.md +170 -170
- package/ftm-routine.yml +4 -4
- package/ftm-state/blackboard/capabilities.json +5 -5
- package/ftm-state/blackboard/capabilities.schema.json +27 -27
- package/ftm-state/blackboard/context.json +37 -23
- package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
- package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
- package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
- package/ftm-state/blackboard/experiences/index.json +58 -9
- package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
- package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
- package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
- package/ftm-state/blackboard/patterns.json +6 -6
- package/ftm-state/schemas/context.schema.json +130 -130
- package/ftm-state/schemas/experience-index.schema.json +77 -77
- package/ftm-state/schemas/experience.schema.json +78 -78
- package/ftm-state/schemas/patterns.schema.json +44 -44
- package/ftm-upgrade/SKILL.md +194 -194
- package/ftm-upgrade/scripts/check-version.sh +76 -76
- package/ftm-upgrade/scripts/upgrade.sh +143 -143
- package/ftm-upgrade.yml +2 -2
- package/ftm-verify.yml +2 -2
- package/ftm.yml +2 -2
- package/hooks/ftm-auto-log.sh +137 -0
- package/hooks/ftm-blackboard-enforcer.sh +93 -93
- package/hooks/ftm-discovery-reminder.sh +90 -90
- package/hooks/ftm-drafts-gate.sh +61 -61
- package/hooks/ftm-event-logger.mjs +107 -107
- package/hooks/ftm-install-hooks.sh +240 -0
- package/hooks/ftm-learning-capture.sh +117 -0
- package/hooks/ftm-map-autodetect.sh +79 -79
- package/hooks/ftm-pending-sync-check.sh +22 -22
- package/hooks/ftm-plan-gate.sh +92 -92
- package/hooks/ftm-post-commit-trigger.sh +57 -57
- package/hooks/ftm-post-compaction.sh +138 -0
- package/hooks/ftm-pre-compaction.sh +147 -0
- package/hooks/ftm-session-end.sh +52 -0
- package/hooks/ftm-session-snapshot.sh +213 -0
- package/hooks/settings-template.json +81 -81
- package/install.sh +363 -363
- package/package.json +84 -84
- package/uninstall.sh +25 -25
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# playbook_engine - Reusable workflow playbooks for eng-buddy
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Configure sys.path so tests can run from project root or module directory."""
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Add playbook_engine directory to sys.path so `from models import ...` works
|
|
6
|
+
_this_dir = Path(__file__).parent
|
|
7
|
+
if str(_this_dir) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(_this_dir))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""PlaybookExtractor - extracts playbooks from completed traces."""
|
|
2
|
+
|
|
3
|
+
from .models import Playbook, PlaybookStep
|
|
4
|
+
from .registry import ToolRegistry
|
|
5
|
+
from .tracer import Trace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlaybookExtractor:
|
|
9
|
+
"""Extracts a draft playbook from a workflow trace."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, registry: ToolRegistry):
|
|
12
|
+
self.registry = registry
|
|
13
|
+
|
|
14
|
+
def extract_from_trace(self, trace: Trace, name: str = "Untitled") -> Playbook:
|
|
15
|
+
steps = []
|
|
16
|
+
for i, event in enumerate(trace.events, 1):
|
|
17
|
+
step = PlaybookStep(
|
|
18
|
+
number=i,
|
|
19
|
+
description=event.human_instruction or event.action or f"Step {i}",
|
|
20
|
+
tool=event.tool,
|
|
21
|
+
tool_params=event.params,
|
|
22
|
+
requires_human=bool(event.correction),
|
|
23
|
+
notes=event.correction or "",
|
|
24
|
+
)
|
|
25
|
+
steps.append(step)
|
|
26
|
+
|
|
27
|
+
return Playbook(
|
|
28
|
+
name=name,
|
|
29
|
+
description=f"Extracted from trace {trace.id}",
|
|
30
|
+
steps=steps,
|
|
31
|
+
source="extracted",
|
|
32
|
+
confidence=0.7, # draft confidence
|
|
33
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""PlaybookManager - CRUD and matching for playbooks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .models import Playbook
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PlaybookManager:
|
|
11
|
+
"""Manages approved and draft playbooks on disk as JSON files."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, playbooks_dir: str):
|
|
14
|
+
self.playbooks_dir = playbooks_dir
|
|
15
|
+
self.approved_dir = playbooks_dir # approved live at root
|
|
16
|
+
self.drafts_dir = os.path.join(playbooks_dir, "drafts")
|
|
17
|
+
self.archive_dir = os.path.join(playbooks_dir, "archive")
|
|
18
|
+
os.makedirs(self.approved_dir, exist_ok=True)
|
|
19
|
+
os.makedirs(self.drafts_dir, exist_ok=True)
|
|
20
|
+
os.makedirs(self.archive_dir, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
# --- Read ---
|
|
23
|
+
|
|
24
|
+
def _load_from_dir(self, directory: str) -> List[Playbook]:
|
|
25
|
+
playbooks = []
|
|
26
|
+
for f in sorted(os.listdir(directory)):
|
|
27
|
+
if not f.endswith(".json"):
|
|
28
|
+
continue
|
|
29
|
+
path = os.path.join(directory, f)
|
|
30
|
+
try:
|
|
31
|
+
with open(path) as fh:
|
|
32
|
+
playbooks.append(Playbook.from_dict(json.load(fh)))
|
|
33
|
+
except (json.JSONDecodeError, KeyError):
|
|
34
|
+
continue
|
|
35
|
+
return playbooks
|
|
36
|
+
|
|
37
|
+
def list_playbooks(self) -> List[Playbook]:
|
|
38
|
+
return self._load_from_dir(self.approved_dir)
|
|
39
|
+
|
|
40
|
+
def list_drafts(self) -> List[Playbook]:
|
|
41
|
+
return self._load_from_dir(self.drafts_dir)
|
|
42
|
+
|
|
43
|
+
def get(self, playbook_id: str) -> Optional[Playbook]:
|
|
44
|
+
for pb in self.list_playbooks():
|
|
45
|
+
if pb.id == playbook_id:
|
|
46
|
+
return pb
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get_draft(self, playbook_id: str) -> Optional[Playbook]:
|
|
50
|
+
for pb in self.list_drafts():
|
|
51
|
+
if pb.id == playbook_id:
|
|
52
|
+
return pb
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
# --- Write ---
|
|
56
|
+
|
|
57
|
+
def _save(self, pb: Playbook, directory: str) -> str:
|
|
58
|
+
path = os.path.join(directory, f"{pb.id}.json")
|
|
59
|
+
with open(path, "w") as fh:
|
|
60
|
+
json.dump(pb.to_dict(), fh, indent=2)
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
def save_playbook(self, pb: Playbook) -> str:
|
|
64
|
+
return self._save(pb, self.approved_dir)
|
|
65
|
+
|
|
66
|
+
def save_draft(self, pb: Playbook) -> str:
|
|
67
|
+
return self._save(pb, self.drafts_dir)
|
|
68
|
+
|
|
69
|
+
def promote_draft(self, playbook_id: str) -> Optional[Playbook]:
|
|
70
|
+
pb = self.get_draft(playbook_id)
|
|
71
|
+
if not pb:
|
|
72
|
+
return None
|
|
73
|
+
# Move from drafts to approved
|
|
74
|
+
draft_path = os.path.join(self.drafts_dir, f"{pb.id}.json")
|
|
75
|
+
self.save_playbook(pb)
|
|
76
|
+
if os.path.exists(draft_path):
|
|
77
|
+
os.remove(draft_path)
|
|
78
|
+
return pb
|
|
79
|
+
|
|
80
|
+
def delete_draft(self, playbook_id: str) -> bool:
|
|
81
|
+
path = os.path.join(self.drafts_dir, f"{playbook_id}.json")
|
|
82
|
+
if os.path.exists(path):
|
|
83
|
+
os.remove(path)
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# --- Matching ---
|
|
88
|
+
|
|
89
|
+
def match_ticket(self, ticket_type: str = "", text: str = "", source: str = "") -> List[Playbook]:
|
|
90
|
+
"""Find playbooks whose trigger_keywords match the given text."""
|
|
91
|
+
text_lower = text.lower()
|
|
92
|
+
matches = []
|
|
93
|
+
for pb in self.list_playbooks():
|
|
94
|
+
score = 0
|
|
95
|
+
for kw in pb.trigger_keywords:
|
|
96
|
+
if kw.lower() in text_lower:
|
|
97
|
+
score += 1
|
|
98
|
+
if score > 0:
|
|
99
|
+
# Temporarily set confidence based on keyword hit ratio
|
|
100
|
+
pb.confidence = round(score / max(len(pb.trigger_keywords), 1), 2)
|
|
101
|
+
matches.append(pb)
|
|
102
|
+
return sorted(matches, key=lambda p: p.confidence, reverse=True)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Core data models for playbooks."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PlaybookStep:
|
|
10
|
+
"""A single step in a playbook."""
|
|
11
|
+
number: int
|
|
12
|
+
description: str
|
|
13
|
+
tool: str = "" # e.g. "browser", "freshservice-api", "manual"
|
|
14
|
+
tool_params: dict = field(default_factory=dict)
|
|
15
|
+
requires_human: bool = False
|
|
16
|
+
notes: str = ""
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"number": self.number,
|
|
21
|
+
"description": self.description,
|
|
22
|
+
"tool": self.tool,
|
|
23
|
+
"tool_params": self.tool_params,
|
|
24
|
+
"requires_human": self.requires_human,
|
|
25
|
+
"notes": self.notes,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, d: dict) -> "PlaybookStep":
|
|
30
|
+
return cls(
|
|
31
|
+
number=d["number"],
|
|
32
|
+
description=d["description"],
|
|
33
|
+
tool=d.get("tool", ""),
|
|
34
|
+
tool_params=d.get("tool_params", {}),
|
|
35
|
+
requires_human=d.get("requires_human", False),
|
|
36
|
+
notes=d.get("notes", ""),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Playbook:
|
|
42
|
+
"""A reusable workflow playbook."""
|
|
43
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
44
|
+
name: str = ""
|
|
45
|
+
description: str = ""
|
|
46
|
+
trigger_keywords: List[str] = field(default_factory=list)
|
|
47
|
+
steps: List[PlaybookStep] = field(default_factory=list)
|
|
48
|
+
confidence: float = 1.0
|
|
49
|
+
version: int = 1
|
|
50
|
+
executions: int = 0
|
|
51
|
+
source: str = "manual" # "manual", "extracted", "observed"
|
|
52
|
+
runbook_path: str = "" # link to full runbook doc
|
|
53
|
+
related_links: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict:
|
|
56
|
+
return {
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"name": self.name,
|
|
59
|
+
"description": self.description,
|
|
60
|
+
"trigger_keywords": self.trigger_keywords,
|
|
61
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
62
|
+
"confidence": self.confidence,
|
|
63
|
+
"version": self.version,
|
|
64
|
+
"executions": self.executions,
|
|
65
|
+
"source": self.source,
|
|
66
|
+
"runbook_path": self.runbook_path,
|
|
67
|
+
"related_links": self.related_links,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, d: dict) -> "Playbook":
|
|
72
|
+
return cls(
|
|
73
|
+
id=d["id"],
|
|
74
|
+
name=d.get("name", ""),
|
|
75
|
+
description=d.get("description", ""),
|
|
76
|
+
trigger_keywords=d.get("trigger_keywords", []),
|
|
77
|
+
steps=[PlaybookStep.from_dict(s) for s in d.get("steps", [])],
|
|
78
|
+
confidence=d.get("confidence", 1.0),
|
|
79
|
+
version=d.get("version", 1),
|
|
80
|
+
executions=d.get("executions", 0),
|
|
81
|
+
source=d.get("source", "manual"),
|
|
82
|
+
runbook_path=d.get("runbook_path", ""),
|
|
83
|
+
related_links=d.get("related_links", {}),
|
|
84
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""ToolRegistry - maps tools to their capabilities and defaults."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolRegistry:
|
|
9
|
+
"""Loads tool definitions from the registry directory."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, registry_dir: str):
|
|
12
|
+
self.registry_dir = registry_dir
|
|
13
|
+
self._tools: dict = {}
|
|
14
|
+
self._load()
|
|
15
|
+
|
|
16
|
+
def _load(self):
|
|
17
|
+
if not os.path.exists(self.registry_dir):
|
|
18
|
+
return
|
|
19
|
+
for f in os.listdir(self.registry_dir):
|
|
20
|
+
if not f.endswith(".json"):
|
|
21
|
+
continue
|
|
22
|
+
path = os.path.join(self.registry_dir, f)
|
|
23
|
+
try:
|
|
24
|
+
with open(path) as fh:
|
|
25
|
+
data = json.load(fh)
|
|
26
|
+
tool_name = data.get("name", f.replace(".json", ""))
|
|
27
|
+
self._tools[tool_name] = data
|
|
28
|
+
except (json.JSONDecodeError, KeyError):
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
def get_tool(self, name: str) -> Optional[dict]:
|
|
32
|
+
return self._tools.get(name)
|
|
33
|
+
|
|
34
|
+
def list_tools(self) -> list:
|
|
35
|
+
return list(self._tools.keys())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import tempfile
|
|
3
|
+
import yaml
|
|
4
|
+
from tracer import WorkflowTracer, TraceEvent
|
|
5
|
+
from registry import ToolRegistry
|
|
6
|
+
from extractor import PlaybookExtractor
|
|
7
|
+
from models import Playbook
|
|
8
|
+
|
|
9
|
+
def make_registry(tmp_path):
|
|
10
|
+
reg_dir = tmp_path / "tool-registry"
|
|
11
|
+
reg_dir.mkdir()
|
|
12
|
+
(reg_dir / "_registry.yml").write_text(yaml.dump({
|
|
13
|
+
"tools": {
|
|
14
|
+
"jira": {"type": "mcp", "prefix": "mcp__mcp-atlassian__jira_", "capabilities": ["create_issue"], "auth": "persistent", "domains": ["ticket_management"]},
|
|
15
|
+
"slack": {"type": "mcp", "prefix": "mcp__slack__", "capabilities": ["post_message"], "auth": "persistent", "domains": ["communication"]},
|
|
16
|
+
"freshservice": {"type": "mcp", "prefix": "mcp__freshservice-mcp__", "capabilities": ["update_ticket"], "auth": "persistent", "domains": ["service_desk"]},
|
|
17
|
+
}
|
|
18
|
+
}))
|
|
19
|
+
(reg_dir / "jira.defaults.yml").write_text(yaml.dump({"create_issue": {"assignee": "test@test.com"}}))
|
|
20
|
+
return ToolRegistry(str(reg_dir))
|
|
21
|
+
|
|
22
|
+
def test_extract_playbook_from_trace(tmp_path):
|
|
23
|
+
registry = make_registry(tmp_path)
|
|
24
|
+
tracer = WorkflowTracer(traces_dir=str(tmp_path / "traces"))
|
|
25
|
+
|
|
26
|
+
tracer.start_trace("ITWORK2-100")
|
|
27
|
+
tracer.add_event(TraceEvent(type="user_instruction", content="Do SSO onboarding for Linear"))
|
|
28
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__mcp-atlassian__jira_create_issue", params={"project": "ITWORK2", "summary": "[SSO] Linear"}))
|
|
29
|
+
tracer.add_event(TraceEvent(type="user_manual_action", content="Configured SAML in Okta", inferred_step="Configure SAML", action_binding="playwright", auth_note="needs Okta admin"))
|
|
30
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__slack__post_message", params={"channel": "C123", "text": "SSO configured"}))
|
|
31
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__freshservice-mcp__update_ticket", params={"ticket_id": 456, "status": "resolved"}))
|
|
32
|
+
|
|
33
|
+
extractor = PlaybookExtractor(registry=registry)
|
|
34
|
+
pb = extractor.extract_from_trace(tracer.get_trace("ITWORK2-100"), name="SSO Onboarding")
|
|
35
|
+
|
|
36
|
+
assert pb.id == "sso-onboarding"
|
|
37
|
+
assert pb.confidence == "low"
|
|
38
|
+
assert pb.created_from == "session"
|
|
39
|
+
assert len(pb.steps) == 4 # jira + manual + slack + freshservice
|
|
40
|
+
assert pb.steps[0].action.tool == "mcp__mcp-atlassian__jira_create_issue"
|
|
41
|
+
assert pb.steps[1].human_required is True # manual action
|
|
42
|
+
assert pb.steps[1].action.tool == "playwright"
|
|
43
|
+
|
|
44
|
+
def test_extract_identifies_dynamic_params(tmp_path):
|
|
45
|
+
registry = make_registry(tmp_path)
|
|
46
|
+
tracer = WorkflowTracer(traces_dir=str(tmp_path / "traces"))
|
|
47
|
+
|
|
48
|
+
tracer.start_trace("t1")
|
|
49
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__mcp-atlassian__jira_create_issue", params={"project": "ITWORK2", "summary": "[SSO] Linear", "assignee": "test@test.com"}))
|
|
50
|
+
|
|
51
|
+
extractor = PlaybookExtractor(registry=registry)
|
|
52
|
+
pb = extractor.extract_from_trace(tracer.get_trace("t1"), name="Test")
|
|
53
|
+
|
|
54
|
+
# assignee matches default, so should NOT be in playbook params (it comes from defaults)
|
|
55
|
+
# summary is ticket-specific, so should be a param with a source
|
|
56
|
+
step = pb.steps[0]
|
|
57
|
+
assert "assignee" not in step.action.params # comes from defaults
|
|
58
|
+
assert "summary" in step.action.params or "summary" in step.action.param_sources
|
|
59
|
+
|
|
60
|
+
def test_extract_captures_user_rules(tmp_path):
|
|
61
|
+
registry = make_registry(tmp_path)
|
|
62
|
+
tracer = WorkflowTracer(traces_dir=str(tmp_path / "traces"))
|
|
63
|
+
|
|
64
|
+
tracer.start_trace("t1")
|
|
65
|
+
tracer.add_event(TraceEvent(type="user_rule", content="Always add SSO label", applies_to=["jira.create_issue"], persist=True))
|
|
66
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__mcp-atlassian__jira_create_issue", params={"project": "ITWORK2"}))
|
|
67
|
+
|
|
68
|
+
extractor = PlaybookExtractor(registry=registry)
|
|
69
|
+
pb = extractor.extract_from_trace(tracer.get_trace("t1"), name="Test")
|
|
70
|
+
assert pb is not None
|
|
71
|
+
# The extractor should note persistent rules for default updates
|
|
72
|
+
assert len(extractor.pending_default_updates) > 0
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""End-to-end test: trace capture -> extraction -> storage -> matching -> execution dispatch."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import yaml
|
|
5
|
+
import tempfile
|
|
6
|
+
import json
|
|
7
|
+
from tracer import WorkflowTracer, TraceEvent
|
|
8
|
+
from registry import ToolRegistry
|
|
9
|
+
from extractor import PlaybookExtractor
|
|
10
|
+
from manager import PlaybookManager
|
|
11
|
+
from models import Playbook
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def env(tmp_path):
|
|
15
|
+
"""Set up a complete playbook environment."""
|
|
16
|
+
playbooks_dir = tmp_path / "playbooks"
|
|
17
|
+
playbooks_dir.mkdir()
|
|
18
|
+
(playbooks_dir / "drafts").mkdir()
|
|
19
|
+
(playbooks_dir / "archive").mkdir()
|
|
20
|
+
|
|
21
|
+
reg_dir = playbooks_dir / "tool-registry"
|
|
22
|
+
reg_dir.mkdir()
|
|
23
|
+
(reg_dir / "_registry.yml").write_text(yaml.dump({
|
|
24
|
+
"tools": {
|
|
25
|
+
"jira": {"type": "mcp", "prefix": "mcp__mcp-atlassian__jira_", "capabilities": ["create_issue", "transition_issue"], "auth": "persistent", "domains": ["ticket_management"]},
|
|
26
|
+
"slack": {"type": "mcp", "prefix": "mcp__slack__", "capabilities": ["post_message"], "auth": "persistent", "domains": ["communication"]},
|
|
27
|
+
"freshservice": {"type": "mcp", "prefix": "mcp__freshservice-mcp__", "capabilities": ["update_ticket"], "auth": "persistent", "domains": ["service_desk"]},
|
|
28
|
+
"playwright_cli": {"type": "browser", "tool": "playwright_cli", "capabilities": ["navigate", "click", "fill", "snapshot", "eval"], "auth": "per_domain", "domains": ["standard_web_ui"]},
|
|
29
|
+
}
|
|
30
|
+
}))
|
|
31
|
+
(reg_dir / "jira.defaults.yml").write_text(yaml.dump({
|
|
32
|
+
"create_issue": {"assignee": "kioja@test.com", "board_id": 70},
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
traces_dir = tmp_path / "traces"
|
|
36
|
+
return {
|
|
37
|
+
"playbooks_dir": str(playbooks_dir),
|
|
38
|
+
"traces_dir": str(traces_dir),
|
|
39
|
+
"registry_dir": str(reg_dir),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def test_full_flow(env):
|
|
43
|
+
"""Simulate: work SSO ticket -> extract playbook -> match new ticket -> dispatch."""
|
|
44
|
+
|
|
45
|
+
# 1. Capture a workflow trace
|
|
46
|
+
tracer = WorkflowTracer(traces_dir=env["traces_dir"])
|
|
47
|
+
tracer.start_trace("ITWORK2-100")
|
|
48
|
+
|
|
49
|
+
tracer.add_event(TraceEvent(type="user_instruction", content="Do SSO onboarding for Linear"))
|
|
50
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__mcp-atlassian__jira_create_issue",
|
|
51
|
+
params={"project": "ITWORK2", "summary": "[SSO] Linear", "assignee": "kioja@test.com", "board_id": 70}))
|
|
52
|
+
tracer.add_event(TraceEvent(type="user_manual_action", content="Configured SAML in Okta",
|
|
53
|
+
inferred_step="Configure SAML in IdP", action_binding="playwright", auth_note="Okta admin"))
|
|
54
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__slack__post_message",
|
|
55
|
+
params={"channel": "C123", "text": "SSO ready for Linear"}))
|
|
56
|
+
tracer.add_event(TraceEvent(type="tool_call", tool="mcp__freshservice-mcp__update_ticket",
|
|
57
|
+
params={"ticket_id": 456, "status": 5}))
|
|
58
|
+
tracer.flush("ITWORK2-100")
|
|
59
|
+
|
|
60
|
+
# 2. Extract a playbook
|
|
61
|
+
registry = ToolRegistry(env["registry_dir"])
|
|
62
|
+
extractor = PlaybookExtractor(registry=registry)
|
|
63
|
+
trace = tracer.get_trace("ITWORK2-100")
|
|
64
|
+
pb = extractor.extract_from_trace(trace, name="SSO Onboarding")
|
|
65
|
+
|
|
66
|
+
assert pb.id == "sso-onboarding"
|
|
67
|
+
assert pb.confidence == "low"
|
|
68
|
+
assert len(pb.steps) == 4
|
|
69
|
+
assert pb.steps[1].human_required is True # manual SAML config
|
|
70
|
+
|
|
71
|
+
# 3. Save as draft, review, promote
|
|
72
|
+
manager = PlaybookManager(env["playbooks_dir"])
|
|
73
|
+
manager.save_draft(pb)
|
|
74
|
+
assert len(manager.list_drafts()) == 1
|
|
75
|
+
|
|
76
|
+
manager.promote_draft("sso-onboarding")
|
|
77
|
+
assert len(manager.list_drafts()) == 0
|
|
78
|
+
assert len(manager.list_playbooks()) == 1
|
|
79
|
+
|
|
80
|
+
# 4. Match against a new ticket
|
|
81
|
+
matches = manager.match_ticket(
|
|
82
|
+
ticket_type="Service Request",
|
|
83
|
+
text="Please set up SSO for Notion",
|
|
84
|
+
source="freshservice",
|
|
85
|
+
)
|
|
86
|
+
assert len(matches) == 1
|
|
87
|
+
assert matches[0].id == "sso-onboarding"
|
|
88
|
+
|
|
89
|
+
# 5. Record execution and check confidence progression
|
|
90
|
+
promoted = manager.get("sso-onboarding")
|
|
91
|
+
promoted.record_execution(success=True)
|
|
92
|
+
assert promoted.confidence == "medium"
|
|
93
|
+
assert promoted.executions == 1
|
|
94
|
+
manager.save(promoted)
|
|
95
|
+
|
|
96
|
+
# Verify persistence
|
|
97
|
+
reloaded = manager.get("sso-onboarding")
|
|
98
|
+
assert reloaded.confidence == "medium"
|
|
99
|
+
assert reloaded.executions == 1
|
|
100
|
+
|
|
101
|
+
def test_no_match_returns_empty(env):
|
|
102
|
+
manager = PlaybookManager(env["playbooks_dir"])
|
|
103
|
+
matches = manager.match_ticket(text="New laptop request", source="freshservice")
|
|
104
|
+
assert matches == []
|
|
105
|
+
|
|
106
|
+
def test_dictated_playbook_flow(env):
|
|
107
|
+
"""Path 2: User describes steps, engine creates playbook."""
|
|
108
|
+
registry = ToolRegistry(env["registry_dir"])
|
|
109
|
+
extractor = PlaybookExtractor(registry=registry)
|
|
110
|
+
|
|
111
|
+
pb = extractor.extract_from_description(
|
|
112
|
+
name="Employee Offboarding",
|
|
113
|
+
steps_text=[
|
|
114
|
+
"Disable user account in Okta",
|
|
115
|
+
"Remove from all Slack channels",
|
|
116
|
+
"Archive Jira tickets",
|
|
117
|
+
"Send confirmation email to manager",
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert pb.id == "employee-offboarding"
|
|
122
|
+
assert pb.confidence == "medium" # dictated starts at medium
|
|
123
|
+
assert len(pb.steps) == 4
|
|
124
|
+
assert pb.created_from == "dictated"
|
|
125
|
+
|
|
126
|
+
manager = PlaybookManager(env["playbooks_dir"])
|
|
127
|
+
manager.save(pb)
|
|
128
|
+
loaded = manager.get("employee-offboarding")
|
|
129
|
+
assert loaded is not None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import tempfile
|
|
3
|
+
import os
|
|
4
|
+
from manager import PlaybookManager
|
|
5
|
+
from models import Playbook, PlaybookStep, ActionBinding, TriggerPattern
|
|
6
|
+
|
|
7
|
+
def make_playbook(id="test", name="Test", confidence="high", keywords=None, steps=None):
|
|
8
|
+
return Playbook(
|
|
9
|
+
id=id, name=name, version=1, confidence=confidence,
|
|
10
|
+
trigger_patterns=[TriggerPattern(keywords=keywords or ["SSO"], source=["freshservice"])],
|
|
11
|
+
created_from="session", executions=3,
|
|
12
|
+
steps=steps or [],
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def test_save_and_load(tmp_path):
|
|
16
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
17
|
+
pb = make_playbook()
|
|
18
|
+
mgr.save(pb)
|
|
19
|
+
loaded = mgr.get("test")
|
|
20
|
+
assert loaded.id == "test"
|
|
21
|
+
assert loaded.confidence == "high"
|
|
22
|
+
|
|
23
|
+
def test_list_playbooks(tmp_path):
|
|
24
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
25
|
+
mgr.save(make_playbook(id="a", name="A"))
|
|
26
|
+
mgr.save(make_playbook(id="b", name="B"))
|
|
27
|
+
pbs = mgr.list_playbooks()
|
|
28
|
+
assert len(pbs) == 2
|
|
29
|
+
assert {p.id for p in pbs} == {"a", "b"}
|
|
30
|
+
|
|
31
|
+
def test_match_ticket(tmp_path):
|
|
32
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
33
|
+
mgr.save(make_playbook(id="sso", keywords=["SSO", "SAML"]))
|
|
34
|
+
mgr.save(make_playbook(id="cert", keywords=["certificate", "renewal"]))
|
|
35
|
+
matches = mgr.match_ticket(ticket_type="Service Request", text="Set up SSO for Linear", source="freshservice")
|
|
36
|
+
assert len(matches) == 1
|
|
37
|
+
assert matches[0].id == "sso"
|
|
38
|
+
|
|
39
|
+
def test_save_draft(tmp_path):
|
|
40
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
41
|
+
pb = make_playbook(id="draft-test", confidence="low")
|
|
42
|
+
mgr.save_draft(pb)
|
|
43
|
+
drafts = mgr.list_drafts()
|
|
44
|
+
assert len(drafts) == 1
|
|
45
|
+
assert drafts[0].id == "draft-test"
|
|
46
|
+
|
|
47
|
+
def test_promote_draft(tmp_path):
|
|
48
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
49
|
+
pb = make_playbook(id="promote-test", confidence="low")
|
|
50
|
+
mgr.save_draft(pb)
|
|
51
|
+
mgr.promote_draft("promote-test")
|
|
52
|
+
assert mgr.get("promote-test") is not None
|
|
53
|
+
assert len(mgr.list_drafts()) == 0
|
|
54
|
+
|
|
55
|
+
def test_archive_version(tmp_path):
|
|
56
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
57
|
+
pb = make_playbook(id="versioned", confidence="high")
|
|
58
|
+
mgr.save(pb)
|
|
59
|
+
pb.version = 2
|
|
60
|
+
pb.update_history.append({"version": 2, "reason": "added step"})
|
|
61
|
+
mgr.save(pb, archive_previous=True)
|
|
62
|
+
loaded = mgr.get("versioned")
|
|
63
|
+
assert loaded.version == 2
|
|
64
|
+
archives = mgr.list_archive("versioned")
|
|
65
|
+
assert len(archives) == 1
|
|
66
|
+
|
|
67
|
+
def test_invalid_playbook_id_rejected(tmp_path):
|
|
68
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
69
|
+
import pytest
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
mgr.get("../../../etc/passwd")
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
mgr.get("UPPER_CASE")
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
mgr.get("")
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
mgr.delete_draft("../../bad")
|
|
78
|
+
|
|
79
|
+
def test_delete_nonexistent_draft(tmp_path):
|
|
80
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
81
|
+
assert mgr.delete_draft("nonexistent") is False
|
|
82
|
+
|
|
83
|
+
def test_promote_nonexistent_draft(tmp_path):
|
|
84
|
+
mgr = PlaybookManager(str(tmp_path))
|
|
85
|
+
assert mgr.promote_draft("nonexistent") is None
|