procler 0.2.0__py3-none-any.whl

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.
Files changed (83) hide show
  1. procler/__init__.py +3 -0
  2. procler/__main__.py +6 -0
  3. procler/api/__init__.py +5 -0
  4. procler/api/app.py +261 -0
  5. procler/api/deps.py +21 -0
  6. procler/api/routes/__init__.py +5 -0
  7. procler/api/routes/config.py +290 -0
  8. procler/api/routes/groups.py +62 -0
  9. procler/api/routes/logs.py +43 -0
  10. procler/api/routes/processes.py +185 -0
  11. procler/api/routes/recipes.py +69 -0
  12. procler/api/routes/snippets.py +134 -0
  13. procler/api/routes/ws.py +459 -0
  14. procler/cli.py +1478 -0
  15. procler/config/__init__.py +65 -0
  16. procler/config/changelog.py +148 -0
  17. procler/config/loader.py +256 -0
  18. procler/config/schema.py +315 -0
  19. procler/core/__init__.py +54 -0
  20. procler/core/context_base.py +117 -0
  21. procler/core/context_docker.py +384 -0
  22. procler/core/context_local.py +287 -0
  23. procler/core/daemon_detector.py +325 -0
  24. procler/core/events.py +74 -0
  25. procler/core/groups.py +419 -0
  26. procler/core/health.py +280 -0
  27. procler/core/log_tailer.py +262 -0
  28. procler/core/process_manager.py +1277 -0
  29. procler/core/recipes.py +330 -0
  30. procler/core/snippets.py +231 -0
  31. procler/core/variable_substitution.py +65 -0
  32. procler/db.py +96 -0
  33. procler/logging.py +41 -0
  34. procler/models.py +130 -0
  35. procler/py.typed +0 -0
  36. procler/settings.py +29 -0
  37. procler/static/assets/AboutView-BwZnsfpW.js +4 -0
  38. procler/static/assets/AboutView-UHbxWXcS.css +1 -0
  39. procler/static/assets/Code-HTS-H1S6.js +74 -0
  40. procler/static/assets/ConfigView-CGJcmp9G.css +1 -0
  41. procler/static/assets/ConfigView-aVtbRDf8.js +1 -0
  42. procler/static/assets/DashboardView-C5jw9Nsd.css +1 -0
  43. procler/static/assets/DashboardView-Dab7Cu9v.js +1 -0
  44. procler/static/assets/DataTable-z39TOAa4.js +746 -0
  45. procler/static/assets/DescriptionsItem-B2E8YbqJ.js +74 -0
  46. procler/static/assets/Divider-Dk-6aD2Y.js +42 -0
  47. procler/static/assets/Empty-MuygEHZM.js +24 -0
  48. procler/static/assets/Grid-CZ9QVKAT.js +1 -0
  49. procler/static/assets/GroupsView-BALG7i1X.js +1 -0
  50. procler/static/assets/GroupsView-gXAI1CVC.css +1 -0
  51. procler/static/assets/Input-e0xaxoWE.js +259 -0
  52. procler/static/assets/PhArrowsClockwise.vue-DqDg31az.js +1 -0
  53. procler/static/assets/PhCheckCircle.vue-Fwj9sh9m.js +1 -0
  54. procler/static/assets/PhEye.vue-JcPHciC2.js +1 -0
  55. procler/static/assets/PhPlay.vue-CZm7Gy3u.js +1 -0
  56. procler/static/assets/PhPlus.vue-yTWqKlSh.js +1 -0
  57. procler/static/assets/PhStop.vue-DxsqwIki.js +1 -0
  58. procler/static/assets/PhTrash.vue-DcqQbN1_.js +125 -0
  59. procler/static/assets/PhXCircle.vue-BXWmrabV.js +1 -0
  60. procler/static/assets/ProcessDetailView-DDbtIWq9.css +1 -0
  61. procler/static/assets/ProcessDetailView-DPtdNV-q.js +1 -0
  62. procler/static/assets/ProcessesView-B3a6Umur.js +1 -0
  63. procler/static/assets/ProcessesView-goLmghbJ.css +1 -0
  64. procler/static/assets/RecipesView-D2VxdneD.js +166 -0
  65. procler/static/assets/RecipesView-DXnFDCK4.css +1 -0
  66. procler/static/assets/Select-BBR17AHq.js +317 -0
  67. procler/static/assets/SnippetsView-B3a9q3AI.css +1 -0
  68. procler/static/assets/SnippetsView-DBCB2yGq.js +1 -0
  69. procler/static/assets/Spin-BXTjvFUk.js +90 -0
  70. procler/static/assets/Tag-Bh_qV63A.js +71 -0
  71. procler/static/assets/changelog-KkTT4H9-.js +1 -0
  72. procler/static/assets/groups-Zu-_v8ey.js +1 -0
  73. procler/static/assets/index-BsN-YMXq.css +1 -0
  74. procler/static/assets/index-BzW1XhyH.js +1282 -0
  75. procler/static/assets/procler-DOrSB1Vj.js +1 -0
  76. procler/static/assets/recipes-1w5SseGb.js +1 -0
  77. procler/static/index.html +17 -0
  78. procler/static/procler.png +0 -0
  79. procler-0.2.0.dist-info/METADATA +545 -0
  80. procler-0.2.0.dist-info/RECORD +83 -0
  81. procler-0.2.0.dist-info/WHEEL +4 -0
  82. procler-0.2.0.dist-info/entry_points.txt +2 -0
  83. procler-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,65 @@
