haybale-haystack 0.2.4__tar.gz → 0.3.0__tar.gz

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 (43) hide show
  1. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/PKG-INFO +5 -5
  2. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/README.md +6 -5
  3. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/OVERVIEW.md +6 -5
  4. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/QUICKREF.md +7 -6
  5. haybale_haystack-0.3.0/haybale_haystack/docs/haybale-haystack.panel.EditMacroMenuPanel.md +18 -0
  6. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.panel.GraphRunSettingsPanel.md +2 -0
  7. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.panel.OpenInHaystackMenuPanel.md +2 -0
  8. haybale_haystack-0.3.0/haybale_haystack/docs/haybale-haystack.panel.UpdateMacroMenuPanel.md +21 -0
  9. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.setting.HaystackSettings.md +2 -0
  10. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.state.HaystackState.md +2 -0
  11. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/editors/haystack_editor.py +87 -25
  12. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/farmhands/graph_tools.py +4 -9
  13. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/graph_entry.py +26 -1
  14. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/haybale.toml +1 -1
  15. haybale_haystack-0.3.0/haybale_haystack/panels/__init__.py +11 -0
  16. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/panels/file_browser/menu/file.py +1 -0
  17. haybale_haystack-0.3.0/haybale_haystack/panels/graph/menu/macro.py +269 -0
  18. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/panels/properties/introspect/graph_run_settings_panel.py +2 -1
  19. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/settings/haystack_settings.py +1 -1
  20. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/state/haystack_state.py +137 -21
  21. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/pyproject.toml +5 -5
  22. haybale_haystack-0.2.4/haybale_haystack/docs/haybale-haystack.panel.EditMacroMenuPanel.md +0 -16
  23. haybale_haystack-0.2.4/haybale_haystack/panels/__init__.py +0 -11
  24. haybale_haystack-0.2.4/haybale_haystack/panels/graph/menu/macro.py +0 -105
  25. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/.gitignore +0 -0
  26. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/__init__.py +0 -0
  27. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.editor.HaystackEditor.md +0 -0
  28. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.close_graph.md +0 -0
  29. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.compile_graph.md +0 -0
  30. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.create_graph.md +0 -0
  31. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.list_graphs.md +0 -0
  32. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.open_graph.md +0 -0
  33. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.rename_graph.md +0 -0
  34. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.save_graph.md +0 -0
  35. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.start_graph.md +0 -0
  36. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/docs/haybale-haystack.farmhand.stop_graph.md +0 -0
  37. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/editors/__init__.py +0 -0
  38. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/farmhands/__init__.py +0 -0
  39. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/persistence.py +0 -0
  40. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/settings/__init__.py +0 -0
  41. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/settings/graph_run_settings.py +0 -0
  42. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/signals.py +0 -0
  43. {haybale_haystack-0.2.4 → haybale_haystack-0.3.0}/haybale_haystack/state/__init__.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: haybale-haystack
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: File-centric multi-graph manager
5
5
  License: MIT
6
6
  Keywords: graph-management
7
7
  Requires-Python: >=3.12
8
- Requires-Dist: haybale-graph-editor>=0.2.4
9
- Requires-Dist: haybale-studio>=0.2.4
10
- Requires-Dist: haywire-core>=0.2.4
11
- Requires-Dist: nicegui>=3.12.1
8
+ Requires-Dist: haybale-graph-editor>=0.3.0
9
+ Requires-Dist: haybale-studio>=0.3.0
10
+ Requires-Dist: haywire-core>=0.3.0
11
+ Requires-Dist: nicegui>=3.18.0
12
12
  Requires-Dist: toml>=0.10.2
@@ -7,7 +7,7 @@
7
7
  File-centric multi-graph manager
8
8
 
9
9
  ## Settings
10
- - **Haystack** —
10
+ - **Haystack** — Per-workspace settings for haystack scalars.
11
11
 
12
12
  ## Farmhands
13
13
  - **Close graph** — Close an open graph entry. NEVER deletes the file on disk.
@@ -21,12 +21,13 @@ File-centric multi-graph manager
21
21
  - **Stop graph** — Stop a running graph (bounded grace, then teardown).
22
22
 
23
23
  ## States
24
- - **Haystack State** —
24
+ - **Haystack State** — In-memory registry of open graphs, one entry per file path.
25
25
 
26
26
  ## Panels
