nuspace 0.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.
Files changed (47) hide show
  1. nuspace/__init__.py +40 -0
  2. nuspace/_root.py +34 -0
  3. nuspace/apps/__init__.py +75 -0
  4. nuspace/apps/ops.py +280 -0
  5. nuspace/apps/runner.py +250 -0
  6. nuspace/apps/shapes.py +50 -0
  7. nuspace/cli/__init__.py +6 -0
  8. nuspace/cli/__main__.py +7 -0
  9. nuspace/cli/_meta.py +12 -0
  10. nuspace/cli/main.py +35 -0
  11. nuspace/core/__init__.py +18 -0
  12. nuspace/core/fields.py +184 -0
  13. nuspace/core/host.py +255 -0
  14. nuspace/core/ids.py +30 -0
  15. nuspace/core/session.py +125 -0
  16. nuspace/core/shapes.py +56 -0
  17. nuspace/core/tpl.py +243 -0
  18. nuspace/pages/__init__.py +101 -0
  19. nuspace/pages/ops.py +510 -0
  20. nuspace/pages/runner.py +422 -0
  21. nuspace/pages/shapes.py +111 -0
  22. nuspace/web/__init__.py +75 -0
  23. nuspace/web/apps/__init__.py +18 -0
  24. nuspace/web/apps/driver.py +176 -0
  25. nuspace/web/apps/ref.py +113 -0
  26. nuspace/web/arms.py +103 -0
  27. nuspace/web/lens/__init__.py +22 -0
  28. nuspace/web/lens/driver.py +98 -0
  29. nuspace/web/lens/ref.py +73 -0
  30. nuspace/web/lens/reflect.py +371 -0
  31. nuspace/web/nav.py +74 -0
  32. nuspace/web/pages/__init__.py +21 -0
  33. nuspace/web/pages/driver.py +295 -0
  34. nuspace/web/pages/ref.py +170 -0
  35. nuspace/web/pages/session.py +170 -0
  36. nuspace/web/serve/__init__.py +33 -0
  37. nuspace/web/serve/app.py +150 -0
  38. nuspace/web/serve/fabric.py +186 -0
  39. nuspace/web/serve/session.py +180 -0
  40. nuspace/web/serve/shell.py +216 -0
  41. nuspace/web/space.py +199 -0
  42. nuspace/web/wire.py +47 -0
  43. nuspace-0.1.0.dist-info/METADATA +38 -0
  44. nuspace-0.1.0.dist-info/RECORD +47 -0
  45. nuspace-0.1.0.dist-info/WHEEL +4 -0
  46. nuspace-0.1.0.dist-info/entry_points.txt +2 -0
  47. nuspace-0.1.0.dist-info/licenses/LICENSE.md +667 -0
