branchkit 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.
- branchkit/__init__.py +87 -0
- branchkit/closed_vocab_gen.py +95 -0
- branchkit/collection.py +188 -0
- branchkit/collection_log.py +97 -0
- branchkit/commands.py +221 -0
- branchkit/contracts_gen.py +666 -0
- branchkit/correlation.py +28 -0
- branchkit/debug.py +43 -0
- branchkit/effects.py +73 -0
- branchkit/harness.py +296 -0
- branchkit/hud.py +18 -0
- branchkit/listen.py +244 -0
- branchkit/log.py +10 -0
- branchkit/log_events_gen.py +138 -0
- branchkit/methods_gen.py +4431 -0
- branchkit/mirror.py +117 -0
- branchkit/plugin.py +475 -0
- branchkit/proxy.py +157 -0
- branchkit/settings.py +97 -0
- branchkit/settings_route.py +30 -0
- branchkit/types_gen.py +4768 -0
- branchkit/ui.py +149 -0
- branchkit/upstream.py +65 -0
- branchkit-0.1.0.dist-info/METADATA +84 -0
- branchkit-0.1.0.dist-info/RECORD +28 -0
- branchkit-0.1.0.dist-info/WHEEL +5 -0
- branchkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- branchkit-0.1.0.dist-info/top_level.txt +1 -0
branchkit/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""BranchKit plugin SDK for Python.
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import branchkit
|
|
5
|
+
|
|
6
|
+
plugin = branchkit.Plugin()
|
|
7
|
+
|
|
8
|
+
@plugin.handle("my.method")
|
|
9
|
+
async def my_method(params):
|
|
10
|
+
return {"ok": True}
|
|
11
|
+
|
|
12
|
+
asyncio.run(plugin.run())
|
|
13
|
+
|
|
14
|
+
Handlers may be `async def` (run on the loop) or plain `def`
|
|
15
|
+
(auto-offloaded to a thread, so a blocking body cannot freeze the
|
|
16
|
+
plugin). Stdlib-only by design: importing the SDK forces no third-party
|
|
17
|
+
dependency on any plugin."""
|
|
18
|
+
|
|
19
|
+
from . import proxy as _proxy
|
|
20
|
+
|
|
21
|
+
# Route stdlib HTTP through BRANCHKIT_PROXY when sandboxed (per-host
|
|
22
|
+
# tier) — same import-time side effect as the TS SDK's entry module.
|
|
23
|
+
_proxy.install_proxy_from_env()
|
|
24
|
+
|
|
25
|
+
from .plugin import (
|
|
26
|
+
PluginCore,
|
|
27
|
+
RecordingDisabledError,
|
|
28
|
+
RpcCallError,
|
|
29
|
+
api_version,
|
|
30
|
+
error_kind_of,
|
|
31
|
+
models_dir,
|
|
32
|
+
plugin_data_dir,
|
|
33
|
+
plugin_dir,
|
|
34
|
+
)
|
|
35
|
+
from .collection import CollectionMixin, list_opts, scope_collection, scope_group
|
|
36
|
+
from .collection_log import CollectionLogMixin, log_list_opts
|
|
37
|
+
from .debug import DebugMixin
|
|
38
|
+
from .effects import EffectsMixin
|
|
39
|
+
from .hud import HudMixin
|
|
40
|
+
from .log import log
|
|
41
|
+
from .methods_gen import MethodsMixin
|
|
42
|
+
from .mirror import CollectionMirror, MirrorMixin
|
|
43
|
+
from .settings import SettingsMirror, SettingsMixin
|
|
44
|
+
from .commands import (
|
|
45
|
+
CommandBuilder,
|
|
46
|
+
capture,
|
|
47
|
+
command,
|
|
48
|
+
load_commands,
|
|
49
|
+
one_of,
|
|
50
|
+
push_command_group,
|
|
51
|
+
push_command_specs,
|
|
52
|
+
push_commands,
|
|
53
|
+
text,
|
|
54
|
+
word,
|
|
55
|
+
)
|
|
56
|
+
from .listen import Listener, inherited_listener_count, listen_local
|
|
57
|
+
from .settings_route import method_post, method_url
|
|
58
|
+
from .ui import (
|
|
59
|
+
Expr,
|
|
60
|
+
args,
|
|
61
|
+
confirm_button,
|
|
62
|
+
expr,
|
|
63
|
+
input_value,
|
|
64
|
+
post_button,
|
|
65
|
+
signal_button,
|
|
66
|
+
signal_name,
|
|
67
|
+
)
|
|
68
|
+
from .upstream import UpstreamClient, UpstreamResponse
|
|
69
|
+
from .closed_vocab_gen import * # noqa: F401,F403 — error kinds, directives, effects
|
|
70
|
+
from .contracts_gen import * # noqa: F401,F403 — method/hook/event/tag constants
|
|
71
|
+
from .log_events_gen import * # noqa: F401,F403 — observability log-event names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Plugin(
|
|
75
|
+
CollectionMixin,
|
|
76
|
+
CollectionLogMixin,
|
|
77
|
+
EffectsMixin,
|
|
78
|
+
DebugMixin,
|
|
79
|
+
HudMixin,
|
|
80
|
+
MirrorMixin,
|
|
81
|
+
SettingsMixin,
|
|
82
|
+
MethodsMixin,
|
|
83
|
+
PluginCore,
|
|
84
|
+
):
|
|
85
|
+
"""The plugin: JSON-RPC transport plus the generated method wrappers
|
|
86
|
+
and the state/log/effects/debug/HUD/mirror/settings façades, one
|
|
87
|
+
class. See PluginCore for the transport contract."""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# AUTO-GENERATED by emit-sdk — do not edit.
|
|
2
|
+
# Run: just contracts
|
|
3
|
+
|
|
4
|
+
from typing import NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
# INPUT_DIRECTIVE_* are the closed-vocabulary keys plugins may write
|
|
7
|
+
# into `_platform.input.directives`. Source of truth:
|
|
8
|
+
# `actuator/src/state.rs::InputDirectives::KNOWN_KEYS`.
|
|
9
|
+
INPUT_DIRECTIVE_LIFT_KEYS_ON_DISPATCH = "lift_keys_on_dispatch"
|
|
10
|
+
|
|
11
|
+
# EFFECT_* are the registered effect names plugins may declare in
|
|
12
|
+
# `provides.effects`. Source of truth:
|
|
13
|
+
# `actuator/src/effects.rs::EffectDef` inventory entries.
|
|
14
|
+
EFFECT_DISABLE_SCREEN_DIM = "disable_screen_dim"
|
|
15
|
+
EFFECT_MUTE_AUDIO_TO_OTHER_APPS = "mute_audio_to_other_apps"
|
|
16
|
+
EFFECT_PAUSE_MICROPHONE_INDICATOR = "pause_microphone_indicator"
|
|
17
|
+
EFFECT_PREVENT_FOCUS_STEAL = "prevent_focus_steal"
|
|
18
|
+
EFFECT_SIGNAL_RECORDING_ACTIVE = "signal_recording_active"
|
|
19
|
+
EFFECT_SUPPRESS_HUDS = "suppress_huds"
|
|
20
|
+
EFFECT_SUPPRESS_KEYBINDS = "suppress_keybinds"
|
|
21
|
+
EFFECT_SUPPRESS_NOTIFICATIONS = "suppress_notifications"
|
|
22
|
+
|
|
23
|
+
# The full closed-vocabulary sets.
|
|
24
|
+
KNOWN_INPUT_DIRECTIVES = (
|
|
25
|
+
"lift_keys_on_dispatch",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
KNOWN_EFFECTS = (
|
|
29
|
+
"disable_screen_dim",
|
|
30
|
+
"mute_audio_to_other_apps",
|
|
31
|
+
"pause_microphone_indicator",
|
|
32
|
+
"prevent_focus_steal",
|
|
33
|
+
"signal_recording_active",
|
|
34
|
+
"suppress_huds",
|
|
35
|
+
"suppress_keybinds",
|
|
36
|
+
"suppress_notifications",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# ERROR_KIND_* are the closed-vocabulary `kind` values the actuator puts
|
|
40
|
+
# in a JSON-RPC error's `data.kind`. Branch on RpcCallError.kind rather
|
|
41
|
+
# than matching the message prose. Source of truth:
|
|
42
|
+
# `actuator/src/fault.rs::ErrorKind`.
|
|
43
|
+
#
|
|
44
|
+
# NOTE: ErrorKind is `str`, deliberately NOT a Literal union of the
|
|
45
|
+
# values below. An actuator newer than this SDK may send a kind that is
|
|
46
|
+
# not listed here, and that must fall through a match rather than fail
|
|
47
|
+
# to parse.
|
|
48
|
+
ErrorKind = str
|
|
49
|
+
ERROR_KIND_NOT_PERMITTED = "not_permitted"
|
|
50
|
+
ERROR_KIND_RECORDING_DISABLED = "recording_disabled"
|
|
51
|
+
ERROR_KIND_NOT_FOUND = "not_found"
|
|
52
|
+
ERROR_KIND_VALIDATION = "validation"
|
|
53
|
+
ERROR_KIND_FORBIDDEN = "forbidden"
|
|
54
|
+
ERROR_KIND_STORAGE = "storage"
|
|
55
|
+
ERROR_KIND_METHOD_NOT_FOUND = "method_not_found"
|
|
56
|
+
ERROR_KIND_INVALID_PARAMS = "invalid_params"
|
|
57
|
+
ERROR_KIND_INTERNAL = "internal"
|
|
58
|
+
|
|
59
|
+
# ERROR_CODE_FOR maps a kind to the JSON-RPC error code the actuator
|
|
60
|
+
# sends with it. Derived from the kind actuator-side.
|
|
61
|
+
ERROR_CODE_FOR: dict[str, int] = {
|
|
62
|
+
"not_permitted": -32002,
|
|
63
|
+
"recording_disabled": -32006,
|
|
64
|
+
"not_found": -32001,
|
|
65
|
+
"validation": -32004,
|
|
66
|
+
"forbidden": -32003,
|
|
67
|
+
"storage": -32005,
|
|
68
|
+
"method_not_found": -32601,
|
|
69
|
+
"invalid_params": -32602,
|
|
70
|
+
"internal": -32603,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# KNOWN_ERROR_KINDS lists the full closed-vocabulary set.
|
|
74
|
+
KNOWN_ERROR_KINDS = (
|
|
75
|
+
"not_permitted",
|
|
76
|
+
"recording_disabled",
|
|
77
|
+
"not_found",
|
|
78
|
+
"validation",
|
|
79
|
+
"forbidden",
|
|
80
|
+
"storage",
|
|
81
|
+
"method_not_found",
|
|
82
|
+
"invalid_params",
|
|
83
|
+
"internal",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# FaultData is the structured payload of a JSON-RPC error's `data`
|
|
87
|
+
# member. Only `kind` is guaranteed; the rest are populated when they
|
|
88
|
+
# apply. Source of truth: `actuator/src/fault.rs::FaultData`.
|
|
89
|
+
FaultData = TypedDict("FaultData", {
|
|
90
|
+
"kind": str,
|
|
91
|
+
"collection": NotRequired[str],
|
|
92
|
+
"detail": NotRequired[str],
|
|
93
|
+
"id": NotRequired[str],
|
|
94
|
+
"op": NotRequired[str],
|
|
95
|
+
})
|
branchkit/collection.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""State uniform helpers — the collection façade over the generated
|
|
2
|
+
`collection.*` wrappers. Python twin of plugin-sdk-go/collection.go and
|
|
3
|
+
plugin-sdk-ts/src/collection.ts."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .contracts_gen import EVENT_COLLECTION_UPDATED
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def scope_collection() -> dict:
|
|
13
|
+
"""Every other record THIS PLUGIN owns in the collection is the
|
|
14
|
+
complement: after the call, the records you own here are exactly the
|
|
15
|
+
ones you passed. Other plugins' records, and any the user added through
|
|
16
|
+
Settings, are untouched and invisible to the diff."""
|
|
17
|
+
return {"kind": "collection"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scope_group(group: str) -> dict:
|
|
21
|
+
"""Narrows further, to this plugin's records carrying the named group
|
|
22
|
+
label — and stamps that label on every entry written. The label must be
|
|
23
|
+
non-empty; an empty one is indistinguishable from "ungrouped" and the
|
|
24
|
+
platform refuses it."""
|
|
25
|
+
return {"kind": "group", "value": group}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_opts(
|
|
29
|
+
*,
|
|
30
|
+
since_ms: int | None = None,
|
|
31
|
+
until_ms: int | None = None,
|
|
32
|
+
limit: int | None = None,
|
|
33
|
+
cursor: str | None = None,
|
|
34
|
+
writer: str | None = None,
|
|
35
|
+
) -> dict:
|
|
36
|
+
"""Build a ListOpts with typed scalar values. `writer` filters to
|
|
37
|
+
records owned by that writer — pair with `plugin.id` to ask for your
|
|
38
|
+
own records, the read half of a scoped write."""
|
|
39
|
+
out: dict[str, Any] = {}
|
|
40
|
+
if since_ms is not None:
|
|
41
|
+
out["since_ms"] = since_ms
|
|
42
|
+
if until_ms is not None:
|
|
43
|
+
out["until_ms"] = until_ms
|
|
44
|
+
if limit is not None:
|
|
45
|
+
out["limit"] = limit
|
|
46
|
+
if cursor is not None:
|
|
47
|
+
out["cursor"] = cursor
|
|
48
|
+
if writer is not None:
|
|
49
|
+
out["writer"] = writer
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CollectionMixin:
|
|
54
|
+
"""The uniform state verbs. Composed into `Plugin`."""
|
|
55
|
+
|
|
56
|
+
async def get(self, name: str, id: str) -> dict | None:
|
|
57
|
+
"""The record with that id, or None. On a keyed (compacted-changelog)
|
|
58
|
+
log this is the RAW entry — use `get_compacted` for the folded
|
|
59
|
+
current state."""
|
|
60
|
+
res = await self.collection_fetch(id, name)
|
|
61
|
+
rec = (res or {}).get("record")
|
|
62
|
+
return rec if isinstance(rec, dict) else None
|
|
63
|
+
|
|
64
|
+
async def get_compacted(self, name: str, key: str) -> dict | None:
|
|
65
|
+
"""A keyed log's folded CURRENT state for one key — the point-read
|
|
66
|
+
half of the compacted-changelog projection (pairs with
|
|
67
|
+
`list_compacted`)."""
|
|
68
|
+
res = await self.collection_fetch_compacted(key, name)
|
|
69
|
+
rec = (res or {}).get("record")
|
|
70
|
+
return rec if isinstance(rec, dict) else None
|
|
71
|
+
|
|
72
|
+
async def list(self, name: str, opts: dict | None = None) -> list[dict]:
|
|
73
|
+
res = await self.collection_list(name, opts)
|
|
74
|
+
return (res or {}).get("records") or []
|
|
75
|
+
|
|
76
|
+
async def list_compacted(self, name: str, opts: dict | None = None) -> list[dict]:
|
|
77
|
+
"""The compacted-changelog projection of a keyed log — one folded
|
|
78
|
+
record per key instead of the raw append history."""
|
|
79
|
+
merged = dict(opts or {})
|
|
80
|
+
merged["compacted"] = True
|
|
81
|
+
res = await self.collection_list(name, merged)
|
|
82
|
+
return (res or {}).get("records") or []
|
|
83
|
+
|
|
84
|
+
async def list_page(self, name: str, opts: dict | None = None) -> tuple[list[dict], int]:
|
|
85
|
+
"""Like `list` but also returns the unfiltered total."""
|
|
86
|
+
res = await self.collection_list(name, opts)
|
|
87
|
+
res = res or {}
|
|
88
|
+
return res.get("records") or [], res.get("total") or 0
|
|
89
|
+
|
|
90
|
+
async def count(self, name: str) -> int:
|
|
91
|
+
res = await self.collection_count(name)
|
|
92
|
+
return (res or {}).get("count") or 0
|
|
93
|
+
|
|
94
|
+
async def put(self, name: str, id: str, payload: Any) -> None:
|
|
95
|
+
"""Single-record upsert. An unregistered name auto-registers as a
|
|
96
|
+
record-keyed dynamic collection — memory-only and EPHEMERAL; declare
|
|
97
|
+
the collection in the manifest for durable storage."""
|
|
98
|
+
await self.collection_put(name, [{"id": id, "payload": payload}])
|
|
99
|
+
|
|
100
|
+
async def put_many(self, name: str, entries: list[dict]) -> int:
|
|
101
|
+
"""Bulk upsert. Validation runs across all entries before any
|
|
102
|
+
commit, so a partial batch with one invalid entry leaves the
|
|
103
|
+
backend untouched."""
|
|
104
|
+
if not entries:
|
|
105
|
+
return 0
|
|
106
|
+
res = await self.collection_put(name, entries)
|
|
107
|
+
return (res or {}).get("count") or 0
|
|
108
|
+
|
|
109
|
+
async def replace(
|
|
110
|
+
self,
|
|
111
|
+
name: str,
|
|
112
|
+
entries: list[dict],
|
|
113
|
+
scope: dict,
|
|
114
|
+
*,
|
|
115
|
+
roles: dict | None = None,
|
|
116
|
+
label: str | None = None,
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""Make the records in scope exactly `entries`: upsert what changed,
|
|
119
|
+
delete what is absent, skip what is byte-identical. Returns
|
|
120
|
+
``{"put": n, "deleted": n, "skipped": n}``.
|
|
121
|
+
|
|
122
|
+
Scope is required and never inferred — pass `scope_collection()` or
|
|
123
|
+
`scope_group(...)`. See notes/DESIGN_COLLECTION_REPLACE.md."""
|
|
124
|
+
# Refused locally rather than sent: guessing between "everything I
|
|
125
|
+
# own here" and "the subset under this key space" is how a refresh
|
|
126
|
+
# silently becomes a wipe.
|
|
127
|
+
if not isinstance(scope, dict) or scope.get("kind") not in ("collection", "group"):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
"replace: scope is required — use scope_collection() or scope_group(name)"
|
|
130
|
+
)
|
|
131
|
+
# No early return on empty `entries`: replacing with the empty set
|
|
132
|
+
# is how a caller CLEARS its scope.
|
|
133
|
+
res = await self.collection_replace(name, scope, entries, label, roles)
|
|
134
|
+
res = res or {}
|
|
135
|
+
return {
|
|
136
|
+
"put": res.get("put") or 0,
|
|
137
|
+
"deleted": res.get("deleted") or 0,
|
|
138
|
+
"skipped": res.get("skipped") or 0,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async def put_many_with_roles(self, name: str, entries: list[dict], roles: dict) -> int:
|
|
142
|
+
"""Bulk upsert with per-payload-field display roles (field → role).
|
|
143
|
+
Roles persist on the collection — pass them on the first put to a
|
|
144
|
+
new name, then omit."""
|
|
145
|
+
return await self.put_many_with_display(name, entries, roles, "")
|
|
146
|
+
|
|
147
|
+
async def put_many_with_display(
|
|
148
|
+
self, name: str, entries: list[dict], roles: dict | None, label: str
|
|
149
|
+
) -> int:
|
|
150
|
+
"""Bulk upsert that also sets the collection's human-readable label.
|
|
151
|
+
Pass "" to leave the label unchanged; like roles, it persists."""
|
|
152
|
+
if not entries:
|
|
153
|
+
return 0
|
|
154
|
+
res = await self.collection_put(name, entries, None, label or None, roles)
|
|
155
|
+
return (res or {}).get("count") or 0
|
|
156
|
+
|
|
157
|
+
async def patch(self, name: str, id: str, fields: Any) -> None:
|
|
158
|
+
"""Errors NOT_FOUND if no record with that id exists, or
|
|
159
|
+
OPERATION_NOT_PERMITTED on collections the state forbids patching."""
|
|
160
|
+
await self.collection_patch(fields, id, name)
|
|
161
|
+
|
|
162
|
+
async def delete(self, name: str, id: str) -> bool:
|
|
163
|
+
"""Single-record delete. Returns whether the record existed."""
|
|
164
|
+
res = await self.collection_delete_records(name, [id])
|
|
165
|
+
return ((res or {}).get("deleted") or 0) > 0
|
|
166
|
+
|
|
167
|
+
async def delete_many(self, name: str, ids: list[str]) -> tuple[int, int]:
|
|
168
|
+
"""Bulk delete. Returns (deleted, already_absent) so callers can
|
|
169
|
+
detect drift between their view and the platform's."""
|
|
170
|
+
if not ids:
|
|
171
|
+
return 0, 0
|
|
172
|
+
res = await self.collection_delete_records(name, ids)
|
|
173
|
+
res = res or {}
|
|
174
|
+
return res.get("deleted") or 0, res.get("already_absent") or 0
|
|
175
|
+
|
|
176
|
+
def subscribe(self, name: str, fn: Callable) -> None:
|
|
177
|
+
"""Run `fn(evt)` whenever `_platform.collection.updated` fires for
|
|
178
|
+
this collection. The ordered pump awaits an async `fn`, so two rapid
|
|
179
|
+
updates cannot race. Subscriptions live for the process lifetime."""
|
|
180
|
+
|
|
181
|
+
async def _on_updated(params):
|
|
182
|
+
evt = params if isinstance(params, dict) else {}
|
|
183
|
+
if evt.get("collection") == name:
|
|
184
|
+
# Await, don't discard: this is what keeps wire order. Dual
|
|
185
|
+
# dispatch, same as any handler — a plain-`def` fn offloads.
|
|
186
|
+
await self._invoke(fn, evt)
|
|
187
|
+
|
|
188
|
+
self.on(EVENT_COLLECTION_UPDATED, _on_updated)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Helpers for log-kind collections — append-only record stores declared
|
|
2
|
+
in the plugin manifest as `kind: "log"`. Sugar over the unified verbs
|
|
3
|
+
(the wire surface is collection.list / collection.fetch /
|
|
4
|
+
collection.delete_records; log-shaped reads are the same list with
|
|
5
|
+
time-window opts). Python twin of collection_log.{go,ts}."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def log_list_opts(
|
|
13
|
+
*,
|
|
14
|
+
since_ms: int | None = None,
|
|
15
|
+
until_ms: int | None = None,
|
|
16
|
+
limit: int | None = None,
|
|
17
|
+
cursor: str | None = None,
|
|
18
|
+
) -> dict:
|
|
19
|
+
"""Build a LogListOpts with typed scalar values — field-identical to
|
|
20
|
+
the unified list opts by design."""
|
|
21
|
+
out: dict[str, Any] = {}
|
|
22
|
+
if since_ms is not None:
|
|
23
|
+
out["since_ms"] = since_ms
|
|
24
|
+
if until_ms is not None:
|
|
25
|
+
out["until_ms"] = until_ms
|
|
26
|
+
if limit is not None:
|
|
27
|
+
out["limit"] = limit
|
|
28
|
+
if cursor is not None:
|
|
29
|
+
out["cursor"] = cursor
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _record_to_log_entry(r: dict) -> dict:
|
|
34
|
+
"""Project the unified record envelope onto the log view. Lossless: log
|
|
35
|
+
records carry their append time in timestamp_ms and owner in writer."""
|
|
36
|
+
return {
|
|
37
|
+
"id": r.get("id"),
|
|
38
|
+
"timestamp_ms": r.get("timestamp_ms"),
|
|
39
|
+
"payload": r.get("payload"),
|
|
40
|
+
"writer": r.get("writer"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CollectionLogMixin:
|
|
45
|
+
async def append(self, name: str, payload: Any) -> str:
|
|
46
|
+
"""Append an entry to a log-kind collection; the actuator assigns a
|
|
47
|
+
ULID and timestamp. Returns the assigned entry id. Raises
|
|
48
|
+
RecordingDisabledError if the collection's recording flag is off."""
|
|
49
|
+
entry = await self.collection_append(name, payload)
|
|
50
|
+
if not entry:
|
|
51
|
+
raise RuntimeError("collection.append: actuator returned no entry")
|
|
52
|
+
return entry["id"]
|
|
53
|
+
|
|
54
|
+
async def append_entry(self, name: str, payload: Any) -> dict:
|
|
55
|
+
"""Like `append` but returns the full LogEntry."""
|
|
56
|
+
entry = await self.collection_append(name, payload)
|
|
57
|
+
if not entry:
|
|
58
|
+
raise RuntimeError("collection.append: actuator returned no entry")
|
|
59
|
+
return entry
|
|
60
|
+
|
|
61
|
+
async def append_keyed(self, name: str, key: str, payload: Any) -> None:
|
|
62
|
+
"""Annotate a keyed log (`log` preset, `id_strategy: by_field`):
|
|
63
|
+
appends `payload` with `key` stamped into the key field, as a fresh
|
|
64
|
+
append; same-key appends fold. Read the merged view with
|
|
65
|
+
`list_compacted`. See notes/DESIGN_LOG_ANNOTATION_PROJECTION.md."""
|
|
66
|
+
await self.collection_append_keyed(key, name, payload)
|
|
67
|
+
|
|
68
|
+
async def list_log(self, name: str, opts: dict | None = None) -> list[dict]:
|
|
69
|
+
"""List log entries newest-first."""
|
|
70
|
+
entries, _ = await self.list_log_page(name, opts)
|
|
71
|
+
return entries
|
|
72
|
+
|
|
73
|
+
async def list_log_page(self, name: str, opts: dict | None = None) -> tuple[list[dict], int]:
|
|
74
|
+
"""Like `list_log` but also returns the unfiltered total."""
|
|
75
|
+
records, total = await self.list_page(name, opts)
|
|
76
|
+
return [_record_to_log_entry(r) for r in records], total
|
|
77
|
+
|
|
78
|
+
async def get_log_entry(self, name: str, id: str) -> dict | None:
|
|
79
|
+
"""One entry by id, or None. The RAW entry — on a keyed log use
|
|
80
|
+
`get_compacted` for a key's current state."""
|
|
81
|
+
rec = await self.get(name, id)
|
|
82
|
+
return _record_to_log_entry(rec) if rec else None
|
|
83
|
+
|
|
84
|
+
async def delete_log_entry(self, name: str, id: str) -> bool:
|
|
85
|
+
"""Delete one entry by id. Returns whether it existed."""
|
|
86
|
+
return await self.delete(name, id)
|
|
87
|
+
|
|
88
|
+
async def set_collection_recording(self, name: str, enabled: bool) -> None:
|
|
89
|
+
"""Toggle the recording flag on a log-kind collection. When False,
|
|
90
|
+
subsequent `append` calls raise RecordingDisabledError."""
|
|
91
|
+
await self.privacy_set_recording(enabled, name)
|
|
92
|
+
|
|
93
|
+
async def get_collection_recording(self, name: str) -> bool:
|
|
94
|
+
"""The effective recording flag — the user override if set,
|
|
95
|
+
otherwise the manifest's `default_recording_enabled`."""
|
|
96
|
+
res = await self.privacy_get_recording(name)
|
|
97
|
+
return bool((res or {}).get("enabled", False))
|
branchkit/commands.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Command loading and authoring. Python twin of commands.{go,ts}.
|
|
2
|
+
|
|
3
|
+
File layout:
|
|
4
|
+
|
|
5
|
+
$BRANCHKIT_PLUGIN_DIR/
|
|
6
|
+
commands.json ← base commands (no context)
|
|
7
|
+
commands/ ← optional directory of context files
|
|
8
|
+
warp.json ← context-scoped commands
|
|
9
|
+
|
|
10
|
+
Commands in a context file inherit the context's requires_tags (merged
|
|
11
|
+
with any on the command itself)."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# The actuator's command parser rejects an explicit JSON `null` for these
|
|
20
|
+
# array fields (it accepts an array or an absent field). The builder
|
|
21
|
+
# defaults them to [], but a spec from load_commands carries whatever the
|
|
22
|
+
# file had — coerce None to [] before the wire. Mirrors Go's
|
|
23
|
+
# normalizeCommandSpec.
|
|
24
|
+
_COMMAND_SPEC_ARRAY_FIELDS = (
|
|
25
|
+
"requires_tags",
|
|
26
|
+
"sets_tags",
|
|
27
|
+
"clears_tags",
|
|
28
|
+
"sets_on_partial",
|
|
29
|
+
"variants",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def word(w: str) -> str:
|
|
34
|
+
"""A literal spoken word."""
|
|
35
|
+
return w
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def one_of(*alts: str) -> list[str]:
|
|
39
|
+
"""An alternatives slot: any of the given words matches, sharing one
|
|
40
|
+
action."""
|
|
41
|
+
return list(alts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def capture(name: str, collection: str) -> str:
|
|
45
|
+
"""A list-capture token `<name:collection>` whose matched value binds
|
|
46
|
+
to `name`. An empty name uses the collection as the binding name."""
|
|
47
|
+
return f"<{name}:{collection}>" if name else f"<{collection}>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def text(name: str = "") -> str:
|
|
51
|
+
"""A free-text capture token `<name:text>` (or `<text>`)."""
|
|
52
|
+
return f"<{name}:text>" if name else "<text>"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CommandBuilder:
|
|
56
|
+
"""Accumulates a CommandSpec via chained setters; finish with build()."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, slots: list):
|
|
59
|
+
self._spec: dict[str, Any] = {
|
|
60
|
+
"pattern": list(slots),
|
|
61
|
+
"cancels_bridge": False,
|
|
62
|
+
"requires_tags": [],
|
|
63
|
+
"sets_tags": [],
|
|
64
|
+
"clears_tags": [],
|
|
65
|
+
"sets_on_partial": [],
|
|
66
|
+
"display_sources": {},
|
|
67
|
+
"variants": [],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def action(self, type: str, params: dict | None = None) -> "CommandBuilder":
|
|
71
|
+
"""Set the action fired on match. `type` is the action's type (a
|
|
72
|
+
built-in like "key" or a dotted plugin action); `params` are merged
|
|
73
|
+
into the action object."""
|
|
74
|
+
self._spec["action"] = {"type": type, **(params or {})}
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def requires_tags(self, *tags: str) -> "CommandBuilder":
|
|
78
|
+
self._spec["requires_tags"].extend(tags)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def sets_tags(self, *tags: str) -> "CommandBuilder":
|
|
82
|
+
self._spec["sets_tags"].extend(tags)
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def clears_tags(self, *tags: str) -> "CommandBuilder":
|
|
86
|
+
self._spec["clears_tags"].extend(tags)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def display_source(self, capture: str, collection: str) -> "CommandBuilder":
|
|
90
|
+
"""Discovery-HUD display override for one capture: enumerate
|
|
91
|
+
`collection` in the HUD instead of the capture's matching
|
|
92
|
+
collection. Matching is untouched."""
|
|
93
|
+
self._spec["display_sources"][capture] = collection
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def sets_on_partial(self, *tags: str) -> "CommandBuilder":
|
|
97
|
+
self._spec["sets_on_partial"].extend(tags)
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def cancels_bridge(self) -> "CommandBuilder":
|
|
101
|
+
self._spec["cancels_bridge"] = True
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def discovery(self, mode: str) -> "CommandBuilder":
|
|
105
|
+
"""Declare the command's prefix-discovery affordance ("prefix" or
|
|
106
|
+
"exclusive"). Valid only on a literal-prefix + single-tail-capture
|
|
107
|
+
pattern. See notes/DESIGN_DISCOVERABLE_PREFIX.md."""
|
|
108
|
+
self._spec["discovery"] = mode
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def category(self, c: str) -> "CommandBuilder":
|
|
112
|
+
self._spec["category"] = c
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def description(self, d: str) -> "CommandBuilder":
|
|
116
|
+
self._spec["description"] = d
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def build(self) -> dict:
|
|
120
|
+
return self._spec
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def command(*slots) -> CommandBuilder:
|
|
124
|
+
"""Start a command builder with the given pattern slots."""
|
|
125
|
+
return CommandBuilder(list(slots))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_command_file(path: str) -> list[dict]:
|
|
129
|
+
"""An ABSENT file is not an error — a plugin may ship only context
|
|
130
|
+
files, or none at all. Any other read failure propagates: swallowing
|
|
131
|
+
them is how an unreadable commands.json silently pushed zero
|
|
132
|
+
commands."""
|
|
133
|
+
try:
|
|
134
|
+
with open(path, encoding="utf-8") as f:
|
|
135
|
+
data = f.read()
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
return []
|
|
138
|
+
except OSError as e:
|
|
139
|
+
raise RuntimeError(f"{path}: {e}") from e
|
|
140
|
+
return json.loads(data)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_context_file(path: str) -> list[dict]:
|
|
144
|
+
with open(path, encoding="utf-8") as f:
|
|
145
|
+
cf = json.load(f)
|
|
146
|
+
context_tags = ((cf.get("context") or {}).get("requires_tags")) or []
|
|
147
|
+
if not context_tags:
|
|
148
|
+
raise RuntimeError(f"{path}: missing or empty context.requires_tags")
|
|
149
|
+
commands = cf.get("commands") or []
|
|
150
|
+
return [_merge_requires_tags(cmd, context_tags) for cmd in commands]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _merge_requires_tags(cmd: dict, context_tags: list[str]) -> dict:
|
|
154
|
+
existing = cmd.get("requires_tags") or []
|
|
155
|
+
return {**cmd, "requires_tags": [*context_tags, *existing]}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_commands() -> list[dict]:
|
|
159
|
+
"""Load commands.json and any context files from commands/ WITHOUT
|
|
160
|
+
pushing — lets a plugin union file-authored static commands with built
|
|
161
|
+
dynamic ones and push them in a single push_command_specs call."""
|
|
162
|
+
plugin_dir = os.environ.get("BRANCHKIT_PLUGIN_DIR")
|
|
163
|
+
if not plugin_dir:
|
|
164
|
+
return []
|
|
165
|
+
raw: list[dict] = []
|
|
166
|
+
raw.extend(_load_command_file(os.path.join(plugin_dir, "commands.json")))
|
|
167
|
+
context_dir = os.path.join(plugin_dir, "commands")
|
|
168
|
+
try:
|
|
169
|
+
entries = sorted(e for e in os.listdir(context_dir) if e.endswith(".json"))
|
|
170
|
+
except OSError:
|
|
171
|
+
entries = []
|
|
172
|
+
for entry in entries:
|
|
173
|
+
raw.extend(_load_context_file(os.path.join(context_dir, entry)))
|
|
174
|
+
return raw
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _normalize_command_spec(spec: dict) -> dict:
|
|
178
|
+
out = dict(spec)
|
|
179
|
+
for field in _COMMAND_SPEC_ARRAY_FIELDS:
|
|
180
|
+
if out.get(field) is None:
|
|
181
|
+
out[field] = []
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def push_commands(plugin) -> int:
|
|
186
|
+
"""Load commands.json + context files and push them all via
|
|
187
|
+
commands.push. Returns the number of command variants registered."""
|
|
188
|
+
specs = load_commands()
|
|
189
|
+
if not specs:
|
|
190
|
+
return 0
|
|
191
|
+
return await push_command_specs(plugin, specs)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def push_command_specs(plugin, specs: list[dict]) -> int:
|
|
195
|
+
"""Register a built/loaded set of commands via commands.push
|
|
196
|
+
(replace-per-plugin semantics). Returns the number of command variants
|
|
197
|
+
registered."""
|
|
198
|
+
resp = await plugin.call(
|
|
199
|
+
"commands.push", {"commands": [_normalize_command_spec(s) for s in specs]}
|
|
200
|
+
)
|
|
201
|
+
return (resp or {}).get("count") or 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def push_command_group(plugin, group: str, specs: list[dict]) -> int:
|
|
205
|
+
"""Register `specs` as a NAMED GROUP within this plugin's command set,
|
|
206
|
+
replacing only that group. Use whenever a plugin has more than one
|
|
207
|
+
command source — plain push_command_specs replaces the ENTIRE set, so
|
|
208
|
+
two sources pushing independently race. Returns the number of command
|
|
209
|
+
variants now active for the whole plugin."""
|
|
210
|
+
# Refused locally rather than sent: an empty group name is
|
|
211
|
+
# indistinguishable on the wire from an ungrouped push, which replaces
|
|
212
|
+
# EVERY group. A caller must not reach whole-set semantics by accident.
|
|
213
|
+
if not group:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
"push_command_group: group name is required (use push_command_specs to replace the whole set)"
|
|
216
|
+
)
|
|
217
|
+
resp = await plugin.call(
|
|
218
|
+
"commands.push",
|
|
219
|
+
{"commands": [_normalize_command_spec(s) for s in specs], "group": group},
|
|
220
|
+
)
|
|
221
|
+
return (resp or {}).get("count") or 0
|