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.
@@ -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
@@ -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=(",", ":"))