tasklease 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.
- tasklease/__init__.py +7 -0
- tasklease/__main__.py +5 -0
- tasklease/cli/__init__.py +53 -0
- tasklease/cli/common.py +87 -0
- tasklease/cli/identity.py +106 -0
- tasklease/cli/inbox.py +37 -0
- tasklease/cli/monitor.py +57 -0
- tasklease/cli/requester.py +197 -0
- tasklease/cli/service.py +169 -0
- tasklease/cli/tasks.py +175 -0
- tasklease/cli/topics.py +65 -0
- tasklease/cli/worker.py +186 -0
- tasklease/client.py +283 -0
- tasklease/cloudflare/__init__.py +6 -0
- tasklease/cloudflare/bundle.py +78 -0
- tasklease/cloudflare/deployer.py +714 -0
- tasklease/cloudflare/wrangler.py +300 -0
- tasklease/config.py +206 -0
- tasklease/errors.py +89 -0
- tasklease/fsio.py +275 -0
- tasklease/generated/__init__.py +1 -0
- tasklease/generated/protocol.py +478 -0
- tasklease/monitor/__init__.py +1 -0
- tasklease/monitor/server.py +325 -0
- tasklease/monitor/static/THIRD_PARTY_NOTICES.md +25 -0
- tasklease/monitor/static/assets/index-ClHk7fvq.css +1 -0
- tasklease/monitor/static/assets/index-CmFr3mRG.js +6 -0
- tasklease/monitor/static/index.html +13 -0
- tasklease/names.py +60 -0
- tasklease/py.typed +0 -0
- tasklease/service_bundle/README.md +1 -0
- tasklease/service_bundle/THIRD_PARTY_NOTICES.md +2163 -0
- tasklease/service_bundle/index.js +18533 -0
- tasklease/service_bundle/index.js.map +8 -0
- tasklease/service_bundle/manifest.json +4 -0
- tasklease/service_bundle/wrangler.template.jsonc +53 -0
- tasklease/store.py +669 -0
- tasklease/worker.py +444 -0
- tasklease/worker_control.py +114 -0
- tasklease/worker_templates/launchd.plist +22 -0
- tasklease/worker_templates/systemd.service +15 -0
- tasklease/worker_templates/windows-stop.ps1 +4 -0
- tasklease/worker_templates/windows.xml +27 -0
- tasklease-0.1.0.dist-info/METADATA +495 -0
- tasklease-0.1.0.dist-info/RECORD +48 -0
- tasklease-0.1.0.dist-info/WHEEL +4 -0
- tasklease-0.1.0.dist-info/entry_points.txt +2 -0
- tasklease-0.1.0.dist-info/licenses/LICENSE +202 -0
tasklease/__init__.py
ADDED
tasklease/__main__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The tasklease command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from tasklease import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
14
|
+
"""Parse a command without side effects until its handler runs."""
|
|
15
|
+
from tasklease.cli import (
|
|
16
|
+
identity,
|
|
17
|
+
inbox,
|
|
18
|
+
monitor,
|
|
19
|
+
requester,
|
|
20
|
+
service,
|
|
21
|
+
tasks,
|
|
22
|
+
topics,
|
|
23
|
+
worker,
|
|
24
|
+
)
|
|
25
|
+
from tasklease.cli.common import message
|
|
26
|
+
from tasklease.errors import TaskleaseError
|
|
27
|
+
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="tl", description="A leased task board for agents and workers."
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--config", type=Path)
|
|
35
|
+
parser.add_argument("--url")
|
|
36
|
+
parser.add_argument("--worker")
|
|
37
|
+
commands = parser.add_subparsers(dest="command")
|
|
38
|
+
for module in (identity, requester, inbox, tasks, topics, service, worker, monitor):
|
|
39
|
+
module.configure_parser(commands)
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
selected: object = getattr(args, "handler", None)
|
|
42
|
+
if selected is None:
|
|
43
|
+
parser.print_help()
|
|
44
|
+
return 0
|
|
45
|
+
handler = cast(Callable[[argparse.Namespace], int], selected)
|
|
46
|
+
try:
|
|
47
|
+
return handler(args)
|
|
48
|
+
except (TaskleaseError, OSError, ValueError) as error:
|
|
49
|
+
message(str(error))
|
|
50
|
+
return 1
|
|
51
|
+
except KeyboardInterrupt:
|
|
52
|
+
message("Interrupted")
|
|
53
|
+
return 130
|
tasklease/cli/common.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Shared command configuration and output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from tasklease.client import Client
|
|
14
|
+
from tasklease.config import Config, load_config
|
|
15
|
+
from tasklease.errors import ConfigError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def connection_options(parser: argparse.ArgumentParser) -> None:
|
|
19
|
+
parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
20
|
+
parser.add_argument("--url", default=argparse.SUPPRESS)
|
|
21
|
+
parser.add_argument("--worker", default=argparse.SUPPRESS)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def optional_text(args: argparse.Namespace, name: str) -> str | None:
|
|
25
|
+
value: object = getattr(args, name, None)
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
raise ConfigError(f"{name} must be text")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def text(args: argparse.Namespace, name: str) -> str:
|
|
34
|
+
value = optional_text(args, name)
|
|
35
|
+
if value is None:
|
|
36
|
+
raise ConfigError(f"{name} is required")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def flag(args: argparse.Namespace, name: str) -> bool:
|
|
41
|
+
value: object = getattr(args, name, False)
|
|
42
|
+
return value is True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def duration_seconds(value: str, *, name: str = "Duration") -> int:
|
|
46
|
+
"""Parse a positive duration expressed in seconds, minutes, hours or days."""
|
|
47
|
+
match = re.fullmatch(r"([0-9]+)(s|m|h|d)", value)
|
|
48
|
+
if not match or int(match[1]) <= 0:
|
|
49
|
+
raise ConfigError(
|
|
50
|
+
f"{name} must be positive seconds, minutes, hours or days, e.g. 3d"
|
|
51
|
+
)
|
|
52
|
+
return int(match[1]) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[match[2]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def configuration(args: argparse.Namespace) -> Config:
|
|
56
|
+
path: object = getattr(args, "config", None)
|
|
57
|
+
if path is not None and not isinstance(path, (str, Path)):
|
|
58
|
+
raise ConfigError("config must be a path")
|
|
59
|
+
return load_config(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def domain(args: argparse.Namespace, config: Config) -> str:
|
|
63
|
+
value = optional_text(args, "domain") or config.domain
|
|
64
|
+
if not value:
|
|
65
|
+
raise ConfigError("Give a domain or set domain in configuration")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@contextmanager
|
|
70
|
+
def client(args: argparse.Namespace, *, admin: bool = False) -> Iterator[Client]:
|
|
71
|
+
config = configuration(args)
|
|
72
|
+
url = optional_text(args, "url") or config.url
|
|
73
|
+
if not url:
|
|
74
|
+
raise ConfigError("Set the service URL in config.toml or use --url")
|
|
75
|
+
worker = optional_text(args, "worker") or config.worker_label
|
|
76
|
+
with Client(url, config.token(admin=admin), worker) as api:
|
|
77
|
+
yield api
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def output(value: object) -> None:
|
|
81
|
+
# Escaping also preserves unusual wire JSON strings, including isolated
|
|
82
|
+
# surrogate code units, without failing at the terminal's UTF-8 boundary.
|
|
83
|
+
print(json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":")))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def message(value: str) -> None:
|
|
87
|
+
print(value, file=sys.stderr)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Identity administration and inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from tasklease.cli.common import (
|
|
8
|
+
client,
|
|
9
|
+
connection_options,
|
|
10
|
+
flag,
|
|
11
|
+
message,
|
|
12
|
+
optional_text,
|
|
13
|
+
output,
|
|
14
|
+
text,
|
|
15
|
+
)
|
|
16
|
+
from tasklease.errors import ConfigError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _globs(args: argparse.Namespace, name: str) -> list[str]:
|
|
20
|
+
value = optional_text(args, name)
|
|
21
|
+
if value is None:
|
|
22
|
+
return []
|
|
23
|
+
result = value.split(",")
|
|
24
|
+
if any(not item for item in result):
|
|
25
|
+
raise ConfigError(f"--{name} contains an empty glob")
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _add(args: argparse.Namespace) -> int:
|
|
30
|
+
domains: object = getattr(args, "domain", None)
|
|
31
|
+
if not isinstance(domains, list):
|
|
32
|
+
raise ConfigError("Give at least one --domain")
|
|
33
|
+
checked_domains: list[str] = []
|
|
34
|
+
for item in domains:
|
|
35
|
+
if not isinstance(item, str):
|
|
36
|
+
raise ConfigError("Domain names must be text")
|
|
37
|
+
checked_domains.append(item)
|
|
38
|
+
own, create = _globs(args, "own"), _globs(args, "create")
|
|
39
|
+
with client(args, admin=True) as api:
|
|
40
|
+
result = api.identity_add(
|
|
41
|
+
{
|
|
42
|
+
"name": text(args, "name"),
|
|
43
|
+
"domains": checked_domains,
|
|
44
|
+
"scopes": {"own": own, "create": create, "read": _globs(args, "read")},
|
|
45
|
+
"operator": flag(args, "operator"),
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
output(result)
|
|
49
|
+
added = list(
|
|
50
|
+
dict.fromkeys(f"{glob}/error" for glob in own if f"{glob}/error" not in create)
|
|
51
|
+
)
|
|
52
|
+
if added:
|
|
53
|
+
message(f"Added create grants: {', '.join(added)}")
|
|
54
|
+
message("Store the identity token securely; it is shown only once.")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _revoke(args: argparse.Namespace) -> int:
|
|
59
|
+
with client(args, admin=True) as api:
|
|
60
|
+
output(api.identity_revoke({"name": text(args, "name")}))
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _rotate(args: argparse.Namespace) -> int:
|
|
65
|
+
with client(args, admin=True) as api:
|
|
66
|
+
output(api.identity_rotate({"name": text(args, "name")}))
|
|
67
|
+
message(
|
|
68
|
+
"Store the replacement token securely; the old token expires within 60 seconds."
|
|
69
|
+
)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _list(args: argparse.Namespace) -> int:
|
|
74
|
+
with client(args, admin=True) as api:
|
|
75
|
+
output(api.identity_list({}))
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _whoami(args: argparse.Namespace) -> int:
|
|
80
|
+
with client(args) as api:
|
|
81
|
+
output(api.identity_whoami({}))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def configure_parser(
|
|
86
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
87
|
+
) -> None:
|
|
88
|
+
parser = subparsers.add_parser("identity", help="Manage machine identities")
|
|
89
|
+
commands = parser.add_subparsers(dest="identity_command", required=True)
|
|
90
|
+
add = commands.add_parser("add", help="Issue an identity token")
|
|
91
|
+
connection_options(add)
|
|
92
|
+
add.add_argument("name")
|
|
93
|
+
add.add_argument("--domain", action="append", required=True)
|
|
94
|
+
for scope in ("own", "create", "read"):
|
|
95
|
+
add.add_argument(f"--{scope}", help="Comma-separated topic globs")
|
|
96
|
+
add.add_argument("--operator", action="store_true")
|
|
97
|
+
add.set_defaults(handler=_add)
|
|
98
|
+
for name, handler in (("revoke", _revoke), ("rotate", _rotate), ("list", _list)):
|
|
99
|
+
command = commands.add_parser(name)
|
|
100
|
+
connection_options(command)
|
|
101
|
+
if name != "list":
|
|
102
|
+
command.add_argument("name")
|
|
103
|
+
command.set_defaults(handler=handler)
|
|
104
|
+
whoami = subparsers.add_parser("whoami", help="Show the current identity")
|
|
105
|
+
connection_options(whoami)
|
|
106
|
+
whoami.set_defaults(handler=_whoami)
|
tasklease/cli/inbox.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Inspect and prune the machine's durable local reply stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from tasklease.cli.common import connection_options, flag, output
|
|
8
|
+
from tasklease.cli.requester import agent_store
|
|
9
|
+
from tasklease.store import Store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _inbox(args: argparse.Namespace) -> int:
|
|
13
|
+
stores = (
|
|
14
|
+
[Store(agent_id) for agent_id in Store.agents()]
|
|
15
|
+
if flag(args, "all")
|
|
16
|
+
else [agent_store(args)]
|
|
17
|
+
)
|
|
18
|
+
pruned = sum(store.prune() for store in stores) if flag(args, "prune") else None
|
|
19
|
+
result: dict[str, object] = {
|
|
20
|
+
"replies": [reply.to_dict() for store in stores for reply in store.inbox()]
|
|
21
|
+
}
|
|
22
|
+
if pruned is not None:
|
|
23
|
+
result["pruned"] = pruned
|
|
24
|
+
output(result)
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def configure_parser(
|
|
29
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
30
|
+
) -> None:
|
|
31
|
+
parser = subparsers.add_parser("inbox", help="List replies saved on this machine")
|
|
32
|
+
connection_options(parser)
|
|
33
|
+
parser.add_argument("--all", action="store_true", help="Include every local agent")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--prune", action="store_true", help="Remove records over 7 days"
|
|
36
|
+
)
|
|
37
|
+
parser.set_defaults(handler=_inbox)
|
tasklease/cli/monitor.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Open a read-only dashboard with credentials kept in this Python process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tasklease.cli.common import (
|
|
11
|
+
client,
|
|
12
|
+
connection_options,
|
|
13
|
+
flag,
|
|
14
|
+
message,
|
|
15
|
+
optional_text,
|
|
16
|
+
output,
|
|
17
|
+
)
|
|
18
|
+
from tasklease.errors import ConfigError
|
|
19
|
+
from tasklease.monitor.server import MonitorServer, validate_domain_selection
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run(args: argparse.Namespace) -> int:
|
|
23
|
+
selected_domain = validate_domain_selection(optional_text(args, "domain"))
|
|
24
|
+
port: object = getattr(args, "port", 0)
|
|
25
|
+
state_root: object = getattr(args, "state_dir", None)
|
|
26
|
+
if type(port) is not int or not 0 <= port <= 65535:
|
|
27
|
+
raise ConfigError("port must be an integer from 0 to 65535")
|
|
28
|
+
if state_root is not None and not isinstance(state_root, Path):
|
|
29
|
+
raise ConfigError("state-dir must be a path")
|
|
30
|
+
with (
|
|
31
|
+
client(args) as api,
|
|
32
|
+
MonitorServer(
|
|
33
|
+
api, port=port, state_root=state_root, domain=selected_domain
|
|
34
|
+
) as server,
|
|
35
|
+
):
|
|
36
|
+
output({"url": server.url})
|
|
37
|
+
message(f"Monitor at {server.url}; press Ctrl-C to stop")
|
|
38
|
+
sys.stdout.flush()
|
|
39
|
+
sys.stderr.flush()
|
|
40
|
+
if not flag(args, "no_browser"):
|
|
41
|
+
webbrowser.open(server.url)
|
|
42
|
+
server.serve_forever(poll_interval=0.2)
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def configure_parser(
|
|
47
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
48
|
+
) -> None:
|
|
49
|
+
parser = subparsers.add_parser("monitor", help="Open the read-only local dashboard")
|
|
50
|
+
connection_options(parser)
|
|
51
|
+
parser.add_argument("--domain", help="Limit service views to an authorized domain")
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--port", type=int, default=0, help="Local port, default free port"
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--no-browser", action="store_true")
|
|
56
|
+
parser.add_argument("--state-dir", type=Path)
|
|
57
|
+
parser.set_defaults(handler=_run)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Durable requester commands and recovery after uncertain submissions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from tasklease.cli.common import (
|
|
12
|
+
client,
|
|
13
|
+
configuration,
|
|
14
|
+
connection_options,
|
|
15
|
+
domain,
|
|
16
|
+
duration_seconds,
|
|
17
|
+
flag,
|
|
18
|
+
message,
|
|
19
|
+
optional_text,
|
|
20
|
+
output,
|
|
21
|
+
text,
|
|
22
|
+
)
|
|
23
|
+
from tasklease.client import Client, reject_nonfinite
|
|
24
|
+
from tasklease.errors import ConfigError, TaskleaseError
|
|
25
|
+
from tasklease.store import RequestRecord, Store
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def agent_store(args: argparse.Namespace) -> Store:
|
|
29
|
+
"""Select a single agent without requiring any network configuration."""
|
|
30
|
+
agent_id = configuration(args).agent_id
|
|
31
|
+
if agent_id is None:
|
|
32
|
+
raise ConfigError("Set agent_id in config.toml or TL_AGENT_ID")
|
|
33
|
+
return Store(agent_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _data(value: str) -> dict[str, object]:
|
|
37
|
+
if value.startswith("@"):
|
|
38
|
+
if len(value) == 1:
|
|
39
|
+
raise ConfigError("--data @file requires a file path")
|
|
40
|
+
try:
|
|
41
|
+
value = Path(value[1:]).read_text(encoding="utf-8")
|
|
42
|
+
except (OSError, UnicodeError):
|
|
43
|
+
raise ConfigError("Cannot read --data file") from None
|
|
44
|
+
try:
|
|
45
|
+
decoded: object = json.loads(value, parse_constant=reject_nonfinite)
|
|
46
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
47
|
+
raise ConfigError("--data must contain a valid JSON object") from None
|
|
48
|
+
if not isinstance(decoded, dict):
|
|
49
|
+
raise ConfigError("--data must contain a JSON object")
|
|
50
|
+
return cast(dict[str, object], decoded)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _status(record: RequestRecord) -> None:
|
|
54
|
+
output(
|
|
55
|
+
{
|
|
56
|
+
"request_id": record.request_id,
|
|
57
|
+
"task_id": record.task_id,
|
|
58
|
+
"state": record.state,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _report_recovery(store: Store, request_id: str) -> None:
|
|
64
|
+
try:
|
|
65
|
+
record = store.request(request_id)
|
|
66
|
+
except (TaskleaseError, OSError, ValueError):
|
|
67
|
+
return
|
|
68
|
+
if record is None:
|
|
69
|
+
return
|
|
70
|
+
_status(record)
|
|
71
|
+
if record.state == "submission_unknown":
|
|
72
|
+
message(
|
|
73
|
+
"Submission outcome is unknown. Resume with tl wait --request-id "
|
|
74
|
+
f"{request_id}, or resend with tl request --resend {request_id}. "
|
|
75
|
+
"Cancellation is unavailable until a task ID is saved."
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
message(
|
|
79
|
+
f"Request {request_id} remains saved. "
|
|
80
|
+
f"Resume with tl wait --request-id {request_id}."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wait_for(store: Store, api: Client, request_id: str, timeout_seconds: int) -> int:
|
|
85
|
+
reply = store.wait(api, request_id, timeout_seconds=timeout_seconds)
|
|
86
|
+
if reply is None:
|
|
87
|
+
output({"request_id": request_id, "timed_out": True})
|
|
88
|
+
message(f"Timed out; request {request_id} remains saved for tl wait")
|
|
89
|
+
return 3
|
|
90
|
+
output({"request_id": request_id, "data": reply.data, "creator": reply.creator})
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _request(args: argparse.Namespace) -> int:
|
|
95
|
+
timeout = duration_seconds(text(args, "timeout"), name="Timeout")
|
|
96
|
+
resend = optional_text(args, "resend")
|
|
97
|
+
if resend is not None:
|
|
98
|
+
if any(
|
|
99
|
+
optional_text(args, name) is not None
|
|
100
|
+
for name in ("domain", "topic", "reply_topic", "data", "request_id")
|
|
101
|
+
):
|
|
102
|
+
raise ConfigError(
|
|
103
|
+
"--resend uses the saved request; omit --domain, --topic, "
|
|
104
|
+
"--reply-topic, --data and --request-id"
|
|
105
|
+
)
|
|
106
|
+
request_id = resend
|
|
107
|
+
payload = None
|
|
108
|
+
selected = topic = reply_topic = None
|
|
109
|
+
else:
|
|
110
|
+
selected = domain(args, configuration(args))
|
|
111
|
+
topic = text(args, "topic")
|
|
112
|
+
reply_topic = text(args, "reply_topic")
|
|
113
|
+
payload = _data(text(args, "data"))
|
|
114
|
+
supplied_id = optional_text(args, "request_id")
|
|
115
|
+
request_id = uuid4().hex if supplied_id is None else supplied_id
|
|
116
|
+
store = agent_store(args)
|
|
117
|
+
with client(args) as api:
|
|
118
|
+
try:
|
|
119
|
+
if resend is not None:
|
|
120
|
+
record = store.resend(api, request_id)
|
|
121
|
+
else:
|
|
122
|
+
assert selected is not None and topic is not None
|
|
123
|
+
assert reply_topic is not None and payload is not None
|
|
124
|
+
record = store.submit(
|
|
125
|
+
api,
|
|
126
|
+
selected,
|
|
127
|
+
topic,
|
|
128
|
+
reply_topic,
|
|
129
|
+
payload,
|
|
130
|
+
request_id=request_id,
|
|
131
|
+
)
|
|
132
|
+
if flag(args, "wait"):
|
|
133
|
+
return _wait_for(store, api, request_id, timeout)
|
|
134
|
+
except (TaskleaseError, OSError, ValueError, KeyboardInterrupt):
|
|
135
|
+
_report_recovery(store, request_id)
|
|
136
|
+
raise
|
|
137
|
+
_status(record)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _wait(args: argparse.Namespace) -> int:
|
|
142
|
+
timeout = duration_seconds(text(args, "timeout"), name="Timeout")
|
|
143
|
+
store = agent_store(args)
|
|
144
|
+
with client(args) as api:
|
|
145
|
+
return _wait_for(store, api, text(args, "request_id"), timeout)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cancel(args: argparse.Namespace) -> int:
|
|
149
|
+
store = agent_store(args)
|
|
150
|
+
request_id = text(args, "request_id")
|
|
151
|
+
with client(args) as api:
|
|
152
|
+
result = store.cancel(api, request_id)
|
|
153
|
+
output(
|
|
154
|
+
{
|
|
155
|
+
"request_id": request_id,
|
|
156
|
+
"cancelled": result == "cancelled",
|
|
157
|
+
"state": result,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
if result == "submission_unknown":
|
|
161
|
+
message("Cannot cancel: the submission has no saved task ID; use tl wait")
|
|
162
|
+
return 1
|
|
163
|
+
if result == "not_found":
|
|
164
|
+
message(
|
|
165
|
+
"Cannot confirm cancellation: the task was already taken or removed. "
|
|
166
|
+
"The request remains saved for tl wait."
|
|
167
|
+
)
|
|
168
|
+
return 1
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def configure_parser(
|
|
173
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
174
|
+
) -> None:
|
|
175
|
+
request = subparsers.add_parser("request", help="Submit a durable agent request")
|
|
176
|
+
connection_options(request)
|
|
177
|
+
request.add_argument("--domain")
|
|
178
|
+
request.add_argument("--topic")
|
|
179
|
+
request.add_argument("--reply-topic")
|
|
180
|
+
request.add_argument("--data", help="JSON object or @file")
|
|
181
|
+
identifiers = request.add_mutually_exclusive_group()
|
|
182
|
+
identifiers.add_argument("--request-id")
|
|
183
|
+
identifiers.add_argument("--resend", metavar="REQUEST_ID")
|
|
184
|
+
request.add_argument("--wait", action="store_true")
|
|
185
|
+
request.add_argument("--timeout", default="6h", help="Wait duration, e.g. 6h")
|
|
186
|
+
request.set_defaults(handler=_request)
|
|
187
|
+
|
|
188
|
+
wait = subparsers.add_parser("wait", help="Wait for a saved request's reply")
|
|
189
|
+
connection_options(wait)
|
|
190
|
+
wait.add_argument("--request-id", required=True)
|
|
191
|
+
wait.add_argument("--timeout", default="6h", help="Wait duration, e.g. 6h")
|
|
192
|
+
wait.set_defaults(handler=_wait)
|
|
193
|
+
|
|
194
|
+
cancel = subparsers.add_parser("cancel", help="Cancel a saved unowned request")
|
|
195
|
+
connection_options(cancel)
|
|
196
|
+
cancel.add_argument("--request-id", required=True)
|
|
197
|
+
cancel.set_defaults(handler=_cancel)
|