iowap-node 2.3.20__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.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""CLI task subcommands — submit / result / note / wait (T-117 split).
|
|
2
|
+
|
|
3
|
+
Handler signatures are plain ``(client, args) -> int``; the
|
|
4
|
+
``node_cli.with_client`` decorator is applied at parser-registration time
|
|
5
|
+
in the facade, so this module never imports ``node_cli`` (avoids a
|
|
6
|
+
circular import and keeps the ``cli.RelayClient`` monkeypatch working).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from nodes.common.relay_client import RelayClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_stage_arg(stage: str) -> tuple[str, dict[str, Any]]:
|
|
23
|
+
"""Parse ``<cap>:<json_payload>`` into (capability, payload)."""
|
|
24
|
+
if ":" not in stage:
|
|
25
|
+
raise SystemExit(f"invalid --stage value {stage!r}; expected <capability>:<json-payload>")
|
|
26
|
+
cap, _, payload_str = stage.partition(":")
|
|
27
|
+
cap = cap.strip()
|
|
28
|
+
if not cap:
|
|
29
|
+
raise SystemExit(f"invalid --stage value {stage!r}; empty capability")
|
|
30
|
+
try:
|
|
31
|
+
payload = json.loads(payload_str) if payload_str.strip() else {}
|
|
32
|
+
except json.JSONDecodeError as exc:
|
|
33
|
+
raise SystemExit(f"invalid --stage payload JSON: {exc}") from exc
|
|
34
|
+
if not isinstance(payload, dict):
|
|
35
|
+
raise SystemExit("--stage payload must be a JSON object")
|
|
36
|
+
return cap, payload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmd_task_submit(client: RelayClient, args) -> int:
|
|
40
|
+
cap, payload = _parse_stage_arg(args.stage)
|
|
41
|
+
resp = client.submit_simple_task(
|
|
42
|
+
cap,
|
|
43
|
+
payload,
|
|
44
|
+
name=args.name or "",
|
|
45
|
+
priority=args.priority,
|
|
46
|
+
owner_node_id=args.owner,
|
|
47
|
+
)
|
|
48
|
+
if args.json:
|
|
49
|
+
print(json.dumps(resp, default=str))
|
|
50
|
+
return 0
|
|
51
|
+
print(json.dumps(resp, indent=2, default=str))
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cmd_task_result(client: RelayClient, args) -> int:
|
|
56
|
+
data = client.get_task(args.task_id)
|
|
57
|
+
if "error" in data:
|
|
58
|
+
print(f"Task {args.task_id}: {data['error']}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
if args.json:
|
|
61
|
+
print(json.dumps(data, default=str))
|
|
62
|
+
return 0
|
|
63
|
+
_print_task_result(data)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cmd_task_note(client: RelayClient, args) -> int:
|
|
68
|
+
"""node-cli task note <task_id> <message> — append a note to a task.
|
|
69
|
+
|
|
70
|
+
T-179 Task 4: ``task_id`` falls back to ``RELAY_TASK_ID`` (set by
|
|
71
|
+
handler_runner for every handler) — handler scripts can post notes
|
|
72
|
+
without repeating their task ID. Explicit argument wins over env.
|
|
73
|
+
Without any ID → stderr message + exit 1.
|
|
74
|
+
"""
|
|
75
|
+
task_id = args.task_id or os.environ.get("RELAY_TASK_ID")
|
|
76
|
+
if not task_id:
|
|
77
|
+
print(
|
|
78
|
+
"note: no task_id and no RELAY_TASK_ID in env",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
return 1
|
|
82
|
+
try:
|
|
83
|
+
data = client.add_task_note(task_id, args.message)
|
|
84
|
+
except httpx.HTTPStatusError as exc:
|
|
85
|
+
if exc.response.status_code == 404:
|
|
86
|
+
print(f"Task {task_id} not found", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
print(f"Error: {exc.response.status_code} {exc.response.text}", file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
if args.json:
|
|
91
|
+
print(json.dumps(data, default=str))
|
|
92
|
+
return 0
|
|
93
|
+
print(f"✅ Note added to task {data.get('task_id', task_id)}")
|
|
94
|
+
print(f" {data.get('message', '')} ({data.get('created_at', '')})")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_task_wait(client: RelayClient, args) -> int:
|
|
99
|
+
task_id = args.task_id
|
|
100
|
+
interval = max(1, args.interval)
|
|
101
|
+
|
|
102
|
+
last_note_count = 0
|
|
103
|
+
while True:
|
|
104
|
+
data = client.get_task(task_id)
|
|
105
|
+
if "error" in data:
|
|
106
|
+
print(f"Task {task_id}: {data['error']}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
task = data.get("task", {})
|
|
110
|
+
status = task.get("status", "unknown")
|
|
111
|
+
|
|
112
|
+
# T-052: surface any new notes that arrived since the last poll.
|
|
113
|
+
notes = data.get("notes", [])
|
|
114
|
+
if len(notes) > last_note_count:
|
|
115
|
+
for n in notes[last_note_count:]:
|
|
116
|
+
print(f"\n💬 [{n.get('node_id', '?')}] {n.get('message', '')} ({n.get('created_at', '?')})")
|
|
117
|
+
last_note_count = len(notes)
|
|
118
|
+
|
|
119
|
+
if status in ("completed", "failed", "timed_out"):
|
|
120
|
+
if args.json:
|
|
121
|
+
print(json.dumps(data, default=str))
|
|
122
|
+
return 0 if status == "completed" else 1
|
|
123
|
+
print(f"\n✅ Task {task_id} — {status}\n")
|
|
124
|
+
_print_task_result(data)
|
|
125
|
+
return 0 if status == "completed" else 1
|
|
126
|
+
|
|
127
|
+
# Show spinner / progress
|
|
128
|
+
stages = data.get("stages", [])
|
|
129
|
+
done = sum(1 for s in stages if s.get("status") == "completed")
|
|
130
|
+
total = len(stages)
|
|
131
|
+
print(f"\r⏳ {status} — {done}/{total} stages completed...", end="", flush=True)
|
|
132
|
+
time.sleep(interval)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _print_task_result(data: dict[str, Any]) -> None:
|
|
136
|
+
task = data.get("task", {})
|
|
137
|
+
stages = data.get("stages", [])
|
|
138
|
+
artifacts = data.get("artifacts", [])
|
|
139
|
+
notes = data.get("notes", [])
|
|
140
|
+
|
|
141
|
+
print(f" Task: {task.get('task_name', '?')} ({task.get('task_id', '?')})")
|
|
142
|
+
print(f" Status: {task.get('status', '?')}")
|
|
143
|
+
print(f" Created: {task.get('created_at', '?')}")
|
|
144
|
+
print(f" Updated: {task.get('updated_at', '?')}")
|
|
145
|
+
print()
|
|
146
|
+
|
|
147
|
+
if stages:
|
|
148
|
+
print(" Stages:")
|
|
149
|
+
for s in stages:
|
|
150
|
+
status_icon = "✅" if s.get("status") == "completed" else "⏳" if s.get("status") == "claimed" else "⬜"
|
|
151
|
+
result_str = ""
|
|
152
|
+
if s.get("result"):
|
|
153
|
+
result_str = f" result={json.dumps(s['result'])}"
|
|
154
|
+
print(f" {status_icon} {s.get('stage_name','?')} [{s.get('capability','?')}] — {s.get('status','?')}{result_str}")
|
|
155
|
+
# T-053: show resolved capability metadata when present.
|
|
156
|
+
cd = s.get("capability_details")
|
|
157
|
+
if cd:
|
|
158
|
+
if cd.get("description"):
|
|
159
|
+
print(f" description: {cd['description']}")
|
|
160
|
+
if cd.get("type"):
|
|
161
|
+
print(f" type: {cd['type']}")
|
|
162
|
+
if cd.get("input_schema"):
|
|
163
|
+
print(f" input_schema: {json.dumps(cd['input_schema'])}")
|
|
164
|
+
# Surface handler diagnostics (exit code, stdout size,
|
|
165
|
+
# stderr snippet) so callers can debug empty responses
|
|
166
|
+
# without downloading artifacts. Populated by
|
|
167
|
+
# handler_runner.run_handler() on success (exit 0).
|
|
168
|
+
handler_info = (s.get("result") or {}).get("_handler")
|
|
169
|
+
if handler_info:
|
|
170
|
+
stderr_snippet = (handler_info.get("stderr") or "")[:200]
|
|
171
|
+
print(
|
|
172
|
+
f" [handler] exit={handler_info.get('exit_code')} "
|
|
173
|
+
f"stdout={handler_info.get('stdout_length','?')}B "
|
|
174
|
+
f"stderr={stderr_snippet!r}"
|
|
175
|
+
)
|
|
176
|
+
print()
|
|
177
|
+
|
|
178
|
+
if artifacts:
|
|
179
|
+
print(" Artifacts:")
|
|
180
|
+
for a in artifacts:
|
|
181
|
+
size = a.get("size_bytes", 0)
|
|
182
|
+
size_str = f"{size/1024:.0f} KB" if size < 1024*1024 else f"{size/1024/1024:.1f} MB"
|
|
183
|
+
print(f" 📄 {a.get('name','?')} ({a.get('artifact_id','?')}) — {size_str}")
|
|
184
|
+
else:
|
|
185
|
+
print(" (no artifacts linked to this task)")
|
|
186
|
+
|
|
187
|
+
if notes:
|
|
188
|
+
print()
|
|
189
|
+
print(f" Notes ({len(notes)}):")
|
|
190
|
+
for n in notes:
|
|
191
|
+
print(f" 💬 [{n.get('node_id', '?')}] {n.get('message', '')} ({n.get('created_at', '?')})")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CLI update subcommands — check / apply (wheel-based, successor of T-062).
|
|
2
|
+
|
|
3
|
+
The git-based update path was removed: wheel-deployed nodes have no git
|
|
4
|
+
checkout, and a pull would never touch the package running from
|
|
5
|
+
site-packages. The source of truth is now the newest ``wheel-vX.Y.Z``
|
|
6
|
+
release on GitHub (iowap-org/iowap-node); the installed distribution
|
|
7
|
+
version (importlib.metadata) is compared against it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from nodes.common.node_utils import apply_wheel_update, check_wheel_updates
|
|
15
|
+
from nodes.common.relay_client import _setup_logging
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cmd_update_check(args) -> int:
|
|
19
|
+
"""node-cli update check — compare installed version vs. newest release."""
|
|
20
|
+
_setup_logging("ERROR" if args.json else args.log_level)
|
|
21
|
+
info = check_wheel_updates()
|
|
22
|
+
if args.json:
|
|
23
|
+
print(json.dumps(info, default=str))
|
|
24
|
+
return 0
|
|
25
|
+
print(f"Local version: {info.get('local_version') or '-'}")
|
|
26
|
+
print(f"Latest release: {info.get('latest_version') or '-'} ({info.get('tag') or 'no wheel release'})")
|
|
27
|
+
if info.get("error"):
|
|
28
|
+
print(f"Error: {info['error']}")
|
|
29
|
+
return 1
|
|
30
|
+
if info.get("update_available"):
|
|
31
|
+
print("Status: update available")
|
|
32
|
+
return 0
|
|
33
|
+
print("Status: up to date")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cmd_update_apply(args) -> int:
|
|
38
|
+
"""node-cli update apply — download wheel, pip reinstall, restart unit."""
|
|
39
|
+
_setup_logging("ERROR" if args.json else args.log_level)
|
|
40
|
+
result = apply_wheel_update(service_unit=args.service_unit, restart_command=args.restart_command)
|
|
41
|
+
if args.json:
|
|
42
|
+
print(json.dumps(result, default=str))
|
|
43
|
+
return 0 if result.get("success") else 1
|
|
44
|
+
print(f"Before: {result.get('before_version') or '-'}")
|
|
45
|
+
print(f"After: {result.get('after_version') or '-'}")
|
|
46
|
+
print(f"Restarted: {'yes' if result.get('restarted') else 'no'}")
|
|
47
|
+
print(f"Result: {result.get('message')}")
|
|
48
|
+
return 0 if result.get("success") else 1
|
nodes/common/envelope.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""T-179: Envelope format v1 for handler primitives (put/get).
|
|
2
|
+
|
|
3
|
+
Contract: the sending node-cli replaces a payload field with the
|
|
4
|
+
envelope; the receiving node-cli recognizes ``__iowap_ref__`` and
|
|
5
|
+
resolves it. Scripts see only a path in / a path out.
|
|
6
|
+
|
|
7
|
+
FROZEN (Phase 1, T-179) — every signature in this module is immutable
|
|
8
|
+
for Phase 2 (scaffold) and Phase 3 (integrate). Field semantics v1:
|
|
9
|
+
|
|
10
|
+
* ``v`` — version, must be exactly 1 (anything else: hard
|
|
11
|
+
error; forward-compat never silently breaks old
|
|
12
|
+
receivers).
|
|
13
|
+
* ``src`` — ``inline`` | ``artifact`` | ``bridge``; unknown → error.
|
|
14
|
+
* ``filename`` — display name inside the envelope (not trustworthy
|
|
15
|
+
as a path — receivers sanitize).
|
|
16
|
+
* ``size_bytes`` — inline: exact byte length; otherwise 0 (placeholder,
|
|
17
|
+
the size only becomes fixed after resolution).
|
|
18
|
+
* ``sha256`` — inline: always set (computed); otherwise optional; the
|
|
19
|
+
sender may know it when it saw the bytes.
|
|
20
|
+
* ``data_base64`` — only for src=inline.
|
|
21
|
+
* ``artifact_id`` — only for src=artifact.
|
|
22
|
+
* ``storage_ref`` — only for src=bridge (``{type, id, ...}``).
|
|
23
|
+
|
|
24
|
+
The envelope replaces the payload field completely (no wrapper object).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
ENVELOPE_KEY = "__iowap_ref__"
|
|
34
|
+
SUPPORTED_VERSION = 1
|
|
35
|
+
SUPPORTED_SOURCES = ("inline", "artifact", "bridge")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EnvelopeError(ValueError):
|
|
39
|
+
"""Envelope missing, foreign, or carrying an unsupported format."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_envelope(
|
|
43
|
+
*,
|
|
44
|
+
src: str,
|
|
45
|
+
filename: str,
|
|
46
|
+
data: bytes | None = None,
|
|
47
|
+
artifact_id: str | None = None,
|
|
48
|
+
storage_ref: dict | None = None,
|
|
49
|
+
sha256: str | None = None,
|
|
50
|
+
size_bytes: int | None = None,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""Build a v1 envelope. ``data`` only for src=inline (base64-encoded).
|
|
53
|
+
|
|
54
|
+
Raise: EnvelopeError on unknown ``src`` or a contradicting field
|
|
55
|
+
combination (inline+artifact_id, inline without data, bridge+data …).
|
|
56
|
+
Fail fast at the producer — an invalid envelope should never leave
|
|
57
|
+
the repo, so the receiver only sees transport-related errors.
|
|
58
|
+
|
|
59
|
+
``size_bytes``: actual file size for artifact/bridge (there the
|
|
60
|
+
``data`` is not inside the envelope). Default ``0`` — the original
|
|
61
|
+
T-166 delivery erroneously always had ``0`` for non-inline
|
|
62
|
+
(bugfix after the live E2E). Negative or smaller than a set
|
|
63
|
+
``data`` length → EnvelopeError.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(size_bytes, int) or isinstance(size_bytes, bool):
|
|
66
|
+
if size_bytes is not None:
|
|
67
|
+
raise EnvelopeError("size_bytes must be an int")
|
|
68
|
+
size_bytes = None
|
|
69
|
+
if size_bytes is not None and size_bytes < 0:
|
|
70
|
+
raise EnvelopeError("size_bytes must be >= 0")
|
|
71
|
+
if src not in SUPPORTED_SOURCES:
|
|
72
|
+
raise EnvelopeError(f"unsupported src {src!r}")
|
|
73
|
+
if not filename:
|
|
74
|
+
raise EnvelopeError("envelope requires a filename")
|
|
75
|
+
is_inline = src == "inline"
|
|
76
|
+
if is_inline and data is None:
|
|
77
|
+
raise EnvelopeError("inline envelope requires data")
|
|
78
|
+
if not is_inline and data is not None:
|
|
79
|
+
raise EnvelopeError(f"{src!r} envelope must not carry data")
|
|
80
|
+
if src == "artifact" and not artifact_id:
|
|
81
|
+
raise EnvelopeError("artifact envelope requires artifact_id")
|
|
82
|
+
if src == "bridge" and storage_ref is None:
|
|
83
|
+
raise EnvelopeError("bridge envelope requires storage_ref")
|
|
84
|
+
if is_inline and artifact_id is not None:
|
|
85
|
+
raise EnvelopeError("inline envelope must not carry artifact_id")
|
|
86
|
+
if src == "bridge" and artifact_id is not None:
|
|
87
|
+
raise EnvelopeError("bridge envelope must not carry artifact_id")
|
|
88
|
+
|
|
89
|
+
ref: dict[str, Any] = {
|
|
90
|
+
"v": SUPPORTED_VERSION,
|
|
91
|
+
"src": src,
|
|
92
|
+
"filename": filename,
|
|
93
|
+
# inline: always derived from data; artifact/bridge: explicit
|
|
94
|
+
# size (the file is not inside the envelope), default 0.
|
|
95
|
+
"size_bytes": len(data) if data is not None else (
|
|
96
|
+
size_bytes if size_bytes is not None else 0
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
if data is not None:
|
|
100
|
+
ref["data_base64"] = base64.b64encode(data).decode("ascii")
|
|
101
|
+
# T-166 (F8): sha256 in the envelope — inline computes it from the
|
|
102
|
+
# data, bridge/artifact carry the sender hash explicitly (F8
|
|
103
|
+
# verification at the receiver even without inline payload).
|
|
104
|
+
if sha256 is not None:
|
|
105
|
+
ref["sha256"] = sha256
|
|
106
|
+
elif data is not None:
|
|
107
|
+
ref["sha256"] = hashlib.sha256(data).hexdigest()
|
|
108
|
+
if artifact_id is not None:
|
|
109
|
+
ref["artifact_id"] = artifact_id
|
|
110
|
+
if storage_ref is not None:
|
|
111
|
+
ref["storage_ref"] = storage_ref
|
|
112
|
+
return {ENVELOPE_KEY: ref}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_envelope(envelope: dict[str, Any]) -> tuple[str, Any]:
|
|
116
|
+
"""Validate an envelope, return ``(src, raw reference)``.
|
|
117
|
+
|
|
118
|
+
inline → second value is the decoded bytes (``bytes``)
|
|
119
|
+
artifact → second value is the artifact_id (``str``)
|
|
120
|
+
bridge → second value is the storage_ref dict
|
|
121
|
+
|
|
122
|
+
Raise: EnvelopeError on a missing/contradicting envelope —
|
|
123
|
+
unknown ``v`` or unknown ``src`` breaks the receiver loudly
|
|
124
|
+
(forward-compat: new modes never break old receivers silently).
|
|
125
|
+
"""
|
|
126
|
+
if not isinstance(envelope, dict) or ENVELOPE_KEY not in envelope:
|
|
127
|
+
raise EnvelopeError(f"no {ENVELOPE_KEY} key — not an envelope")
|
|
128
|
+
ref = envelope[ENVELOPE_KEY]
|
|
129
|
+
if not isinstance(ref, dict):
|
|
130
|
+
raise EnvelopeError("envelope payload must be an object")
|
|
131
|
+
version = ref.get("v")
|
|
132
|
+
if version != SUPPORTED_VERSION:
|
|
133
|
+
raise EnvelopeError(f"unsupported envelope version {version!r}")
|
|
134
|
+
src = ref.get("src")
|
|
135
|
+
if src not in SUPPORTED_SOURCES:
|
|
136
|
+
raise EnvelopeError(f"unsupported envelope src {src!r}")
|
|
137
|
+
if src == "inline":
|
|
138
|
+
raw = ref.get("data_base64")
|
|
139
|
+
if not raw:
|
|
140
|
+
raise EnvelopeError("inline envelope without data_base64")
|
|
141
|
+
try:
|
|
142
|
+
return src, base64.b64decode(raw)
|
|
143
|
+
except (ValueError, TypeError) as exc:
|
|
144
|
+
# binascii.Error is a ValueError subclass.
|
|
145
|
+
raise EnvelopeError(f"invalid base64 in inline envelope: {exc}") from exc
|
|
146
|
+
if src == "artifact":
|
|
147
|
+
artifact_id = ref.get("artifact_id")
|
|
148
|
+
if not artifact_id:
|
|
149
|
+
raise EnvelopeError("artifact envelope without artifact_id")
|
|
150
|
+
return src, artifact_id
|
|
151
|
+
storage_ref = ref.get("storage_ref")
|
|
152
|
+
if not isinstance(storage_ref, dict):
|
|
153
|
+
raise EnvelopeError("bridge envelope without storage_ref")
|
|
154
|
+
return src, storage_ref
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_envelope(value: Any) -> bool:
|
|
158
|
+
"""True if ``value`` is a dict with the ``__iowap_ref__`` key.
|
|
159
|
+
|
|
160
|
+
Pure shape check without validation — for "is this payload field an
|
|
161
|
+
envelope?" decisions on the receiver side.
|
|
162
|
+
"""
|
|
163
|
+
return isinstance(value, dict) and ENVELOPE_KEY in value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def dump(envelope: dict[str, Any]) -> str:
|
|
167
|
+
"""Serialize an envelope as compact JSON (no separator lines)."""
|
|
168
|
+
return json.dumps(envelope, separators=(",", ":"))
|