AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.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 (36) hide show
  1. abstractruntime/__init__.py +7 -2
  2. abstractruntime/core/config.py +14 -1
  3. abstractruntime/core/event_keys.py +62 -0
  4. abstractruntime/core/models.py +12 -1
  5. abstractruntime/core/runtime.py +2444 -14
  6. abstractruntime/core/vars.py +95 -0
  7. abstractruntime/evidence/__init__.py +10 -0
  8. abstractruntime/evidence/recorder.py +325 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/constants.py +19 -0
  11. abstractruntime/integrations/abstractcore/default_tools.py +134 -0
  12. abstractruntime/integrations/abstractcore/effect_handlers.py +255 -6
  13. abstractruntime/integrations/abstractcore/factory.py +95 -10
  14. abstractruntime/integrations/abstractcore/llm_client.py +456 -52
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +586 -0
  16. abstractruntime/integrations/abstractcore/observability.py +80 -0
  17. abstractruntime/integrations/abstractcore/summarizer.py +154 -0
  18. abstractruntime/integrations/abstractcore/tool_executor.py +481 -24
  19. abstractruntime/memory/__init__.py +21 -0
  20. abstractruntime/memory/active_context.py +746 -0
  21. abstractruntime/memory/active_memory.py +452 -0
  22. abstractruntime/memory/compaction.py +105 -0
  23. abstractruntime/rendering/__init__.py +17 -0
  24. abstractruntime/rendering/agent_trace_report.py +256 -0
  25. abstractruntime/rendering/json_stringify.py +136 -0
  26. abstractruntime/scheduler/scheduler.py +93 -2
  27. abstractruntime/storage/__init__.py +3 -1
  28. abstractruntime/storage/artifacts.py +20 -5
  29. abstractruntime/storage/json_files.py +15 -2
  30. abstractruntime/storage/observable.py +99 -0
  31. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/METADATA +5 -1
  32. abstractruntime-0.4.0.dist-info/RECORD +49 -0
  33. abstractruntime-0.4.0.dist-info/entry_points.txt +2 -0
  34. abstractruntime-0.2.0.dist-info/RECORD +0 -32
  35. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/WHEEL +0 -0
  36. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,256 @@