27
- - **Edit Macro** —
28
- - **Graph Settings** —
29
- - **Load into Haystack** —
27
+ - **Edit Macro** — Open the document behind a macro placement, in its own editor tab.
28
+ - **Graph Settings** — Renders the GraphRunSettings for the currently active graph.
29
+ - **Load into Haystack** — Load a .haywire graph file into the Haystack (does not open an editor tab).
30
+ - **Update Macro** — Send a modified instance's changes back to the macro it came from.
30
31
 
31
32
  ## Editors
32
33
  - **Haystack** — All open graphs. Click to switch; "+" to create a new graph.
@@ -3,7 +3,7 @@
3
3
  File-centric multi-graph manager
4
4
 
5
5
  ## Settings
6
- - **Haystack** —
6
+ - **Haystack** — Per-workspace settings for haystack scalars.
7
7
 
8
8
  ## Farmhands
9
9
  - **Close graph** — Close an open graph entry. NEVER deletes the file on disk.
@@ -17,12 +17,13 @@ File-centric multi-graph manager
17
17
  - **Stop graph** — Stop a running graph (bounded grace, then teardown).
18
18
 
19
19
  ## States
20
- - **Haystack State** —
20
+ - **Haystack State** — In-memory registry of open graphs, one entry per file path.
21
21
 
22
22
  ## Panels
23
- - **Edit Macro** —
24
- - **Graph Settings** —
25
- - **Load into Haystack** —
23
+ - **Edit Macro** — Open the document behind a macro placement, in its own editor tab.
24
+ - **Graph Settings** — Renders the GraphRunSettings for the currently active graph.
25
+ - **Load into Haystack** — Load a .haywire graph file into the Haystack (does not open an editor tab).
26
+ - **Update Macro** — Send a modified instance's changes back to the macro it came from.
26
27
 
27
28
  ## Editors
28
29
  - **Haystack** — All open graphs. Click to switch; "+" to create a new graph.
@@ -1,7 +1,7 @@
1
- # haybale-haystack — component index (v0.2.4)
1
+ # haybale-haystack — component index (v0.3.0)
2
2
 
3
3
  ## setting
4
- - `haybale-haystack:setting:HaystackSettings` — Haystack —
4
+ - `haybale-haystack:setting:HaystackSettings` — Haystack — Per-workspace settings for haystack scalars.
5
5
 
6
6
  ## farmhand
7
7
  - `haybale-haystack:farmhand:close_graph` — Close graph — Close an open graph entry. NEVER deletes the file on disk.
@@ -15,12 +15,13 @@
15
15
  - `haybale-haystack:farmhand:stop_graph` — Stop graph — Stop a running graph (bounded grace, then teardown).
16
16
 
17
17
  ## state
18
- - `haybale-haystack:state:HaystackState` — Haystack State —
18
+ - `haybale-haystack:state:HaystackState` — Haystack State — In-memory registry of open graphs, one entry per file path.
19
19
 
20
20
  ## panel
21
- - `haybale-haystack:panel:EditMacroMenuPanel` — Edit Macro —
22
- - `haybale-haystack:panel:GraphRunSettingsPanel` — Graph Settings —
23
- - `haybale-haystack:panel:OpenInHaystackMenuPanel` — Load into Haystack —
21
+ - `haybale-haystack:panel:EditMacroMenuPanel` — Edit Macro — Open the document behind a macro placement, in its own editor tab.
22
+ - `haybale-haystack:panel:GraphRunSettingsPanel` — Graph Settings — Renders the GraphRunSettings for the currently active graph.
23
+ - `haybale-haystack:panel:OpenInHaystackMenuPanel` — Load into Haystack — Load a .haywire graph file into the Haystack (does not open an editor tab).
24
+ - `haybale-haystack:panel:UpdateMacroMenuPanel` — Update Macro — Send a modified instance's changes back to the macro it came from.
24
25
 
25
26
  ## editor
26
27
  - `haybale-haystack:editor:HaystackEditor` — Haystack — All open graphs. Click to switch; "+" to create a new graph.
@@ -0,0 +1,18 @@
1
+ # Edit Macro
2
+
3
+ `haybale-haystack:panel:EditMacroMenuPanel` · kind: panel
4
+
5
+ Open the document behind a macro placement, in its own editor tab.
6
+
7
+ ## Details
8
+
9
+ - **surface**: `selection-macro`
10
+ - **order**: `10`
11
+
12
+ ## Notes
13
+
14
+ Open the document behind a macro placement, in its own editor tab.
15
+
16
+ Only visible on a placement. Its interior is runtime state rebuilt from the
17
+ template, so it cannot be edited in place; this row opens the document that
18
+ can.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:panel:GraphRunSettingsPanel` · kind: panel
4
4
 
5
+ Renders the GraphRunSettings for the currently active graph.
6
+
5
7
  ## Details
6
8
 
7
9
  - **surface**: `execution`
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:panel:OpenInHaystackMenuPanel` · kind: panel
4
4
 
