arctx 0.2.0b2__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.
- arctx/__init__.py +48 -0
- arctx/core/__init__.py +43 -0
- arctx/core/_json.py +28 -0
- arctx/core/append.py +46 -0
- arctx/core/cuts.py +61 -0
- arctx/core/graph_view.py +26 -0
- arctx/core/ids.py +32 -0
- arctx/core/run/__init__.py +5 -0
- arctx/core/run/anchor.py +40 -0
- arctx/core/run/attach.py +49 -0
- arctx/core/run/cut.py +50 -0
- arctx/core/run/dump.py +174 -0
- arctx/core/run/handle.py +163 -0
- arctx/core/run/outcomes.py +26 -0
- arctx/core/run/trace.py +63 -0
- arctx/core/run/transition.py +89 -0
- arctx/core/run/view.py +34 -0
- arctx/core/run_graph.py +241 -0
- arctx/core/schema/__init__.py +28 -0
- arctx/core/schema/graph.py +40 -0
- arctx/core/schema/payloads.py +288 -0
- arctx/core/schema/requirements.py +22 -0
- arctx/core/schema/snapshots.py +21 -0
- arctx/core/schema/work.py +79 -0
- arctx/core/schema/work_helpers.py +297 -0
- arctx/core/sync/__init__.py +18 -0
- arctx/core/sync/local.py +405 -0
- arctx/core/sync/records.py +88 -0
- arctx/core/sync/shared_store.py +65 -0
- arctx/core/types.py +30 -0
- arctx/ext/__init__.py +83 -0
- arctx/ext/base.py +108 -0
- arctx/ext/command/__init__.py +49 -0
- arctx/ext/command/payloads.py +77 -0
- arctx/ext/command/verbs/__init__.py +1 -0
- arctx/ext/command/verbs/run.py +135 -0
- arctx/ext/enabled.py +73 -0
- arctx/ext/git/__init__.py +158 -0
- arctx/ext/git/events.py +132 -0
- arctx/ext/git/helpers/__init__.py +1 -0
- arctx/ext/git/helpers/attach.py +93 -0
- arctx/ext/git/helpers/finish.py +353 -0
- arctx/ext/git/helpers/repo.py +299 -0
- arctx/ext/git/helpers/session.py +180 -0
- arctx/ext/git/helpers/start.py +120 -0
- arctx/ext/git/payloads.py +288 -0
- arctx/ext/git/queries.py +44 -0
- arctx/ext/git/verbs/__init__.py +1 -0
- arctx/ext/git/verbs/_forward_transition.py +244 -0
- arctx/ext/git/verbs/cherry_pick.py +168 -0
- arctx/ext/git/verbs/commit.py +92 -0
- arctx/ext/git/verbs/merge.py +178 -0
- arctx/ext/git/verbs/reset.py +172 -0
- arctx/ext/git/verbs/revert.py +205 -0
- arctx/ext/git/verbs/rewrite.py +105 -0
- arctx/ext/git/verbs/verify.py +169 -0
- arctx/paths.py +149 -0
- arctx/payload_builder.py +153 -0
- arctx/session/__init__.py +157 -0
- arctx/storage/__init__.py +14 -0
- arctx/storage/_cache.py +103 -0
- arctx/storage/base.py +42 -0
- arctx/storage/jsonl.py +385 -0
- arctx/storage/sqlite.py +355 -0
- arctx-0.2.0b2.dist-info/METADATA +48 -0
- arctx-0.2.0b2.dist-info/RECORD +67 -0
- arctx-0.2.0b2.dist-info/WHEEL +4 -0
arctx/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""arctx: records the process of optimization and problem-solving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from arctx.core.graph_view import GraphView
|
|
6
|
+
from arctx.core.run import RunHandle
|
|
7
|
+
from arctx.core.run import init as _core_init
|
|
8
|
+
from arctx.core.run_graph import RunGraph
|
|
9
|
+
from arctx.core.schema import (
|
|
10
|
+
CutPayload,
|
|
11
|
+
Node,
|
|
12
|
+
NodePayload,
|
|
13
|
+
Payload,
|
|
14
|
+
PayloadBase,
|
|
15
|
+
Requirement,
|
|
16
|
+
TraceContext,
|
|
17
|
+
Transition,
|
|
18
|
+
TransitionPayload,
|
|
19
|
+
register_payload_class,
|
|
20
|
+
)
|
|
21
|
+
from arctx.core.types import (
|
|
22
|
+
TargetKind,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.2.0b2"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init(requirement: Requirement, *, run_id: str | None = None) -> RunHandle:
|
|
29
|
+
"""Create a core run handle without enabling extensions."""
|
|
30
|
+
return _core_init(requirement, run_id=run_id)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CutPayload",
|
|
34
|
+
"GraphView",
|
|
35
|
+
"Node",
|
|
36
|
+
"NodePayload",
|
|
37
|
+
"Payload",
|
|
38
|
+
"PayloadBase",
|
|
39
|
+
"Requirement",
|
|
40
|
+
"RunGraph",
|
|
41
|
+
"RunHandle",
|
|
42
|
+
"TargetKind",
|
|
43
|
+
"TraceContext",
|
|
44
|
+
"Transition",
|
|
45
|
+
"TransitionPayload",
|
|
46
|
+
"init",
|
|
47
|
+
"register_payload_class",
|
|
48
|
+
]
|
arctx/core/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Core graph model."""
|
|
2
|
+
|
|
3
|
+
from arctx.core.graph_view import GraphView
|
|
4
|
+
from arctx.core.ids import opaque_id, sequential_id, slugify, timestamp_id
|
|
5
|
+
from arctx.core.run import RunHandle, init
|
|
6
|
+
from arctx.core.run_graph import RunGraph
|
|
7
|
+
from arctx.core.schema import (
|
|
8
|
+
CutPayload,
|
|
9
|
+
Node,
|
|
10
|
+
NodePayload,
|
|
11
|
+
Payload,
|
|
12
|
+
PayloadBase,
|
|
13
|
+
Requirement,
|
|
14
|
+
TraceContext,
|
|
15
|
+
Transition,
|
|
16
|
+
TransitionPayload,
|
|
17
|
+
register_payload_class,
|
|
18
|
+
)
|
|
19
|
+
from arctx.core.types import (
|
|
20
|
+
TargetKind,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CutPayload",
|
|
25
|
+
"GraphView",
|
|
26
|
+
"Node",
|
|
27
|
+
"NodePayload",
|
|
28
|
+
"Payload",
|
|
29
|
+
"PayloadBase",
|
|
30
|
+
"Requirement",
|
|
31
|
+
"RunGraph",
|
|
32
|
+
"RunHandle",
|
|
33
|
+
"TargetKind",
|
|
34
|
+
"TraceContext",
|
|
35
|
+
"Transition",
|
|
36
|
+
"TransitionPayload",
|
|
37
|
+
"init",
|
|
38
|
+
"opaque_id",
|
|
39
|
+
"register_payload_class",
|
|
40
|
+
"sequential_id",
|
|
41
|
+
"slugify",
|
|
42
|
+
"timestamp_id",
|
|
43
|
+
]
|
arctx/core/_json.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Fast JSON helpers with orjson fallback.
|
|
2
|
+
|
|
3
|
+
Used by storage hot paths. CLI output formatting should keep using
|
|
4
|
+
the stdlib `json` module directly for ensure_ascii / indent control.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import orjson as _orjson
|
|
10
|
+
|
|
11
|
+
def loads(s: str | bytes) -> object:
|
|
12
|
+
return _orjson.loads(s)
|
|
13
|
+
|
|
14
|
+
def dumps(obj: object) -> str:
|
|
15
|
+
return _orjson.dumps(obj, option=_orjson.OPT_SORT_KEYS).decode("utf-8")
|
|
16
|
+
|
|
17
|
+
HAVE_ORJSON: bool = True
|
|
18
|
+
|
|
19
|
+
except ImportError:
|
|
20
|
+
import json as _json
|
|
21
|
+
|
|
22
|
+
def loads(s: str | bytes) -> object: # type: ignore[misc]
|
|
23
|
+
return _json.loads(s)
|
|
24
|
+
|
|
25
|
+
def dumps(obj: object) -> str: # type: ignore[misc]
|
|
26
|
+
return _json.dumps(obj, ensure_ascii=False, sort_keys=True)
|
|
27
|
+
|
|
28
|
+
HAVE_ORJSON = False
|
arctx/core/append.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Append-only storage batches for concurrent writers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, Union
|
|
7
|
+
|
|
8
|
+
from arctx.core.graph_view import GraphView
|
|
9
|
+
from arctx.core.schema.graph import Node, Transition
|
|
10
|
+
from arctx.core.schema.payloads import PayloadBase
|
|
11
|
+
from arctx.core.schema.work import WorkEvent, WorkSession
|
|
12
|
+
|
|
13
|
+
GraphRecordKind = Literal["node", "transition", "payload", "view"]
|
|
14
|
+
GraphRecord = Union[Node, Transition, PayloadBase, GraphView]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class GraphRecordEnvelope:
|
|
19
|
+
"""A graph record plus the table/category it belongs to."""
|
|
20
|
+
|
|
21
|
+
record_kind: GraphRecordKind
|
|
22
|
+
record_id: str
|
|
23
|
+
record: GraphRecord
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AppendBatch:
|
|
28
|
+
"""One atomic append unit for a run."""
|
|
29
|
+
|
|
30
|
+
run_id: str
|
|
31
|
+
user_id: str
|
|
32
|
+
work_session_id: str
|
|
33
|
+
records: tuple[GraphRecordEnvelope, ...]
|
|
34
|
+
work_session: WorkSession
|
|
35
|
+
events: tuple[WorkEvent, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class AppendResult:
|
|
40
|
+
"""Result returned after an append batch is committed."""
|
|
41
|
+
|
|
42
|
+
event_id: str
|
|
43
|
+
event_seq: int
|
|
44
|
+
record_ids: tuple[str, ...]
|
|
45
|
+
event_ids: tuple[str, ...] = ()
|
|
46
|
+
event_seqs: tuple[int, ...] = ()
|
arctx/core/cuts.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Read-time computation of inactive transitions and nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from arctx.core.run_graph import RunGraph
|
|
6
|
+
from arctx.core.schema.payloads import CutPayload
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _cut_payloads(graph: RunGraph) -> list[CutPayload]:
|
|
10
|
+
return [p for p in graph.payloads.values() if isinstance(p, CutPayload)]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cut_transition_ids(graph: RunGraph) -> set[str]:
|
|
14
|
+
return {p.target_id for p in _cut_payloads(graph) if p.target_kind == "transition"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cut_node_ids(graph: RunGraph) -> set[str]:
|
|
18
|
+
return {p.target_id for p in _cut_payloads(graph) if p.target_kind == "node"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _compute_inactive(graph: RunGraph) -> tuple[set[str], set[str]]:
|
|
22
|
+
inactive_transitions: set[str] = set(cut_transition_ids(graph))
|
|
23
|
+
inactive_nodes: set[str] = set(cut_node_ids(graph))
|
|
24
|
+
|
|
25
|
+
frontier_nodes = list(inactive_nodes)
|
|
26
|
+
frontier_transitions = list(inactive_transitions)
|
|
27
|
+
|
|
28
|
+
while frontier_nodes or frontier_transitions:
|
|
29
|
+
while frontier_transitions:
|
|
30
|
+
transition_id = frontier_transitions.pop()
|
|
31
|
+
out = graph.transition_output(transition_id)
|
|
32
|
+
if out and out not in inactive_nodes:
|
|
33
|
+
inactive_nodes.add(out)
|
|
34
|
+
frontier_nodes.append(out)
|
|
35
|
+
|
|
36
|
+
while frontier_nodes:
|
|
37
|
+
node_id = frontier_nodes.pop()
|
|
38
|
+
for transition_id in graph.transitions_from_node(node_id):
|
|
39
|
+
if transition_id not in inactive_transitions:
|
|
40
|
+
inactive_transitions.add(transition_id)
|
|
41
|
+
frontier_transitions.append(transition_id)
|
|
42
|
+
|
|
43
|
+
return inactive_transitions, inactive_nodes
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def inactive_transition_ids(graph: RunGraph) -> set[str]:
|
|
47
|
+
inactive_transitions, _ = _compute_inactive(graph)
|
|
48
|
+
return inactive_transitions
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def inactive_node_ids(graph: RunGraph) -> set[str]:
|
|
52
|
+
_, inactive_nodes = _compute_inactive(graph)
|
|
53
|
+
return inactive_nodes
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_active_node(graph: RunGraph, node_id: str) -> bool:
|
|
57
|
+
return node_id not in inactive_node_ids(graph)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_inactive_transition(graph: RunGraph, transition_id: str) -> bool:
|
|
61
|
+
return transition_id in inactive_transition_ids(graph)
|
arctx/core/graph_view.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""GraphView: a named label anchored to a root node in RunGraph.
|
|
2
|
+
|
|
3
|
+
The contents of a view are determined at read time by reachability from
|
|
4
|
+
root_node_id via output transitions. No membership sets are stored.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from arctx.core.types import JSONValue, to_jsonable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class GraphView:
|
|
16
|
+
"""A named label for a subgraph rooted at a single node."""
|
|
17
|
+
|
|
18
|
+
view_id: str
|
|
19
|
+
name: str
|
|
20
|
+
root_node_id: str
|
|
21
|
+
metadata: dict[str, JSONValue] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, JSONValue]:
|
|
24
|
+
d = to_jsonable(self)
|
|
25
|
+
assert isinstance(d, dict)
|
|
26
|
+
return d # type: ignore[return-value]
|
arctx/core/ids.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Identifier helpers for run and transition records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_SAFE_ID = re.compile(r"[^a-zA-Z0-9_.-]+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def slugify(value: str, fallback: str = "item") -> str:
|
|
14
|
+
"""Return a stable filesystem-safe slug."""
|
|
15
|
+
slug = _SAFE_ID.sub("_", value.strip()).strip("._-").lower()
|
|
16
|
+
return slug or fallback
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def timestamp_id(prefix: str, now: datetime | None = None) -> str:
|
|
20
|
+
"""Return a timestamp-based id."""
|
|
21
|
+
current = now or datetime.now(timezone.utc)
|
|
22
|
+
return f"{slugify(prefix)}_{current.strftime('%Y%m%d_%H%M%S')}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sequential_id(prefix: str, index: int, width: int = 4) -> str:
|
|
26
|
+
"""Return ids such as ``state_0001``."""
|
|
27
|
+
return f"{slugify(prefix)}_{index:0{width}d}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def opaque_id(prefix: str) -> str:
|
|
31
|
+
"""Return a collision-resistant opaque id with a readable kind prefix."""
|
|
32
|
+
return f"{slugify(prefix)}_{uuid.uuid4().hex}"
|
arctx/core/run/anchor.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""RunHandle.anchor implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from arctx.core.schema.graph import Node
|
|
6
|
+
from arctx.core.schema.payloads import TransitionPayload
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def anchor_impl(
|
|
10
|
+
self,
|
|
11
|
+
from_node_id: str,
|
|
12
|
+
label: str,
|
|
13
|
+
*,
|
|
14
|
+
metadata: dict | None = None,
|
|
15
|
+
user_id: str | None = None,
|
|
16
|
+
work_session_id: str | None = None,
|
|
17
|
+
) -> Node:
|
|
18
|
+
"""Create a lightweight scope anchor node from an existing node.
|
|
19
|
+
|
|
20
|
+
An anchor is a Transition with type="anchor" and a generated output node.
|
|
21
|
+
The output node can then be used as a shared branching point for experiments.
|
|
22
|
+
"""
|
|
23
|
+
meta = dict(metadata or {})
|
|
24
|
+
meta.setdefault("kind", "anchor")
|
|
25
|
+
meta.setdefault("label", label)
|
|
26
|
+
|
|
27
|
+
payload = TransitionPayload(
|
|
28
|
+
payload_id="pending",
|
|
29
|
+
target_id="pending",
|
|
30
|
+
type="anchor",
|
|
31
|
+
content={"label": label},
|
|
32
|
+
metadata=meta,
|
|
33
|
+
)
|
|
34
|
+
transition = self.transition(
|
|
35
|
+
[from_node_id],
|
|
36
|
+
payload,
|
|
37
|
+
user_id=user_id,
|
|
38
|
+
work_session_id=work_session_id,
|
|
39
|
+
)
|
|
40
|
+
return self.run_graph.nodes[transition.output_node_id]
|
arctx/core/run/attach.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""RunHandle.attach implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from arctx.core.schema.payloads import PayloadBase
|
|
6
|
+
from arctx.core.run.transition import _clone_payload
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def attach_impl(
|
|
10
|
+
self,
|
|
11
|
+
node_id: str,
|
|
12
|
+
payload: PayloadBase,
|
|
13
|
+
*,
|
|
14
|
+
user_id: str | None = None,
|
|
15
|
+
work_session_id: str | None = None,
|
|
16
|
+
) -> PayloadBase:
|
|
17
|
+
"""Attach a node-targeting payload to a node.
|
|
18
|
+
|
|
19
|
+
*payload* must be a node-targeting payload (target_kind="node").
|
|
20
|
+
Returns the attached payload (with a freshly minted payload_id).
|
|
21
|
+
"""
|
|
22
|
+
if payload.target_kind != "node":
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"attach() requires a node-targeting payload "
|
|
25
|
+
f"(target_kind='node'), got {payload.target_kind!r}"
|
|
26
|
+
)
|
|
27
|
+
if node_id not in self.run_graph.nodes:
|
|
28
|
+
raise KeyError(f"unknown node_id: {node_id}")
|
|
29
|
+
|
|
30
|
+
cloned = _clone_payload(payload, self._next_id("pl"), node_id)
|
|
31
|
+
self.run_graph.attach_payload(cloned)
|
|
32
|
+
self.record_work_event(
|
|
33
|
+
user_id=user_id,
|
|
34
|
+
work_session_id=work_session_id,
|
|
35
|
+
event_type="payload_attached",
|
|
36
|
+
target_kind="node",
|
|
37
|
+
target_id=node_id,
|
|
38
|
+
created_records=(cloned.payload_id,),
|
|
39
|
+
summary=_node_payload_summary(cloned),
|
|
40
|
+
)
|
|
41
|
+
return cloned
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _node_payload_summary(payload: PayloadBase) -> str | None:
|
|
45
|
+
for attr in ("type", "text"):
|
|
46
|
+
val = getattr(payload, attr, None)
|
|
47
|
+
if isinstance(val, str) and val:
|
|
48
|
+
return val
|
|
49
|
+
return None
|
arctx/core/run/cut.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""RunHandle.cut implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from arctx.core.cuts import cut_node_ids, cut_transition_ids
|
|
8
|
+
from arctx.core.schema.payloads import CutPayload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cut_impl(
|
|
12
|
+
self,
|
|
13
|
+
target_id: str,
|
|
14
|
+
*,
|
|
15
|
+
target_kind: Literal["node", "transition"],
|
|
16
|
+
reason: str | None = None,
|
|
17
|
+
user_id: str | None = None,
|
|
18
|
+
work_session_id: str | None = None,
|
|
19
|
+
) -> CutPayload:
|
|
20
|
+
"""Append a CutPayload to mark a Node or Transition inactive."""
|
|
21
|
+
if target_kind == "node":
|
|
22
|
+
if target_id not in self.run_graph.nodes:
|
|
23
|
+
raise KeyError(f"unknown node_id: {target_id}")
|
|
24
|
+
if target_id in cut_node_ids(self.run_graph):
|
|
25
|
+
raise ValueError(f"node already cut: {target_id}")
|
|
26
|
+
elif target_kind == "transition":
|
|
27
|
+
if target_id not in self.run_graph.transitions:
|
|
28
|
+
raise KeyError(f"unknown transition_id: {target_id}")
|
|
29
|
+
if target_id in cut_transition_ids(self.run_graph):
|
|
30
|
+
raise ValueError(f"transition already cut: {target_id}")
|
|
31
|
+
else:
|
|
32
|
+
raise ValueError(f"invalid target_kind: {target_kind!r}")
|
|
33
|
+
|
|
34
|
+
cut = CutPayload(
|
|
35
|
+
payload_id=self._next_id("pl"),
|
|
36
|
+
target_id=target_id,
|
|
37
|
+
target_kind=target_kind,
|
|
38
|
+
reason=reason,
|
|
39
|
+
)
|
|
40
|
+
self.run_graph.attach_payload(cut)
|
|
41
|
+
self.record_work_event(
|
|
42
|
+
user_id=user_id,
|
|
43
|
+
work_session_id=work_session_id,
|
|
44
|
+
event_type="cut_added",
|
|
45
|
+
target_kind=target_kind,
|
|
46
|
+
target_id=target_id,
|
|
47
|
+
created_records=(cut.payload_id,),
|
|
48
|
+
summary=reason,
|
|
49
|
+
)
|
|
50
|
+
return cut
|
arctx/core/run/dump.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Dump RunGraph as outline or mermaid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from arctx.core.cuts import inactive_node_ids, inactive_transition_ids
|
|
8
|
+
from arctx.core.run.handle import RunHandle
|
|
9
|
+
from arctx.core.run_graph import RunGraph
|
|
10
|
+
from arctx.core.schema.payloads import CutPayload, NodePayload, TransitionPayload
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DumpOptions:
|
|
15
|
+
node_id: str | None = None
|
|
16
|
+
depth: int | None = None
|
|
17
|
+
full_payloads: bool = False
|
|
18
|
+
observed_only: bool = False # unused after schema change; kept for CLI compat
|
|
19
|
+
predicted_only: bool = False # unused after schema change; kept for CLI compat
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _truncate(s: str | None, n: int) -> str:
|
|
23
|
+
if not s:
|
|
24
|
+
return ""
|
|
25
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _node_summary(graph: RunGraph, node_id: str) -> str | None:
|
|
29
|
+
for payload in graph.payloads_for_node(node_id):
|
|
30
|
+
if isinstance(payload, NodePayload):
|
|
31
|
+
text = payload.content.get("text")
|
|
32
|
+
if isinstance(text, str) and text:
|
|
33
|
+
return text
|
|
34
|
+
return payload.type
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _transition_summary(graph: RunGraph, transition_id: str, full: bool) -> str:
|
|
39
|
+
payloads = graph.payloads_for_transition(transition_id)
|
|
40
|
+
parts = []
|
|
41
|
+
for payload in payloads:
|
|
42
|
+
if isinstance(payload, CutPayload):
|
|
43
|
+
parts.append("✂cut")
|
|
44
|
+
elif isinstance(payload, TransitionPayload):
|
|
45
|
+
parts.append(payload.type)
|
|
46
|
+
if full and payload.content:
|
|
47
|
+
import json
|
|
48
|
+
parts.append(json.dumps(payload.content)[:60])
|
|
49
|
+
else:
|
|
50
|
+
parts.append(payload.payload_type)
|
|
51
|
+
return " ".join(parts) if parts else "transition"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_outline(handle: RunHandle, opts: DumpOptions) -> str:
|
|
55
|
+
graph = handle.run_graph
|
|
56
|
+
inactive_nodes = inactive_node_ids(graph)
|
|
57
|
+
inactive_trans = inactive_transition_ids(graph)
|
|
58
|
+
root_id = opts.node_id or handle.root_node_id
|
|
59
|
+
|
|
60
|
+
lines = [
|
|
61
|
+
(
|
|
62
|
+
f"run={handle.run_id} nodes={len(graph.nodes)} "
|
|
63
|
+
f"transitions={len(graph.transitions)}"
|
|
64
|
+
),
|
|
65
|
+
"",
|
|
66
|
+
]
|
|
67
|
+
visited_nodes: set[str] = set()
|
|
68
|
+
visited_transitions: set[str] = set()
|
|
69
|
+
|
|
70
|
+
# Count multi-input transitions for joins index.
|
|
71
|
+
multi_input_trans = [
|
|
72
|
+
tid for tid, t in graph.transitions.items() if len(t.input_node_ids) > 1
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
def emit_node(node_id: str, prefix: str, is_last: bool, depth: int) -> None:
|
|
76
|
+
cut = " ✂" if node_id in inactive_nodes else ""
|
|
77
|
+
connector = "" if depth == 0 else ("└─" if is_last else "├─")
|
|
78
|
+
if node_id in visited_nodes:
|
|
79
|
+
lines.append(f"{prefix}{connector}↻ {node_id}{cut}")
|
|
80
|
+
return
|
|
81
|
+
visited_nodes.add(node_id)
|
|
82
|
+
lines.append(f"{prefix}{connector}{node_id}{cut}")
|
|
83
|
+
note = _node_summary(graph, node_id)
|
|
84
|
+
child_prefix = prefix + (" " if depth == 0 or is_last else "│ ")
|
|
85
|
+
if note:
|
|
86
|
+
lines.append(f"{child_prefix}note: {_truncate(note, 80)}")
|
|
87
|
+
if opts.depth is not None and depth >= opts.depth:
|
|
88
|
+
return
|
|
89
|
+
transition_ids = graph.transitions_from_node(node_id)
|
|
90
|
+
for index, transition_id in enumerate(transition_ids):
|
|
91
|
+
t = graph.transitions[transition_id]
|
|
92
|
+
# Only render as primary if this node is inputs[0].
|
|
93
|
+
if t.input_node_ids and t.input_node_ids[0] != node_id:
|
|
94
|
+
lines.append(
|
|
95
|
+
f"{child_prefix}▸ feeds {transition_id} (@{t.input_node_ids[0]})"
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
emit_transition(
|
|
99
|
+
transition_id,
|
|
100
|
+
child_prefix,
|
|
101
|
+
index == len(transition_ids) - 1,
|
|
102
|
+
depth + 1,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def emit_transition(transition_id: str, prefix: str, is_last: bool, depth: int) -> None:
|
|
106
|
+
t = graph.transitions[transition_id]
|
|
107
|
+
summary = _transition_summary(graph, transition_id, opts.full_payloads)
|
|
108
|
+
cut = " ✂" if transition_id in inactive_trans else ""
|
|
109
|
+
connector = "└─" if is_last else "├─"
|
|
110
|
+
if transition_id in visited_transitions:
|
|
111
|
+
lines.append(f"{prefix}{connector}↻ {transition_id}{cut}")
|
|
112
|
+
return
|
|
113
|
+
visited_transitions.add(transition_id)
|
|
114
|
+
# Show extra inputs inline.
|
|
115
|
+
extras = ""
|
|
116
|
+
if len(t.input_node_ids) > 1:
|
|
117
|
+
extras = " " + " ".join(f"(+{n})" for n in t.input_node_ids[1:])
|
|
118
|
+
lines.append(f"{prefix}{connector}→ {transition_id}{cut}{extras} {summary}")
|
|
119
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
120
|
+
if t.output_node_id:
|
|
121
|
+
emit_node(t.output_node_id, child_prefix, True, depth + 1)
|
|
122
|
+
|
|
123
|
+
emit_node(root_id, "", True, 0)
|
|
124
|
+
|
|
125
|
+
if len(multi_input_trans) >= 3:
|
|
126
|
+
lines.append("")
|
|
127
|
+
lines.append("joins:")
|
|
128
|
+
for tid in multi_input_trans:
|
|
129
|
+
t = graph.transitions[tid]
|
|
130
|
+
lines.append(f" {tid}: inputs={list(t.input_node_ids)}")
|
|
131
|
+
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_mermaid(handle: RunHandle, opts: DumpOptions) -> str:
|
|
136
|
+
graph = handle.run_graph
|
|
137
|
+
inactive_nodes = inactive_node_ids(graph)
|
|
138
|
+
inactive_trans = inactive_transition_ids(graph)
|
|
139
|
+
lines = ["```mermaid", "flowchart TD"]
|
|
140
|
+
for node_id in graph.nodes:
|
|
141
|
+
label = "State"
|
|
142
|
+
note = _node_summary(graph, node_id)
|
|
143
|
+
if note:
|
|
144
|
+
label = _truncate(note, 36).replace('"', "'")
|
|
145
|
+
is_root = node_id == handle.root_node_id
|
|
146
|
+
cls = "root" if is_root else "cut" if node_id in inactive_nodes else "state"
|
|
147
|
+
lines.append(f' {node_id}["{label}"]')
|
|
148
|
+
if cls != "state":
|
|
149
|
+
lines.append(f" class {node_id} {cls}")
|
|
150
|
+
|
|
151
|
+
for transition_id, t in graph.transitions.items():
|
|
152
|
+
summary = _transition_summary(graph, transition_id, False)
|
|
153
|
+
summary = _truncate(summary, 42).replace('"', "'")
|
|
154
|
+
is_cut = transition_id in inactive_trans
|
|
155
|
+
if t.output_node_id:
|
|
156
|
+
for inp in t.input_node_ids:
|
|
157
|
+
lines.append(f' {inp} -->|"{summary}"| {t.output_node_id}')
|
|
158
|
+
if is_cut:
|
|
159
|
+
lines.append(f" class {transition_id} cut")
|
|
160
|
+
|
|
161
|
+
if inactive_nodes:
|
|
162
|
+
lines.append(f" class {','.join(sorted(inactive_nodes))} cut")
|
|
163
|
+
lines.append(" classDef cut stroke:#999,stroke-dasharray: 4 4,color:#999")
|
|
164
|
+
lines.append(" classDef root fill:#ffcc00,stroke:#1d4ed8")
|
|
165
|
+
lines.append("```")
|
|
166
|
+
return "\n".join(lines)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def dump(handle: RunHandle, fmt: str, opts: DumpOptions) -> str:
|
|
170
|
+
if fmt == "outline":
|
|
171
|
+
return render_outline(handle, opts)
|
|
172
|
+
if fmt == "mermaid":
|
|
173
|
+
return render_mermaid(handle, opts)
|
|
174
|
+
raise ValueError(f"unknown dump format: {fmt!r}")
|