1
+ """Agent scratchpad → Markdown report renderer.
2
+
3
+ Goal:
4
+ - Clear, complete, and token-efficient review artifact for agent runs.
5
+ - No truncation of tool call arguments or tool execution results.
6
+
7
+ Input shape:
8
+ The "scratchpad" passed around by hosts is expected to include runtime-owned node traces,
9
+ typically at `scratchpad["node_traces"]`, which is sourced from:
10
+ `RunState.vars["_runtime"]["node_traces"]` (ADR-0010).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ from .json_stringify import JsonStringifyMode, stringify_json
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AgentTraceMarkdownReportConfig:
24
+ """Rendering configuration.
25
+
26
+ Keep defaults conservative to avoid bloating outputs.
27
+ """
28
+
29
+ include_timestamps: bool = False
30
+ json_mode: JsonStringifyMode = JsonStringifyMode.BEAUTIFY
31
+
32
+
33
+ def _as_dict(value: Any) -> Optional[Dict[str, Any]]:
34
+ return value if isinstance(value, dict) else None
35
+
36
+
37
+ def _collect_trace_steps(node_traces: Dict[str, Any]) -> List[Tuple[str, str, Dict[str, Any]]]:
38
+ """Flatten node_traces into a chronologically sortable list of (ts, node_id, entry)."""
39
+ out: list[tuple[str, str, Dict[str, Any]]] = []
40
+ for node_id, trace in node_traces.items():
41
+ t = _as_dict(trace)
42
+ if not t:
43
+ continue
44
+ steps = t.get("steps")
45
+ if not isinstance(steps, list):
46
+ continue
47
+ for entry in steps:
48
+ e = _as_dict(entry)
49
+ if not e:
50
+ continue
51
+ ts = e.get("ts")
52
+ ts_s = ts if isinstance(ts, str) else ""
53
+ out.append((ts_s, node_id, e))
54
+ # ISO timestamps are lexicographically sortable.
55
+ out.sort(key=lambda x: x[0])
56
+ return out
57
+
58
+
59
+ def _code_block(value: Any, *, language: str) -> str:
60
+ """Render a value inside a fenced code block (no truncation)."""
61
+ if language == "json":
62
+ text = stringify_json(value, mode=JsonStringifyMode.BEAUTIFY, sort_keys=False, parse_strings=False)
63
+ else:
64
+ text = "" if value is None else str(value)
65
+ return f"```{language}\n{text}\n```"
66
+
67
+
68
+ def _render_tool_call(call: Dict[str, Any], result: Optional[Dict[str, Any]], *, cfg: AgentTraceMarkdownReportConfig) -> str:
69
+ name = call.get("name")
70
+ call_id = call.get("call_id") or call.get("id") or ""
71
+ args = call.get("arguments", {})
72
+
73
+ title = f"#### Tool: `{name}`"
74
+ if isinstance(call_id, str) and call_id:
75
+ title += f" (call_id={call_id})"
76
+
77
+ lines: list[str] = [title, "", "**Arguments**", _code_block(args, language="json")]
78
+
79
+ if result is None:
80
+ lines.extend(["", "**Result**", "_missing tool result in trace entry_"])
81
+ return "\n".join(lines)
82
+
83
+ success = result.get("success") if isinstance(result.get("success"), bool) else None
84
+ error = result.get("error")
85
+ output = result.get("output")
86
+
87
+ lines.append("")
88
+ lines.append("**Result**")
89
+ if success is not None:
90
+ lines.append(f"- **success**: {str(success).lower()}")
91
+ if error is not None:
92
+ lines.append(f"- **error**: {error}")
93
+
94
+ # Output can be string or JSON.
95
+ if isinstance(output, (dict, list, bool, int, float)) or output is None:
96
+ lines.append(_code_block(output, language="json"))
97
+ else:
98
+ lines.append(_code_block(output, language="text"))
99
+
100
+ return "\n".join(lines)
101
+
102
+
103
+ def _index_tool_results_by_call_id(tool_results: Any) -> Dict[str, Dict[str, Any]]:
104
+ """Build a call_id → result mapping from a TOOL_CALLS effect outcome."""
105
+ if not isinstance(tool_results, dict):
106
+ return {}
107
+ results = tool_results.get("results")
108
+ if not isinstance(results, list):
109
+ return {}
110
+ out: dict[str, Dict[str, Any]] = {}
111
+ for r in results:
112
+ rr = _as_dict(r)
113
+ if not rr:
114
+ continue
115
+ call_id = rr.get("call_id")
116
+ if isinstance(call_id, str) and call_id:
117
+ out[call_id] = rr
118
+ return out
119
+
120
+
121
+ def render_agent_trace_markdown(scratchpad: Any, *, config: Optional[AgentTraceMarkdownReportConfig] = None) -> str:
122
+ """Render an agent scratchpad (runtime-owned node traces) into Markdown."""
123
+ cfg = config or AgentTraceMarkdownReportConfig()
124
+
125
+ sp = _as_dict(scratchpad)
126
+ if sp is None:
127
+ return "# Agent Trace Report\n\n_No scratchpad provided._\n"
128
+
129
+ node_traces = sp.get("node_traces")
130
+ if not isinstance(node_traces, dict):
131
+ # Allow passing node_traces directly.
132
+ if isinstance(scratchpad, dict) and "steps" in scratchpad and "node_id" in scratchpad:
133
+ node_traces = {str(scratchpad.get("node_id")): scratchpad}
134
+ else:
135
+ return "# Agent Trace Report\n\n_No node_traces found in scratchpad._\n"
136
+
137
+ header: list[str] = ["# Agent Trace Report"]
138
+
139
+ sub_run_id = sp.get("sub_run_id")
140
+ workflow_id = sp.get("workflow_id")
141
+ if isinstance(sub_run_id, str) and sub_run_id:
142
+ header.append(f"- **sub_run_id**: `{sub_run_id}`")
143
+ if isinstance(workflow_id, str) and workflow_id:
144
+ header.append(f"- **workflow_id**: `{workflow_id}`")
145
+
146
+ header.append("")
147
+
148
+ steps = _collect_trace_steps(node_traces)
149
+ if not steps:
150
+ return "\n".join(header + ["_No trace steps found._", ""])
151
+
152
+ lines: list[str] = header + ["## Timeline", ""]
153
+
154
+ for idx, (ts, node_id, entry) in enumerate(steps, start=1):
155
+ status = entry.get("status")
156
+ status_s = status if isinstance(status, str) else ""
157
+ effect = _as_dict(entry.get("effect")) or {}
158
+ effect_type = effect.get("type")
159
+ effect_type_s = effect_type if isinstance(effect_type, str) else ""
160
+
161
+ lines.append(f"### {idx}. `{node_id}` — `{effect_type_s}` ({status_s})")
162
+ if cfg.include_timestamps and ts:
163
+ lines.append(f"- **ts**: `{ts}`")
164
+ duration_ms = entry.get("duration_ms")
165
+ if isinstance(duration_ms, (int, float)) and duration_ms >= 0:
166
+ lines.append(f"- **duration_ms**: {float(duration_ms):.3f}")
167
+
168
+ if status_s == "failed":
169
+ err = entry.get("error")
170
+ if err is not None:
171
+ lines.append("")
172
+ lines.append("**Error**")
173
+ lines.append(_code_block(err, language="text"))
174
+ lines.append("")
175
+ continue
176
+
177
+ result = _as_dict(entry.get("result"))
178
+
179
+ if effect_type_s == "llm_call":
180
+ # Keep it token-efficient: only show what the LLM produced + whether it asked for tools.
181
+ content = result.get("content") if result else None
182
+ tool_calls = result.get("tool_calls") if result else None
183
+ model = result.get("model") if result else None
184
+ finish_reason = result.get("finish_reason") if result else None
185
+
186
+ if isinstance(model, str) and model:
187
+ lines.append(f"- **model**: `{model}`")
188
+ if isinstance(finish_reason, str) and finish_reason:
189
+ lines.append(f"- **finish_reason**: `{finish_reason}`")
190
+
191
+ if isinstance(tool_calls, list) and tool_calls:
192
+ lines.append("- **tool_calls_requested**:")
193
+ for c in tool_calls:
194
+ cc = _as_dict(c)
195
+ if not cc:
196
+ continue
197
+ nm = cc.get("name")
198
+ cid = cc.get("call_id") or ""
199
+ if isinstance(nm, str) and nm:
200
+ suffix = f" (call_id={cid})" if isinstance(cid, str) and cid else ""
201
+ lines.append(f" - `{nm}`{suffix}")
202
+ else:
203
+ lines.append("- **tool_calls_requested**: none")
204
+
205
+ if isinstance(content, str) and content.strip():
206
+ lines.append("")
207
+ lines.append("**Assistant content**")
208
+ lines.append(_code_block(content, language="markdown"))
209
+ lines.append("")
210
+ continue
211
+
212
+ if effect_type_s == "tool_calls":
213
+ payload = _as_dict(effect.get("payload")) or {}
214
+ calls = payload.get("tool_calls")
215
+ calls_list: list[Any]
216
+ if isinstance(calls, list):
217
+ calls_list = calls
218
+ elif calls is None:
219
+ calls_list = []
220
+ else:
221
+ calls_list = [calls]
222
+
223
+ results_by_id = _index_tool_results_by_call_id(result)
224
+ if not calls_list:
225
+ lines.append("- **tool_calls**: none")
226
+ lines.append("")
227
+ continue
228
+
229
+ lines.append("")
230
+ for call_any in calls_list:
231
+ call = _as_dict(call_any)
232
+ if not call:
233
+ continue
234
+ call_id = call.get("call_id")
235
+ call_id_s = call_id if isinstance(call_id, str) else ""
236
+ r = results_by_id.get(call_id_s) if call_id_s else None
237
+ lines.append(_render_tool_call(call, r, cfg=cfg))
238
+ lines.append("")
239
+ continue
240
+
241
+ # Fallback: show a compact JSON of the result (still no truncation).
242
+ if result is not None:
243
+ lines.append("")
244
+ lines.append("**Result (raw)**")
245
+ lines.append(_code_block(result, language="json"))
246
+ lines.append("")
247
+
248
+ # Validate that report is JSON-safe when embedded (defensive, should always be true).
249
+ try:
250
+ json.dumps({"report": "\n".join(lines)})
251
+ except Exception:
252
+ pass
253
+
254
+ return "\n".join(lines).rstrip() + "\n"
255
+
256
+
@@ -0,0 +1,136 @@
1
+ """JSON stringify utilities.
2
+
3
+ Why this exists in AbstractRuntime:
4
+ - Many hosts need consistent "JSON → string" semantics (UI preview, reports, prompts).
5
+ - Keeping the core logic in runtime avoids host-specific divergence (layering, ADR-0001).
6
+
7
+ This intentionally stays dependency-light (stdlib only).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import json
14
+ from enum import Enum
15
+ from typing import Any, Optional
16
+
17
+
18
+ class JsonStringifyMode(str, Enum):
19
+ """Formatting mode for JSON stringification."""
20
+
21
+ NONE = "none" # default json.dumps formatting (single line, spaces after separators)
22
+ BEAUTIFY = "beautify" # multi-line, indented
23
+ MINIFIED = "minified" # condensed separators (no spaces)
24
+
25
+
26
+ def _strip_code_fence(text: str) -> str:
27
+ s = text.strip()
28
+ if not s.startswith("```"):
29
+ return s
30
+ # Opening fence line can be ```json / ```js etc; drop it.
31
+ nl = s.find("\n")
32
+ if nl == -1:
33
+ return s.strip("`").strip()
34
+ body = s[nl + 1 :]
35
+ end = body.rfind("```")
36
+ if end != -1:
37
+ body = body[:end]
38
+ return body.strip()
39
+
40
+
41
+ def _jsonify(value: Any) -> Any:
42
+ """Convert a value into JSON-serializable types (best-effort)."""
43
+ if value is None or isinstance(value, (bool, int, float, str)):
44
+ return value
45
+ if isinstance(value, dict):
46
+ return {str(k): _jsonify(v) for k, v in value.items()}
47
+ if isinstance(value, list):
48
+ return [_jsonify(v) for v in value]
49
+ if isinstance(value, tuple):
50
+ return [_jsonify(v) for v in value]
51
+ return str(value)
52
+
53
+
54
+ def _parse_jsonish_maybe(text: str) -> Optional[Any]:
55
+ """Best-effort parse of JSON-ish strings.
56
+
57
+ Accepts:
58
+ - strict JSON
59
+ - JSON embedded in a larger string (extract first object/array substring)
60
+ - Python-literal dict/list (common LLM output), via ast.literal_eval
61
+ """
62
+ s = _strip_code_fence(text)
63
+ if not s:
64
+ return None
65
+ s = s.strip()
66
+ if not s:
67
+ return None
68
+
69
+ try:
70
+ return json.loads(s)
71
+ except Exception:
72
+ pass
73
+
74
+ # Best-effort: parse the first JSON object/array substring.
75
+ decoder = json.JSONDecoder()
76
+ starts: list[int] = []
77
+ for i, ch in enumerate(s):
78
+ if ch in "{[":
79
+ starts.append(i)
80
+ if len(starts) >= 64:
81
+ break
82
+ for i in starts:
83
+ try:
84
+ parsed, _end = decoder.raw_decode(s[i:])
85
+ return parsed
86
+ except Exception:
87
+ continue
88
+
89
+ # Last resort: tolerate Python-literal dict/list output.
90
+ try:
91
+ return ast.literal_eval(s)
92
+ except Exception:
93
+ return None
94
+
95
+
96
+ def stringify_json(
97
+ value: Any,
98
+ *,
99
+ mode: str | JsonStringifyMode = JsonStringifyMode.BEAUTIFY,
100
+ beautify_indent: int = 2,
101
+ sort_keys: bool = False,
102
+ parse_strings: bool = True,
103
+ ) -> str:
104
+ """Render a JSON-like value into a string.
105
+
106
+ Args:
107
+ value: Any JSON-like value (dict/list/scalar). If `parse_strings=True`, a string
108
+ that contains JSON (or JSON-ish text) is parsed and then rendered.
109
+ mode: none | beautify | minified.
110
+ beautify_indent: Indentation width for beautify mode.
111
+ sort_keys: When true, sort object keys for deterministic output.
112
+ parse_strings: When true, attempt to parse JSON-ish strings before rendering.
113
+ """
114
+ mode_value = mode.value if isinstance(mode, JsonStringifyMode) else str(mode or "").strip().lower()
115
+ if mode_value not in {m.value for m in JsonStringifyMode}:
116
+ mode_value = JsonStringifyMode.BEAUTIFY.value
117
+
118
+ if parse_strings and isinstance(value, str) and value.strip():
119
+ parsed = _parse_jsonish_maybe(value)
120
+ if parsed is not None:
121
+ value = parsed
122
+
123
+ safe = _jsonify(value)
124
+
125
+ if mode_value == JsonStringifyMode.MINIFIED.value:
126
+ return json.dumps(safe, ensure_ascii=False, sort_keys=sort_keys, separators=(",", ":"))
127
+
128
+ if mode_value == JsonStringifyMode.NONE.value:
129
+ return json.dumps(safe, ensure_ascii=False, sort_keys=sort_keys)
130
+
131
+ indent = beautify_indent if isinstance(beautify_indent, int) else 2
132
+ if indent < 0:
133
+ indent = 2
134
+ return json.dumps(safe, ensure_ascii=False, sort_keys=sort_keys, indent=indent)
135
+
136
+
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, List, Optional
19
19
 
