induscode 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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""NDJSON framer — the concrete, separator-safe line transport
|
|
2
|
+
(port of TS ``src/channels/framer.ts``).
|
|
3
|
+
|
|
4
|
+
Implements the :class:`~induscode.channels.contract.NdjsonFramer` surface
|
|
5
|
+
declared in the contract: a pure encoder (:func:`encode_line`) and a
|
|
6
|
+
pull-model async-generator decoder (:func:`decode_lines`). Both halves are
|
|
7
|
+
I/O-free seams over an injected stream, so the server, the driver, and the
|
|
8
|
+
oneshot channel share one correct framing implementation.
|
|
9
|
+
|
|
10
|
+
The correctness pin: U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR)
|
|
11
|
+
are legal inside a JSON string but a downstream splitter that treats them as
|
|
12
|
+
line boundaries corrupts the frame. :func:`encode_line` escapes both to their
|
|
13
|
+
backslash-u forms, and :func:`decode_lines` splits strictly on the line feed
|
|
14
|
+
(U+000A) only — so any value the encoder emits round-trips through the
|
|
15
|
+
decoder unchanged regardless of payload content.
|
|
16
|
+
|
|
17
|
+
Port notes
|
|
18
|
+
----------
|
|
19
|
+
- Python's ``json.dumps`` does **not** escape U+2028/U+2029 (plan rule 7;
|
|
20
|
+
with ``ensure_ascii=False`` they pass through raw, exactly like TS
|
|
21
|
+
``JSON.stringify``) — the explicit escape is kept verbatim. ``separators``
|
|
22
|
+
are the compact ``(",", ":")`` pair so the wire bytes match
|
|
23
|
+
``JSON.stringify``.
|
|
24
|
+
- Byte chunks are decoded with ``codecs.getincrementaldecoder("utf-8")``
|
|
25
|
+
(plan rule 8) so a multi-byte rune split across two chunks is reassembled
|
|
26
|
+
rather than corrupted — the analogue of the TS streaming ``TextDecoder``.
|
|
27
|
+
Each :func:`decode_lines` call creates a **fresh** decoder (the TS
|
|
28
|
+
module-level shared decoder was safe only because one reader owns a
|
|
29
|
+
stream; the per-call decoder removes even that coupling).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import codecs
|
|
35
|
+
import json
|
|
36
|
+
from collections.abc import AsyncGenerator
|
|
37
|
+
from typing import Any, Final
|
|
38
|
+
|
|
39
|
+
from induscode.channels.contract import NdjsonFramer, ReadableChunks
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"decode_lines",
|
|
43
|
+
"encode_line",
|
|
44
|
+
"ndjson_framer",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Line feed (U+000A): the one and only frame boundary.
|
|
49
|
+
_LINE_FEED: Final[str] = "\n"
|
|
50
|
+
|
|
51
|
+
#: LINE SEPARATOR (U+2028) — legal in a JSON string, breaks naive splitters.
|
|
52
|
+
_LINE_SEPARATOR: Final[str] = "\u2028"
|
|
53
|
+
|
|
54
|
+
#: PARAGRAPH SEPARATOR (U+2029) — legal in a JSON string, breaks splitters.
|
|
55
|
+
_PARAGRAPH_SEPARATOR: Final[str] = "\u2029"
|
|
56
|
+
|
|
57
|
+
#: The escaped form of U+2028 that survives line splitting.
|
|
58
|
+
_LINE_SEPARATOR_ESCAPE: Final[str] = "\\u2028"
|
|
59
|
+
|
|
60
|
+
#: The escaped form of U+2029 that survives line splitting.
|
|
61
|
+
_PARAGRAPH_SEPARATOR_ESCAPE: Final[str] = "\\u2029"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _escape_separators(json_text: str) -> str:
|
|
65
|
+
"""Replace each U+2028 / U+2029 with its backslash-u escape.
|
|
66
|
+
|
|
67
|
+
Operates on the already-serialized JSON text, so the two separators only
|
|
68
|
+
ever appear inside string literals; rewriting them to their escape
|
|
69
|
+
sequences yields an equivalent JSON document with no raw line/paragraph
|
|
70
|
+
separators left.
|
|
71
|
+
"""
|
|
72
|
+
return json_text.replace(_LINE_SEPARATOR, _LINE_SEPARATOR_ESCAPE).replace(
|
|
73
|
+
_PARAGRAPH_SEPARATOR, _PARAGRAPH_SEPARATOR_ESCAPE
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encode_line(value: Any) -> str:
|
|
78
|
+
"""Encode one value as a single, separator-safe NDJSON line.
|
|
79
|
+
|
|
80
|
+
Serializes ``value`` with ``json.dumps`` (compact separators, non-ASCII
|
|
81
|
+
left raw — the ``JSON.stringify`` wire shape), escapes the two Unicode
|
|
82
|
+
line separators, and appends exactly one line feed. The result contains
|
|
83
|
+
no raw U+2028 / U+2029 and exactly one terminating ``\\n``, so a
|
|
84
|
+
strictly-newline splitter recovers it intact.
|
|
85
|
+
|
|
86
|
+
:param value: any JSON-serializable value (a request, a reply, a signal)
|
|
87
|
+
:returns: the encoded line, terminated by a single ``\\n``
|
|
88
|
+
"""
|
|
89
|
+
return _escape_separators(json.dumps(value, ensure_ascii=False, separators=(",", ":"))) + _LINE_FEED
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def decode_lines(stream: ReadableChunks) -> AsyncGenerator[Any, None]:
|
|
93
|
+
"""Decode a chunk stream into a stream of parsed JSON values.
|
|
94
|
+
|
|
95
|
+
Pull model: buffers incoming text, splits on ``\\n`` only, parses each
|
|
96
|
+
complete line, and yields the parsed value. A partial trailing line is
|
|
97
|
+
held until its newline arrives; an empty (or whitespace-only) line is
|
|
98
|
+
skipped. A trailing unterminated line at end-of-stream is parsed if it
|
|
99
|
+
carries content, so a non-newline-terminated final frame is not silently
|
|
100
|
+
dropped. Byte chunks flow through an incremental UTF-8 decoder so a rune
|
|
101
|
+
split across chunk boundaries is reassembled.
|
|
102
|
+
|
|
103
|
+
:param stream: the raw chunk stream (stdin, a child pipe, an in-memory pipe)
|
|
104
|
+
"""
|
|
105
|
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
106
|
+
buffer = ""
|
|
107
|
+
|
|
108
|
+
async for chunk in stream:
|
|
109
|
+
buffer += chunk if isinstance(chunk, str) else decoder.decode(chunk)
|
|
110
|
+
|
|
111
|
+
newline_at = buffer.find(_LINE_FEED)
|
|
112
|
+
while newline_at != -1:
|
|
113
|
+
line = buffer[:newline_at]
|
|
114
|
+
buffer = buffer[newline_at + 1 :]
|
|
115
|
+
if len(line.strip()) > 0:
|
|
116
|
+
yield json.loads(line)
|
|
117
|
+
newline_at = buffer.find(_LINE_FEED)
|
|
118
|
+
|
|
119
|
+
# Flush any decoder-internal state, then emit a final unterminated frame.
|
|
120
|
+
buffer += decoder.decode(b"", final=True)
|
|
121
|
+
if len(buffer.strip()) > 0:
|
|
122
|
+
yield json.loads(buffer)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: The bundled framer pair: the canonical :class:`NdjsonFramer` instance.
|
|
126
|
+
#: Frozen so consumers share one implementation by reference rather than
|
|
127
|
+
#: re-deriving framing at each call site; an embedder may still inject an
|
|
128
|
+
#: alternate framer via the driver / server options.
|
|
129
|
+
ndjson_framer: Final[NdjsonFramer] = NdjsonFramer(
|
|
130
|
+
encode_line=encode_line,
|
|
131
|
+
decode_lines=decode_lines,
|
|
132
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Link channel — public barrel (port of TS ``src/channels/link/index.ts``).
|
|
2
|
+
|
|
3
|
+
The link channel is the long-lived, bidirectional half of the channels
|
|
4
|
+
subsystem: a JSON-RPC 2.0 server that dispatches framed requests through an
|
|
5
|
+
op registry as concurrent tasks (``server``), a generated client built from a
|
|
6
|
+
``__getattr__`` trap rather than hand-written methods (``driver``), and the
|
|
7
|
+
ask / tell dialog bridge that both ends share (``dialog``). Consumers import
|
|
8
|
+
the link surface from ``induscode.channels.link`` rather than the individual
|
|
9
|
+
modules.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from induscode.channels.link.dialog import (
|
|
15
|
+
DIALOG_TABLE,
|
|
16
|
+
DialogBridgeDeps,
|
|
17
|
+
DialogSpec,
|
|
18
|
+
LinkedDialogBridge,
|
|
19
|
+
create_dialog_bridge,
|
|
20
|
+
make_dialog_methods,
|
|
21
|
+
)
|
|
22
|
+
from induscode.channels.link.driver import (
|
|
23
|
+
LinkClient,
|
|
24
|
+
LinkDriverHandle,
|
|
25
|
+
LinkDriverIo,
|
|
26
|
+
LinkRequestError,
|
|
27
|
+
create_link_driver,
|
|
28
|
+
)
|
|
29
|
+
from induscode.channels.link.server import (
|
|
30
|
+
LinkServer,
|
|
31
|
+
LinkServerIo,
|
|
32
|
+
create_link_server,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DIALOG_TABLE",
|
|
37
|
+
"DialogBridgeDeps",
|
|
38
|
+
"DialogSpec",
|
|
39
|
+
"LinkClient",
|
|
40
|
+
"LinkDriverHandle",
|
|
41
|
+
"LinkDriverIo",
|
|
42
|
+
"LinkRequestError",
|
|
43
|
+
"LinkServer",
|
|
44
|
+
"LinkServerIo",
|
|
45
|
+
"LinkedDialogBridge",
|
|
46
|
+
"create_dialog_bridge",
|
|
47
|
+
"create_link_driver",
|
|
48
|
+
"create_link_server",
|
|
49
|
+
"make_dialog_methods",
|
|
50
|
+
]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Dialog bridge — the ask / tell seam over the link transport
|
|
2
|
+
(port of TS ``src/channels/link/dialog.ts``).
|
|
3
|
+
|
|
4
|
+
The agent (and the extensions it hosts) needs to interact with whoever drives
|
|
5
|
+
the channel: ask a multiple-choice question, confirm a destructive action,
|
|
6
|
+
prompt for free text, flash a notice, set a status line. Rather than a
|
|
7
|
+
per-interaction handler ladder, the whole surface reduces to two primitives:
|
|
8
|
+
|
|
9
|
+
- :meth:`LinkedDialogBridge.ask` — one generic round-trip: emit an ``ask``
|
|
10
|
+
frame, suspend until a correlated answer arrives, and resolve with its
|
|
11
|
+
value (or a caller-supplied fallback when the deadline lapses or the driver
|
|
12
|
+
dismisses the request).
|
|
13
|
+
- :meth:`LinkedDialogBridge.tell` — one generic fire-and-forget: emit a
|
|
14
|
+
``tell`` frame and return immediately.
|
|
15
|
+
|
|
16
|
+
Concrete dialog methods (select / confirm / input / notify / status / title)
|
|
17
|
+
are expressed as a *data table* of named entries keyed by interaction kind,
|
|
18
|
+
not as bespoke method bodies. :func:`make_dialog_methods` closes that table
|
|
19
|
+
over a single bridge so a caller gets named conveniences while the bridge
|
|
20
|
+
keeps the lone round-trip / fire-and-forget implementation.
|
|
21
|
+
|
|
22
|
+
Port notes
|
|
23
|
+
----------
|
|
24
|
+
- The TS promise + ``setTimeout`` pending entry becomes an
|
|
25
|
+
:class:`asyncio.Future` + ``loop.call_later`` timer; timers never keep the
|
|
26
|
+
loop alive on their own (the TS ``unref`` concern does not arise).
|
|
27
|
+
- The TS closure-object bridge becomes the :class:`LinkedDialogBridge` class
|
|
28
|
+
(same four-method surface: ask / tell / deliver / drain).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from types import MappingProxyType
|
|
37
|
+
from typing import Any, Callable, Final, Literal, Mapping
|
|
38
|
+
|
|
39
|
+
from induscode.channels.contract import (
|
|
40
|
+
AskAnswer,
|
|
41
|
+
ChannelTimings,
|
|
42
|
+
DialogBridge,
|
|
43
|
+
NdjsonFramer,
|
|
44
|
+
WritableLine,
|
|
45
|
+
mint_wire_id,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DIALOG_TABLE",
|
|
50
|
+
"DialogBridgeDeps",
|
|
51
|
+
"DialogSpec",
|
|
52
|
+
"LinkedDialogBridge",
|
|
53
|
+
"create_dialog_bridge",
|
|
54
|
+
"make_dialog_methods",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: The correlation-id prefix every ask frame carries (TS ``"ask-"``).
|
|
59
|
+
_ASK_ID_PREFIX: Final[str] = "ask-"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class _Pending:
|
|
64
|
+
"""One suspended ask, awaiting its correlated answer."""
|
|
65
|
+
|
|
66
|
+
#: Settles the awaiting future with the answer value (or the fallback).
|
|
67
|
+
future: asyncio.Future[Any]
|
|
68
|
+
#: Timer that fires the fallback if no answer arrives in time.
|
|
69
|
+
timer: asyncio.TimerHandle
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class DialogBridgeDeps:
|
|
74
|
+
"""Everything :func:`create_dialog_bridge` needs to emit frames and time
|
|
75
|
+
out."""
|
|
76
|
+
|
|
77
|
+
#: Sink the ask/tell frames are written to (already framed).
|
|
78
|
+
out: WritableLine
|
|
79
|
+
#: Framer used to encode each frame to a line.
|
|
80
|
+
framer: NdjsonFramer
|
|
81
|
+
#: Timings; only ``dialogMs`` is consulted here.
|
|
82
|
+
timings: ChannelTimings
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LinkedDialogBridge:
|
|
86
|
+
"""A :class:`DialogBridge` whose pending answers are fed by an external
|
|
87
|
+
reader.
|
|
88
|
+
|
|
89
|
+
The bridge itself does not read the stream — the server owns the single
|
|
90
|
+
decode loop and routes any inbound answer frame to :meth:`deliver`. This
|
|
91
|
+
keeps one reader over the transport (the server) rather than competing
|
|
92
|
+
consumers.
|
|
93
|
+
|
|
94
|
+
``ask`` writes an ask frame, registers the pending resolver under a fresh
|
|
95
|
+
id, and arms a fallback timer; :meth:`deliver` settles the matching
|
|
96
|
+
pending resolver when the answer returns. ``tell`` writes a tell frame
|
|
97
|
+
and returns at once. No stdio, no global state — every dependency is
|
|
98
|
+
injected.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
__slots__ = ("_deps", "_pending", "_seq")
|
|
102
|
+
|
|
103
|
+
def __init__(self, deps: DialogBridgeDeps) -> None:
|
|
104
|
+
self._deps = deps
|
|
105
|
+
self._pending: dict[str, _Pending] = {}
|
|
106
|
+
self._seq = 0
|
|
107
|
+
|
|
108
|
+
def _emit(self, frame: Mapping[str, Any]) -> None:
|
|
109
|
+
self._deps.out.write(self._deps.framer.encode_line(frame))
|
|
110
|
+
|
|
111
|
+
def _next_ask_id(self) -> str:
|
|
112
|
+
"""Mint a fresh, process-unique correlation id for an ask."""
|
|
113
|
+
self._seq += 1
|
|
114
|
+
return mint_wire_id(_ASK_ID_PREFIX, int(time.time() * 1000), self._seq)
|
|
115
|
+
|
|
116
|
+
async def ask(self, kind: str, payload: Any, fallback: Any) -> Any:
|
|
117
|
+
"""Emit an ask frame and resolve with the matching answer value, or
|
|
118
|
+
``fallback`` when no answer arrives before ``dialogMs`` lapses."""
|
|
119
|
+
ask_id = self._next_ask_id()
|
|
120
|
+
frame: dict[str, Any] = {"type": "ask", "id": ask_id, "kind": kind}
|
|
121
|
+
if payload is not None: # JSON.stringify dropped the undefined payload
|
|
122
|
+
frame["payload"] = payload
|
|
123
|
+
|
|
124
|
+
loop = asyncio.get_running_loop()
|
|
125
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
126
|
+
|
|
127
|
+
def on_timeout() -> None:
|
|
128
|
+
self._pending.pop(ask_id, None)
|
|
129
|
+
if not future.done():
|
|
130
|
+
future.set_result(fallback)
|
|
131
|
+
|
|
132
|
+
timer = loop.call_later(self._deps.timings.dialogMs / 1000, on_timeout)
|
|
133
|
+
self._pending[ask_id] = _Pending(future=future, timer=timer)
|
|
134
|
+
self._emit(frame)
|
|
135
|
+
return await future
|
|
136
|
+
|
|
137
|
+
def tell(self, kind: str, payload: Any = None) -> None:
|
|
138
|
+
"""Emit a tell frame and return immediately."""
|
|
139
|
+
frame: dict[str, Any] = {"type": "tell", "kind": kind}
|
|
140
|
+
if payload is not None:
|
|
141
|
+
frame["payload"] = payload
|
|
142
|
+
self._emit(frame)
|
|
143
|
+
|
|
144
|
+
def deliver(self, answer: AskAnswer) -> bool:
|
|
145
|
+
"""Resolve the pending ask that minted ``answer.id``.
|
|
146
|
+
|
|
147
|
+
A no-op when no ask is pending under that id (a late or duplicate
|
|
148
|
+
answer), so a misbehaving driver cannot throw into the read loop.
|
|
149
|
+
|
|
150
|
+
:param answer: the inbound answer frame
|
|
151
|
+
:returns: whether a pending ask was settled by this answer
|
|
152
|
+
"""
|
|
153
|
+
entry = self._pending.pop(answer.id, None)
|
|
154
|
+
if entry is None:
|
|
155
|
+
return False
|
|
156
|
+
entry.timer.cancel()
|
|
157
|
+
if not entry.future.done():
|
|
158
|
+
entry.future.set_result(answer.value)
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
def drain(self) -> None:
|
|
162
|
+
"""Settle every still-pending ask with ``None`` (used on shutdown),
|
|
163
|
+
so no awaiting handler is left suspended when the link closes."""
|
|
164
|
+
for entry in self._pending.values():
|
|
165
|
+
entry.timer.cancel()
|
|
166
|
+
if not entry.future.done():
|
|
167
|
+
entry.future.set_result(None)
|
|
168
|
+
self._pending.clear()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_dialog_bridge(deps: DialogBridgeDeps) -> LinkedDialogBridge:
|
|
172
|
+
"""Construct a :class:`LinkedDialogBridge` over an injected sink + framer."""
|
|
173
|
+
return LinkedDialogBridge(deps)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Data-driven concrete dialog methods
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True, slots=True)
|
|
182
|
+
class DialogSpec:
|
|
183
|
+
"""One concrete dialog method expressed as data, not code.
|
|
184
|
+
|
|
185
|
+
``kind`` is the wire interaction kind; ``mode`` selects which primitive
|
|
186
|
+
carries it (a round-trip ``ask`` or a fire-and-forget ``tell``);
|
|
187
|
+
``fallback`` is the value a round-trip resolves with on timeout /
|
|
188
|
+
dismissal (unused for ``tell``). The whole dialog surface is this table —
|
|
189
|
+
adding an interaction is adding a row.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
#: The wire interaction kind (the ``Ask.kind`` / ``Tell.kind``).
|
|
193
|
+
kind: str
|
|
194
|
+
#: Whether the method round-trips for an answer or is fire-and-forget.
|
|
195
|
+
mode: Literal["ask", "tell"]
|
|
196
|
+
#: The value an ``ask`` resolves with when no answer arrives in time.
|
|
197
|
+
fallback: Any = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
#: The canonical dialog table: every concrete interaction the channel
|
|
201
|
+
#: exposes, each one row of data over the two primitives.
|
|
202
|
+
#:
|
|
203
|
+
#: Replaces a copied switch of bespoke UI-context methods: the bridge has
|
|
204
|
+
#: exactly one round-trip and one fire-and-forget body, and this table names
|
|
205
|
+
#: the kinds.
|
|
206
|
+
DIALOG_TABLE: Final[Mapping[str, DialogSpec]] = MappingProxyType(
|
|
207
|
+
{
|
|
208
|
+
"select": DialogSpec(kind="select", mode="ask", fallback=None),
|
|
209
|
+
"confirm": DialogSpec(kind="confirm", mode="ask", fallback=False),
|
|
210
|
+
"input": DialogSpec(kind="input", mode="ask", fallback=None),
|
|
211
|
+
"editor": DialogSpec(kind="editor", mode="ask", fallback=None),
|
|
212
|
+
"notify": DialogSpec(kind="notify", mode="tell"),
|
|
213
|
+
"status": DialogSpec(kind="status", mode="tell"),
|
|
214
|
+
"title": DialogSpec(kind="title", mode="tell"),
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def make_dialog_methods(bridge: DialogBridge) -> dict[str, Callable[..., Any]]:
|
|
220
|
+
"""Close the :data:`DIALOG_TABLE` over one :class:`DialogBridge` into
|
|
221
|
+
named convenience methods.
|
|
222
|
+
|
|
223
|
+
Each ``ask`` row becomes a thunk that round-trips through ``bridge.ask``
|
|
224
|
+
with the row fallback (call it with an optional payload and await the
|
|
225
|
+
result); each ``tell`` row becomes a thunk that fires through
|
|
226
|
+
``bridge.tell``. There is no per-method choreography — the bodies are
|
|
227
|
+
generated from the table.
|
|
228
|
+
|
|
229
|
+
:param bridge: the round-trip / fire-and-forget primitive pair
|
|
230
|
+
:returns: the named method map projected from the table
|
|
231
|
+
"""
|
|
232
|
+
methods: dict[str, Callable[..., Any]] = {}
|
|
233
|
+
for name, spec in DIALOG_TABLE.items():
|
|
234
|
+
if spec.mode == "ask":
|
|
235
|
+
|
|
236
|
+
def ask_thunk(payload: Any = None, *, _spec: DialogSpec = spec) -> Any:
|
|
237
|
+
return bridge.ask(_spec.kind, payload, _spec.fallback)
|
|
238
|
+
|
|
239
|
+
methods[name] = ask_thunk
|
|
240
|
+
else:
|
|
241
|
+
|
|
242
|
+
def tell_thunk(payload: Any = None, *, _spec: DialogSpec = spec) -> None:
|
|
243
|
+
bridge.tell(_spec.kind, payload)
|
|
244
|
+
|
|
245
|
+
methods[name] = tell_thunk
|
|
246
|
+
return methods
|