offwork 0.4.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.
Files changed (42) hide show
  1. offwork/__init__.py +167 -0
  2. offwork/__main__.py +770 -0
  3. offwork/_venv.py +174 -0
  4. offwork/core/__init__.py +15 -0
  5. offwork/core/errors.py +83 -0
  6. offwork/core/models.py +174 -0
  7. offwork/core/pairing.py +389 -0
  8. offwork/core/progress.py +91 -0
  9. offwork/core/signing.py +91 -0
  10. offwork/core/task.py +520 -0
  11. offwork/core/token.py +184 -0
  12. offwork/core/version.py +10 -0
  13. offwork/graph/__init__.py +5 -0
  14. offwork/graph/analyzer.py +637 -0
  15. offwork/graph/decorator.py +87 -0
  16. offwork/graph/graph.py +995 -0
  17. offwork/graph/store.py +500 -0
  18. offwork/graph/tracing.py +429 -0
  19. offwork/py.typed +0 -0
  20. offwork/typing.py +48 -0
  21. offwork/worker/__init__.py +18 -0
  22. offwork/worker/backends/__init__.py +3 -0
  23. offwork/worker/backends/base.py +149 -0
  24. offwork/worker/backends/http.py +237 -0
  25. offwork/worker/backends/local.py +452 -0
  26. offwork/worker/backends/rabbitmq.py +410 -0
  27. offwork/worker/backends/redis.py +175 -0
  28. offwork/worker/deps.py +365 -0
  29. offwork/worker/remote.py +793 -0
  30. offwork/worker/result.py +276 -0
  31. offwork/worker/sandbox/Dockerfile +24 -0
  32. offwork/worker/sandbox/__init__.py +18 -0
  33. offwork/worker/sandbox/_protocol.py +50 -0
  34. offwork/worker/sandbox/docker.py +438 -0
  35. offwork/worker/sandbox/guest_agent.py +622 -0
  36. offwork/worker/schedule.py +26 -0
  37. offwork/worker/worker.py +263 -0
  38. offwork-0.4.0.dist-info/METADATA +143 -0
  39. offwork-0.4.0.dist-info/RECORD +42 -0
  40. offwork-0.4.0.dist-info/WHEEL +4 -0
  41. offwork-0.4.0.dist-info/entry_points.txt +3 -0
  42. offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env python3