20
20
  from ..core.models import RunState, RunStatus, WaitReason, WaitState
21
21
  from ..core.runtime import Runtime
22
+ from ..core.event_keys import build_event_wait_key
22
23
  from ..storage.base import QueryableRunStore
23
24
  from .registry import WorkflowRegistry
24
25
 
@@ -30,6 +31,18 @@ def utc_now_iso() -> str:
30
31
  return datetime.now(timezone.utc).isoformat()
31
32
 
32
33
 
34
+ def _is_paused(vars: Any) -> bool:
35
+ if not isinstance(vars, dict):
36
+ return False
37
+ runtime_ns = vars.get("_runtime")
38
+ if not isinstance(runtime_ns, dict):
39
+ return False
40
+ control = runtime_ns.get("control")
41
+ if not isinstance(control, dict):
42
+ return False
43
+ return bool(control.get("paused") is True)
44
+
45
+
33
46
  @dataclass
34
47
  class SchedulerStats:
35
48
  """Statistics about scheduler operation."""
@@ -218,6 +231,76 @@ class Scheduler:
218
231
  payload=payload,
219
232
  )
220
233
 
234
+ def emit_event(
235
+ self,
236
+ *,
237
+ name: str,
238
+ payload: Dict[str, Any],
239
+ scope: str = "session",
240
+ session_id: Optional[str] = None,
241
+ workflow_id: Optional[str] = None,
242
+ run_id: Optional[str] = None,
243
+ max_steps: int = 100,
244
+ limit: int = 10_000,
245
+ ) -> List[RunState]:
246
+ """Emit an event and resume all matching WAIT_EVENT runs.
247
+
248
+ This is the host-facing API for external signals (Temporal-style).
249
+
250
+ Default scope is "session" (workflow instance). For session scope, you must
251
+ provide `session_id` (typically the root run_id for that instance).
252
+ """
253
+ name2 = str(name or "").strip()
254
+ if not name2:
255
+ raise ValueError("emit_event requires a non-empty name")
256
+
257
+ scope2 = str(scope or "session").strip().lower() or "session"
258
+
259
+ wait_key = build_event_wait_key(
260
+ scope=scope2,
261
+ name=name2,
262
+ session_id=session_id,
263
+ workflow_id=workflow_id,
264
+ run_id=run_id,
265
+ )
266
+
267
+ # Find runs waiting for this event key.
268
+ waiting_runs = self._run_store.list_runs(
269
+ status=RunStatus.WAITING,
270
+ wait_reason=WaitReason.EVENT,
271
+ limit=limit,
272
+ )
273
+
274
+ resumed: List[RunState] = []
275
+ envelope: Dict[str, Any] = {
276
+ "event_id": None,
277
+ "name": name2,
278
+ "scope": scope2,
279
+ "session_id": session_id,
280
+ "payload": dict(payload) if isinstance(payload, dict) else {"value": payload},
281
+ "emitted_at": utc_now_iso(),
282
+ "emitter": {"source": "external"},
283
+ }
284
+
285
+ for r in waiting_runs:
286
+ if _is_paused(getattr(r, "vars", None)):
287
+ continue
288
+ if r.waiting is None:
289
+ continue
290
+ if r.waiting.wait_key != wait_key:
291
+ continue
292
+ wf = self._registry.get_or_raise(r.workflow_id)
293
+ new_state = self._runtime.resume(
294
+ workflow=wf,
295
+ run_id=r.run_id,
296
+ wait_key=wait_key,
297
+ payload=envelope,
298
+ max_steps=max_steps,
299
+ )
300
+ resumed.append(new_state)
301
+
302
+ return resumed
303
+
221
304
  def find_waiting_runs(
222
305
  self,
223
306
  *,
@@ -289,11 +372,17 @@ class Scheduler:
289
372
 
290
373
  resumed_count = 0
291
374
  for run in due_runs:
375
+ if _is_paused(getattr(run, "vars", None)):
376
+ continue
377
+ # Record resumption immediately to avoid a race where the run completes
378
+ # (via runtime.tick) before the main thread observes updated stats.
379
+ self._stats.runs_resumed += 1
380
+ resumed_count += 1
292
381
  try:
293
382
  self._resume_wait_until(run)
294
- resumed_count += 1
295
- self._stats.runs_resumed += 1
296
383
  except Exception as e:
384
+ self._stats.runs_resumed -= 1
385
+ resumed_count -= 1
297
386
  logger.error("Failed to resume run %s: %s", run.run_id, e)
298
387
  self._stats.runs_failed += 1
299
388
  self._record_error(f"Run {run.run_id}: {e}")
@@ -368,6 +457,8 @@ class Scheduler:
368
457
  )