nuspace/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Nuspace: a space that runs applications over one shape tree, built on Nu.
2
+
3
+ Rebuild in progress. The imperative driver layer was deleted wholesale
4
+ because it was python wearing a Nu costume: Control atoms holding opaque
5
+ callables, per-run state on host objects, and a python -> Nu -> arun ->
6
+ python cycle instead of one tree.
7
+
8
+ What nuspace is meant to be is one large Nu program. Storage shapes, the
9
+ tpl registry and the wysiwyg template below survived because they are
10
+ already that. Everything else gets written against the guides in
11
+ ``go/progress/tasks/task-144-nuspace-v1/reference/``.
12
+ """
13
+
14
+ from nuspace.apps import App, run_apps
15
+ from nuspace.core import (
16
+ TPL_PROGRAM,
17
+ TPL_TEXT,
18
+ Space,
19
+ Tpl,
20
+ mint_ordered_id,
21
+ resolve,
22
+ )
23
+ from nuspace.pages import Page, Section, page_tree
24
+
25
+
26
+ __version__ = "0.0.0"
27
+
28
+ __all__ = [
29
+ "TPL_PROGRAM",
30
+ "TPL_TEXT",
31
+ "App",
32
+ "Page",
33
+ "Section",
34
+ "Space",
35
+ "Tpl",
36
+ "mint_ordered_id",
37
+ "page_tree",
38
+ "resolve",
39
+ "run_apps",
40
+ ]
nuspace/_root.py ADDED
@@ -0,0 +1,34 @@
1
+ """Which space root class a call addresses, resolved at call time.
2
+
3
+ ``core.shapes`` imports the per-submodule shapes to build ``Space``, so a
4
+ submodule that named ``Space`` at import time would close the cycle. This is
5
+ the one place the import is deferred instead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from nuspace.core import Space
15
+
16
+
17
+ __all__ = ["resolve_root"]
18
+
19
+
20
+ def resolve_root(root: type[Space] | None) -> type[Space]:
21
+ """``root``, or ``Space`` when it is None.
22
+
23
+ ``Space.apps`` and ``DemoSpace.apps`` are different addresses, so an op
24
+ against a subclassed space has to be told which root it is working.
25
+
26
+ Args:
27
+ root: the space's root Shape class, or None for the stock ``Space``.
28
+ """
29
+ if root is not None:
30
+ return root
31
+ # Deferred on purpose: see the module docstring.
32
+ from nuspace.core.shapes import Space
33
+
34
+ return Space
@@ -0,0 +1,75 @@
1
+ """nuspace.apps -- every app in the space, running, as one Nu tree.
2
+
3
+ Module layout:
4
+
5
+ - :mod:`.shapes` -- store layout (``App``) and the host's own ``Runner``.
6
+ - :mod:`.ops` -- write + read primitives (``add_app`` / ``snippet_of`` / ...).
7
+ What a ui, a cli or an agent calls instead of writing ref chains.
8
+ - :mod:`.runner` -- the driver: seed, reconcile, live loop, ``run_apps``.
9
+
10
+ No ``interactions`` module: every op here is a plain ``-> Nu`` function over
11
+ existing atoms, and nothing in this layer touches the host directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .ops import (
17
+ add_app,
18
+ app_ids,
19
+ attached,
20
+ error_of,
21
+ exists,
22
+ is_running,
23
+ remove_app,
24
+ rename_app,
25
+ running,
26
+ set_policy,
27
+ set_snippet,
28
+ snippet_of,
29
+ )
30
+ from .runner import (
31
+ CHANGED_APP_INDEX,
32
+ DEFAULT_CHANNEL_PREFIX,
33
+ app_body,
34
+ apps_store,
35
+ apps_tree,
36
+ changed_app,
37
+ driver,
38
+ free_port,
39
+ reconcile,
40
+ run_apps,
41
+ supervisor,
42
+ worker_init,
43
+ )
44
+ from .shapes import DEFAULT_POLICY, App, Runner
45
+
46
+
47
+ __all__ = [
48
+ "CHANGED_APP_INDEX",
49
+ "DEFAULT_CHANNEL_PREFIX",
50
+ "DEFAULT_POLICY",
51
+ "App",
52
+ "Runner",
53
+ "add_app",
54
+ "app_body",
55
+ "app_ids",
56
+ "apps_store",
57
+ "apps_tree",
58
+ "attached",
59
+ "changed_app",
60
+ "driver",
61
+ "error_of",
62
+ "exists",
63
+ "free_port",
64
+ "is_running",
65
+ "reconcile",
66
+ "remove_app",
67
+ "rename_app",
68
+ "run_apps",
69
+ "running",
70
+ "set_policy",
71
+ "set_snippet",
72
+ "snippet_of",
73
+ "supervisor",
74
+ "worker_init",
75
+ ]
nuspace/apps/ops.py ADDED
@@ -0,0 +1,280 @@
1
+ """Write + read primitives over the apps store.
2
+
3
+ Every one returns a Nu tree and nothing else, so a ui, a cli or an agent
4
+ composes them instead of hand-writing ref chains. ``root`` is the space's own
5
+ root Shape class, resolved at call time by :func:`~nuspace._root.resolve_root`
6
+ so this module never needs ``Space`` while it is being imported.
7
+
8
+ Write:
9
+ - :func:`init_apps` -- the container, once, on a cold store.
10
+ - :func:`add_app` -- every field of a new app, in one tree.
11
+ - :func:`remove_app` -- drop the row. The runner kills the worker.
12
+ - :func:`set_snippet` / :func:`rename_app` / :func:`set_policy` -- one field.
13
+ - :func:`clear_error` -- forget what the runner last recorded.
14
+
15
+ Read:
16
+ - :func:`app_ids` / :func:`exists` / :func:`snippet_of` / :func:`error_of`
17
+ -- the store.
18
+ - :func:`running` / :func:`is_running` / :func:`attached` -- the host's own
19
+ ``Runner``, which is mem and therefore only readable from inside the
20
+ process the runner is in.
21
+ - whole rows, one dict per app: :func:`app_rows` / :func:`app_statuses`. What
22
+ a rail or a status bar is filled from.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ import nu
30
+ from nuspace._root import resolve_root
31
+ from nuspace.core.ids import mint_ordered_id
32
+
33
+ from .shapes import DEFAULT_POLICY, Runner
34
+
35
+
36
+ if TYPE_CHECKING:
37
+ from nu.domains.shape import Shape
38
+
39
+
40
+ __all__ = [
41
+ "add_app",
42
+ "app_ids",
43
+ "app_rows",
44
+ "app_statuses",
45
+ "attached",
46
+ "clear_error",
47
+ "error_of",
48
+ "exists",
49
+ "init_apps",
50
+ "is_running",
51
+ "remove_app",
52
+ "rename_app",
53
+ "running",
54
+ "set_policy",
55
+ "set_snippet",
56
+ "snippet_of",
57
+ ]
58
+
59
+
60
+ #: The name ``Map`` binds the current element under, and the ref that reads it.
61
+ _ITEM = "_na_item"
62
+ _item = nu.AnyAttrRef(_ITEM)
63
+
64
+
65
+ def _state_key(app_id: nu.StrArg, suffix: str) -> nu.Nu:
66
+ """``apps.<app_id><suffix>`` as a term, for either a python str or a term."""
67
+ return nu.Str("apps.") + app_id + nu.Str(suffix)
68
+
69
+
70
+ # --- write: the space ------------------------------------------------------
71
+
72
+
73
+ def init_apps(*, root: type[Shape] | None = None) -> nu.Nu:
74
+ """Create the apps container if the store has none. Idempotent.
75
+
76
+ A subscription over a missing container resolves to INVALID and silently
77
+ never fires, so anything that means to watch ``apps`` boots through here
78
+ first. ``Dict.create()``, not ``{}``: a literal is captured once at Form
79
+ construction and shared across every evaluation of the term.
80
+ """
81
+ return resolve_root(root).apps.init(nu.Dict.create())
82
+
83
+
84
+ # --- write -----------------------------------------------------------------
85
+
86
+
87
+ def add_app(
88
+ source: nu.StrArg,
89
+ *,
90
+ app_id: nu.StrArg | None = None,
91
+ name: nu.StrArg | None = None,
92
+ policy: nu.StrArg = DEFAULT_POLICY,
93
+ root: type[Shape] | None = None,
94
+ ) -> nu.Nu:
95
+ """Write a whole app: id, name, policy and source, in one tree.
96
+
97
+ Args:
98
+ source: the snippet, a ``nu.prog`` module with an ``out`` entry point.
99
+ app_id: the app's key. Minted in creation order when absent, in which
100
+ case the caller never learns it -- pass ``mint_ordered_id("a")``
101
+ yourself if you mean to address the app afterwards.
102
+ name: what to call it. Defaults to the id.
103
+ policy: when it runs. Nothing reads it yet.
104
+ root: the space's root Shape class.
105
+ """
106
+ # Minted while the tree is built, not while it runs, so re-running one tree
107
+ # rewrites one app rather than adding another.
108
+ app_id = mint_ordered_id("a") if app_id is None else app_id
109
+ app = resolve_root(root).apps[app_id]
110
+ # Snippet last: every field write wakes its own reconcile, so this order
111
+ # leaves the pass that actually launches the app holding the final source.
112
+ return (
113
+ app.name.set(app_id if name is None else name)
114
+ >> app.policy.set(policy)
115
+ >> app.snippet.set(source)
116
+ )
117
+
118
+
119
+ def remove_app(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
120
+ """Drop an app from the store. A no-op when it is not there."""
121
+ apps = resolve_root(root).apps
122
+ # Guarded rather than bare: del_item on a missing key raises, and removing
123
+ # something already gone is exactly what a retried ui click does.
124
+ return nu.IfDo(apps.contains(app_id), apps.del_item(app_id))
125
+
126
+
127
+ def set_snippet(app_id: nu.StrArg, source: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
128
+ """Replace an app's source. The runner restarts that app and no other."""
129
+ return resolve_root(root).apps[app_id].snippet.set(source)
130
+
131
+
132
+ def rename_app(app_id: nu.StrArg, name: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
133
+ """Replace an app's display name. The id does not move."""
134
+ return resolve_root(root).apps[app_id].name.set(name)
135
+
136
+
137
+ def set_policy(app_id: nu.StrArg, policy: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
138
+ """Replace an app's policy string."""
139
+ return resolve_root(root).apps[app_id].policy.set(policy)
140
+
141
+
142
+ def clear_error(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
143
+ """Forget the construction error recorded for this app. A no-op when clean.
144
+
145
+ Guarded rather than bare: ``del_item`` on a missing key raises, and an app
146
+ that never failed has no key.
147
+ """
148
+ state = resolve_root(root).state
149
+ key = _state_key(app_id, ".error")
150
+ return nu.IfDo(state.contains(key), state.del_item(key))
151
+
152
+
153
+ # --- read ------------------------------------------------------------------
154
+
155
+
156
+ def app_ids(*, root: type[Shape] | None = None) -> nu.Nu:
157
+ """Every app id in the space, as a list."""
158
+ # nu.list, not the bare keys view: the view is lazy and dies with its
159
+ # Snapshot, so an undrained one reads as StorageClosedError later.
160
+ return nu.list(resolve_root(root).apps.keys())
161
+
162
+
163
+ def exists(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
164
+ """Whether the store has an app under this id."""
165
+ return resolve_root(root).apps.contains(app_id)
166
+
167
+
168
+ def snippet_of(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
169
+ """An app's source, verbatim. EMPTY when there is no such app."""
170
+ return resolve_root(root).apps[app_id].snippet
171
+
172
+
173
+ def error_of(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
174
+ """The construction error the runner recorded for this app, or ``""``.
175
+
176
+ Written by ``app_body`` when a snippet does not construct, since a
177
+ dispatched body has no waiter to raise into.
178
+ """
179
+ return resolve_root(root).state.get_item(_state_key(app_id, ".error"), nu.Str(""))
180
+
181
+
182
+ def running() -> nu.Nu:
183
+ """Which apps have a worker, and its pool id, as a dict.
184
+
185
+ ``Runner.workers`` is mem in the host process, so this only answers inside
186
+ the runner's own tree.
187
+ """
188
+ return nu.dict(Runner.workers.items())
189
+
190
+
191
+ def is_running(app_id: nu.StrArg) -> nu.Nu:
192
+ """Whether this app has a worker on record right now."""
193
+ return Runner.workers.contains(app_id)
194
+
195
+
196
+ def attached() -> nu.Nu:
197
+ """Whether a runner is supervising apps in this very process. A live read.
198
+
199
+ Total, and true only where the bookkeeping really is: the ``FabricExists``
200
+ half answers False rather than raising where no ``dict`` is bound at all,
201
+ and ``Runner.attached`` is mem, so a runner in another process cannot make
202
+ this say yes.
203
+ """
204
+ return nu.And(nu.FabricRef(dict).exists(), nu.NotEmpty(Runner.attached))
205
+
206
+
207
+ # --- read: whole rows ------------------------------------------------------
208
+ #
209
+ # Both answer with a list of dicts rather than a scalar, so one read fills a
210
+ # rail or a status bar. They exist because a caller that wants every app would
211
+ # otherwise read the ids and then loop in its own language, which puts a python
212
+ # (or a javascript) for-loop back in the middle of what is meant to be one tree.
213
+
214
+
215
+ def app_rows(*, root: type[Shape] | None = None) -> nu.Nu:
216
+ """Every app as ``{id, name, source, policy}``, one dict per app.
217
+
218
+ Keys sort by mint time, so this is creation order with no order slot.
219
+ """
220
+ apps = resolve_root(root).apps
221
+ app = apps[_item]
222
+ return nu.Collect(
223
+ nu.Map(
224
+ # nu.list, not the bare keys view, for the reason app_ids gives.
225
+ nu.list(apps.keys()),
226
+ nu.Dict.of(id=_item, name=app.name, source=app.snippet, policy=app.policy),
227
+ key=_ITEM,
228
+ )
229
+ )
230
+
231
+
232
+ def _live(supervised: nu.BoolArg) -> nu.Nu | None:
233
+ """Whether the app ``Map`` is on has a worker, or None when nobody asked.
234
+
235
+ ``supervised`` is ``False`` for a tree that must not touch mem at all, and
236
+ a term for one that wants the answer read rather than assumed.
237
+ """
238
+ if supervised is False:
239
+ return None
240
+ running_now = is_running(nu.StrAttrRef(_ITEM))
241
+ if supervised is True:
242
+ return running_now
243
+ return nu.And(supervised, running_now)
244
+
245
+
246
+ def app_statuses(*, supervised: nu.BoolArg = False, root: type[Shape] | None = None) -> nu.Nu:
247
+ """Every app as ``{section_id, state, error, started_at}``, in the same order.
248
+
249
+ ``section_id`` rather than ``app_id`` because this is the supervisor's
250
+ contract, not the Apps surface's: an app and a section are the same
251
+ substance and share one status shape. An app whose namespace holds an
252
+ ``error`` key reads ``failed``.
253
+
254
+ Args:
255
+ supervised: whether ``Runner.workers`` is readable here, which it only
256
+ is inside the runner's own tree. ``False`` never claims an app is
257
+ running and never touches mem; ``True`` reads it outright; a term
258
+ (see :func:`attached`) reads whether to read it, which is what a
259
+ web driver that may or may not share a process with a runner wants.
260
+ root: the space's root Shape class.
261
+ """
262
+ root = resolve_root(root)
263
+ # Same binding Map makes, read as a Str so `+` concatenates rather than
264
+ # collapsing to INVALID the way it would on an untyped AnyAttrRef.
265
+ key = _state_key(nu.StrAttrRef(_ITEM), ".error")
266
+ idle = nu.Str("idle")
267
+ live = _live(supervised)
268
+ quiet = idle if live is None else nu.If(live, nu.Str("running"), idle)
269
+ return nu.Collect(
270
+ nu.Map(
271
+ app_ids(root=root),
272
+ nu.Dict.of(
273
+ section_id=_item,
274
+ state=nu.If(root.state.contains(key), nu.Str("failed"), quiet),
275
+ error=nu.ToStr(root.state.get_item(key, nu.Str(""))),
276
+ started_at=nu.Int(0),
277
+ ),
278
+ key=_ITEM,
279
+ )
280
+ )
nuspace/apps/runner.py ADDED
@@ -0,0 +1,250 @@
1
+ """The apps driver: every app in the space, running, as one Nu tree.
2
+
3
+ One ``arun`` owns it: seed from the store, then react to it forever, with a
4
+ single reconcile path covering add, edit and delete. A snippet owns its own
5
+ atomicity, and without ``redis_url`` a worker can read and write the store
6
+ but cannot react to another process's writes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ import nu
14
+ import nu.kv
15
+ import nu.mp_pool
16
+ import nu.prog
17
+ from nuspace._root import resolve_root
18
+ from nuspace.core.host import DEFAULT_CHANNEL_PREFIX, free_port, host, worker_init
19
+
20
+ from .shapes import Runner
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from nu.domains.shape import Shape
25
+
26
+
27
+ __all__ = [
28
+ "CHANGED_APP_INDEX",
29
+ "DEFAULT_CHANNEL_PREFIX",
30
+ "app_body",
31
+ "apps_store",
32
+ "apps_tree",
33
+ "changed_app",
34
+ "driver",
35
+ "free_port",
36
+ "reconcile",
37
+ "run_apps",
38
+ "supervisor",
39
+ "worker_init",
40
+ ]
41
+
42
+
43
+ #: Position of the app id in a key from ``Space.apps.on_change()``. The
44
+ #: subscription is depth-unbounded, so a key is either ``('/', 'apps', id)`` or
45
+ #: ``('/', 'apps', id, field)``; index 2 reaches the id in both. Measured, and
46
+ #: pinned by ``tests/nuspace/apps/test_changed_key.py``.
47
+ CHANGED_APP_INDEX = 2
48
+
49
+
50
+ #: The app a reconcile pass is about, carried into the worker by ``carry=True``.
51
+ _APP = nu.StrAttrRef("app")
52
+
53
+ #: The pool worker a reconcile pass just launched.
54
+ _WORKER = nu.IntAttrRef("w")
55
+
56
+ #: The key that woke the live loop, and the app id inside it.
57
+ _KEY = nu.TupleAttrRef("k")
58
+ changed_app = _KEY[CHANGED_APP_INDEX]
59
+
60
+
61
+ def app_body(*, root: type[Shape] | None = None) -> nu.Nu:
62
+ """One app running, as the single term every ``Dispatch`` ships.
63
+
64
+ One per space, not one per app: a ``Dispatch`` body is payload, so it is
65
+ fixed at construction and the app arrives as the carried attr ``app``. The
66
+ ``auto_flow_atomic`` covers the runner's own ``LoadNu`` read and stops
67
+ there, since the pass does not descend through ``Eval``.
68
+ """
69
+ root = resolve_root(root)
70
+ # Construction failures are written to the app's namespace, not raised: a
71
+ # dispatched body has no waiter, so an uncaught error vanishes silently.
72
+ load = root.apps[_APP].snippet.load(scope={"path": nu.Str("apps.") + _APP})
73
+ report = nu.kv.auto_flow_atomic(
74
+ root.state.set_item(
75
+ nu.Str("apps.") + _APP + nu.Str(".error"), nu.ToStr(nu.AttrRef("error"))
76
+ ),
77
+ scope=root,
78
+ )
79
+ return nu.TryCatch(
80
+ nu.prog.Eval(nu.kv.auto_flow_atomic(load, scope=root)),
81
+ catch=report,
82
+ errors=nu.prog.ConstructionError,
83
+ )
84
+
85
+
86
+ def reconcile(*, root: type[Shape] | None = None) -> nu.Nu:
87
+ """Make the world agree with the store, for the one app bound at ``app``.
88
+
89
+ One path for add, edit and delete: whatever is running for this app stops,
90
+ and if the store still has the app it starts again. ``Launch``, ``Dispatch``
91
+ and ``Kill`` all return promptly, so the seed can run this sequentially
92
+ without the first app blocking the rest.
93
+ """
94
+ root = resolve_root(root)
95
+ pool = nu.mp_pool.PoolRef()
96
+ stop = nu.IfDo(
97
+ Runner.workers.contains(_APP),
98
+ pool.kill(Runner.workers[_APP]) >> Runner.workers.del_item(_APP),
99
+ )
100
+ start = nu.IfDo(
101
+ # An app that has been deleted reaches here too -- that is the delete
102
+ # path, and this is what stops it coming back.
103
+ root.apps.contains(_APP),
104
+ # The worker id is read twice, recorded and then dispatched to, so it
105
+ # is bound once and scoped to the two reads that want it.
106
+ nu.Let(
107
+ "w",
108
+ pool.launch(),
109
+ body=Runner.workers.set_item(_APP, _WORKER)
110
+ >> pool.dispatch(app_body(root=root), _WORKER, carry=True),
111
+ ),
112
+ )
113
+ return stop >> start
114
+
115
+
116
+ def driver(*, root: type[Shape] | None = None) -> tuple[nu.Nu, nu.Nu]:
117
+ """The seed pass and the live loop, as two terms.
118
+
119
+ Returned separately because they compose differently: the seed must finish
120
+ before anything else starts, and the live loop never finishes at all.
121
+ """
122
+ root = resolve_root(root)
123
+ body = reconcile(root=root)
124
+ # Both containers must exist before anything reads them: a subscription
125
+ # over a missing container resolves to INVALID and silently never fires.
126
+ # Dict.create(), not {} -- a literal dict is captured once at Form
127
+ # construction and shared across every evaluation of the term.
128
+ #
129
+ # `attached` is the marker a web driver in this same process reads to say
130
+ # whether anything is supervising at all. It is written here rather than
131
+ # assumed by whoever assembled the tree, so the browser is told what is
132
+ # true rather than what the build script believed.
133
+ boot = (
134
+ root.apps.init(nu.Dict.create())
135
+ >> Runner.workers.init(nu.Dict.create())
136
+ >> Runner.attached.set(nu.Bool(True))
137
+ )
138
+ # nu.list is load-bearing: the keys view is lazy and auto_flow_atomic
139
+ # brackets the items slot separately, so an undrained view outlives its
140
+ # Snapshot and dies with StorageClosedError.
141
+ seed = boot >> nu.ForEachDo(nu.list(root.apps.keys()), body, item="app")
142
+ # The bare key ('/', 'apps') also fires, naming no app. It must be skipped
143
+ # explicitly: indexing past the end raises IndexError inside the react
144
+ # loop, killing it and leaving the runner silently deaf.
145
+ live = nu.ReactForever(
146
+ root.apps.on_change(),
147
+ nu.IfDo(
148
+ nu.Len(_KEY) > nu.Int(CHANGED_APP_INDEX),
149
+ # Same binding the seed pass makes with ForEachDo(item="app"), and
150
+ # scoped the same way, so no reaction leaves one behind.
151
+ nu.Let("app", changed_app, body=body),
152
+ ),
153
+ changed_key="k",
154
+ )
155
+ return seed, live
156
+
157
+
158
+ def supervisor(
159
+ *,
160
+ root: type[Shape] | None = None,
161
+ alongside: nu.Nu | None = None,
162
+ duration: float | None = None,
163
+ ) -> nu.Nu:
164
+ """Every app in the space, supervised, as one term for whoever mounts it.
165
+
166
+ No ``With`` head: the store, the pool and the ``dict`` behind
167
+ ``Runner.workers`` come from the context this is run in, which is what
168
+ lets a web server and this share one process and one store.
169
+
170
+ Args:
171
+ root: the space's root Shape class.
172
+ alongside: a tree to run beside the live loop, for demos and tests.
173
+ duration: stop after this many seconds. None runs forever.
174
+ """
175
+ root = resolve_root(root)
176
+ seed, live = driver(root=root)
177
+ flow = live if alongside is None else (live | alongside)
178
+ if duration is not None:
179
+ flow = nu.Race(flow, nu.DelayedDo(nu.Float(duration), nu.Noop()))
180
+ return nu.kv.auto_flow_atomic(seed >> flow, scope=root)
181
+
182
+
183
+ def apps_store(
184
+ path: str,
185
+ *,
186
+ redis_url: str | None = None,
187
+ channel_prefix: str = DEFAULT_CHANNEL_PREFIX,
188
+ ) -> nu.Nu:
189
+ """The navigator bracket for a space on disk, with or without redis."""
190
+ if redis_url is None:
191
+ return nu.kv.rocksdb_navigator(path)
192
+ return nu.kv.rocksdb_navigator_redis(path, redis_url=redis_url, channel_prefix=channel_prefix)
193
+
194
+
195
+ def apps_tree(
196
+ *,
197
+ path: str,
198
+ address: str,
199
+ root: type[Shape] | None = None,
200
+ redis_url: str | None = None,
201
+ channel_prefix: str = DEFAULT_CHANNEL_PREFIX,
202
+ alongside: nu.Nu | None = None,
203
+ duration: float | None = None,
204
+ ) -> nu.Nu:
205
+ """The whole runner: one tree, for one ``arun``.
206
+
207
+ Args:
208
+ path: the store directory. This tree takes its write lock, which is
209
+ why workers reach it through a proxy rather than opening it.
210
+ address: where the Navigator is served, ``host:port``.
211
+ root: the space's root Shape class. Defaults to ``Space``.
212
+ redis_url: Redis carrying change notifications, or None.
213
+ channel_prefix: namespaces the Redis channels.
214
+ alongside: a tree to run beside the live loop, for demos and tests.
215
+ duration: stop after this many seconds. None runs forever.
216
+
217
+ Returns:
218
+ The tree. Brackets tear down LIFO when it ends, reaping every worker.
219
+ """
220
+ return host(
221
+ supervisor(root=root, alongside=alongside, duration=duration),
222
+ store=apps_store(path, redis_url=redis_url, channel_prefix=channel_prefix),
223
+ address=address,
224
+ redis_url=redis_url,
225
+ channel_prefix=channel_prefix,
226
+ )
227
+
228
+
229
+ async def run_apps(
230
+ path: str,
231
+ *,
232
+ address: str | None = None,
233
+ root: type[Shape] | None = None,
234
+ redis_url: str | None = None,
235
+ channel_prefix: str = DEFAULT_CHANNEL_PREFIX,
236
+ alongside: nu.Nu | None = None,
237
+ duration: float | None = None,
238
+ max_parallel: int = 64,
239
+ ) -> None:
240
+ """Assemble the tree and run it. Deployment glue, nothing else."""
241
+ tree = apps_tree(
242
+ path=path,
243
+ address=address or f"127.0.0.1:{free_port()}",
244
+ root=root,
245
+ redis_url=redis_url,
246
+ channel_prefix=channel_prefix,
247
+ alongside=alongside,
248
+ duration=duration,
249
+ )
250
+ await nu.arun(tree, nu.Context(), max_parallel=max_parallel)