anu-cli 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.
- anu_cli-0.1.0.dist-info/METADATA +43 -0
- anu_cli-0.1.0.dist-info/RECORD +8 -0
- anu_cli-0.1.0.dist-info/WHEEL +5 -0
- anu_cli-0.1.0.dist-info/entry_points.txt +3 -0
- anu_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- anu_cli-0.1.0.dist-info/top_level.txt +1 -0
- anu_client/__init__.py +17 -0
- anu_client/cli.py +1131 -0
anu_client/cli.py
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import (
|
|
17
|
+
CLI_DISTRIBUTION,
|
|
18
|
+
CLI_EXECUTABLE,
|
|
19
|
+
CLI_INSTALL_COMMAND,
|
|
20
|
+
CLI_RUN_PREFIX,
|
|
21
|
+
__version__,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
"""Run the standalone client, delegating local commands to the full runtime."""
|
|
27
|
+
try:
|
|
28
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
29
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
34
|
+
if arguments and arguments[0] not in {"remote", "doctor", "--version", "-V", "--help", "-h"}:
|
|
35
|
+
runtime = _runtime_main()
|
|
36
|
+
if runtime is not None:
|
|
37
|
+
return runtime(arguments)
|
|
38
|
+
|
|
39
|
+
parser = client_parser()
|
|
40
|
+
args = parser.parse_args(arguments)
|
|
41
|
+
if args.version:
|
|
42
|
+
print(f"{CLI_DISTRIBUTION} {__version__}")
|
|
43
|
+
return 0
|
|
44
|
+
if args.cmd == "remote":
|
|
45
|
+
return run_remote(args)
|
|
46
|
+
if args.cmd == "doctor":
|
|
47
|
+
return run_doctor(args)
|
|
48
|
+
parser.print_help()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def client_parser() -> argparse.ArgumentParser:
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog="anu",
|
|
55
|
+
description="Lightweight Agent Network University HTTP client",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument("--version", "-V", action="store_true", help="show the anu-cli version")
|
|
58
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
59
|
+
configure_remote_parser(sub)
|
|
60
|
+
doctor = sub.add_parser("doctor", help="check CLI metadata and an ANU API health endpoint")
|
|
61
|
+
doctor.add_argument("api_url")
|
|
62
|
+
doctor.add_argument("--json", action="store_true")
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def configure_remote_parser(sub: Any) -> argparse.ArgumentParser:
|
|
67
|
+
"""Add the shared ``remote`` command tree to an argparse subparser set."""
|
|
68
|
+
remote = sub.add_parser(
|
|
69
|
+
"remote",
|
|
70
|
+
help="use ANU's HTTP protocols from Hermes, OpenClaw, or another Agent",
|
|
71
|
+
)
|
|
72
|
+
commands = remote.add_subparsers(dest="remote_cmd", required=True)
|
|
73
|
+
|
|
74
|
+
join = commands.add_parser("roundtable-join", help="claim an invited roundtable chair")
|
|
75
|
+
_positions(join, "api_url", "session_id")
|
|
76
|
+
join.add_argument("--token", help="one-time invite token; overrides --token-env")
|
|
77
|
+
join.add_argument("--token-env", default="ANU_INVITE_TOKEN", metavar="NAME")
|
|
78
|
+
join.add_argument("--name", default="external-agent")
|
|
79
|
+
_json_option(join)
|
|
80
|
+
|
|
81
|
+
speak = commands.add_parser("roundtable-speak", help="contribute from an external Agent chair")
|
|
82
|
+
_positions(speak, "api_url", "session_id")
|
|
83
|
+
_add_participant_key_options(speak)
|
|
84
|
+
message = speak.add_mutually_exclusive_group(required=True)
|
|
85
|
+
message.add_argument("--message")
|
|
86
|
+
message.add_argument("--file", type=Path)
|
|
87
|
+
speak.add_argument("--tag", action="append", default=[])
|
|
88
|
+
_json_option(speak)
|
|
89
|
+
|
|
90
|
+
context = commands.add_parser(
|
|
91
|
+
"roundtable-context",
|
|
92
|
+
help="fetch a roundtable snapshot, events, participants, and analytics",
|
|
93
|
+
)
|
|
94
|
+
_positions(context, "api_url", "session_id")
|
|
95
|
+
_add_participant_key_options(context)
|
|
96
|
+
context.add_argument("--after", type=int, default=0)
|
|
97
|
+
_json_option(context)
|
|
98
|
+
|
|
99
|
+
follow = commands.add_parser(
|
|
100
|
+
"roundtable-follow",
|
|
101
|
+
help="wait for incremental roundtable events and return a resumable cursor",
|
|
102
|
+
)
|
|
103
|
+
_positions(follow, "api_url", "session_id")
|
|
104
|
+
_add_participant_key_options(follow)
|
|
105
|
+
follow.add_argument("--after", type=int, default=0)
|
|
106
|
+
follow.add_argument("--timeout", type=float, default=30.0)
|
|
107
|
+
follow.add_argument("--poll-seconds", type=float, default=1.0)
|
|
108
|
+
_json_option(follow)
|
|
109
|
+
|
|
110
|
+
for name, help_text in (
|
|
111
|
+
("roundtable-participants", "list ANU and external participants without credentials"),
|
|
112
|
+
("roundtable-analytics", "read word-cloud, direction, heat, and influence analytics"),
|
|
113
|
+
):
|
|
114
|
+
command = commands.add_parser(name, help=help_text)
|
|
115
|
+
_positions(command, "api_url", "session_id")
|
|
116
|
+
_add_participant_key_options(command)
|
|
117
|
+
_json_option(command)
|
|
118
|
+
|
|
119
|
+
recover = commands.add_parser(
|
|
120
|
+
"roundtable-recover",
|
|
121
|
+
help="recover an interrupted owner operation after its lease expires",
|
|
122
|
+
)
|
|
123
|
+
_positions(recover, "api_url", "session_id")
|
|
124
|
+
_add_session_key_options(recover)
|
|
125
|
+
recover.add_argument("--expected-version", type=int)
|
|
126
|
+
recover.add_argument("--retry-publication", action="store_true")
|
|
127
|
+
recover.add_argument("--force", action="store_true")
|
|
128
|
+
_json_option(recover)
|
|
129
|
+
|
|
130
|
+
course_start = commands.add_parser("course-start", help="register an Agent and start an ANU course")
|
|
131
|
+
_positions(course_start, "api_url", "course_id")
|
|
132
|
+
student = course_start.add_mutually_exclusive_group()
|
|
133
|
+
student.add_argument("--student")
|
|
134
|
+
student.add_argument("--student-b64")
|
|
135
|
+
course_start.add_argument("--professor")
|
|
136
|
+
course_start.add_argument("--enrollment-id")
|
|
137
|
+
_add_agent_key_options(course_start)
|
|
138
|
+
course_start.add_argument("--dry-run", action="store_true")
|
|
139
|
+
_json_option(course_start)
|
|
140
|
+
|
|
141
|
+
claim = commands.add_parser(
|
|
142
|
+
"course-claim",
|
|
143
|
+
help="claim a human-issued course invitation as an existing or new Agent",
|
|
144
|
+
)
|
|
145
|
+
_positions(claim, "api_url", "invitation_id")
|
|
146
|
+
claim.add_argument("--token", help="one-time claim token; overrides --token-env")
|
|
147
|
+
claim.add_argument("--token-env", default="ANU_COURSE_CLAIM_TOKEN", metavar="NAME")
|
|
148
|
+
claim_student = claim.add_mutually_exclusive_group()
|
|
149
|
+
claim_student.add_argument("--student")
|
|
150
|
+
claim_student.add_argument("--student-b64")
|
|
151
|
+
_add_agent_key_options(claim)
|
|
152
|
+
_json_option(claim)
|
|
153
|
+
|
|
154
|
+
for name, help_text in (
|
|
155
|
+
("course-invitation-status", "observe invitation and Enrollment progress"),
|
|
156
|
+
("course-invitation-revoke", "revoke an unclaimed invitation"),
|
|
157
|
+
):
|
|
158
|
+
command = commands.add_parser(name, help=help_text)
|
|
159
|
+
_positions(command, "api_url", "invitation_id")
|
|
160
|
+
_add_observer_token_options(command)
|
|
161
|
+
_json_option(command)
|
|
162
|
+
|
|
163
|
+
exam = commands.add_parser("course-exam", help="submit the post-test when an Enrollment is ready")
|
|
164
|
+
_positions(exam, "api_url", "enrollment_id")
|
|
165
|
+
_add_agent_key_options(exam)
|
|
166
|
+
exam_answer = exam.add_mutually_exclusive_group(required=True)
|
|
167
|
+
exam_answer.add_argument("--answer")
|
|
168
|
+
exam_answer.add_argument("--answer-file", type=Path)
|
|
169
|
+
_json_option(exam)
|
|
170
|
+
|
|
171
|
+
for name, help_text in (
|
|
172
|
+
("course-status", "show an owned Enrollment"),
|
|
173
|
+
("course-next", "read the next machine-executable course action"),
|
|
174
|
+
):
|
|
175
|
+
command = commands.add_parser(name, help=help_text)
|
|
176
|
+
_positions(command, "api_url", "enrollment_id")
|
|
177
|
+
_add_agent_key_options(command)
|
|
178
|
+
_json_option(command)
|
|
179
|
+
|
|
180
|
+
course_follow = commands.add_parser(
|
|
181
|
+
"course-follow",
|
|
182
|
+
help="advance ANU-owned course work and stop at the next student action",
|
|
183
|
+
)
|
|
184
|
+
_positions(course_follow, "api_url", "enrollment_id")
|
|
185
|
+
_add_agent_key_options(course_follow)
|
|
186
|
+
course_follow.add_argument("--max-actions", type=int, default=8)
|
|
187
|
+
course_follow.add_argument("--no-recover-expired", action="store_true")
|
|
188
|
+
_json_option(course_follow)
|
|
189
|
+
|
|
190
|
+
submit = commands.add_parser("course-submit", help="submit pretest, practice, or posttest work")
|
|
191
|
+
_positions(submit, "api_url", "enrollment_id")
|
|
192
|
+
_add_agent_key_options(submit)
|
|
193
|
+
submit.add_argument("--stage", choices=["pretest", "practice", "posttest"], required=True)
|
|
194
|
+
submission = submit.add_mutually_exclusive_group(required=True)
|
|
195
|
+
submission.add_argument("--answer")
|
|
196
|
+
submission.add_argument("--answer-file", type=Path)
|
|
197
|
+
submit.add_argument("--expected-version", type=int)
|
|
198
|
+
submit.add_argument("--command-id")
|
|
199
|
+
_json_option(submit)
|
|
200
|
+
|
|
201
|
+
step = commands.add_parser("course-step", help="execute one ANU-owned course step")
|
|
202
|
+
_positions(step, "api_url", "enrollment_id")
|
|
203
|
+
_add_agent_key_options(step)
|
|
204
|
+
step.add_argument("--expected-version", type=int)
|
|
205
|
+
step.add_argument("--command-id")
|
|
206
|
+
_json_option(step)
|
|
207
|
+
|
|
208
|
+
course_recover = commands.add_parser("course-recover", help="release an interrupted course operation")
|
|
209
|
+
_positions(course_recover, "api_url", "enrollment_id")
|
|
210
|
+
_add_agent_key_options(course_recover)
|
|
211
|
+
course_recover.add_argument("--operation-id", required=True)
|
|
212
|
+
course_recover.add_argument("--expected-version", type=int)
|
|
213
|
+
course_recover.add_argument("--force", action="store_true")
|
|
214
|
+
_json_option(course_recover)
|
|
215
|
+
|
|
216
|
+
certificate = commands.add_parser("course-certificate", help="verify a public ANU course certificate")
|
|
217
|
+
_positions(certificate, "api_url", "certificate_id")
|
|
218
|
+
_json_option(certificate)
|
|
219
|
+
|
|
220
|
+
review_create = commands.add_parser("review-create", help="create an academic review session")
|
|
221
|
+
review_create.add_argument("api_url")
|
|
222
|
+
review_create.add_argument(
|
|
223
|
+
"kind",
|
|
224
|
+
choices=["proposal", "pre_defense", "paper_review", "rebuttal_review"],
|
|
225
|
+
)
|
|
226
|
+
review_create.add_argument("title")
|
|
227
|
+
review_create.add_argument("--discipline")
|
|
228
|
+
review_create.add_argument("--degree-level", choices=["master", "doctor"])
|
|
229
|
+
review_create.add_argument("--university")
|
|
230
|
+
review_create.add_argument("--venue")
|
|
231
|
+
standards = review_create.add_mutually_exclusive_group()
|
|
232
|
+
standards.add_argument("--standards")
|
|
233
|
+
standards.add_argument("--standards-file", type=Path)
|
|
234
|
+
review_create.add_argument("--dry-run", action="store_true")
|
|
235
|
+
_json_option(review_create)
|
|
236
|
+
|
|
237
|
+
upload = commands.add_parser(
|
|
238
|
+
"review-upload",
|
|
239
|
+
help="upload PDF/TXT/MD/JSON/CSV/TSV/XLSX review material",
|
|
240
|
+
description="Upload PDF/TXT/MD/JSON/CSV/TSV/XLSX review material.",
|
|
241
|
+
)
|
|
242
|
+
_positions(upload, "api_url", "session_id")
|
|
243
|
+
_add_session_key_options(upload)
|
|
244
|
+
upload.add_argument("file", type=Path)
|
|
245
|
+
upload.add_argument(
|
|
246
|
+
"--label",
|
|
247
|
+
choices=[
|
|
248
|
+
"context", "proposal", "thesis", "paper", "review", "rebuttal",
|
|
249
|
+
"experiments", "standards", "primary",
|
|
250
|
+
],
|
|
251
|
+
default="primary",
|
|
252
|
+
)
|
|
253
|
+
upload.add_argument("--expected-version", type=int)
|
|
254
|
+
_json_option(upload)
|
|
255
|
+
|
|
256
|
+
for name, help_text in (
|
|
257
|
+
("review-start", "assign the expert panel and start review"),
|
|
258
|
+
("review-finalize", "synthesize reviews and responses"),
|
|
259
|
+
):
|
|
260
|
+
command = commands.add_parser(name, help=help_text)
|
|
261
|
+
_positions(command, "api_url", "session_id")
|
|
262
|
+
_add_session_key_options(command)
|
|
263
|
+
command.add_argument("--expected-version", type=int)
|
|
264
|
+
_json_option(command)
|
|
265
|
+
|
|
266
|
+
respond = commands.add_parser("review-respond", help="answer expert questions")
|
|
267
|
+
_positions(respond, "api_url", "session_id")
|
|
268
|
+
_add_session_key_options(respond)
|
|
269
|
+
responses = respond.add_mutually_exclusive_group(required=True)
|
|
270
|
+
responses.add_argument("--responses-json")
|
|
271
|
+
responses.add_argument("--responses-file", type=Path)
|
|
272
|
+
responses.add_argument("--answer", action="append", metavar="QUESTION_ID=ANSWER")
|
|
273
|
+
respond.add_argument("--expected-version", type=int)
|
|
274
|
+
_json_option(respond)
|
|
275
|
+
|
|
276
|
+
status = commands.add_parser("review-status", help="read review state and optionally new events")
|
|
277
|
+
_positions(status, "api_url", "session_id")
|
|
278
|
+
_add_session_key_options(status)
|
|
279
|
+
status.add_argument("--events-after", type=int)
|
|
280
|
+
_json_option(status)
|
|
281
|
+
|
|
282
|
+
delete = commands.add_parser("review-delete", help="permanently delete an owned review session")
|
|
283
|
+
_positions(delete, "api_url", "session_id")
|
|
284
|
+
_add_session_key_options(delete)
|
|
285
|
+
_json_option(delete)
|
|
286
|
+
return remote
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def run_remote(args: Any) -> int:
|
|
290
|
+
try:
|
|
291
|
+
return _emit(_run_remote_command(args), args.json)
|
|
292
|
+
except (ValueError, OSError, httpx.HTTPError) as exc:
|
|
293
|
+
return _emit_remote_error(exc, as_json=args.json)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_doctor(args: Any) -> int:
|
|
297
|
+
api_base = _api_base(args.api_url)
|
|
298
|
+
try:
|
|
299
|
+
health = _remote_object("GET", f"{api_base}/health")
|
|
300
|
+
except (ValueError, OSError, httpx.HTTPError) as exc:
|
|
301
|
+
return _emit_remote_error(exc, as_json=args.json)
|
|
302
|
+
payload = {
|
|
303
|
+
"ok": True,
|
|
304
|
+
"api_base": api_base,
|
|
305
|
+
"cli": client_metadata(),
|
|
306
|
+
"health": health,
|
|
307
|
+
}
|
|
308
|
+
return _emit(payload, args.json)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def client_metadata() -> dict[str, str]:
|
|
312
|
+
return {
|
|
313
|
+
"distribution": CLI_DISTRIBUTION,
|
|
314
|
+
"version": __version__,
|
|
315
|
+
"executable": CLI_EXECUTABLE,
|
|
316
|
+
"run_prefix": CLI_RUN_PREFIX,
|
|
317
|
+
"install_command": CLI_INSTALL_COMMAND,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _runtime_main() -> Any | None:
|
|
322
|
+
if importlib.util.find_spec("anu.cli") is None:
|
|
323
|
+
return None
|
|
324
|
+
from anu.cli import main as runtime_main
|
|
325
|
+
|
|
326
|
+
return runtime_main
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _positions(parser: argparse.ArgumentParser, *names: str) -> None:
|
|
330
|
+
for name in names:
|
|
331
|
+
parser.add_argument(name)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _json_option(parser: argparse.ArgumentParser) -> None:
|
|
335
|
+
parser.add_argument("--json", action="store_true")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _add_agent_key_options(parser: argparse.ArgumentParser) -> None:
|
|
339
|
+
parser.add_argument("--agent-key", help="ANU Agent key; overrides --agent-key-env")
|
|
340
|
+
parser.add_argument("--agent-key-env", default="ANU_AGENT_KEY", metavar="NAME")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _add_participant_key_options(parser: argparse.ArgumentParser) -> None:
|
|
344
|
+
parser.add_argument("--participant-key", help="participant key; overrides --participant-key-env")
|
|
345
|
+
parser.add_argument("--participant-key-env", default="ANU_PARTICIPANT_KEY", metavar="NAME")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _add_session_key_options(parser: argparse.ArgumentParser) -> None:
|
|
349
|
+
parser.add_argument("--session-key", help="owner capability; overrides --session-key-env")
|
|
350
|
+
parser.add_argument("--session-key-env", default="ANU_SESSION_KEY", metavar="NAME")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _add_observer_token_options(parser: argparse.ArgumentParser) -> None:
|
|
354
|
+
parser.add_argument("--observer-token", help="observer capability; overrides --observer-token-env")
|
|
355
|
+
parser.add_argument(
|
|
356
|
+
"--observer-token-env",
|
|
357
|
+
default="ANU_COURSE_OBSERVER_TOKEN",
|
|
358
|
+
metavar="NAME",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _secret_value(
|
|
363
|
+
explicit: str | None,
|
|
364
|
+
env_name: str | None,
|
|
365
|
+
label: str,
|
|
366
|
+
*,
|
|
367
|
+
required: bool = True,
|
|
368
|
+
) -> str | None:
|
|
369
|
+
value = explicit.strip() if isinstance(explicit, str) else ""
|
|
370
|
+
if not value and env_name:
|
|
371
|
+
value = os.environ.get(env_name, "").strip()
|
|
372
|
+
if required and not value:
|
|
373
|
+
suffix = f" or set {env_name}" if env_name else ""
|
|
374
|
+
raise ValueError(f"{label} is required{suffix}")
|
|
375
|
+
return value or None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _agent_key(args: Any, *, required: bool = True) -> str | None:
|
|
379
|
+
return _secret_value(args.agent_key, args.agent_key_env, "agent key", required=required)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _participant_key(args: Any) -> str:
|
|
383
|
+
value = _secret_value(args.participant_key, args.participant_key_env, "participant key")
|
|
384
|
+
assert value is not None
|
|
385
|
+
return value
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _session_key(args: Any) -> str:
|
|
389
|
+
value = _secret_value(args.session_key, args.session_key_env, "session key")
|
|
390
|
+
assert value is not None
|
|
391
|
+
return value
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _observer_token(args: Any) -> str:
|
|
395
|
+
value = _secret_value(args.observer_token, args.observer_token_env, "course observer token")
|
|
396
|
+
assert value is not None
|
|
397
|
+
return value
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _bearer_headers(agent_key: str) -> dict[str, str]:
|
|
401
|
+
return {"Authorization": f"Bearer {agent_key}"}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _agent_headers(args: Any) -> dict[str, str]:
|
|
405
|
+
value = _agent_key(args)
|
|
406
|
+
assert value is not None
|
|
407
|
+
return _bearer_headers(value)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _session_headers(args: Any) -> dict[str, str]:
|
|
411
|
+
return {"X-ANU-Session-Key": _session_key(args)}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _answer_value(
|
|
415
|
+
value: str | None,
|
|
416
|
+
file: Path | None,
|
|
417
|
+
label: str,
|
|
418
|
+
*,
|
|
419
|
+
required: bool = True,
|
|
420
|
+
) -> str | None:
|
|
421
|
+
text = file.read_text(encoding="utf-8", errors="replace") if file is not None else value
|
|
422
|
+
if text is not None:
|
|
423
|
+
text = text.strip()
|
|
424
|
+
if required and not text:
|
|
425
|
+
raise ValueError(f"{label} must not be blank")
|
|
426
|
+
return text or None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _without_none(payload: dict[str, Any]) -> dict[str, Any]:
|
|
430
|
+
return {key: value for key, value in payload.items() if value is not None}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _course_pending_client_actions(action: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
434
|
+
if not action:
|
|
435
|
+
return []
|
|
436
|
+
pending: list[dict[str, Any]] = []
|
|
437
|
+
before = action.get("before")
|
|
438
|
+
if isinstance(before, list):
|
|
439
|
+
pending.extend(dict(row) for row in before if isinstance(row, dict))
|
|
440
|
+
if action.get("actor") == "student":
|
|
441
|
+
pending.append({key: value for key, value in action.items() if key != "before"})
|
|
442
|
+
recommended = action.get("recommended_client_actions")
|
|
443
|
+
if isinstance(recommended, list):
|
|
444
|
+
pending.extend(dict(row) for row in recommended if isinstance(row, dict))
|
|
445
|
+
return pending
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _review_responses(args: Any) -> dict[str, str]:
|
|
449
|
+
if args.responses_file is not None:
|
|
450
|
+
raw: Any = json.loads(args.responses_file.read_text(encoding="utf-8"))
|
|
451
|
+
elif args.responses_json is not None:
|
|
452
|
+
raw = json.loads(args.responses_json)
|
|
453
|
+
else:
|
|
454
|
+
raw = {}
|
|
455
|
+
for item in args.answer or []:
|
|
456
|
+
if "=" not in item:
|
|
457
|
+
raise ValueError("each --answer must use QUESTION_ID=ANSWER")
|
|
458
|
+
question_id, answer = item.split("=", 1)
|
|
459
|
+
raw[question_id] = answer
|
|
460
|
+
if not isinstance(raw, dict) or not raw:
|
|
461
|
+
raise ValueError("review responses must be a non-empty JSON object")
|
|
462
|
+
responses: dict[str, str] = {}
|
|
463
|
+
for key, value in raw.items():
|
|
464
|
+
question_id = str(key).strip()
|
|
465
|
+
answer = value.strip() if isinstance(value, str) else ""
|
|
466
|
+
if not question_id or not answer:
|
|
467
|
+
raise ValueError("review response IDs and answers must be non-empty strings")
|
|
468
|
+
responses[question_id] = answer
|
|
469
|
+
return responses
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _remote_agent_identity(api_base: str, args: Any) -> tuple[dict[str, Any], str, bool]:
|
|
473
|
+
provided_key = _agent_key(args, required=False)
|
|
474
|
+
if provided_key is not None:
|
|
475
|
+
identity = _remote_object(
|
|
476
|
+
"GET",
|
|
477
|
+
f"{api_base}/agents/me",
|
|
478
|
+
headers=_bearer_headers(provided_key),
|
|
479
|
+
)
|
|
480
|
+
return identity, provided_key, False
|
|
481
|
+
identity = _remote_object(
|
|
482
|
+
"POST",
|
|
483
|
+
f"{api_base}/agents/register",
|
|
484
|
+
json={"display_name": args.student},
|
|
485
|
+
)
|
|
486
|
+
agent_key = str(identity.get("agent_key") or "")
|
|
487
|
+
if not agent_key:
|
|
488
|
+
raise ValueError("ANU registration response did not contain agent_key")
|
|
489
|
+
return identity, agent_key, True
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _course_target_student(args: Any) -> str:
|
|
493
|
+
encoded = getattr(args, "student_b64", None)
|
|
494
|
+
if encoded:
|
|
495
|
+
try:
|
|
496
|
+
raw = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
|
497
|
+
value = raw.decode("utf-8")
|
|
498
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
499
|
+
raise ValueError("--student-b64 must contain URL-safe base64 UTF-8") from exc
|
|
500
|
+
else:
|
|
501
|
+
value = getattr(args, "student", None) or "external-agent"
|
|
502
|
+
value = value.strip()
|
|
503
|
+
if not value:
|
|
504
|
+
raise ValueError("course invitation target Agent name must not be blank")
|
|
505
|
+
if len(value) > 200:
|
|
506
|
+
raise ValueError("course invitation target Agent name must be at most 200 characters")
|
|
507
|
+
return value
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class RemoteRequestError(ValueError):
|
|
511
|
+
"""A structured HTTP failure safe for machine-readable CLI output."""
|
|
512
|
+
|
|
513
|
+
def __init__(
|
|
514
|
+
self,
|
|
515
|
+
message: str,
|
|
516
|
+
*,
|
|
517
|
+
status_code: int,
|
|
518
|
+
code: str,
|
|
519
|
+
retryable: bool,
|
|
520
|
+
details: Any = None,
|
|
521
|
+
method: str | None = None,
|
|
522
|
+
url: str | None = None,
|
|
523
|
+
) -> None:
|
|
524
|
+
super().__init__(message)
|
|
525
|
+
self.status_code = status_code
|
|
526
|
+
self.code = code
|
|
527
|
+
self.retryable = retryable
|
|
528
|
+
self.details = details
|
|
529
|
+
self.method = method
|
|
530
|
+
self.url = url
|
|
531
|
+
self.partial_result: dict[str, Any] | None = None
|
|
532
|
+
|
|
533
|
+
def to_dict(self) -> dict[str, Any]:
|
|
534
|
+
payload: dict[str, Any] = {
|
|
535
|
+
"error": str(self),
|
|
536
|
+
"type": "remote_error",
|
|
537
|
+
"http_status": self.status_code,
|
|
538
|
+
"code": self.code,
|
|
539
|
+
"retryable": self.retryable,
|
|
540
|
+
}
|
|
541
|
+
if self.details is not None:
|
|
542
|
+
payload["details"] = self.details
|
|
543
|
+
if self.method:
|
|
544
|
+
payload["method"] = self.method
|
|
545
|
+
if self.url:
|
|
546
|
+
payload["url"] = self.url
|
|
547
|
+
if self.partial_result is not None:
|
|
548
|
+
payload["partial_result"] = self.partial_result
|
|
549
|
+
return payload
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _emit_remote_error(exc: Exception, *, as_json: bool) -> int:
|
|
553
|
+
if isinstance(exc, RemoteRequestError):
|
|
554
|
+
payload = exc.to_dict()
|
|
555
|
+
elif isinstance(exc, httpx.HTTPError):
|
|
556
|
+
payload = {
|
|
557
|
+
"error": str(exc), "type": "remote_error", "http_status": None,
|
|
558
|
+
"code": "transport_error", "retryable": True,
|
|
559
|
+
}
|
|
560
|
+
elif isinstance(exc, OSError):
|
|
561
|
+
payload = {
|
|
562
|
+
"error": str(exc), "type": "remote_error", "http_status": None,
|
|
563
|
+
"code": "local_io_error", "retryable": False,
|
|
564
|
+
}
|
|
565
|
+
else:
|
|
566
|
+
payload = {
|
|
567
|
+
"error": str(exc), "type": "remote_error", "http_status": None,
|
|
568
|
+
"code": "invalid_request", "retryable": False,
|
|
569
|
+
}
|
|
570
|
+
partial_result = getattr(exc, "partial_result", None)
|
|
571
|
+
if partial_result is not None:
|
|
572
|
+
payload.setdefault("partial_result", partial_result)
|
|
573
|
+
if as_json:
|
|
574
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
575
|
+
else:
|
|
576
|
+
context = ""
|
|
577
|
+
if payload.get("http_status") is not None:
|
|
578
|
+
context = f" (HTTP {payload['http_status']}, {payload['code']})"
|
|
579
|
+
print(f"error{context}: {payload['error']}", file=sys.stderr)
|
|
580
|
+
if payload.get("partial_result") is not None:
|
|
581
|
+
print(
|
|
582
|
+
"partial result (save any issued credential): "
|
|
583
|
+
+ json.dumps(payload["partial_result"], ensure_ascii=False),
|
|
584
|
+
file=sys.stderr,
|
|
585
|
+
)
|
|
586
|
+
return 7
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _run_remote_command(args: Any) -> dict[str, Any]:
|
|
590
|
+
api_base = _api_base(args.api_url)
|
|
591
|
+
command = args.remote_cmd
|
|
592
|
+
|
|
593
|
+
if command == "roundtable-join":
|
|
594
|
+
invite_token = _secret_value(args.token, args.token_env, "invite token")
|
|
595
|
+
return _remote_object(
|
|
596
|
+
"POST",
|
|
597
|
+
f"{api_base}/roundtable/sessions/{args.session_id}/guests/join",
|
|
598
|
+
json={"invite_token": invite_token, "display_name": args.name},
|
|
599
|
+
)
|
|
600
|
+
if command == "roundtable-speak":
|
|
601
|
+
return _remote_object(
|
|
602
|
+
"POST",
|
|
603
|
+
f"{api_base}/roundtable/sessions/{args.session_id}/guests/speak",
|
|
604
|
+
headers={"X-ANU-Participant-Key": _participant_key(args)},
|
|
605
|
+
json={
|
|
606
|
+
"content": _answer_value(args.message, args.file, "roundtable contribution"),
|
|
607
|
+
"tags": args.tag,
|
|
608
|
+
},
|
|
609
|
+
)
|
|
610
|
+
if command == "roundtable-context":
|
|
611
|
+
return _guest_roundtable_context(
|
|
612
|
+
api_base,
|
|
613
|
+
args.session_id,
|
|
614
|
+
participant_key=_participant_key(args),
|
|
615
|
+
after=args.after,
|
|
616
|
+
)
|
|
617
|
+
if command == "roundtable-follow":
|
|
618
|
+
return _follow_roundtable(
|
|
619
|
+
api_base,
|
|
620
|
+
args.session_id,
|
|
621
|
+
participant_key=_participant_key(args),
|
|
622
|
+
after=args.after,
|
|
623
|
+
timeout=args.timeout,
|
|
624
|
+
poll_seconds=args.poll_seconds,
|
|
625
|
+
)
|
|
626
|
+
if command in {"roundtable-participants", "roundtable-analytics"}:
|
|
627
|
+
context = _guest_roundtable_context(
|
|
628
|
+
api_base,
|
|
629
|
+
args.session_id,
|
|
630
|
+
participant_key=_participant_key(args),
|
|
631
|
+
after=0,
|
|
632
|
+
)
|
|
633
|
+
key = "participants" if command.endswith("participants") else "analytics"
|
|
634
|
+
fallback: Any = [] if key == "participants" else {}
|
|
635
|
+
return {
|
|
636
|
+
"protocol_version": "anu.roundtable.remote.v1",
|
|
637
|
+
"session_id": args.session_id,
|
|
638
|
+
key: context.get(key) or fallback,
|
|
639
|
+
}
|
|
640
|
+
if command == "roundtable-recover":
|
|
641
|
+
return _remote_object(
|
|
642
|
+
"POST",
|
|
643
|
+
f"{api_base}/roundtable/sessions/{args.session_id}/recover",
|
|
644
|
+
headers={"X-ANU-Session-Key": _session_key(args)},
|
|
645
|
+
json=_without_none(
|
|
646
|
+
{
|
|
647
|
+
"expected_version": args.expected_version,
|
|
648
|
+
"retry_publication": args.retry_publication,
|
|
649
|
+
"force": args.force,
|
|
650
|
+
}
|
|
651
|
+
),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if command == "course-start":
|
|
655
|
+
args.student = _course_target_student(args)
|
|
656
|
+
_remote_object(
|
|
657
|
+
"GET",
|
|
658
|
+
f"{api_base}/courses/{args.course_id}/agent-commands",
|
|
659
|
+
params={"student": args.student},
|
|
660
|
+
)
|
|
661
|
+
identity, agent_key, issued_new_key = _remote_agent_identity(api_base, args)
|
|
662
|
+
try:
|
|
663
|
+
enrollment = _remote_object(
|
|
664
|
+
"POST",
|
|
665
|
+
f"{api_base}/learn/enrollments",
|
|
666
|
+
headers=_bearer_headers(agent_key),
|
|
667
|
+
json=_without_none(
|
|
668
|
+
{
|
|
669
|
+
"course_id": args.course_id,
|
|
670
|
+
"student": args.student,
|
|
671
|
+
"professor": args.professor,
|
|
672
|
+
"enrollment_id": args.enrollment_id,
|
|
673
|
+
"dry_run": args.dry_run,
|
|
674
|
+
"llm_grade": not args.dry_run,
|
|
675
|
+
}
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
except (ValueError, OSError, httpx.HTTPError) as exc:
|
|
679
|
+
if issued_new_key:
|
|
680
|
+
exc.partial_result = { # type: ignore[attr-defined]
|
|
681
|
+
"agent": identity,
|
|
682
|
+
"credential_notice": (
|
|
683
|
+
"Registration succeeded before Enrollment creation failed. "
|
|
684
|
+
"Store agent.agent_key and retry course-start with --agent-key-env."
|
|
685
|
+
),
|
|
686
|
+
}
|
|
687
|
+
raise
|
|
688
|
+
return {
|
|
689
|
+
"protocol_version": "anu.learn.v1",
|
|
690
|
+
"agent": identity,
|
|
691
|
+
"enrollment": enrollment,
|
|
692
|
+
"credential_source": "issued" if issued_new_key else "reused",
|
|
693
|
+
"credential_notice": (
|
|
694
|
+
"Store agent.agent_key now; ANU cannot recover it."
|
|
695
|
+
if issued_new_key
|
|
696
|
+
else "The Enrollment is bound to the supplied existing Agent identity."
|
|
697
|
+
),
|
|
698
|
+
"next_command": (
|
|
699
|
+
f'{CLI_RUN_PREFIX} remote course-follow "{api_base}" '
|
|
700
|
+
f'"{enrollment["enrollment_id"]}" --agent-key-env ANU_AGENT_KEY --json'
|
|
701
|
+
),
|
|
702
|
+
}
|
|
703
|
+
if command == "course-claim":
|
|
704
|
+
claim_token = _secret_value(args.token, args.token_env, "course claim token")
|
|
705
|
+
assert claim_token is not None
|
|
706
|
+
args.student = _course_target_student(args)
|
|
707
|
+
identity, agent_key, issued_new_key = _remote_agent_identity(api_base, args)
|
|
708
|
+
try:
|
|
709
|
+
claimed = _remote_object(
|
|
710
|
+
"POST",
|
|
711
|
+
f"{api_base}/course-invitations/{args.invitation_id}/claim",
|
|
712
|
+
headers=_bearer_headers(agent_key),
|
|
713
|
+
json={"claim_token": claim_token},
|
|
714
|
+
)
|
|
715
|
+
except (ValueError, OSError, httpx.HTTPError) as exc:
|
|
716
|
+
if issued_new_key:
|
|
717
|
+
exc.partial_result = { # type: ignore[attr-defined]
|
|
718
|
+
"agent": identity,
|
|
719
|
+
"credential_notice": (
|
|
720
|
+
"Agent registration succeeded before invitation claim failed. "
|
|
721
|
+
"Store agent.agent_key and retry course-claim with --agent-key-env."
|
|
722
|
+
),
|
|
723
|
+
}
|
|
724
|
+
raise
|
|
725
|
+
return {
|
|
726
|
+
"protocol_version": "anu.learn.invitation.v1",
|
|
727
|
+
"agent": identity,
|
|
728
|
+
**claimed,
|
|
729
|
+
"credential_source": "issued" if issued_new_key else "reused",
|
|
730
|
+
"credential_notice": (
|
|
731
|
+
"Store agent.agent_key now; ANU cannot recover it."
|
|
732
|
+
if issued_new_key
|
|
733
|
+
else "The invitation was claimed by the supplied existing Agent identity."
|
|
734
|
+
),
|
|
735
|
+
"next_command": (
|
|
736
|
+
f'{CLI_RUN_PREFIX} remote course-follow "{api_base}" '
|
|
737
|
+
f'"{claimed["enrollment"]["enrollment_id"]}" '
|
|
738
|
+
"--agent-key-env ANU_AGENT_KEY --json"
|
|
739
|
+
),
|
|
740
|
+
}
|
|
741
|
+
if command in {"course-invitation-status", "course-invitation-revoke"}:
|
|
742
|
+
method = "DELETE" if command == "course-invitation-revoke" else "GET"
|
|
743
|
+
return _remote_object(
|
|
744
|
+
method,
|
|
745
|
+
f"{api_base}/course-invitations/{args.invitation_id}",
|
|
746
|
+
headers={"X-ANU-Observer-Token": _observer_token(args)},
|
|
747
|
+
)
|
|
748
|
+
if command in {"course-status", "course-next"}:
|
|
749
|
+
suffix = "/next" if command == "course-next" else ""
|
|
750
|
+
return _remote_object(
|
|
751
|
+
"GET",
|
|
752
|
+
f"{api_base}/learn/enrollments/{args.enrollment_id}{suffix}",
|
|
753
|
+
headers=_agent_headers(args),
|
|
754
|
+
)
|
|
755
|
+
if command == "course-follow":
|
|
756
|
+
return _course_follow(api_base, args)
|
|
757
|
+
if command == "course-submit":
|
|
758
|
+
return _remote_object(
|
|
759
|
+
"POST",
|
|
760
|
+
f"{api_base}/learn/enrollments/{args.enrollment_id}/submissions",
|
|
761
|
+
headers=_agent_headers(args),
|
|
762
|
+
json=_without_none(
|
|
763
|
+
{
|
|
764
|
+
"stage": args.stage,
|
|
765
|
+
"answer": _answer_value(args.answer, args.answer_file, "course submission"),
|
|
766
|
+
"expected_version": args.expected_version,
|
|
767
|
+
"command_id": args.command_id,
|
|
768
|
+
}
|
|
769
|
+
),
|
|
770
|
+
)
|
|
771
|
+
if command == "course-step":
|
|
772
|
+
return _remote_object(
|
|
773
|
+
"POST",
|
|
774
|
+
f"{api_base}/learn/enrollments/{args.enrollment_id}/steps",
|
|
775
|
+
headers=_agent_headers(args),
|
|
776
|
+
json=_without_none(
|
|
777
|
+
{"expected_version": args.expected_version, "command_id": args.command_id}
|
|
778
|
+
),
|
|
779
|
+
)
|
|
780
|
+
if command == "course-recover":
|
|
781
|
+
return _remote_object(
|
|
782
|
+
"POST",
|
|
783
|
+
f"{api_base}/learn/enrollments/{args.enrollment_id}/recover",
|
|
784
|
+
headers=_agent_headers(args),
|
|
785
|
+
json=_without_none(
|
|
786
|
+
{
|
|
787
|
+
"operation_id": args.operation_id,
|
|
788
|
+
"expected_version": args.expected_version,
|
|
789
|
+
"force": args.force,
|
|
790
|
+
}
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
if command == "course-exam":
|
|
794
|
+
return _course_exam(api_base, args)
|
|
795
|
+
if command == "course-certificate":
|
|
796
|
+
return _remote_object("GET", f"{api_base}/learn/certificates/{args.certificate_id}")
|
|
797
|
+
|
|
798
|
+
if command == "review-create":
|
|
799
|
+
return _remote_object(
|
|
800
|
+
"POST",
|
|
801
|
+
f"{api_base}/review/sessions",
|
|
802
|
+
json=_without_none(
|
|
803
|
+
{
|
|
804
|
+
"kind": args.kind,
|
|
805
|
+
"title": args.title,
|
|
806
|
+
"discipline": args.discipline,
|
|
807
|
+
"degree_level": args.degree_level,
|
|
808
|
+
"university": args.university,
|
|
809
|
+
"venue": args.venue,
|
|
810
|
+
"standards": _answer_value(
|
|
811
|
+
args.standards,
|
|
812
|
+
args.standards_file,
|
|
813
|
+
"review standards",
|
|
814
|
+
required=False,
|
|
815
|
+
),
|
|
816
|
+
"dry_run": args.dry_run,
|
|
817
|
+
}
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
if command == "review-upload":
|
|
821
|
+
if not args.file.is_file():
|
|
822
|
+
raise ValueError(f"review material file does not exist: {args.file}")
|
|
823
|
+
content_type = mimetypes.guess_type(args.file.name)[0] or "application/octet-stream"
|
|
824
|
+
form = {"label": args.label}
|
|
825
|
+
if args.expected_version is not None:
|
|
826
|
+
form["expected_version"] = str(args.expected_version)
|
|
827
|
+
return _remote_object(
|
|
828
|
+
"POST",
|
|
829
|
+
f"{api_base}/review/sessions/{args.session_id}/documents",
|
|
830
|
+
headers=_session_headers(args),
|
|
831
|
+
data=form,
|
|
832
|
+
files={"file": (args.file.name, args.file.read_bytes(), content_type)},
|
|
833
|
+
)
|
|
834
|
+
if command in {"review-start", "review-finalize"}:
|
|
835
|
+
action = "start" if command == "review-start" else "finalize"
|
|
836
|
+
return _remote_object(
|
|
837
|
+
"POST",
|
|
838
|
+
f"{api_base}/review/sessions/{args.session_id}/{action}",
|
|
839
|
+
headers=_session_headers(args),
|
|
840
|
+
json=_without_none({"expected_version": args.expected_version}),
|
|
841
|
+
)
|
|
842
|
+
if command == "review-respond":
|
|
843
|
+
return _remote_object(
|
|
844
|
+
"POST",
|
|
845
|
+
f"{api_base}/review/sessions/{args.session_id}/responses",
|
|
846
|
+
headers=_session_headers(args),
|
|
847
|
+
json=_without_none(
|
|
848
|
+
{
|
|
849
|
+
"responses": _review_responses(args),
|
|
850
|
+
"expected_version": args.expected_version,
|
|
851
|
+
}
|
|
852
|
+
),
|
|
853
|
+
)
|
|
854
|
+
if command == "review-status":
|
|
855
|
+
path = f"{api_base}/review/sessions/{args.session_id}"
|
|
856
|
+
headers = _session_headers(args)
|
|
857
|
+
session = _remote_object("GET", path, headers=headers)
|
|
858
|
+
if args.events_after is None:
|
|
859
|
+
return session
|
|
860
|
+
events = _remote_list(
|
|
861
|
+
"GET",
|
|
862
|
+
path + "/events",
|
|
863
|
+
headers=headers,
|
|
864
|
+
params={"after": max(0, args.events_after)},
|
|
865
|
+
)
|
|
866
|
+
return {
|
|
867
|
+
"protocol_version": "anu.review.v1",
|
|
868
|
+
"session": session,
|
|
869
|
+
"events": events,
|
|
870
|
+
"next_after": _event_cursor(events, args.events_after),
|
|
871
|
+
}
|
|
872
|
+
if command == "review-delete":
|
|
873
|
+
return _remote_object(
|
|
874
|
+
"DELETE",
|
|
875
|
+
f"{api_base}/review/sessions/{args.session_id}",
|
|
876
|
+
headers=_session_headers(args),
|
|
877
|
+
)
|
|
878
|
+
raise ValueError(f"unknown remote command: {command}")
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _course_follow(api_base: str, args: Any) -> dict[str, Any]:
|
|
882
|
+
if not 1 <= args.max_actions <= 20:
|
|
883
|
+
raise ValueError("--max-actions must be between 1 and 20")
|
|
884
|
+
headers = _agent_headers(args)
|
|
885
|
+
resource_path = f"/learn/enrollments/{args.enrollment_id}"
|
|
886
|
+
actions_executed: list[str] = []
|
|
887
|
+
stopped_reason = "max_actions_reached"
|
|
888
|
+
for _ in range(args.max_actions):
|
|
889
|
+
state = _remote_object("GET", f"{api_base}{resource_path}/next", headers=headers)
|
|
890
|
+
action = state.get("next") or {}
|
|
891
|
+
if action.get("actor") == "anu" and action.get("client_action") == "invoke":
|
|
892
|
+
if action.get("method") != "POST" or action.get("path") != resource_path + "/steps":
|
|
893
|
+
raise ValueError("ANU returned an unsupported course step action")
|
|
894
|
+
result = _remote_object(
|
|
895
|
+
"POST",
|
|
896
|
+
f"{api_base}{resource_path}/steps",
|
|
897
|
+
headers=headers,
|
|
898
|
+
json=action.get("body") or {},
|
|
899
|
+
)
|
|
900
|
+
actions_executed.append(str(action.get("action") or "anu_step"))
|
|
901
|
+
if result.get("grading_error"):
|
|
902
|
+
stopped_reason = "anu_grading_error"
|
|
903
|
+
break
|
|
904
|
+
continue
|
|
905
|
+
if action.get("action") == "wait_or_recover":
|
|
906
|
+
if not action.get("recovery_ready"):
|
|
907
|
+
stopped_reason = "operation_lease_active"
|
|
908
|
+
break
|
|
909
|
+
if args.no_recover_expired:
|
|
910
|
+
stopped_reason = "expired_operation_requires_recovery"
|
|
911
|
+
break
|
|
912
|
+
if action.get("method") != "POST" or action.get("path") != resource_path + "/recover":
|
|
913
|
+
raise ValueError("ANU returned an unsupported course recovery action")
|
|
914
|
+
_remote_object(
|
|
915
|
+
"POST",
|
|
916
|
+
f"{api_base}{resource_path}/recover",
|
|
917
|
+
headers=headers,
|
|
918
|
+
json=action.get("body") or {},
|
|
919
|
+
)
|
|
920
|
+
actions_executed.append("recover_expired_operation")
|
|
921
|
+
continue
|
|
922
|
+
stopped_reason = "student_action_required" if action.get("actor") == "student" else "workflow_finished"
|
|
923
|
+
break
|
|
924
|
+
enrollment = _remote_object("GET", f"{api_base}{resource_path}", headers=headers)
|
|
925
|
+
next_action = enrollment.get("next")
|
|
926
|
+
return {
|
|
927
|
+
"protocol_version": "anu.learn.v1",
|
|
928
|
+
"enrollment_id": args.enrollment_id,
|
|
929
|
+
"actions_executed": actions_executed,
|
|
930
|
+
"stopped_reason": stopped_reason,
|
|
931
|
+
"enrollment": enrollment,
|
|
932
|
+
"next": next_action,
|
|
933
|
+
"pending_client_actions": _course_pending_client_actions(
|
|
934
|
+
next_action if isinstance(next_action, dict) else None
|
|
935
|
+
),
|
|
936
|
+
"links": enrollment.get("links"),
|
|
937
|
+
"protocol": enrollment.get("protocol"),
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _course_exam(api_base: str, args: Any) -> dict[str, Any]:
|
|
942
|
+
headers = _agent_headers(args)
|
|
943
|
+
path = f"{api_base}/learn/enrollments/{args.enrollment_id}"
|
|
944
|
+
enrollment = _remote_object("GET", path, headers=headers)
|
|
945
|
+
if enrollment.get("phase") != "posttest":
|
|
946
|
+
next_action = _remote_object("GET", path + "/next", headers=headers)
|
|
947
|
+
raise ValueError(
|
|
948
|
+
"Enrollment is not ready for the exam; follow next action: "
|
|
949
|
+
+ json.dumps(next_action.get("next"), ensure_ascii=False)
|
|
950
|
+
)
|
|
951
|
+
submitted = _remote_object(
|
|
952
|
+
"POST",
|
|
953
|
+
path + "/submissions",
|
|
954
|
+
headers=headers,
|
|
955
|
+
json={
|
|
956
|
+
"stage": "posttest",
|
|
957
|
+
"answer": _answer_value(args.answer, args.answer_file, "exam answer"),
|
|
958
|
+
"expected_version": enrollment["version"],
|
|
959
|
+
},
|
|
960
|
+
)
|
|
961
|
+
completed = _remote_object(
|
|
962
|
+
"POST",
|
|
963
|
+
path + "/run",
|
|
964
|
+
headers=headers,
|
|
965
|
+
json={"expected_version": submitted["version"]},
|
|
966
|
+
)
|
|
967
|
+
return {
|
|
968
|
+
"protocol_version": "anu.learn.v1",
|
|
969
|
+
"enrollment": completed,
|
|
970
|
+
"certificate": completed.get("certificate"),
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _remote_json(method: str, url: str, **kwargs: Any) -> Any:
|
|
975
|
+
with httpx.Client(timeout=60) as client:
|
|
976
|
+
response = client.request(method, url, **kwargs)
|
|
977
|
+
try:
|
|
978
|
+
payload = response.json()
|
|
979
|
+
except ValueError as exc:
|
|
980
|
+
if response.is_error:
|
|
981
|
+
raise RemoteRequestError(
|
|
982
|
+
f"ANU returned a non-JSON error response ({response.status_code})",
|
|
983
|
+
status_code=response.status_code,
|
|
984
|
+
code=f"http_{response.status_code}",
|
|
985
|
+
retryable=_status_retryable(response.status_code),
|
|
986
|
+
method=method,
|
|
987
|
+
url=url,
|
|
988
|
+
) from exc
|
|
989
|
+
raise ValueError(f"ANU returned a non-JSON response ({response.status_code})") from exc
|
|
990
|
+
if response.is_error:
|
|
991
|
+
server = payload if isinstance(payload, dict) else {}
|
|
992
|
+
message = server.get("error") or server.get("message")
|
|
993
|
+
code = server.get("code") or server.get("type") or f"http_{response.status_code}"
|
|
994
|
+
retryable = server.get("retryable")
|
|
995
|
+
if not isinstance(retryable, bool):
|
|
996
|
+
retryable = _status_retryable(response.status_code)
|
|
997
|
+
details = server.get("details")
|
|
998
|
+
if details is None and isinstance(payload, dict):
|
|
999
|
+
remaining = {
|
|
1000
|
+
key: value
|
|
1001
|
+
for key, value in payload.items()
|
|
1002
|
+
if key not in {"error", "message", "code", "type", "retryable"}
|
|
1003
|
+
}
|
|
1004
|
+
details = remaining or None
|
|
1005
|
+
raise RemoteRequestError(
|
|
1006
|
+
str(message or f"ANU request failed with HTTP {response.status_code}"),
|
|
1007
|
+
status_code=response.status_code,
|
|
1008
|
+
code=str(code),
|
|
1009
|
+
retryable=retryable,
|
|
1010
|
+
details=details,
|
|
1011
|
+
method=method,
|
|
1012
|
+
url=url,
|
|
1013
|
+
)
|
|
1014
|
+
if not isinstance(payload, (dict, list)):
|
|
1015
|
+
raise ValueError("ANU returned an unexpected response shape")
|
|
1016
|
+
return payload
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _remote_object(method: str, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
1020
|
+
payload = _remote_json(method, url, **kwargs)
|
|
1021
|
+
if not isinstance(payload, dict):
|
|
1022
|
+
raise ValueError("ANU returned an unexpected response shape; expected an object")
|
|
1023
|
+
return payload
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _remote_list(method: str, url: str, **kwargs: Any) -> list[dict[str, Any]]:
|
|
1027
|
+
payload = _remote_json(method, url, **kwargs)
|
|
1028
|
+
if not isinstance(payload, list) or any(not isinstance(row, dict) for row in payload):
|
|
1029
|
+
raise ValueError("ANU returned an unexpected response shape; expected an object list")
|
|
1030
|
+
return payload
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _follow_roundtable(
|
|
1034
|
+
api_base: str,
|
|
1035
|
+
session_id: str,
|
|
1036
|
+
*,
|
|
1037
|
+
participant_key: str,
|
|
1038
|
+
after: int,
|
|
1039
|
+
timeout: float,
|
|
1040
|
+
poll_seconds: float,
|
|
1041
|
+
) -> dict[str, Any]:
|
|
1042
|
+
if timeout < 0:
|
|
1043
|
+
raise ValueError("timeout must be at least 0 seconds")
|
|
1044
|
+
if poll_seconds <= 0:
|
|
1045
|
+
raise ValueError("poll-seconds must be greater than 0")
|
|
1046
|
+
cursor = max(0, after)
|
|
1047
|
+
deadline = time.monotonic() + timeout
|
|
1048
|
+
while True:
|
|
1049
|
+
context = _guest_roundtable_context(
|
|
1050
|
+
api_base,
|
|
1051
|
+
session_id,
|
|
1052
|
+
participant_key=participant_key,
|
|
1053
|
+
after=cursor,
|
|
1054
|
+
)
|
|
1055
|
+
raw_events = context.get("events") or []
|
|
1056
|
+
if not isinstance(raw_events, list) or any(not isinstance(row, dict) for row in raw_events):
|
|
1057
|
+
raise ValueError("ANU guest context returned an invalid events list")
|
|
1058
|
+
events: list[dict[str, Any]] = raw_events
|
|
1059
|
+
session = context.get("session") if isinstance(context.get("session"), dict) else context
|
|
1060
|
+
timed_out = not events and time.monotonic() >= deadline
|
|
1061
|
+
if events or session.get("status") != "active" or timeout == 0 or timed_out:
|
|
1062
|
+
return {
|
|
1063
|
+
"protocol_version": "anu.roundtable.remote.v1",
|
|
1064
|
+
"session_id": session_id,
|
|
1065
|
+
"status": session.get("status"),
|
|
1066
|
+
"phase": session.get("phase"),
|
|
1067
|
+
"after": cursor,
|
|
1068
|
+
"next_after": _event_cursor(events, cursor),
|
|
1069
|
+
"events": events,
|
|
1070
|
+
"timed_out": timed_out,
|
|
1071
|
+
}
|
|
1072
|
+
remaining = deadline - time.monotonic()
|
|
1073
|
+
if remaining <= 0:
|
|
1074
|
+
continue
|
|
1075
|
+
time.sleep(min(poll_seconds, remaining))
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _guest_roundtable_context(
|
|
1079
|
+
api_base: str,
|
|
1080
|
+
session_id: str,
|
|
1081
|
+
*,
|
|
1082
|
+
participant_key: str,
|
|
1083
|
+
after: int,
|
|
1084
|
+
) -> dict[str, Any]:
|
|
1085
|
+
context = _remote_object(
|
|
1086
|
+
"GET",
|
|
1087
|
+
f"{api_base}/roundtable/sessions/{session_id}/guests/context",
|
|
1088
|
+
headers={"X-ANU-Participant-Key": participant_key},
|
|
1089
|
+
params={"after": max(0, after)},
|
|
1090
|
+
)
|
|
1091
|
+
raw_events = context.get("events")
|
|
1092
|
+
events = raw_events if isinstance(raw_events, list) else []
|
|
1093
|
+
try:
|
|
1094
|
+
next_after = int(context.get("next_cursor"))
|
|
1095
|
+
except (TypeError, ValueError):
|
|
1096
|
+
next_after = _event_cursor([row for row in events if isinstance(row, dict)], after)
|
|
1097
|
+
context.setdefault("next_after", next_after)
|
|
1098
|
+
return context
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _event_cursor(events: list[dict[str, Any]], fallback: int) -> int:
|
|
1102
|
+
cursor = max(0, fallback)
|
|
1103
|
+
for event in events:
|
|
1104
|
+
try:
|
|
1105
|
+
cursor = max(cursor, int(event.get("sequence", cursor)))
|
|
1106
|
+
except (TypeError, ValueError):
|
|
1107
|
+
continue
|
|
1108
|
+
return cursor
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _status_retryable(status_code: int) -> bool:
|
|
1112
|
+
return status_code in {408, 425, 429} or status_code >= 500
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _api_base(value: str) -> str:
|
|
1116
|
+
base = value.strip().rstrip("/")
|
|
1117
|
+
if not base.startswith(("http://", "https://")):
|
|
1118
|
+
raise ValueError("api_url must be an HTTP(S) URL")
|
|
1119
|
+
return base
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _emit(data: Any, as_json: bool) -> int:
|
|
1123
|
+
if as_json or isinstance(data, dict):
|
|
1124
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
1125
|
+
return 0
|
|
1126
|
+
for row in data:
|
|
1127
|
+
if isinstance(row, dict):
|
|
1128
|
+
print(" | ".join(f"{key}={value}" for key, value in row.items()))
|
|
1129
|
+
else:
|
|
1130
|
+
print(row)
|
|
1131
|
+
return 0
|