epi-recorder 1.0.0__py3-none-any.whl → 1.1.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.
- epi_cli/keys.py +3 -3
- epi_cli/ls.py +160 -0
- epi_cli/main.py +73 -8
- epi_cli/record.py +10 -3
- epi_cli/run.py +318 -0
- epi_cli/verify.py +20 -20
- epi_cli/view.py +68 -13
- epi_core/redactor.py +14 -1
- epi_core/schemas.py +34 -3
- epi_recorder/api.py +217 -20
- epi_recorder/environment.py +21 -0
- epi_recorder/patcher.py +88 -7
- epi_recorder-1.1.0.dist-info/METADATA +569 -0
- epi_recorder-1.1.0.dist-info/RECORD +27 -0
- epi_viewer_static/app.js +77 -1
- epi_recorder-1.0.0.dist-info/METADATA +0 -503
- epi_recorder-1.0.0.dist-info/RECORD +0 -25
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.0.dist-info}/WHEEL +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.0.dist-info}/entry_points.txt +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.0.dist-info}/top_level.txt +0 -0
epi_cli/verify.py
CHANGED
|
@@ -46,7 +46,7 @@ def verify(
|
|
|
46
46
|
"""
|
|
47
47
|
# Ensure file exists
|
|
48
48
|
if not epi_file.exists():
|
|
49
|
-
console.print(f"[red]
|
|
49
|
+
console.print(f"[red][FAIL] Error:[/red] File not found: {epi_file}")
|
|
50
50
|
raise typer.Exit(1)
|
|
51
51
|
|
|
52
52
|
# Initialize verification state
|
|
@@ -65,11 +65,11 @@ def verify(
|
|
|
65
65
|
try:
|
|
66
66
|
manifest = EPIContainer.read_manifest(epi_file)
|
|
67
67
|
if verbose:
|
|
68
|
-
console.print(" [green]
|
|
69
|
-
console.print(" [green]
|
|
70
|
-
console.print(" [green]
|
|
68
|
+
console.print(" [green][OK][/green] Valid ZIP format")
|
|
69
|
+
console.print(" [green][OK][/green] Valid mimetype")
|
|
70
|
+
console.print(" [green][OK][/green] Valid manifest schema")
|
|
71
71
|
except Exception as e:
|
|
72
|
-
console.print(f"[red]
|
|
72
|
+
console.print(f"[red][FAIL] Structural validation failed:[/red] {e}")
|
|
73
73
|
raise typer.Exit(1)
|
|
74
74
|
|
|
75
75
|
# ========== STEP 2: INTEGRITY CHECKS ==========
|
|
@@ -80,9 +80,9 @@ def verify(
|
|
|
80
80
|
|
|
81
81
|
if verbose:
|
|
82
82
|
if integrity_ok:
|
|
83
|
-
console.print(f" [green]
|
|
83
|
+
console.print(f" [green][OK][/green] All {len(manifest.file_manifest)} files verified")
|
|
84
84
|
else:
|
|
85
|
-
console.print(f" [red]
|
|
85
|
+
console.print(f" [red][FAIL][/red] {len(mismatches)} file(s) failed verification")
|
|
86
86
|
for filename, reason in mismatches.items():
|
|
87
87
|
console.print(f" [red]•[/red] {filename}: {reason}")
|
|
88
88
|
|
|
@@ -101,19 +101,19 @@ def verify(
|
|
|
101
101
|
|
|
102
102
|
if verbose:
|
|
103
103
|
if signature_valid:
|
|
104
|
-
console.print(f" [green]
|
|
104
|
+
console.print(f" [green][OK][/green] {sig_message}")
|
|
105
105
|
else:
|
|
106
|
-
console.print(f" [red]
|
|
106
|
+
console.print(f" [red][FAIL][/red] {sig_message}")
|
|
107
107
|
|
|
108
108
|
except FileNotFoundError:
|
|
109
109
|
signature_valid = False
|
|
110
110
|
if verbose:
|
|
111
|
-
console.print(f" [yellow]
|
|
111
|
+
console.print(f" [yellow][WARN][/yellow] Public key not found: {signer_name}")
|
|
112
112
|
console.print(" [dim]Cannot verify signature without public key[/dim]")
|
|
113
113
|
else:
|
|
114
114
|
signature_valid = None
|
|
115
115
|
if verbose:
|
|
116
|
-
console.print(" [yellow]
|
|
116
|
+
console.print(" [yellow][WARN][/yellow] No signature present (unsigned)")
|
|
117
117
|
|
|
118
118
|
# ========== CREATE REPORT ==========
|
|
119
119
|
report = create_verification_report(
|
|
@@ -143,7 +143,7 @@ def verify(
|
|
|
143
143
|
if verbose:
|
|
144
144
|
console.print_exception()
|
|
145
145
|
else:
|
|
146
|
-
console.print(f"[red]
|
|
146
|
+
console.print(f"[red][FAIL] Verification failed:[/red] {e}")
|
|
147
147
|
raise typer.Exit(1)
|
|
148
148
|
|
|
149
149
|
|
|
@@ -158,15 +158,15 @@ def print_trust_report(report: dict, epi_file: Path, verbose: bool = False):
|
|
|
158
158
|
"""
|
|
159
159
|
# Determine overall status symbol and color
|
|
160
160
|
if report["trust_level"] == "HIGH":
|
|
161
|
-
status_symbol = "
|
|
161
|
+
status_symbol = "[OK]"
|
|
162
162
|
status_color = "green"
|
|
163
163
|
panel_style = "green"
|
|
164
164
|
elif report["trust_level"] == "MEDIUM":
|
|
165
|
-
status_symbol = "
|
|
165
|
+
status_symbol = "[WARN]"
|
|
166
166
|
status_color = "yellow"
|
|
167
167
|
panel_style = "yellow"
|
|
168
168
|
else:
|
|
169
|
-
status_symbol = "
|
|
169
|
+
status_symbol = "[FAIL]"
|
|
170
170
|
status_color = "red"
|
|
171
171
|
panel_style = "red"
|
|
172
172
|
|
|
@@ -179,17 +179,17 @@ def print_trust_report(report: dict, epi_file: Path, verbose: bool = False):
|
|
|
179
179
|
|
|
180
180
|
# Integrity status
|
|
181
181
|
if report["integrity_ok"]:
|
|
182
|
-
content_lines.append(f"[green]
|
|
182
|
+
content_lines.append(f"[green][OK] Integrity:[/green] Verified ({report['files_checked']} files)")
|
|
183
183
|
else:
|
|
184
|
-
content_lines.append(f"[red]
|
|
184
|
+
content_lines.append(f"[red][FAIL] Integrity:[/red] Failed ({report['mismatches_count']} mismatches)")
|
|
185
185
|
|
|
186
186
|
# Signature status
|
|
187
187
|
if report["signature_valid"]:
|
|
188
|
-
content_lines.append(f"[green]
|
|
188
|
+
content_lines.append(f"[green][OK] Signature:[/green] Valid (key: {report['signer']})")
|
|
189
189
|
elif report["signature_valid"] is None:
|
|
190
|
-
content_lines.append("[yellow]
|
|
190
|
+
content_lines.append("[yellow][WARN] Signature:[/yellow] Not signed")
|
|
191
191
|
else:
|
|
192
|
-
content_lines.append(f"[red]
|
|
192
|
+
content_lines.append(f"[red][FAIL] Signature:[/red] Invalid")
|
|
193
193
|
|
|
194
194
|
# Show metadata if verbose
|
|
195
195
|
if verbose:
|
epi_cli/view.py
CHANGED
|
@@ -17,26 +17,81 @@ console = Console()
|
|
|
17
17
|
|
|
18
18
|
app = typer.Typer(name="view", help="View .epi file in browser")
|
|
19
19
|
|
|
20
|
+
DEFAULT_DIR = Path("epi-recordings")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_epi_file(name_or_path: str) -> Path:
|
|
24
|
+
"""
|
|
25
|
+
Resolve a name or path to an .epi file.
|
|
26
|
+
|
|
27
|
+
Tries in order:
|
|
28
|
+
1. Exact path if it exists
|
|
29
|
+
2. Add .epi extension if missing
|
|
30
|
+
3. Look in ./epi-recordings/ directory
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name_or_path: User input (name or path)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Resolved Path object
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
FileNotFoundError if file cannot be found
|
|
40
|
+
"""
|
|
41
|
+
path = Path(name_or_path)
|
|
42
|
+
|
|
43
|
+
# Try exact path
|
|
44
|
+
if path.exists() and path.is_file():
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
# Try adding .epi extension
|
|
48
|
+
if not str(path).endswith(".epi"):
|
|
49
|
+
with_ext = path.with_suffix(".epi")
|
|
50
|
+
if with_ext.exists():
|
|
51
|
+
return with_ext
|
|
52
|
+
|
|
53
|
+
# Try in default directory
|
|
54
|
+
in_default = DEFAULT_DIR / path.name
|
|
55
|
+
if in_default.exists():
|
|
56
|
+
return in_default
|
|
57
|
+
|
|
58
|
+
# Try in default directory with .epi extension
|
|
59
|
+
in_default_with_ext = DEFAULT_DIR / f"{path.stem}.epi"
|
|
60
|
+
if in_default_with_ext.exists():
|
|
61
|
+
return in_default_with_ext
|
|
62
|
+
|
|
63
|
+
# Not found
|
|
64
|
+
raise FileNotFoundError(f"Recording not found: {name_or_path}")
|
|
65
|
+
|
|
20
66
|
|
|
21
67
|
@app.callback(invoke_without_command=True)
|
|
22
68
|
def view(
|
|
23
69
|
ctx: typer.Context,
|
|
24
|
-
epi_file:
|
|
70
|
+
epi_file: str = typer.Argument(..., help="Path or name of .epi file to view"),
|
|
25
71
|
):
|
|
26
72
|
"""
|
|
27
73
|
Open .epi file in browser viewer.
|
|
28
74
|
|
|
29
|
-
|
|
30
|
-
|
|
75
|
+
Accepts file path, name, or base name. Automatically resolves:
|
|
76
|
+
- foo -> ./epi-recordings/foo.epi
|
|
77
|
+
- foo.epi -> ./epi-recordings/foo.epi
|
|
78
|
+
- /path/to/file.epi -> /path/to/file.epi
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
epi view my_script_20251121_231501
|
|
82
|
+
epi view my_recording.epi
|
|
31
83
|
"""
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
84
|
+
# Resolve the file path
|
|
85
|
+
try:
|
|
86
|
+
resolved_path = _resolve_epi_file(epi_file)
|
|
87
|
+
except FileNotFoundError as e:
|
|
88
|
+
console.print(f"[red][FAIL] Error:[/red] {e}")
|
|
89
|
+
console.print(f"[dim]Tip: Run 'epi ls' to see available recordings[/dim]")
|
|
35
90
|
raise typer.Exit(1)
|
|
36
91
|
|
|
37
92
|
# Validate it's a ZIP file
|
|
38
|
-
if not zipfile.is_zipfile(
|
|
39
|
-
console.print(f"[red]
|
|
93
|
+
if not zipfile.is_zipfile(resolved_path):
|
|
94
|
+
console.print(f"[red][FAIL] Error:[/red] Not a valid .epi file: {resolved_path}")
|
|
40
95
|
raise typer.Exit(1)
|
|
41
96
|
|
|
42
97
|
try:
|
|
@@ -45,9 +100,9 @@ def view(
|
|
|
45
100
|
viewer_path = temp_dir / "viewer.html"
|
|
46
101
|
|
|
47
102
|
# Extract viewer.html
|
|
48
|
-
with zipfile.ZipFile(
|
|
103
|
+
with zipfile.ZipFile(resolved_path, "r") as zf:
|
|
49
104
|
if "viewer.html" not in zf.namelist():
|
|
50
|
-
console.print("[red]
|
|
105
|
+
console.print("[red][FAIL] Error:[/red] No viewer found in .epi file")
|
|
51
106
|
console.print("[dim]This file may have been created with an older version of EPI[/dim]")
|
|
52
107
|
raise typer.Exit(1)
|
|
53
108
|
|
|
@@ -61,14 +116,14 @@ def view(
|
|
|
61
116
|
success = webbrowser.open(file_url)
|
|
62
117
|
|
|
63
118
|
if success:
|
|
64
|
-
console.print("[green]
|
|
119
|
+
console.print("[green][OK][/green] Viewer opened in browser")
|
|
65
120
|
else:
|
|
66
|
-
console.print("[yellow]
|
|
121
|
+
console.print("[yellow][WARN] Could not open browser automatically[/yellow]")
|
|
67
122
|
console.print(f"[dim]Open manually:[/dim] {file_url}")
|
|
68
123
|
|
|
69
124
|
except KeyboardInterrupt:
|
|
70
125
|
console.print("\n[yellow]Cancelled[/yellow]")
|
|
71
126
|
raise typer.Exit(130)
|
|
72
127
|
except Exception as e:
|
|
73
|
-
console.print(f"[red]
|
|
128
|
+
console.print(f"[red][FAIL] Error:[/red] {e}")
|
|
74
129
|
raise typer.Exit(1)
|
epi_core/redactor.py
CHANGED
|
@@ -78,17 +78,19 @@ class Redactor:
|
|
|
78
78
|
from captured workflow data.
|
|
79
79
|
"""
|
|
80
80
|
|
|
81
|
-
def __init__(self, config_path: Path | None = None, enabled: bool = True):
|
|
81
|
+
def __init__(self, config_path: Path | None = None, enabled: bool = True, allowlist: List[str] = None):
|
|
82
82
|
"""
|
|
83
83
|
Initialize redactor with optional custom configuration.
|
|
84
84
|
|
|
85
85
|
Args:
|
|
86
86
|
config_path: Optional path to config.toml with custom patterns
|
|
87
87
|
enabled: Whether redaction is enabled (default: True)
|
|
88
|
+
allowlist: Optional list of strings to NEVER redact
|
|
88
89
|
"""
|
|
89
90
|
self.enabled = enabled
|
|
90
91
|
self.patterns: List[Tuple[re.Pattern, str]] = []
|
|
91
92
|
self.env_vars_to_redact = REDACT_ENV_VARS.copy()
|
|
93
|
+
self.allowlist = set(allowlist) if allowlist else set()
|
|
92
94
|
|
|
93
95
|
# Compile default patterns
|
|
94
96
|
for pattern_str, description in DEFAULT_REDACTION_PATTERNS:
|
|
@@ -132,6 +134,10 @@ class Redactor:
|
|
|
132
134
|
# Load custom env vars
|
|
133
135
|
if 'redaction' in config and 'env_vars' in config['redaction']:
|
|
134
136
|
self.env_vars_to_redact.update(config['redaction']['env_vars'])
|
|
137
|
+
|
|
138
|
+
# Load allowlist
|
|
139
|
+
if 'redaction' in config and 'allowlist' in config['redaction']:
|
|
140
|
+
self.allowlist.update(config['redaction']['allowlist'])
|
|
135
141
|
|
|
136
142
|
except Exception as e:
|
|
137
143
|
print(f"Warning: Could not load config from {config_path}: {e}")
|
|
@@ -176,6 +182,10 @@ class Redactor:
|
|
|
176
182
|
return redacted_list, redaction_count
|
|
177
183
|
|
|
178
184
|
elif isinstance(data, str):
|
|
185
|
+
# Check allowlist first
|
|
186
|
+
if data in self.allowlist:
|
|
187
|
+
return data, 0
|
|
188
|
+
|
|
179
189
|
redacted_str = data
|
|
180
190
|
for pattern, description in self.patterns:
|
|
181
191
|
matches = pattern.findall(redacted_str)
|
|
@@ -239,6 +249,9 @@ enabled = true
|
|
|
239
249
|
|
|
240
250
|
# Additional environment variable names to redact
|
|
241
251
|
# env_vars = ["MY_SECRET_VAR", "CUSTOM_TOKEN"]
|
|
252
|
+
|
|
253
|
+
# Allowlist: Strings that should NEVER be redacted (exact match)
|
|
254
|
+
# allowlist = ["sk-not-actually-a-key", "my-public-token"]
|
|
242
255
|
"""
|
|
243
256
|
|
|
244
257
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
epi_core/schemas.py
CHANGED
|
@@ -3,7 +3,7 @@ EPI Core Schemas - Pydantic models for manifest and steps.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from typing import Any, Dict, Optional
|
|
6
|
+
from typing import Any, Dict, Optional, List, Union
|
|
7
7
|
from uuid import UUID, uuid4
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel, ConfigDict, Field
|
|
@@ -52,6 +52,32 @@ class ManifestModel(BaseModel):
|
|
|
52
52
|
description="Ed25519 signature of the canonical CBOR hash of this manifest (excluding signature field)"
|
|
53
53
|
)
|
|
54
54
|
|
|
55
|
+
# New metadata fields for decision tracking
|
|
56
|
+
goal: Optional[str] = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="Goal or objective of this workflow execution"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
notes: Optional[str] = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="Additional notes or context about this workflow"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
metrics: Optional[Dict[str, Union[float, str]]] = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
description="Key-value metrics for this workflow (accuracy, latency, etc.)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
approved_by: Optional[str] = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="Person or entity who approved this workflow execution"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
tags: Optional[List[str]] = Field(
|
|
77
|
+
default=None,
|
|
78
|
+
description="Tags for categorizing this workflow"
|
|
79
|
+
)
|
|
80
|
+
|
|
55
81
|
model_config = ConfigDict(
|
|
56
82
|
json_schema_extra={
|
|
57
83
|
"example": {
|
|
@@ -65,7 +91,12 @@ class ManifestModel(BaseModel):
|
|
|
65
91
|
"env.json": "a3c5f...",
|
|
66
92
|
"artifacts/output.txt": "c7f8a..."
|
|
67
93
|
},
|
|
68
|
-
"signature": "ed25519:3a4b5c6d..."
|
|
94
|
+
"signature": "ed25519:3a4b5c6d...",
|
|
95
|
+
"goal": "Improve model accuracy",
|
|
96
|
+
"notes": "Switched to GPT-4 for better reasoning",
|
|
97
|
+
"metrics": {"accuracy": 0.92, "latency": 210},
|
|
98
|
+
"approved_by": "alice@company.com",
|
|
99
|
+
"tags": ["prod-candidate", "v1.0"]
|
|
69
100
|
}
|
|
70
101
|
}
|
|
71
102
|
)
|
|
@@ -109,4 +140,4 @@ class StepModel(BaseModel):
|
|
|
109
140
|
}
|
|
110
141
|
}
|
|
111
142
|
}
|
|
112
|
-
)
|
|
143
|
+
)
|
epi_recorder/api.py
CHANGED
|
@@ -5,13 +5,15 @@ Provides a context manager for recording EPI packages programmatically
|
|
|
5
5
|
with minimal code changes.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import functools
|
|
8
9
|
import json
|
|
10
|
+
import os
|
|
9
11
|
import shutil
|
|
10
12
|
import tempfile
|
|
11
13
|
import threading
|
|
12
14
|
from datetime import datetime
|
|
13
15
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict, List, Optional
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
15
17
|
|
|
16
18
|
from epi_core.container import EPIContainer
|
|
17
19
|
from epi_core.schemas import ManifestModel
|
|
@@ -45,7 +47,13 @@ class EpiRecorderSession:
|
|
|
45
47
|
tags: Optional[List[str]] = None,
|
|
46
48
|
auto_sign: bool = True,
|
|
47
49
|
redact: bool = True,
|
|
48
|
-
default_key_name: str = "default"
|
|
50
|
+
default_key_name: str = "default",
|
|
51
|
+
# New metadata fields
|
|
52
|
+
goal: Optional[str] = None,
|
|
53
|
+
notes: Optional[str] = None,
|
|
54
|
+
metrics: Optional[Dict[str, Union[float, str]]] = None,
|
|
55
|
+
approved_by: Optional[str] = None,
|
|
56
|
+
metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict with tags parameter
|
|
49
57
|
):
|
|
50
58
|
"""
|
|
51
59
|
Initialize EPI recording session.
|
|
@@ -57,6 +65,11 @@ class EpiRecorderSession:
|
|
|
57
65
|
auto_sign: Whether to automatically sign on exit (default: True)
|
|
58
66
|
redact: Whether to redact secrets (default: True)
|
|
59
67
|
default_key_name: Name of key to use for signing (default: "default")
|
|
68
|
+
goal: Goal or objective of this workflow execution
|
|
69
|
+
notes: Additional notes or context about this workflow
|
|
70
|
+
metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
|
|
71
|
+
approved_by: Person or entity who approved this workflow execution
|
|
72
|
+
metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
|
|
60
73
|
"""
|
|
61
74
|
self.output_path = Path(output_path)
|
|
62
75
|
self.workflow_name = workflow_name or "untitled"
|
|
@@ -65,6 +78,13 @@ class EpiRecorderSession:
|
|
|
65
78
|
self.redact = redact
|
|
66
79
|
self.default_key_name = default_key_name
|
|
67
80
|
|
|
81
|
+
# New metadata fields
|
|
82
|
+
self.goal = goal
|
|
83
|
+
self.notes = notes
|
|
84
|
+
self.metrics = metrics
|
|
85
|
+
self.approved_by = approved_by
|
|
86
|
+
self.metadata_tags = metadata_tags
|
|
87
|
+
|
|
68
88
|
# Runtime state
|
|
69
89
|
self.temp_dir: Optional[Path] = None
|
|
70
90
|
self.recording_context: Optional[RecordingContext] = None
|
|
@@ -97,9 +117,9 @@ class EpiRecorderSession:
|
|
|
97
117
|
set_recording_context(self.recording_context)
|
|
98
118
|
_thread_local.active_session = self
|
|
99
119
|
|
|
100
|
-
# Patch LLM libraries
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
# Patch LLM libraries and HTTP
|
|
121
|
+
from epi_recorder.patcher import patch_all
|
|
122
|
+
patch_all()
|
|
103
123
|
|
|
104
124
|
# Log session start
|
|
105
125
|
self.log_step("session.start", {
|
|
@@ -134,16 +154,19 @@ class EpiRecorderSession:
|
|
|
134
154
|
duration = (end_time - self.start_time).total_seconds()
|
|
135
155
|
|
|
136
156
|
self.log_step("session.end", {
|
|
137
|
-
"workflow_name": self.workflow_name,
|
|
138
157
|
"timestamp": end_time.isoformat(),
|
|
139
158
|
"duration_seconds": duration,
|
|
140
159
|
"success": exc_type is None
|
|
141
160
|
})
|
|
142
161
|
|
|
143
|
-
# Create manifest
|
|
144
|
-
# Note: workflow_name and tags are logged in steps, not manifest
|
|
162
|
+
# Create manifest with metadata
|
|
145
163
|
manifest = ManifestModel(
|
|
146
|
-
created_at=self.start_time
|
|
164
|
+
created_at=self.start_time,
|
|
165
|
+
goal=self.goal,
|
|
166
|
+
notes=self.notes,
|
|
167
|
+
metrics=self.metrics,
|
|
168
|
+
approved_by=self.approved_by,
|
|
169
|
+
tags=self.metadata_tags
|
|
147
170
|
)
|
|
148
171
|
|
|
149
172
|
# Pack into .epi file
|
|
@@ -333,9 +356,10 @@ class EpiRecorderSession:
|
|
|
333
356
|
)
|
|
334
357
|
|
|
335
358
|
# Repack the ZIP with signed manifest
|
|
336
|
-
|
|
359
|
+
# CRITICAL: Write to temp file first to prevent data loss
|
|
360
|
+
temp_output = self.output_path.with_suffix('.epi.tmp')
|
|
337
361
|
|
|
338
|
-
with zipfile.ZipFile(
|
|
362
|
+
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
339
363
|
# Write mimetype first (uncompressed)
|
|
340
364
|
from epi_core.container import EPI_MIMETYPE
|
|
341
365
|
zf.writestr("mimetype", EPI_MIMETYPE, compress_type=zipfile.ZIP_STORED)
|
|
@@ -346,36 +370,209 @@ class EpiRecorderSession:
|
|
|
346
370
|
arc_name = str(file_path.relative_to(tmp_path)).replace("\\", "/")
|
|
347
371
|
zf.write(file_path, arc_name)
|
|
348
372
|
|
|
373
|
+
# Successfully created signed file, now safely replace original
|
|
374
|
+
self.output_path.unlink()
|
|
375
|
+
temp_output.rename(self.output_path)
|
|
376
|
+
|
|
349
377
|
except Exception as e:
|
|
350
378
|
# Non-fatal: log warning but continue
|
|
351
379
|
print(f"Warning: Failed to sign .epi file: {e}")
|
|
352
380
|
|
|
353
381
|
|
|
354
|
-
|
|
382
|
+
def _auto_generate_output_path(name_hint: Optional[str] = None) -> Path:
|
|
383
|
+
"""
|
|
384
|
+
Auto-generate output path in ./epi-recordings/ directory.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
name_hint: Optional base name hint (script name, function name, etc.)
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Path object for the .epi file
|
|
391
|
+
"""
|
|
392
|
+
# Get recordings directory from env or default
|
|
393
|
+
recordings_dir = Path(os.getenv("EPI_RECORDINGS_DIR", "epi-recordings"))
|
|
394
|
+
recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
|
|
396
|
+
# Generate base name
|
|
397
|
+
if name_hint:
|
|
398
|
+
base = Path(name_hint).stem if "." in name_hint else name_hint
|
|
399
|
+
else:
|
|
400
|
+
base = "recording"
|
|
401
|
+
|
|
402
|
+
# Generate timestamp
|
|
403
|
+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
404
|
+
|
|
405
|
+
# Ensure .epi extension
|
|
406
|
+
filename = f"{base}_{timestamp}.epi"
|
|
407
|
+
|
|
408
|
+
return recordings_dir / filename
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _resolve_output_path(output_path: Optional[Path | str]) -> Path:
|
|
412
|
+
"""
|
|
413
|
+
Resolve output path, adding .epi extension and default directory if needed.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
output_path: User-provided path or None for auto-generation
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Resolved Path object
|
|
420
|
+
"""
|
|
421
|
+
if output_path is None:
|
|
422
|
+
return _auto_generate_output_path()
|
|
423
|
+
|
|
424
|
+
path = Path(output_path)
|
|
425
|
+
|
|
426
|
+
# Add .epi extension if missing
|
|
427
|
+
if path.suffix != ".epi":
|
|
428
|
+
path = path.with_suffix(".epi")
|
|
429
|
+
|
|
430
|
+
return path
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# Convenience function for users (supports zero-config)
|
|
355
434
|
def record(
|
|
356
|
-
output_path: Path | str,
|
|
435
|
+
output_path: Optional[Path | str] = None,
|
|
357
436
|
workflow_name: Optional[str] = None,
|
|
437
|
+
tags: Optional[List[str]] = None,
|
|
438
|
+
auto_sign: bool = True,
|
|
439
|
+
redact: bool = True,
|
|
440
|
+
default_key_name: str = "default",
|
|
441
|
+
# New metadata fields
|
|
442
|
+
goal: Optional[str] = None,
|
|
443
|
+
notes: Optional[str] = None,
|
|
444
|
+
metrics: Optional[Dict[str, Union[float, str]]] = None,
|
|
445
|
+
approved_by: Optional[str] = None,
|
|
446
|
+
metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict
|
|
358
447
|
**kwargs
|
|
359
|
-
) -> EpiRecorderSession:
|
|
448
|
+
) -> Union[EpiRecorderSession, Callable]:
|
|
360
449
|
"""
|
|
361
450
|
Create an EPI recording session (context manager).
|
|
362
451
|
|
|
363
452
|
Args:
|
|
364
|
-
output_path: Path for output .epi file
|
|
453
|
+
output_path: Path for output .epi file (optional - auto-generates if None)
|
|
365
454
|
workflow_name: Descriptive name for workflow
|
|
366
|
-
|
|
455
|
+
tags: Tags for categorization
|
|
456
|
+
auto_sign: Whether to automatically sign on exit (default: True)
|
|
457
|
+
redact: Whether to redact secrets (default: True)
|
|
458
|
+
default_key_name: Name of key to use for signing (default: "default")
|
|
459
|
+
goal: Goal or objective of this workflow execution
|
|
460
|
+
notes: Additional notes or context about this workflow
|
|
461
|
+
metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
|
|
462
|
+
approved_by: Person or entity who approved this workflow execution
|
|
463
|
+
metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
|
|
464
|
+
**kwargs: Additional arguments (backward compatibility)
|
|
367
465
|
|
|
368
466
|
Returns:
|
|
369
|
-
EpiRecorderSession context manager
|
|
467
|
+
EpiRecorderSession context manager or decorated function
|
|
370
468
|
|
|
371
469
|
Example:
|
|
372
470
|
from epi_recorder import record
|
|
373
471
|
|
|
374
|
-
|
|
472
|
+
# Zero-config (auto-generates filename in ./epi-recordings/)
|
|
473
|
+
with record():
|
|
474
|
+
# Your code here
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
# With custom name
|
|
478
|
+
with record("my_workflow"):
|
|
479
|
+
# Your code here
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
# With metadata
|
|
483
|
+
with record(
|
|
484
|
+
goal="reduce hallucinations",
|
|
485
|
+
notes="switched to GPT-4",
|
|
486
|
+
metrics={"accuracy": 0.89},
|
|
487
|
+
approved_by="alice@company.com",
|
|
488
|
+
metadata_tags=["prod-candidate"]
|
|
489
|
+
):
|
|
490
|
+
# Your code here
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
# Decorator usage
|
|
494
|
+
@record
|
|
495
|
+
def main():
|
|
496
|
+
# Your code here
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
# Decorator with metadata
|
|
500
|
+
@record(goal="decorator test", metrics={"test_score": 0.95})
|
|
501
|
+
def main():
|
|
375
502
|
# Your code here
|
|
376
503
|
pass
|
|
377
504
|
"""
|
|
378
|
-
|
|
505
|
+
# Check if this is being used as a decorator with arguments
|
|
506
|
+
# If the first argument is not a path but keyword arguments are provided,
|
|
507
|
+
# we need to return a decorator function
|
|
508
|
+
if output_path is None and (goal is not None or notes is not None or metrics is not None or
|
|
509
|
+
approved_by is not None or metadata_tags is not None):
|
|
510
|
+
# This is a decorator with arguments, return a decorator function
|
|
511
|
+
def decorator(func):
|
|
512
|
+
@functools.wraps(func)
|
|
513
|
+
def wrapper(*args, **kwargs):
|
|
514
|
+
# Auto-generate path based on function name
|
|
515
|
+
auto_path = _auto_generate_output_path(func.__name__)
|
|
516
|
+
with EpiRecorderSession(
|
|
517
|
+
auto_path,
|
|
518
|
+
workflow_name or func.__name__,
|
|
519
|
+
tags=tags,
|
|
520
|
+
auto_sign=auto_sign,
|
|
521
|
+
redact=redact,
|
|
522
|
+
default_key_name=default_key_name,
|
|
523
|
+
goal=goal,
|
|
524
|
+
notes=notes,
|
|
525
|
+
metrics=metrics,
|
|
526
|
+
approved_by=approved_by,
|
|
527
|
+
metadata_tags=metadata_tags,
|
|
528
|
+
**kwargs
|
|
529
|
+
):
|
|
530
|
+
return func(*args, **kwargs)
|
|
531
|
+
return wrapper
|
|
532
|
+
return decorator
|
|
533
|
+
|
|
534
|
+
# Handle decorator usage: record is called without parentheses
|
|
535
|
+
if callable(output_path):
|
|
536
|
+
func = output_path
|
|
537
|
+
|
|
538
|
+
@functools.wraps(func)
|
|
539
|
+
def wrapper(*args, **kwargs):
|
|
540
|
+
# Auto-generate path based on function name
|
|
541
|
+
auto_path = _auto_generate_output_path(func.__name__)
|
|
542
|
+
with EpiRecorderSession(
|
|
543
|
+
auto_path,
|
|
544
|
+
workflow_name or func.__name__,
|
|
545
|
+
tags=tags,
|
|
546
|
+
auto_sign=auto_sign,
|
|
547
|
+
redact=redact,
|
|
548
|
+
default_key_name=default_key_name,
|
|
549
|
+
goal=goal,
|
|
550
|
+
notes=notes,
|
|
551
|
+
metrics=metrics,
|
|
552
|
+
approved_by=approved_by,
|
|
553
|
+
metadata_tags=metadata_tags,
|
|
554
|
+
**kwargs
|
|
555
|
+
):
|
|
556
|
+
return func(*args, **kwargs)
|
|
557
|
+
|
|
558
|
+
return wrapper
|
|
559
|
+
|
|
560
|
+
# Normal context manager usage
|
|
561
|
+
resolved_path = _resolve_output_path(output_path)
|
|
562
|
+
return EpiRecorderSession(
|
|
563
|
+
resolved_path,
|
|
564
|
+
workflow_name,
|
|
565
|
+
tags=tags,
|
|
566
|
+
auto_sign=auto_sign,
|
|
567
|
+
redact=redact,
|
|
568
|
+
default_key_name=default_key_name,
|
|
569
|
+
goal=goal,
|
|
570
|
+
notes=notes,
|
|
571
|
+
metrics=metrics,
|
|
572
|
+
approved_by=approved_by,
|
|
573
|
+
metadata_tags=metadata_tags,
|
|
574
|
+
**kwargs
|
|
575
|
+
)
|
|
379
576
|
|
|
380
577
|
|
|
381
578
|
# Make it easy to get current session
|
|
@@ -386,4 +583,4 @@ def get_current_session() -> Optional[EpiRecorderSession]:
|
|
|
386
583
|
Returns:
|
|
387
584
|
EpiRecorderSession or None
|
|
388
585
|
"""
|
|
389
|
-
return getattr(_thread_local, 'active_session', None)
|
|
586
|
+
return getattr(_thread_local, 'active_session', None)
|