2
+ """Lightweight guest agent for offwork sandbox.
3
+
4
+ This script is deployed inside the Docker container and listens for
5
+ execution requests over TCP. It is completely self-contained (stdlib
6
+ only) so the container only needs a working Python ≥ 3.10 interpreter.
7
+
8
+ Wire protocol
9
+ -------------
10
+ Length-prefixed JSON (4-byte big-endian header + UTF-8 JSON payload),
11
+ identical to ``offwork.worker.sandbox._protocol``.
12
+
13
+ Request format::
14
+
15
+ {
16
+ "source": "<reconstructed Python source>",
17
+ "function_name": "f",
18
+ "args": [21],
19
+ "kwargs": {},
20
+ "owner_class": null
21
+ }
22
+
23
+ Success response::
24
+
25
+ {"status": "ok", "result": <value>}
26
+
27
+ Error response::
28
+
29
+ {
30
+ "status": "error",
31
+ "error_type": "ValueError",
32
+ "error_message": "...",
33
+ "error_traceback": "..."
34
+ }
35
+
36
+ Usage::
37
+
38
+ python guest_agent.py [--host 0.0.0.0] [--port 9749]
39
+ """
40
+
41
+ import sys
42
+ import json
43
+ import enum
44
+ import uuid
45
+ import types
46
+ import struct
47
+ import base64
48
+ import pickle
49
+ import asyncio
50
+ import inspect
51
+ import pathlib
52
+ import argparse
53
+ import functools
54
+ import ipaddress
55
+ import collections
56
+ import traceback as tb_mod
57
+ import contextvars
58
+ import datetime as _dt
59
+ from decimal import Decimal
60
+ from fractions import Fraction
61
+ from typing import Any
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Wire helpers (duplicated from _protocol.py to stay dependency-free)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ _HEADER = struct.Struct("!I")
68
+
69
+ _OBJECT_SENTINEL = "__offwork_obj__"
70
+ _BYTES_SENTINEL = "__offwork_bytes__"
71
+ _BUILTIN_SENTINEL = "__offwork_builtin__"
72
+ _TUPLE_SENTINEL = "__offwork_tuple__"
73
+ _DICT_SENTINEL = "__offwork_dict__"
74
+ _PICKLE_SENTINEL = "__offwork_pickle__"
75
+
76
+
77
+ def _encode(obj: dict[str, Any]) -> bytes:
78
+ payload = json.dumps(obj, separators=(",", ":"), default=_json_default).encode()
79
+ return _HEADER.pack(len(payload)) + payload
80
+
81
+
82
+ def _json_default(o: Any) -> Any:
83
+ """Fallback for objects the JSON encoder doesn't natively handle.
84
+
85
+ The host-side encoder pre-walks the tree, but the guest receives
86
+ *real* objects from user code (return values, exceptions) that need
87
+ the same sentinel treatment on the way back.
88
+ """
89
+ return _to_jsonable(o)
90
+
91
+
92
+ async def _recv(reader: asyncio.StreamReader) -> dict[str, Any]:
93
+ raw = await reader.readexactly(_HEADER.size)
94
+ (length,) = _HEADER.unpack(raw)
95
+ data = await reader.readexactly(length)
96
+ result: dict[str, Any] = json.loads(data)
97
+ return result
98
+
99
+
100
+ async def _send(writer: asyncio.StreamWriter, obj: dict[str, Any]) -> None:
101
+ writer.write(_encode(obj))
102
+ await writer.drain()
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Sentinel encoding / decoding (mirrors offwork.core.task)
107
+ # ---------------------------------------------------------------------------
108
+
109
+ _FACTORY_BY_NAME: dict[str, Any] = {
110
+ "int": int, "list": list, "dict": dict, "set": set,
111
+ "tuple": tuple, "frozenset": frozenset, "str": str, "float": float,
112
+ "bytes": bytes, "bool": bool,
113
+ }
114
+
115
+ _PATH_CLASSES: dict[str, type[pathlib.PurePath]] = {
116
+ "PurePath": pathlib.PurePath,
117
+ "PurePosixPath": pathlib.PurePosixPath,
118
+ "PureWindowsPath": pathlib.PureWindowsPath,
119
+ "Path": pathlib.Path,
120
+ "PosixPath": pathlib.PurePosixPath,
121
+ "WindowsPath": pathlib.PureWindowsPath,
122
+ }
123
+
124
+ _IP_CLASSES: dict[str, Any] = {
125
+ "IPv4Address": ipaddress.IPv4Address,
126
+ "IPv6Address": ipaddress.IPv6Address,
127
+ "IPv4Network": ipaddress.IPv4Network,
128
+ "IPv6Network": ipaddress.IPv6Network,
129
+ "IPv4Interface": ipaddress.IPv4Interface,
130
+ "IPv6Interface": ipaddress.IPv6Interface,
131
+ }
132
+
133
+
134
+ def _encode_factory(factory: Any) -> str | None:
135
+ if factory is None:
136
+ return None
137
+ name = getattr(factory, "__name__", None)
138
+ if isinstance(name, str) and _FACTORY_BY_NAME.get(name) is factory:
139
+ return name
140
+ return None
141
+
142
+
143
+ def _encode_builtin(o: object) -> dict[str, Any] | None:
144
+ if isinstance(o, enum.Enum):
145
+ return {
146
+ "type": "enum",
147
+ "cls": type(o).__name__,
148
+ "name": o.name,
149
+ "value": _to_jsonable(o.value),
150
+ }
151
+ if isinstance(o, _dt.datetime):
152
+ return {"type": "datetime", "value": o.isoformat()}
153
+ if isinstance(o, _dt.date):
154
+ return {"type": "date", "value": o.isoformat()}
155
+ if isinstance(o, _dt.time):
156
+ return {"type": "time", "value": o.isoformat()}
157
+ if isinstance(o, _dt.timedelta):
158
+ return {"type": "timedelta", "value": o.total_seconds()}
159
+ if isinstance(o, Decimal):
160
+ return {"type": "decimal", "value": str(o)}
161
+ if isinstance(o, Fraction):
162
+ return {"type": "fraction", "value": [o.numerator, o.denominator]}
163
+ if isinstance(o, uuid.UUID):
164
+ return {"type": "uuid", "value": o.hex}
165
+ if isinstance(o, complex):
166
+ return {"type": "complex", "value": [o.real, o.imag]}
167
+ if isinstance(o, range):
168
+ return {"type": "range", "value": [o.start, o.stop, o.step]}
169
+ if isinstance(o, frozenset):
170
+ return {"type": "frozenset", "value": [_to_jsonable(v) for v in o]}
171
+ if isinstance(o, set):
172
+ return {"type": "set", "value": [_to_jsonable(v) for v in o]}
173
+ if isinstance(o, collections.deque):
174
+ return {
175
+ "type": "deque",
176
+ "value": [_to_jsonable(v) for v in o],
177
+ "maxlen": o.maxlen,
178
+ }
179
+ if isinstance(o, pathlib.PurePath):
180
+ return {"type": "path", "value": str(o), "cls": type(o).__name__}
181
+ if isinstance(
182
+ o,
183
+ (
184
+ ipaddress.IPv4Address, ipaddress.IPv6Address,
185
+ ipaddress.IPv4Network, ipaddress.IPv6Network,
186
+ ipaddress.IPv4Interface, ipaddress.IPv6Interface,
187
+ ),
188
+ ):
189
+ return {"type": "ipaddress", "cls": type(o).__name__, "value": str(o)}
190
+ return None
191
+
192
+
193
+ def _extract_object_state(o: object) -> dict[str, Any] | None:
194
+ if hasattr(o, "__dict__"):
195
+ d = getattr(o, "__dict__", None)
196
+ if isinstance(d, dict):
197
+ return dict(d)
198
+ if hasattr(type(o), "__slots__"):
199
+ all_slots: set[str] = set()
200
+ for klass in type(o).__mro__:
201
+ all_slots.update(getattr(klass, "__slots__", ()))
202
+ all_slots -= {"__weakref__", "__dict__"}
203
+ return {
204
+ slot: getattr(o, slot)
205
+ for slot in sorted(all_slots)
206
+ if hasattr(o, slot)
207
+ }
208
+ return None
209
+
210
+
211
+ def _to_jsonable(o: Any) -> Any:
212
+ if o is None or isinstance(o, (str, bool)):
213
+ return o
214
+ if isinstance(o, enum.Enum):
215
+ return {_BUILTIN_SENTINEL: _encode_builtin(o)}
216
+ if isinstance(o, (int, float)):
217
+ return o
218
+ if isinstance(o, (bytes, bytearray)):
219
+ return {
220
+ _BYTES_SENTINEL: {
221
+ "data": base64.b64encode(bytes(o)).decode("ascii"),
222
+ "type": type(o).__name__,
223
+ }
224
+ }
225
+ if isinstance(o, memoryview):
226
+ return {
227
+ _BYTES_SENTINEL: {
228
+ "data": base64.b64encode(bytes(o)).decode("ascii"),
229
+ "type": "memoryview",
230
+ }
231
+ }
232
+ if isinstance(o, tuple):
233
+ if hasattr(o, "_fields") and hasattr(o, "_asdict"):
234
+ return {
235
+ _BUILTIN_SENTINEL: {
236
+ "type": "namedtuple",
237
+ "cls": type(o).__name__,
238
+ "fields": list(o._fields),
239
+ "values": [_to_jsonable(v) for v in o],
240
+ }
241
+ }
242
+ return {_TUPLE_SENTINEL: [_to_jsonable(v) for v in o]}
243
+ if isinstance(o, list):
244
+ return [_to_jsonable(v) for v in o]
245
+ if isinstance(o, dict):
246
+ if isinstance(o, collections.Counter):
247
+ return {
248
+ _BUILTIN_SENTINEL: {
249
+ "type": "counter",
250
+ "items": [[_to_jsonable(k), v] for k, v in o.items()],
251
+ }
252
+ }
253
+ if isinstance(o, collections.OrderedDict):
254
+ return {
255
+ _BUILTIN_SENTINEL: {
256
+ "type": "ordereddict",
257
+ "items": [
258
+ [_to_jsonable(k), _to_jsonable(v)] for k, v in o.items()
259
+ ],
260
+ }
261
+ }
262
+ if isinstance(o, collections.defaultdict):
263
+ return {
264
+ _BUILTIN_SENTINEL: {
265
+ "type": "defaultdict",
266
+ "factory": _encode_factory(o.default_factory),
267
+ "items": [
268
+ [_to_jsonable(k), _to_jsonable(v)] for k, v in o.items()
269
+ ],
270
+ }
271
+ }
272
+ if all(isinstance(k, str) for k in o):
273
+ return {k: _to_jsonable(v) for k, v in o.items()}
274
+ return {
275
+ _DICT_SENTINEL: [
276
+ [_to_jsonable(k), _to_jsonable(v)] for k, v in o.items()
277
+ ]
278
+ }
279
+ builtin = _encode_builtin(o)
280
+ if builtin is not None:
281
+ return {_BUILTIN_SENTINEL: builtin}
282
+ state = _extract_object_state(o)
283
+ if state is not None:
284
+ return {
285
+ _OBJECT_SENTINEL: {
286
+ "class": type(o).__name__,
287
+ "state": {k: _to_jsonable(v) for k, v in state.items()},
288
+ }
289
+ }
290
+ try:
291
+ data = pickle.dumps(o)
292
+ except Exception as exc:
293
+ raise TypeError(
294
+ f"Object of type {type(o).__name__} is not serializable: {exc}"
295
+ ) from exc
296
+ return {_PICKLE_SENTINEL: base64.b64encode(data).decode("ascii")}
297
+
298
+
299
+ def _decode_builtin(info: dict[str, Any], namespace: dict[str, Any]) -> Any:
300
+ kind = info.get("type")
301
+ raw: Any = info.get("value")
302
+ if kind == "datetime":
303
+ return _dt.datetime.fromisoformat(str(raw))
304
+ if kind == "date":
305
+ return _dt.date.fromisoformat(str(raw))
306
+ if kind == "time":
307
+ return _dt.time.fromisoformat(str(raw))
308
+ if kind == "timedelta":
309
+ return _dt.timedelta(seconds=float(raw))
310
+ if kind == "decimal":
311
+ return Decimal(str(raw))
312
+ if kind == "fraction":
313
+ return Fraction(int(raw[0]), int(raw[1]))
314
+ if kind == "uuid":
315
+ return uuid.UUID(hex=str(raw))
316
+ if kind == "complex":
317
+ return complex(raw[0], raw[1])
318
+ if kind == "range":
319
+ return range(raw[0], raw[1], raw[2])
320
+ if kind == "set":
321
+ return {_resolve(v, namespace) for v in raw}
322
+ if kind == "frozenset":
323
+ return frozenset(_resolve(v, namespace) for v in raw)
324
+ if kind == "deque":
325
+ return collections.deque(
326
+ (_resolve(v, namespace) for v in raw),
327
+ maxlen=info.get("maxlen"),
328
+ )
329
+ if kind == "counter":
330
+ return collections.Counter({
331
+ _resolve(k, namespace): v for k, v in info["items"]
332
+ })
333
+ if kind == "ordereddict":
334
+ return collections.OrderedDict(
335
+ (_resolve(k, namespace), _resolve(v, namespace))
336
+ for k, v in info["items"]
337
+ )
338
+ if kind == "defaultdict":
339
+ factory = _FACTORY_BY_NAME.get(info.get("factory") or "")
340
+ dd: collections.defaultdict[Any, Any] = collections.defaultdict(factory)
341
+ for k, v in info["items"]:
342
+ dd[_resolve(k, namespace)] = _resolve(v, namespace)
343
+ return dd
344
+ if kind == "namedtuple":
345
+ cls = namespace.get(info["cls"])
346
+ values = [_resolve(v, namespace) for v in info["values"]]
347
+ if cls is None:
348
+ return tuple(values)
349
+ return cls(*values)
350
+ if kind == "enum":
351
+ cls = namespace.get(info["cls"])
352
+ if cls is None:
353
+ return _resolve(raw, namespace)
354
+ try:
355
+ return cls[info["name"]]
356
+ except KeyError:
357
+ return cls(_resolve(raw, namespace))
358
+ if kind == "path":
359
+ cls = _PATH_CLASSES.get(info.get("cls", ""), pathlib.PurePath)
360
+ try:
361
+ return cls(str(raw))
362
+ except (NotImplementedError, TypeError):
363
+ return pathlib.PurePath(str(raw))
364
+ if kind == "ipaddress":
365
+ ip_cls = _IP_CLASSES.get(info.get("cls", ""))
366
+ if ip_cls is None:
367
+ return str(raw)
368
+ return ip_cls(str(raw))
369
+ raise ValueError(f"Unknown builtin sentinel type: {kind!r}")
370
+
371
+
372
+ def _reconstruct_object(info: dict[str, Any], namespace: dict[str, Any]) -> Any:
373
+ cls = namespace.get(info["class"])
374
+ if cls is None:
375
+ return {_OBJECT_SENTINEL: info}
376
+ obj = cls.__new__(cls)
377
+ state = {k: _resolve(v, namespace) for k, v in info.get("state", {}).items()}
378
+ if hasattr(obj, "__dict__"):
379
+ obj.__dict__.update(state)
380
+ else:
381
+ for key, val in state.items():
382
+ object.__setattr__(obj, key, val)
383
+ return obj
384
+
385
+
386
+ def _resolve(value: Any, namespace: dict[str, Any]) -> Any:
387
+ if isinstance(value, list):
388
+ return [_resolve(v, namespace) for v in value]
389
+ if not isinstance(value, dict):
390
+ return value
391
+ if len(value) == 1:
392
+ if _OBJECT_SENTINEL in value:
393
+ return _reconstruct_object(value[_OBJECT_SENTINEL], namespace)
394
+ if _BYTES_SENTINEL in value:
395
+ info = value[_BYTES_SENTINEL]
396
+ raw = base64.b64decode(info["data"])
397
+ kind = info.get("type")
398
+ if kind == "bytearray":
399
+ return bytearray(raw)
400
+ if kind == "memoryview":
401
+ return memoryview(raw)
402
+ return raw
403
+ if _BUILTIN_SENTINEL in value:
404
+ return _decode_builtin(value[_BUILTIN_SENTINEL], namespace)
405
+ if _TUPLE_SENTINEL in value:
406
+ return tuple(_resolve(v, namespace) for v in value[_TUPLE_SENTINEL])
407
+ if _DICT_SENTINEL in value:
408
+ return {
409
+ _resolve(k, namespace): _resolve(v, namespace)
410
+ for k, v in value[_DICT_SENTINEL]
411
+ }
412
+ if _PICKLE_SENTINEL in value:
413
+ return pickle.loads(base64.b64decode(value[_PICKLE_SENTINEL]))
414
+ return {k: _resolve(v, namespace) for k, v in value.items()}
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # Execution engine
419
+ # ---------------------------------------------------------------------------
420
+
421
+
422
+ def _extract_callable(
423
+ namespace: dict[str, Any],
424
+ function_name: str,
425
+ owner_class: str | None,
426
+ ) -> Any:
427
+ if owner_class:
428
+ class_name = owner_class.rsplit(".", 1)[-1]
429
+ cls = namespace.get(class_name)
430
+ if cls is None:
431
+ raise RuntimeError(f"Class '{class_name}' not found")
432
+ func = getattr(cls, function_name, None)
433
+ if func is None:
434
+ raise RuntimeError(
435
+ f"Method '{function_name}' not found on '{class_name}'"
436
+ )
437
+ return func
438
+ func = namespace.get(function_name)
439
+ if func is None:
440
+ raise RuntimeError(f"Function '{function_name}' not found")
441
+ return func
442
+
443
+
444
+ def _install_offwork_shim(
445
+ writer: asyncio.StreamWriter | None,
446
+ ) -> tuple[Any, ...]:
447
+ """Install a fake ``offwork`` package so ``from offwork import progress`` works.
448
+
449
+ The shim's ``progress()`` writes a ``{"status": "progress", ...}``
450
+ frame directly to *writer*. When *writer* is ``None`` (unit tests),
451
+ progress calls are silently ignored.
452
+
453
+ Returns the previous ``sys.modules`` entries so they can be restored.
454
+ """
455
+
456
+ def _progress(
457
+ _value: float,
458
+ _total: int | None = None,
459
+ /,
460
+ *,
461
+ message: str | None = None,
462
+ ) -> None:
463
+ if writer is None:
464
+ return
465
+ msg: dict[str, Any] = {"status": "progress", "current": _value}
466
+ if _total is not None:
467
+ msg["total"] = _total
468
+ if message is not None:
469
+ msg["message"] = message
470
+ # Synchronous write — fine from the event-loop thread and from
471
+ # executor threads via loop.call_soon_threadsafe (see below).
472
+ writer.write(_encode(msg))
473
+
474
+ # Build a minimal offwork package hierarchy.
475
+ fake = types.ModuleType("offwork")
476
+ fake.progress = _progress # type: ignore[attr-defined]
477
+ fake_core = types.ModuleType("offwork.core")
478
+ fake_core_progress = types.ModuleType("offwork.core.progress")
479
+ fake_core_progress.progress = _progress # type: ignore[attr-defined]
480
+ fake.core = fake_core # type: ignore[attr-defined]
481
+ fake_core.progress = fake_core_progress # type: ignore[attr-defined]
482
+
483
+ saved = (
484
+ sys.modules.get("offwork"),
485
+ sys.modules.get("offwork.core"),
486
+ sys.modules.get("offwork.core.progress"),
487
+ )
488
+ sys.modules["offwork"] = fake
489
+ sys.modules["offwork.core"] = fake_core
490
+ sys.modules["offwork.core.progress"] = fake_core_progress
491
+ return saved
492
+
493
+
494
+ def _uninstall_offwork_shim(saved: tuple[Any, ...]) -> None:
495
+ for key, prev in zip(
496
+ ("offwork", "offwork.core", "offwork.core.progress"), saved
497
+ ):
498
+ if prev is None:
499
+ sys.modules.pop(key, None)
500
+ else:
501
+ sys.modules[key] = prev
502
+
503
+
504
+ async def _execute_request(
505
+ req: dict[str, Any],
506
+ writer: asyncio.StreamWriter | None = None,
507
+ ) -> dict[str, Any]:
508
+ """Execute a single request and return a response dict.
509
+
510
+ When *writer* is provided, ``offwork.progress()`` calls inside the
511
+ user function are forwarded as ``{"status": "progress", ...}``
512
+ frames over the wire before the final ``ok`` / ``error`` response.
513
+ """
514
+ saved = _install_offwork_shim(writer)
515
+ try:
516
+ source: str = req["source"]
517
+ function_name: str = req["function_name"]
518
+ raw_args: list[Any] = req.get("args", [])
519
+ raw_kwargs: dict[str, Any] = req.get("kwargs", {})
520
+ owner_class: str | None = req.get("owner_class")
521
+
522
+ # Compile and exec
523
+ code = compile(source, f"<offwork-sandbox:{function_name}>", "exec")
524
+ namespace: dict[str, Any] = {}
525
+ exec(code, namespace) # noqa: S102
526
+
527
+ # Resolve serialised object arguments
528
+ args = tuple(_resolve(a, namespace) for a in raw_args)
529
+ kwargs = {k: _resolve(v, namespace) for k, v in raw_kwargs.items()}
530
+
531
+ func = _extract_callable(namespace, function_name, owner_class)
532
+
533
+ if inspect.iscoroutinefunction(func):
534
+ result = await func(*args, **kwargs)
535
+ else:
536
+ # Run sync functions in an executor so the event loop stays
537
+ # free to flush buffered progress writes.
538
+ loop = asyncio.get_running_loop()
539
+ ctx = contextvars.copy_context()
540
+ result = await loop.run_in_executor(
541
+ None, ctx.run, functools.partial(func, *args, **kwargs),
542
+ )
543
+
544
+ # Flush any buffered progress frames before the final response.
545
+ if writer is not None:
546
+ await writer.drain()
547
+
548
+ return {"status": "ok", "result": _to_jsonable(result)}
549
+
550
+ except Exception as exc:
551
+ return {
552
+ "status": "error",
553
+ "error_type": type(exc).__name__,
554
+ "error_message": str(exc),
555
+ "error_traceback": "".join(tb_mod.format_exception(exc)),
556
+ }
557
+ finally:
558
+ _uninstall_offwork_shim(saved)
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # TCP server
563
+ # ---------------------------------------------------------------------------
564
+
565
+
566
+ async def _handle_client(
567
+ reader: asyncio.StreamReader,
568
+ writer: asyncio.StreamWriter,
569
+ ) -> None:
570
+ peer = writer.get_extra_info("peername")
571
+ print(f"[guest-agent] connection from {peer}", flush=True)
572
+ try:
573
+ while True:
574
+ req = await _recv(reader)
575
+ # Cheap liveness handshake used by the host to confirm the
576
+ # in-container agent is actually accepting requests (a TCP
577
+ # connection alone isn't sufficient: on Linux docker-proxy
578
+ # accepts the connection on the host port even before the
579
+ # guest agent process has started listening).
580
+ if req.get("op") == "ping":
581
+ await _send(writer, {"status": "pong"})
582
+ continue
583
+ resp = await _execute_request(req, writer)
584
+ await _send(writer, resp)
585
+ except (asyncio.IncompleteReadError, ConnectionError, OSError):
586
+ pass
587
+ finally:
588
+ writer.close()
589
+
590
+
591
+ async def serve(host: str, port: int) -> None:
592
+ server = await asyncio.start_server(_handle_client, host, port)
593
+ addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
594
+ print(f"[guest-agent] listening on {addrs}", flush=True)
595
+ async with server:
596
+ await server.serve_forever()
597
+
598
+
599
+ # ---------------------------------------------------------------------------
600
+ # CLI
601
+ # ---------------------------------------------------------------------------
602
+
603
+
604
+ def main() -> None:
605
+ parser = argparse.ArgumentParser(description="offwork sandbox guest agent")
606
+ parser.add_argument("--host", default="0.0.0.0", help="Bind address")
607
+ parser.add_argument("--port", type=int, default=9749, help="Bind port")
608
+ args = parser.parse_args()
609
+
610
+ print(
611
+ f"[guest-agent] starting on {args.host}:{args.port} "
612
+ f"(Python {sys.version})",
613
+ flush=True,
614
+ )
615
+ try:
616
+ asyncio.run(serve(args.host, args.port))
617
+ except KeyboardInterrupt:
618
+ print("[guest-agent] shutting down", flush=True)
619
+
620
+
621
+ if __name__ == "__main__":
622
+ main()
@@ -0,0 +1,26 @@
1
+ """Schedule handle for recurring task management."""
2
+
3
+ from offwork.worker.backends.base import Backend
4
+
5
+
6
+ class ScheduleHandle:
7
+ """Handle for a recurring schedule, allowing cancellation."""
8
+
9
+ def __init__(self, schedule_id: str, backend: Backend) -> None:
10
+ self._schedule_id = schedule_id
11
+ self._backend = backend
12
+
13
+ @property
14
+ def schedule_id(self) -> str:
15
+ return self._schedule_id
16
+
17
+ async def cancel(self) -> None:
18
+ """Cancel this recurring schedule.
19
+
20
+ The worker will stop re-enqueuing new occurrences after the
21
+ current one completes.
22
+ """
23
+ await self._backend.cancel_schedule(self._schedule_id)
24
+
25
+ def __repr__(self) -> str:
26
+ return f"ScheduleHandle(schedule_id={self._schedule_id!r})"