1
+ """Configuration management for procler."""
2
+
3
+ from .changelog import ChangelogAction, append_changelog, read_changelog
4
+ from .loader import (
5
+ find_config_dir,
6
+ generate_template_config,
7
+ get_changelog_path,
8
+ get_config,
9
+ get_config_file_path,
10
+ get_state_db_path,
11
+ load_config,
12
+ reload_config,
13
+ )
14
+ from .schema import (
15
+ ContextType,
16
+ DependencyCondition,
17
+ DependencyDef,
18
+ GroupDef,
19
+ HealthCheckDef,
20
+ OnErrorAction,
21
+ ProcessDef,
22
+ ProclerConfig,
23
+ RecipeDef,
24
+ RecipeStep,
25
+ RecipeStepExec,
26
+ RecipeStepGroupStart,
27
+ RecipeStepGroupStop,
28
+ RecipeStepRestart,
29
+ RecipeStepStart,
30
+ RecipeStepStop,
31
+ RecipeStepWait,
32
+ SnippetDef,
33
+ )
34
+
35
+ __all__ = [
36
+ "find_config_dir",
37
+ "load_config",
38
+ "get_config",
39
+ "reload_config",
40
+ "generate_template_config",
41
+ "get_config_file_path",
42
+ "get_changelog_path",
43
+ "get_state_db_path",
44
+ "ProclerConfig",
45
+ "ProcessDef",
46
+ "GroupDef",
47
+ "RecipeDef",
48
+ "RecipeStep",
49
+ "SnippetDef",
50
+ "OnErrorAction",
51
+ "ContextType",
52
+ "RecipeStepStart",
53
+ "RecipeStepStop",
54
+ "RecipeStepRestart",
55
+ "RecipeStepGroupStart",
56
+ "RecipeStepGroupStop",
57
+ "RecipeStepWait",
58
+ "RecipeStepExec",
59
+ "HealthCheckDef",
60
+ "DependencyCondition",
61
+ "DependencyDef",
62
+ "append_changelog",
63
+ "read_changelog",
64
+ "ChangelogAction",
65
+ ]
@@ -0,0 +1,148 @@
1
+ """Append-only changelog for tracking config changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .loader import get_changelog_path
12
+
13
+
14
+ class ChangelogAction(str, Enum):
15
+ """Types of changelog actions."""
16
+
17
+ CREATE = "CREATE"
18
+ UPDATE = "UPDATE"
19
+ DELETE = "DELETE"
20
+ EXECUTE = "EXECUTE" # For recipe executions
21
+ START = "START" # Process started
22
+ STOP = "STOP" # Process stopped
23
+
24
+
25
+ def append_changelog(
26
+ action: ChangelogAction,
27
+ entity_type: str,
28
+ entity_name: str,
29
+ details: dict[str, Any] | None = None,
30
+ changelog_path: Path | None = None,
31
+ ) -> None:
32
+ """
33
+ Append an entry to the changelog.
34
+
35
+ Format: [ISO8601] ACTION type:name {json_details}
36
+
37
+ Args:
38
+ action: The action performed (CREATE, UPDATE, DELETE, EXECUTE, START, STOP)
39
+ entity_type: Type of entity (process, group, recipe, snippet)
40
+ entity_name: Name of the entity
41
+ details: Optional dict of additional details
42
+ changelog_path: Override path for testing
43
+ """
44
+ try:
45
+ if changelog_path is None:
46
+ changelog_path = get_changelog_path()
47
+ except (ValueError, FileNotFoundError):
48
+ # No config directory found - skip logging silently
49
+ return
50
+
51
+ # Ensure directory exists
52
+ changelog_path.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Create header if file doesn't exist
55
+ if not changelog_path.exists():
56
+ header = (
57
+ "# Procler Changelog - AUTO-GENERATED\n"
58
+ "# DO NOT EDIT MANUALLY\n"
59
+ "# Format: [ISO8601] ACTION type:name {json_details}\n"
60
+ "#\n"
61
+ )
62
+ changelog_path.write_text(header)
63
+
64
+ # Build log entry
65
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
66
+ details_json = json.dumps(details or {}, separators=(",", ":"))
67
+ entry = f"[{timestamp}] {action.value} {entity_type}:{entity_name} {details_json}\n"
68
+
69
+ # Append to file
70
+ with open(changelog_path, "a") as f:
71
+ f.write(entry)
72
+
73
+
74
+ def read_changelog(changelog_path: Path | None = None) -> list[dict[str, Any]]:
75
+ """
76
+ Read and parse the changelog.
77
+
78
+ Returns list of entries:
79
+ [
80
+ {
81
+ "timestamp": "2024-01-08T12:00:00Z",
82
+ "action": "CREATE",
83
+ "entity_type": "process",
84
+ "entity_name": "api",
85
+ "details": {...}
86
+ },
87
+ ...
88
+ ]
89
+ """
90
+ if changelog_path is None:
91
+ changelog_path = get_changelog_path()
92
+
93
+ if not changelog_path.exists():
94
+ return []
95
+
96
+ entries = []
97
+ for line in changelog_path.read_text().splitlines():
98
+ line = line.strip()
99
+ # Skip comments and empty lines
100
+ if not line or line.startswith("#"):
101
+ continue
102
+
103
+ try:
104
+ # Parse: [TIMESTAMP] ACTION type:name {json}
105
+ # Find the timestamp
106
+ ts_end = line.index("]")
107
+ timestamp = line[1:ts_end]
108
+
109
+ # Find the action
110
+ rest = line[ts_end + 2 :] # Skip "] "
111
+ parts = rest.split(" ", 2)
112
+ if len(parts) < 2:
113
+ continue
114
+
115
+ action = parts[0]
116
+ entity = parts[1]
117
+ details_str = parts[2] if len(parts) > 2 else "{}"
118
+
119
+ # Parse entity type:name
120
+ entity_type, _, entity_name = entity.partition(":")
121
+
122
+ # Parse details JSON
123
+ details = json.loads(details_str)
124
+
125
+ entries.append(
126
+ {
127
+ "timestamp": timestamp,
128
+ "action": action,
129
+ "entity_type": entity_type,
130
+ "entity_name": entity_name,
131
+ "details": details,
132
+ }
133
+ )
134
+ except (ValueError, json.JSONDecodeError):
135
+ # Skip malformed lines
136
+ continue
137
+
138
+ return entries
139
+
140
+
141
+ def get_entity_history(
142
+ entity_type: str,
143
+ entity_name: str,
144
+ changelog_path: Path | None = None,
145
+ ) -> list[dict[str, Any]]:
146
+ """Get changelog entries for a specific entity."""
147
+ entries = read_changelog(changelog_path)
148
+ return [e for e in entries if e["entity_type"] == entity_type and e["entity_name"] == entity_name]
@@ -0,0 +1,256 @@
1
+ """Config file discovery and loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from .schema import ProclerConfig
12
+
13
+ # Singleton config cache
14
+ _config_cache: ProclerConfig | None = None
15
+ _config_dir_cache: Path | None = None
16
+
17
+
18
+ def find_git_root() -> Path | None:
19
+ """Find the root of the git repository, if any."""
20
+ try:
21
+ result = subprocess.run(
22
+ ["git", "rev-parse", "--show-toplevel"],
23
+ capture_output=True,
24
+ text=True,
25
+ timeout=5,
26
+ )
27
+ if result.returncode == 0:
28
+ return Path(result.stdout.strip())
29
+ except (subprocess.TimeoutExpired, FileNotFoundError):
30
+ pass
31
+ return None
32
+
33
+
34
+ def parse_env_file(path: Path) -> dict[str, str]:
35
+ """Parse a simple .env file (KEY=value format)."""
36
+ env = {}
37
+ if path.exists():
38
+ for line in path.read_text().splitlines():
39
+ line = line.strip()
40
+ if line and not line.startswith("#") and "=" in line:
41
+ key, _, value = line.partition("=")
42
+ # Strip quotes if present
43
+ value = value.strip().strip('"').strip("'")
44
+ env[key.strip()] = value
45
+ return env
46
+
47
+
48
+ def find_config_dir(start_dir: Path | None = None) -> Path:
49
+ """
50
+ Find the procler config directory using this discovery chain:
51
+
52
+ 1. PROCLER_CONFIG_DIR environment variable
53
+ 2. .procler.env file in current dir or parents (sets PROCLER_CONFIG_DIR)
54
+ 3. .procler/ directory in current dir
55
+ 4. .procler/ directory in git root
56
+ 5. ~/.procler/ (global fallback)
57
+ """
58
+ global _config_dir_cache
59
+ if _config_dir_cache is not None:
60
+ return _config_dir_cache
61
+
62
+ start = start_dir or Path.cwd()
63
+
64
+ # 1. Explicit environment variable
65
+ if env_dir := os.environ.get("PROCLER_CONFIG_DIR"):
66
+ _config_dir_cache = Path(env_dir).expanduser().resolve()
67
+ return _config_dir_cache
68
+
69
+ # 2. Search for .procler.env in current dir and parents
70
+ for parent in [start] + list(start.parents):
71
+ env_file = parent / ".procler.env"
72
+ if env_file.exists():
73
+ env = parse_env_file(env_file)
74
+ if config_dir := env.get("PROCLER_CONFIG_DIR"):
75
+ # Resolve relative to the .procler.env location
76
+ resolved = (parent / config_dir).resolve()
77
+ _config_dir_cache = resolved
78
+ return _config_dir_cache
79
+
80
+ # 3. .procler/ in current directory
81
+ local_config = start / ".procler"
82
+ if local_config.exists() and local_config.is_dir():
83
+ _config_dir_cache = local_config
84
+ return _config_dir_cache
85
+
86
+ # 4. .procler/ in git root
87
+ git_root = find_git_root()
88
+ if git_root:
89
+ git_config = git_root / ".procler"
90
+ if git_config.exists() and git_config.is_dir():
91
+ _config_dir_cache = git_config
92
+ return _config_dir_cache
93
+
94
+ # 5. Global fallback
95
+ _config_dir_cache = Path.home() / ".procler"
96
+ return _config_dir_cache
97
+
98
+
99
+ def get_config_file_path() -> Path:
100
+ """Get the path to the config.yaml file."""
101
+ return find_config_dir() / "config.yaml"
102
+
103
+
104
+ def get_changelog_path() -> Path:
105
+ """Get the path to the changelog.log file."""
106
+ return find_config_dir() / "changelog.log"
107
+
108
+
109
+ def get_state_db_path() -> Path:
110
+ """Get the path to the state.db file."""
111
+ return find_config_dir() / "state.db"
112
+
113
+
114
+ def load_config(config_path: Path | None = None) -> ProclerConfig:
115
+ """
116
+ Load configuration from YAML file.
117
+
118
+ Returns an empty config if no config file exists.
119
+ """
120
+ global _config_cache
121
+
122
+ if config_path is None:
123
+ config_path = get_config_file_path()
124
+
125
+ if not config_path.exists():
126
+ # Return empty config - processes/groups/recipes can be empty
127
+ return ProclerConfig()
128
+
129
+ with open(config_path) as f:
130
+ data = yaml.safe_load(f) or {}
131
+
132
+ config = ProclerConfig.model_validate(data)
133
+
134
+ # Warn about suspicious patterns in vars (security check)
135
+ if config.vars:
136
+ from ..core.variable_substitution import warn_suspicious_vars
137
+
138
+ warn_suspicious_vars(config.vars)
139
+
140
+ _config_cache = config
141
+ return config
142
+
143
+
144
+ def get_config() -> ProclerConfig:
145
+ """Get the cached config, loading if necessary."""
146
+ global _config_cache
147
+ if _config_cache is None:
148
+ _config_cache = load_config()
149
+ return _config_cache
150
+
151
+
152
+ def reload_config() -> ProclerConfig:
153
+ """Force reload of config from disk."""
154
+ global _config_cache
155
+ _config_cache = None
156
+ return load_config()
157
+
158
+
159
+ def save_config(config: ProclerConfig, config_path: Path | None = None) -> None:
160
+ """Save configuration to YAML file."""
161
+ if config_path is None:
162
+ config_path = get_config_file_path()
163
+
164
+ # Ensure directory exists
165
+ config_path.parent.mkdir(parents=True, exist_ok=True)
166
+
167
+ # Convert to dict, excluding defaults for cleaner output
168
+ data = config.model_dump(exclude_defaults=True, exclude_none=True)
169
+
170
+ # Always include version
171
+ data["version"] = config.version
172
+
173
+ with open(config_path, "w") as f:
174
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
175
+
176
+
177
+ def reset_config_cache() -> None:
178
+ """Reset the config cache (useful for testing)."""
179
+ global _config_cache, _config_dir_cache
180
+ _config_cache = None
181
+ _config_dir_cache = None
182
+
183
+
184
+ def generate_template_config() -> str:
185
+ """Generate a template config.yaml with examples."""
186
+ return """\
187
+ # Procler Configuration - LLM-First Process Manager
188
+ # https://github.com/gabu-quest/procler
189
+ #
190
+ # This file is VERSION CONTROLLED - commit it to your repo!
191
+ # Each project can have its own .procler/ directory.
192
+ #
193
+ # Files in .procler/:
194
+ # config.yaml - This file (commit to git)
195
+ # changelog.log - Audit trail of operations (commit to git)
196
+ # state.db - Runtime state (auto-gitignored)
197
+ #
198
+ # Discovery order: $PROCLER_CONFIG_DIR > .procler.env > .procler/ > git root > ~/.procler/
199
+ #
200
+ # CLI: `procler config explain` shows what this config does in plain language
201
+ # API: GET /api/config/explain returns the same as JSON
202
+ version: 1
203
+
204
+ # Process definitions - things that run continuously
205
+ processes:
206
+ # Example local process
207
+ # api:
208
+ # command: uvicorn main:app --reload
209
+ # context: local
210
+ # cwd: /path/to/project
211
+ # tags: [backend, api]
212
+ # description: "FastAPI development server"
213
+ #
214
+ # Example docker process
215
+ # worker:
216
+ # command: celery -A app worker
217
+ # context: docker
218
+ # container: my-container
219
+ # description: "Background task worker"
220
+
221
+ # Process groups - ordered start/stop
222
+ # Use: `procler group start backend` / `procler group stop backend`
223
+ groups:
224
+ # Example group
225
+ # backend:
226
+ # description: "Full backend stack"
227
+ # processes: [redis, api, worker] # Start in this order
228
+ # # stop_order defaults to reverse of processes
229
+ # # stop_order: [worker, api, redis] # Custom stop order
230
+
231
+ # Recipes - multi-step operations (like makefiles for processes)
232
+ # Use: `procler recipe run deploy --dry-run` to preview
233
+ recipes:
234
+ # Example recipe
235
+ # deploy:
236
+ # description: "Graceful deployment with migration"
237
+ # on_error: stop # or "continue"
238
+ # steps:
239
+ # - stop: worker
240
+ # - stop: api
241
+ # - wait: 2s
242
+ # - exec: "alembic upgrade head"
243
+ # context: docker
244
+ # container: my-container
245
+ # - start: api
246
+ # - start: worker
247
+
248
+ # Snippets - reusable one-off commands
249
+ # Use: `procler snippet run rebuild`
250
+ snippets:
251
+ # Example snippet
252
+ # rebuild:
253
+ # command: docker compose build
254
+ # description: "Rebuild all containers"
255
+ # tags: [docker, build]
256
+ """