369
458
 
370
459
  for run in waiting_runs:
460
+ if _is_paused(getattr(run, "vars", None)):
461
+ continue
371
462
  if run.waiting is None:
372
463
  continue
373
464
  if run.waiting.reason != WaitReason.SUBWORKFLOW:
@@ -4,6 +4,7 @@ from .base import RunStore, LedgerStore, QueryableRunStore
4
4
  from .in_memory import InMemoryRunStore, InMemoryLedgerStore
5
5
  from .json_files import JsonFileRunStore, JsonlLedgerStore
6
6
  from .ledger_chain import HashChainedLedgerStore, verify_ledger_chain
7
+ from .observable import ObservableLedgerStore, ObservableLedgerStoreProtocol
7
8
  from .snapshots import Snapshot, SnapshotStore, InMemorySnapshotStore, JsonSnapshotStore
8
9
 
9
10
  __all__ = [
@@ -16,10 +17,11 @@ __all__ = [
16
17
  "JsonlLedgerStore",
17
18
  "HashChainedLedgerStore",
18
19
  "verify_ledger_chain",
20
+ "ObservableLedgerStore",
21
+ "ObservableLedgerStoreProtocol",
19
22
  "Snapshot",
20
23
  "SnapshotStore",
21
24
  "InMemorySnapshotStore",
22
25
  "JsonSnapshotStore",
23
26
  ]
24
27
 
25
-
@@ -86,9 +86,24 @@ class Artifact:
86
86
  return json.loads(self.content.decode("utf-8"))
87
87
 
88
88
 
89
- def compute_artifact_id(content: bytes) -> str:
90
- """Compute content-addressed artifact ID using SHA-256."""
91
- return hashlib.sha256(content).hexdigest()[:32]
89
+ def compute_artifact_id(content: bytes, *, run_id: Optional[str] = None) -> str:
90
+ """Compute a deterministic artifact id.
91
+
92
+ By default, artifacts are content-addressed (SHA-256, truncated) so the same bytes
93
+ produce the same id.
94
+
95
+ If `run_id` is provided, the id is *namespaced to that run* to avoid cross-run
96
+ collisions when using a shared `FileArtifactStore(base_dir)` and to preserve
97
+ correct `list_by_run(...)` / purge-by-run semantics.
98
+ """
99
+ h = hashlib.sha256()
100
+ if run_id is not None:
101
+ rid = str(run_id).strip()
102
+ if rid:
103
+ h.update(rid.encode("utf-8"))
104
+ h.update(b"\0")
105
+ h.update(content)
106
+ return h.hexdigest()[:32]
92
107
 
93
108
 
94
109
  def validate_artifact_id(artifact_id: str) -> None:
@@ -318,7 +333,7 @@ class InMemoryArtifactStore(ArtifactStore):
318
333
  artifact_id: Optional[str] = None,
319
334
  ) -> ArtifactMetadata:
320
335
  if artifact_id is None:
321
- artifact_id = compute_artifact_id(content)
336
+ artifact_id = compute_artifact_id(content, run_id=run_id)
322
337
 
323
338
  metadata = ArtifactMetadata(
324
339
  artifact_id=artifact_id,
@@ -397,7 +412,7 @@ class FileArtifactStore(ArtifactStore):
397
412
  artifact_id: Optional[str] = None,
398
413
  ) -> ArtifactMetadata:
399
414
  if artifact_id is None:
400
- artifact_id = compute_artifact_id(content)
415
+ artifact_id = compute_artifact_id(content, run_id=run_id)
401
416
 
402
417
  metadata = ArtifactMetadata(
403
418
  artifact_id=artifact_id,
@@ -10,6 +10,7 @@ This is meant as a straightforward MVP backend.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import uuid
13
14
  from dataclasses import asdict
14
15
  from pathlib import Path
15
16
  from typing import Any, Dict, List, Optional
@@ -36,8 +37,20 @@ class JsonFileRunStore(RunStore):
36
37
 
37
38
  def save(self, run: RunState) -> None:
38
39
  p = self._path(run.run_id)
39
- with p.open("w", encoding="utf-8") as f:
40
- json.dump(asdict(run), f, ensure_ascii=False, indent=2)
40
+ # Atomic write to prevent corrupted/partial JSON when multiple threads/processes
41
+ # (e.g. WS tick loop + UI pause/cancel) write the same run file concurrently.
42
+ tmp = p.with_name(f"{p.name}.{uuid.uuid4().hex}.tmp")
43
+ try:
44
+ with tmp.open("w", encoding="utf-8") as f:
45
+ json.dump(asdict(run), f, ensure_ascii=False, indent=2)
46
+ tmp.replace(p)
47
+ finally:
48
+ # Best-effort cleanup if replace() failed.
49
+ try:
50
+ if tmp.exists():
51
+ tmp.unlink()
52
+ except Exception:
53
+ pass
41
54
 
42
55
  def load(self, run_id: str) -> Optional[RunState]:
43
56
  p = self._path(run_id)