5
+ Load a .haywire graph file into the Haystack (does not open an editor tab).
6
+
5
7
  ## Details
6
8
 
7
9
  - **surface**: `file`
@@ -0,0 +1,21 @@
1
+ # Update Macro
2
+
3
+ `haybale-haystack:panel:UpdateMacroMenuPanel` · kind: panel
4
+
5
+ Send a modified instance's changes back to the macro it came from.
6
+
7
+ ## Details
8
+
9
+ - **surface**: `selection-macro`
10
+ - **order**: `30`
11
+
12
+ ## Notes
13
+
14
+ Send a modified instance's changes back to the macro it came from.
15
+
16
+ Only visible on a Group that was detached from a macro. The card becomes a
17
+ placement of that macro again, and the macro opens in its own tab with the
18
+ instance's contents, unsaved. Saving it updates every placement.
19
+
20
+ Greyed out when the macro is not installed, cannot be edited in place, or
21
+ has unsaved edits in its tab.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:setting:HaystackSettings` · kind: setting
4
4
 
5
+ Per-workspace settings for haystack scalars.
6
+
5
7
  ## Notes
6
8
 
7
9
  Per-workspace settings for haystack scalars.
@@ -2,6 +2,8 @@
2
2
 
3
3
  `haybale-haystack:state:HaystackState` · kind: state
4
4
 
5
+ In-memory registry of open graphs, one entry per file path.
6
+
5
7
  ## Notes
6
8
 
7
9
  In-memory registry of open graphs (one entry per file path)
@@ -6,6 +6,7 @@ from typing import Optional
6
6
 
7
7
  from nicegui import ui
8
8
 
9
+ from haywire.core.access import AccessTier, LevelMode
9
10
  from haywire.ui import elements as hui
10
11
  from haywire.ui.editor import BaseEditor, SlotName, editor
11
12
  from haywire.core.session import (
@@ -19,29 +20,40 @@ from haywire.core.signals import (
19
20
  BroadcastClose,
20
21
  Close,
21
22
  GraphDataMutated,
23
+ GraphUnsavedChanged,
22
24
  Reveal,
23
25
  RevealGraphInstance,
26
+ Save,
24
27
  )
25
28
  from haywire.ui.modals import confirm_modal, pick_modal, rename_modal
26
29
 
27
30
  from haybale_graph_editor.state.edit_state import EditState
28
31
  from haybale_graph_editor.state.graph_app_state import GraphAppState
29
- from haybale_graph_editor.editors.graph_editor import GraphEditor
32
+ from haybale_graph_editor.editors.graph_editor import GraphEditor, definition_path
30
33
  from haybale_graph_editor.editors.graph_save_as import open_graph_save_as_dialog
31
34
 
32
- from ..signals import HaystackReloaded, HaystackTeardown
33
- from ..state.haystack_state import HaystackState
34
- from ..graph_entry import EntryKind, GraphEntry
35
+ from haybale_haystack.signals import HaystackReloaded, HaystackTeardown
36
+ from haybale_haystack.state.haystack_state import HaystackState
37
+ from haybale_haystack.graph_entry import EntryKind, GraphEntry
35
38
 
36
39
  logger = logging.getLogger(__name__)
37
40
 
38
41
 
42
+ def _refused(context: "SessionContext", required: AccessTier, action: str) -> bool:
43
+ """Return whether the principal lacks ``required``, telling them ``"<action> needs <tier> access"``."""
44
+ if context.can_access(required):
45
+ return False
46
+ ui.notify(f"{action} needs {required.value} access", type="warning", position="top-right")
47
+ return True
48
+
49
+
39
50
  @editor(
40
51
  label="Haystack",
41
52
  icon=hui.icon.haystack,
42
53
  default_slot=SlotName.ACTION,
43
54
  description='All open graphs. Click to switch; "+" to create a new graph.',
44
55
  order=20,
56
+ requires=AccessTier.VIEW,
45
57
  )
46
58
  class HaystackEditor(BaseEditor):
47
59
  """
@@ -68,7 +80,7 @@ class HaystackEditor(BaseEditor):
68
80
  # Signal-bus subscriptions / draw
69
81
  # ------------------------------------------------------------------
70
82
 
71
- @redraw_on(ActiveGraphMoved, GraphDataMutated, HaystackReloaded)
83
+ @redraw_on(ActiveGraphMoved, GraphDataMutated, GraphUnsavedChanged, HaystackReloaded)
72
84
  def _refresh_on_state_move(self, context: "SessionContext", event) -> None:
73
85
  """Empty body — the decorator triggers ``wrapper.redraw()`` after
74
86
  this returns, which re-runs ``draw`` and ``_render_list``."""
@@ -85,25 +97,36 @@ class HaystackEditor(BaseEditor):
85
97
  """
86
98
  self._on_haystack_teardown(context, event)
87
99
 
100
+ @react_on(Save)
101
+ def _on_save(self, context: "SessionContext", event: Save) -> None:
102
+ """Save the loaded haystack in place, or ask for a name when none is loaded."""
103
+ self._on_save_haystack_in_place(context)
104
+
88
105
  @react_on(RevealGraphInstance)
89
106
  def _on_reveal_graph_instance_reopen(
90
107
  self, context: "SessionContext", event: "RevealGraphInstance"
91
108
  ) -> None:
92
- """Reopen a loaded-but-tabless graph so it can receive future selects.
109
+ """Bring forward the tab of the document holding ``event.graph_id``, opening it if needed.
93
110
 
94
- Accepted gap: this only re-opens the tab via Reveal (idempotent —
95
- find-or-add — even if a GraphEditor in this session also matched
96
- and already self-revealed). It does NOT retroactively select the
97
- node/edge in the freshly-constructed GraphEditor, which was not
98
- alive to receive the original signal.
111
+ The graph may be the document's own or a Group or macro placement interior
112
+ in it, open or not. A tab this opens carries out the reveal when it draws
113
+ (``EditState.pending_reveal``); a tab already open carries it out itself.
99
114
  """
100
115
  graph_app_state = context.app_data[GraphAppState]
101
116
  for container in graph_app_state.all_containers():
102
- if container.editor.graph.graph_id == event.graph_id:
117
+ graph = container.editor.graph
118
+ if graph.graph_id != event.graph_id and definition_path(graph, event.graph_id) is None:
119
+ continue
120
+ edit_state = context.data[EditState]
121
+ edit_state.pending_reveal = event
122
+ try:
103
123
  context.session.publish(
104
124
  Reveal(editor=GraphEditor, binding_id=container.binding_id, label=container.display_name)
105
125
  )
106
- return
126
+ finally:
127
+ # A tab already open draws nothing, so the reveal must not wait for a later draw.
128
+ edit_state.pending_reveal = None
129
+ return
107
130
 
108
131
  def _on_haystack_teardown(self, context: "SessionContext", signal: "HaystackTeardown") -> None:
109
132
  """Translate the teardown fact into local tab-close lifecycle commands.
@@ -129,7 +152,7 @@ class HaystackEditor(BaseEditor):
129
152
 
130
153
  # No on_focus override needed: @redraw_on fires regardless of active-tab
131
154
  # state, so backgrounded HaystackEditor instances stay current with
132
- # ActiveGraphMoved / GraphDataMutated / HaystackReloaded.
155
+ # ActiveGraphMoved / GraphDataMutated / GraphUnsavedChanged / HaystackReloaded.
133
156
 
134
157
  def draw(self, context: "SessionContext", container: ui.element) -> None:
135
158
  with container:
@@ -144,13 +167,16 @@ class HaystackEditor(BaseEditor):
144
167
  # ------------------------------------------------------------------
145
168
 
146
169
  def _render_header(self, context: "SessionContext") -> None:
170
+ """Draw the title, unsaved dot and haystack actions; the actions are drawn only for edit."""
147
171
  title = self._get_active_haystack_name(context) or "Haystacks"
148
172
  hs = context.app_data[HaystackState]
149
173
  is_dirty = hs.is_haystack_dirty
150
- # The dirty dot (when is_dirty=True) is rendered by panel_header
174
+ # The dirty dot (when is_dirty=True) is rendered by slot_header
151
175
  # itself between the icon and the title — we only add the save
152
176
  # icon and the overflow menu on the action side here.
153
- with hui.panel_header(title, icon=hui.icon.haystack, is_dirty=is_dirty):
177
+ with hui.slot_header(title, icon=hui.icon.haystack, is_dirty=is_dirty):
178
+ if not context.can_edit():
179
+ return
154
180
  # Save icon — paired with the dirty dot; only when dirty.
155
181
  if is_dirty:
156
182
  hui.icon_action(
@@ -203,7 +229,7 @@ class HaystackEditor(BaseEditor):
203
229
  app = context.app
204
230
  if app is None:
205
231
  with self._list_container:
206
- ui.label("Graph manager not available").classes("text-xs hw-text-dim p-2 italic")
232
+ hui.hint("Graph manager not available").classes("p-2 italic")
207
233
  return
208
234
 
209
235
  hs = context.app_data[HaystackState]
@@ -212,7 +238,7 @@ class HaystackEditor(BaseEditor):
212
238
 
213
239
  with self._list_container:
214
240
  if not graphs:
215
- ui.label("No graphs open").classes("text-xs hw-text-dim p-2 italic")
241
+ hui.hint("No graphs open").classes("p-2 italic")
216
242
  else:
217
243
  for entry in graphs:
218
244
  self._render_entry(entry, context)
@@ -221,14 +247,17 @@ class HaystackEditor(BaseEditor):
221
247
  # haystack and cannot be run, so it does not belong among the
222
248
  # entries the play controls and the TOML apply to.
223
249
  if macros:
224
- ui.label("Macros").classes("text-xs hw-text-dim px-2 pt-2 pb-0.5 uppercase")
250
+ hui.section_label("Macros")
225
251
  for entry in macros:
226
252
  self._render_entry(entry, context)
227
253
 
228
- self._render_add_bar(context)
254
+ if context.can_edit():
255
+ self._render_add_bar(context)
229
256
 
230
257
  def _render_entry(self, entry: "GraphEntry", context: "SessionContext") -> None:
231
258
  is_active = entry.graph is context.data[EditState].active_graph
259
+ can_edit = context.can_edit()
260
+ can_operate = context.can_access(AccessTier.OPERATE)
232
261
  is_unsaved = entry.unsaved or entry.path is None
233
262
  is_executing = entry.is_executing
234
263
 
@@ -261,9 +290,12 @@ class HaystackEditor(BaseEditor):
261
290
  # to run, so it carries its kind's icon instead of a control whose
262
291
  # only answer would be a refusal.
263
292
  if not entry.kind.is_executable():
264
- ui.icon(hui.icon.node_source, size="16px").classes("hw-text-dim").tooltip(
293
+ ui.icon(hui.icon.macro, size="16px").classes("hw-text-dim").tooltip(
265
294
  "A macro is run by placing it in a graph"
266
295
  )
296
+ elif not can_operate:
297
+ # The row's execution border still shows a running graph.
298
+ pass
267
299
  elif is_executing:
268
300
  hui.icon_action(
269
301
  hui.icon.execution_stop,
@@ -293,7 +325,7 @@ class HaystackEditor(BaseEditor):
293
325
  ui.label("not saved").classes("text-xs hw-text-warning-dim")
294
326
 
295
327
  # Save icon — paired with the dirty dot; only when unsaved.
296
- if is_unsaved:
328
+ if is_unsaved and can_edit:
297
329
  hui.icon_action(
298
330
  hui.icon.save,
299
331
  tooltip="Save",
@@ -301,7 +333,7 @@ class HaystackEditor(BaseEditor):
301
333
  )
302
334
 
303
335
  # Overflow menu — hidden during execution
304
- if not is_executing:
336
+ if can_edit and not is_executing:
305
337
  with (
306
338
  ui.button(icon="more_vert")
307
339
  .props("flat round dense size=xs")
@@ -352,6 +384,9 @@ class HaystackEditor(BaseEditor):
352
384
 
353
385
  def _on_entry_save(self, binding_id: str, context: "SessionContext") -> None:
354
386
  """Save a graph; opens save-as dialog if untitled."""
387
+ if not context.can_edit():
388
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
389
+ return
355
390
  app = context.app
356
391
  if app is None:
357
392
  ui.notify("Graph manager not available", type="warning")
@@ -393,6 +428,9 @@ class HaystackEditor(BaseEditor):
393
428
 
394
429
  def _on_entry_save_as(self, binding_id: str, context: "SessionContext") -> None:
395
430
  """Always open the save-as dialog."""
431
+ if not context.can_edit():
432
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
433
+ return
396
434
  app = context.app
397
435
  if app is None:
398
436
  ui.notify("Graph manager not available", type="warning")
@@ -416,6 +454,8 @@ class HaystackEditor(BaseEditor):
416
454
 
417
455
  def _on_entry_rename(self, binding_id: str, context: "SessionContext") -> None:
418
456
  """Rename: inline edit for file-backed graphs, save-as for untitled."""
457
+ if _refused(context, AccessTier.EDIT, "Renaming"):
458
+ return
419
459
  entry = self._resolve_entry(binding_id, context)
420
460
  if entry is None:
421
461
  return
@@ -435,6 +475,8 @@ class HaystackEditor(BaseEditor):
435
475
  confirmation that warns about losing unsaved work. Clean entries
436
476
  get a milder confirm that calls out that the file stays on disk.
437
477
  """
478
+ if _refused(context, AccessTier.EDIT, "Removing"):
479
+ return
438
480
  entry = self._resolve_entry(binding_id, context)
439
481
  if entry is None:
440
482
  return
@@ -498,6 +540,8 @@ class HaystackEditor(BaseEditor):
498
540
  edit_state = context.data[EditState]
499
541
  edit_state.active_graph = None
500
542
  edit_state.active_graph_path = None
543
+ edit_state.active_route = None
544
+ context.level_mode = LevelMode.VIEW
501
545
  if session:
502
546
  session.publish(ActiveGraphMoved())
503
547
 
@@ -552,6 +596,8 @@ class HaystackEditor(BaseEditor):
552
596
 
553
597
  def _on_new(self, context: "SessionContext") -> None:
554
598
  """Create a new unnamed graph and activate it."""
599
+ if _refused(context, AccessTier.EDIT, "A new graph"):
600
+ return
555
601
  app: IAppState = context.app
556
602
  session = context.session
557
603
  if app is None or session is None:
@@ -589,6 +635,8 @@ class HaystackEditor(BaseEditor):
589
635
 
590
636
  def _on_start_execution(self, binding_id: str, context: "SessionContext") -> None:
591
637
  """Start execution on a graph entry."""
638
+ if _refused(context, AccessTier.OPERATE, "Starting and stopping"):
639
+ return
592
640
  entry = self._resolve_entry(binding_id, context)
593
641
  if entry is None:
594
642
  return
@@ -603,6 +651,8 @@ class HaystackEditor(BaseEditor):
603
651
 
604
652
  def _on_stop_execution(self, binding_id: str, context: "SessionContext") -> None:
605
653
  """Stop execution on a graph entry."""
654
+ if _refused(context, AccessTier.OPERATE, "Starting and stopping"):
655
+ return
606
656
  entry = self._resolve_entry(binding_id, context)
607
657
  if entry is None:
608
658
  return
@@ -615,7 +665,10 @@ class HaystackEditor(BaseEditor):
615
665
  # ------------------------------------------------------------------
616
666
 
617
667
  def _on_save_haystack(self, context: "SessionContext") -> None:
618
- """Save the current set of open graphs as a haystack ("Save As…")."""
668
+ """Save the current set of open graphs as a haystack ("Save As…"); refused below edit."""
669
+ if not context.can_edit():
670
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
671
+ return
619
672
  app: IAppState = context.app
620
673
  if app is None:
621
674
  ui.notify("Graph manager not available", type="warning")
@@ -642,8 +695,11 @@ class HaystackEditor(BaseEditor):
642
695
 
643
696
  Falls through to :meth:`_on_save_haystack` (the Save-As modal) when
644
697
  no haystack is loaded — there's nothing to overwrite, so the user
645
- has to name it.
698
+ has to name it. Refused below edit.
646
699
  """
700
+ if not context.can_edit():
701
+ ui.notify("Saving needs edit access", type="warning", position="top-right")
702
+ return
647
703
  active = self._get_active_haystack_name(context)
648
704
  if not active:
649
705
  self._on_save_haystack(context)
@@ -667,6 +723,8 @@ class HaystackEditor(BaseEditor):
667
723
 
668
724
  def _on_load_haystack(self, context: "SessionContext") -> None:
669
725
  """Load a haystack, replacing all currently open graphs."""
726
+ if _refused(context, AccessTier.EDIT, "Loading a haystack"):
727
+ return
670
728
  app: IAppState = context.app
671
729
  if app is None:
672
730
  ui.notify("Graph manager not available", type="warning")
@@ -731,6 +789,8 @@ class HaystackEditor(BaseEditor):
731
789
  a GraphEditor tab. The user opens it in an editor by selecting the
732
790
  new entry from the list once it appears.
733
791
  """
792
+ if _refused(context, AccessTier.EDIT, "Loading a graph"):
793
+ return
734
794
  app = context.app
735
795
  if app is None:
736
796
  ui.notify("Graph manager not available", type="warning")
@@ -790,6 +850,8 @@ class HaystackEditor(BaseEditor):
790
850
 
791
851
  def _on_rename_haystack(self, context: "SessionContext") -> None:
792
852
  """Open a modal to rename the current haystack."""
853
+ if _refused(context, AccessTier.EDIT, "Renaming"):
854
+ return
793
855
  old_name = self._get_active_haystack_name(context)
794
856
  if not old_name:
795
857
  ui.notify("No haystack is currently loaded", type="warning")
@@ -59,7 +59,7 @@ def _compile_row(result) -> dict:
59
59
  "to pass to haystack_open_graph.",
60
60
  registry_id="list_graphs",
61
61
  annotations=_READ_ONLY,
62
- access=AccessTier.VIEW,
62
+ requires=AccessTier.VIEW,
63
63
  )
64
64
  class HaystackListGraphsTool(Farmhand):
65
65
  async def run(self, ctx: FarmhandContext, limit: int = 100, offset: int = 0) -> dict:
@@ -101,7 +101,6 @@ class HaystackListGraphsTool(Farmhand):
101
101
  "graph_editor_add_node, then haystack_save_graph (it stays unsaved until you do).",
102
102
  registry_id="create_graph",
103
103
  annotations=_MUTATING,
104
- access=AccessTier.EDIT,
105
104
  )
106
105
  class HaystackCreateGraphTool(Farmhand):
107
106
  async def run(self, ctx: FarmhandContext) -> dict:
@@ -126,7 +125,6 @@ class HaystackCreateGraphTool(Farmhand):
126
125
  "to see valid paths.",
127
126
  registry_id="open_graph",
128
127
  annotations=_MUTATING,
129
- access=AccessTier.EDIT,
130
128
  )
131
129
  class HaystackOpenGraphTool(Farmhand):
132
130
  async def run(self, ctx: FarmhandContext, path: str) -> dict:
@@ -151,7 +149,6 @@ class HaystackOpenGraphTool(Farmhand):
151
149
  "binding_id, save_failed if the write itself fails.",
152
150
  registry_id="save_graph",
153
151
  annotations=_MUTATING,
154
- access=AccessTier.EDIT,
155
152
  )
156
153
  class HaystackSaveGraphTool(Farmhand):
157
154
  async def run(self, ctx: FarmhandContext, binding_id: str, save_as: str | None = None) -> dict:
@@ -175,7 +172,6 @@ class HaystackSaveGraphTool(Farmhand):
175
172
  "binding_id, rename_failed if the rename itself fails (e.g. name collision).",
176
173
  registry_id="rename_graph",
177
174
  annotations=_MUTATING,
178
- access=AccessTier.EDIT,
179
175
  )
180
176
  class HaystackRenameGraphTool(Farmhand):
181
177
  async def run(self, ctx: FarmhandContext, binding_id: str, new_name: str) -> dict:
@@ -196,7 +192,6 @@ class HaystackRenameGraphTool(Farmhand):
196
192
  "haystack_open_graph. Raises graph_not_found for an unknown binding_id.",
197
193
  registry_id="close_graph",
198
194
  annotations=_MUTATING,
199
- access=AccessTier.EDIT,
200
195
  )
201
196
  class HaystackCloseGraphTool(Farmhand):
202
197
  async def run(self, ctx: FarmhandContext, binding_id: str) -> dict:
@@ -214,7 +209,7 @@ class HaystackCloseGraphTool(Farmhand):
214
209
  "when ok).",
215
210
  registry_id="compile_graph",
216
211
  annotations=_READ_ONLY,
217
- access=AccessTier.VIEW,
212
+ requires=AccessTier.VIEW,
218
213
  )
219
214
  class HaystackCompileGraphTool(Farmhand):
220
215
  async def run(self, ctx: FarmhandContext, binding_id: str) -> dict:
@@ -231,7 +226,7 @@ class HaystackCompileGraphTool(Farmhand):
231
226
  "haystack_stop_graph when done.",
232
227
  registry_id="start_graph",
233
228
  annotations=ToolAnnotations(destructive_hint=True),
234
- access=AccessTier.EDIT,
229
+ requires=AccessTier.OPERATE,
235
230
  )
236
231
  class HaystackStartGraphTool(Farmhand):
237
232
  async def run(self, ctx: FarmhandContext, binding_id: str) -> dict:
@@ -253,7 +248,7 @@ class HaystackStartGraphTool(Farmhand):
253
248
  "currently running.",
254
249
  registry_id="stop_graph",
255
250
  annotations=_MUTATING,
256
- access=AccessTier.EDIT,
251
+ requires=AccessTier.OPERATE,
257
252
  )
258
253
  class HaystackStopGraphTool(Farmhand):
259
254
  async def run(self, ctx: FarmhandContext, binding_id: str) -> dict:
@@ -67,6 +67,11 @@ class GraphEntry(GraphContainer):
67
67
  persisted in the haystack TOML under ``[graphs.run]``.
68
68
  kind: Which category this entry belongs to. See
69
69
  :class:`EntryKind`.
70
+ last_undo_availability: ``(can_undo, can_redo, undo_redo_count)`` as
71
+ of the last validation result that changed saved state;
72
+ ``None`` before the first. Lets the validation handler
73
+ broadcast only when availability changes, counting an
74
+ undo or redo in between as a change.
70
75
  """
71
76
 
72
77
  graph: "HaywireGraph"
@@ -77,6 +82,9 @@ class GraphEntry(GraphContainer):
77
82
  haystack: "Optional[HaystackState]" = field(default=None, repr=False)
78
83
  run_settings: GraphRunSettings = field(default_factory=GraphRunSettings)
79
84
  kind: EntryKind = EntryKind.GRAPH
85
+ last_undo_availability: Optional[tuple[bool, bool, int]] = field(default=None, repr=False)
86
+ _abandoned: list["Interpreter"] = field(default_factory=list, init=False, repr=False)
87
+ """Stopped Interpreters whose runner threads are still stuck in a node."""
80
88
 
81
89
  @property
82
90
  def binding_id(self) -> str:
@@ -119,6 +127,8 @@ class GraphEntry(GraphContainer):
119
127
  Returns a failed result for an entry whose :attr:`kind` is not
120
128
  executable, naming the reason: a macro document holds no EVENT node, so
121
129
  assembly would find nothing to run and the verdict would not say why.
130
+ Also refuses while a previous run of this graph is still stuck in a
131
+ node (its runner was abandoned by a stop).
122
132
  """
123
133
  if not self.kind.is_executable():
124
134
  return CompileResult(
@@ -131,6 +141,16 @@ class GraphEntry(GraphContainer):
131
141
  if self.is_executing:
132
142
  return CompileResult(ok=True, error=None)
133
143
 
144
+ self._abandoned = [interp for interp in self._abandoned if interp.has_abandoned_runners()]
145
+ if self._abandoned:
146
+ return CompileResult(
147
+ ok=False,
148
+ error=(
149
+ f"a previous run of '{self.display_name}' is still stuck in a node; "
150
+ "wait for it to finish or restart the studio"
151
+ ),
152
+ )
153
+
134
154
  library_state_container = get_library_state_container()
135
155
  interpreter = Interpreter(library_state_container=library_state_container)
136
156
  try:
@@ -169,9 +189,14 @@ class GraphEntry(GraphContainer):
169
189
  self.interpreter.stop_execution()
170
190
  except Exception as e:
171
191
  logger.warning(f"Error stopping execution on '{self.display_name}': {e}")
172
- self.interpreter = None
192
+ self._retire_interpreter()
173
193
  logger.info(f"Execution stopped for graph '{self.display_name}'")
174
194
 
195
+ def _retire_interpreter(self) -> None:
196
+ interpreter, self.interpreter = self.interpreter, None
197
+ if interpreter is not None and interpreter.has_abandoned_runners():
198
+ self._abandoned.append(interpreter)
199
+
175
200
  def save(self, save_as: "Optional[Path]" = None) -> "Optional[str]":
176
201
  """Persist this entry via its owning HaystackState.
177
202
 
@@ -3,7 +3,7 @@
3
3
  # Edited in the studio's library overview — no reinstall, no reload.
4
4
 
5
5
  name = "haybale-haystack"
6
- version = "0.2.4"
6
+ version = "0.3.0"
7
7
  label = "Haystack"
8
8
  description = "File-centric multi-graph manager"
9
9
  tags = ["graph-management"]
@@ -0,0 +1,11 @@
1
+ from haybale_haystack.panels.file_browser.menu.file import OpenInHaystackMenuPanel
2
+ from haybale_haystack.panels.properties.introspect.graph_run_settings_panel import GraphRunSettingsPanel
3
+
4
+ # Backwards-compat alias
5
+ OpenInHaystackPanel = OpenInHaystackMenuPanel
6
+
7
+ __all__ = [
8
+ "GraphRunSettingsPanel",
9
+ "OpenInHaystackMenuPanel",
10
+ "OpenInHaystackPanel",
11
+ ]