3lc-compute-plugin-sdk 0.3.2__tar.gz → 0.4.0__tar.gz

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 (56) hide show
  1. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/CHANGELOG.md +53 -1
  2. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/PKG-INFO +2 -2
  3. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/api.md +3 -0
  4. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/plugin-guide.md +95 -0
  5. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/pyproject.toml +2 -2
  6. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/__init__.py +2 -2
  7. 3lc_compute_plugin_sdk-0.4.0/src/tlc_plugin_sdk/asgi_app.py +194 -0
  8. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/contract/plugin-api.d.ts +34 -0
  9. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/contract.py +5 -3
  10. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/job_context.py +63 -1
  11. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/config_store.py +24 -1
  12. 3lc_compute_plugin_sdk-0.4.0/src/tlc_plugin_sdk/worker.py +568 -0
  13. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_job_context.py +30 -1
  14. 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_alias_overrides.py +104 -0
  15. 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_cancel_lifecycle.py +143 -0
  16. 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_control_routes.py +202 -0
  17. 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_identity.py +59 -0
  18. 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_token_and_keepalive.py +139 -0
  19. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/uv.lock +19 -19
  20. 3lc_compute_plugin_sdk-0.3.2/src/tlc_plugin_sdk/asgi_app.py +0 -103
  21. 3lc_compute_plugin_sdk-0.3.2/src/tlc_plugin_sdk/worker.py +0 -298
  22. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.github/workflows/ci.yml +0 -0
  23. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.github/workflows/docs.yml +0 -0
  24. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.github/workflows/release.yml +0 -0
  25. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.gitignore +0 -0
  26. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/1.0-READINESS.md +0 -0
  27. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/CLAUDE.md +0 -0
  28. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/LICENSE +0 -0
  29. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/README.md +0 -0
  30. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/conf.py +0 -0
  31. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/index.md +0 -0
  32. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/package-lock.json +0 -0
  33. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/package.json +0 -0
  34. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/requirements.txt +0 -0
  35. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/py.typed +0 -0
  36. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/__init__.py +0 -0
  37. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/alias_override_ui.py +0 -0
  38. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/alias_ui.py +0 -0
  39. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/aliases.py +0 -0
  40. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/config_ui.py +0 -0
  41. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/data_source_routes.py +0 -0
  42. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/data_source_ui.py +0 -0
  43. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/generic_job.py +0 -0
  44. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/images.py +0 -0
  45. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/job_tracker.py +0 -0
  46. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/labels.py +0 -0
  47. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/modality.py +0 -0
  48. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/model_storage.py +0 -0
  49. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/naming.py +0 -0
  50. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/ui_inject.py +0 -0
  51. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/url_utils.py +0 -0
  52. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_data_source_routes.py +0 -0
  53. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_import_light.py +0 -0
  54. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_js_contract_surface.py +0 -0
  55. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_url_utils.py +0 -0
  56. {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_worker_gpu_reclaim.py +0 -0
@@ -7,7 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- _Nothing yet._
10
+ ## [0.4.0] - 2026-09-10
11
+
12
+ ### Added
13
+ - `ctx.identity`: a `JobIdentity` (`user_id`, `org_id`, `project_id` — canonical id strings,
14
+ `None` when the host did not know) saying who a job runs for. The host stamps it under the
15
+ host-owned top-level run-body key `_identity`; the worker pops it before `ctx.params` is built,
16
+ so a plugin never reads the key and never persists it with saved params. Every field is `None`
17
+ on a host that stamps nothing (an older host, or a keyless local one). Plugins read it for
18
+ attribution; a later credential API leases to this identity, never to a plugin.
19
+ - Remote-worker hardening for TCP workers (opt-in; a local Unix-socket worker is unchanged):
20
+ - `python -m tlc_plugin_sdk.worker --token …` (default `$TLC_WORKER_TOKEN`) requires
21
+ `Authorization: Bearer <token>` on every HTTP route and on every websocket a plugin
22
+ declares (closed with 1008). `build_plugin_app(token=…)` carries the same knob. The worker
23
+ no longer serves generated OpenAPI/Swagger routes.
24
+ - `TLC_WORKER_STREAM_KEEPALIVE_S=<seconds>` makes the `/jobs/{id}/run` NDJSON stream emit
25
+ `{"event": "ping"}` between job events so HTTP proxies do not kill quiet streams during
26
+ long epochs.
27
+ - Worker control routes `GET /busy` (`{"active_jobs": n}`) and `POST /jobs/cancel-all`. Both
28
+ are host-owned; plugins implement neither.
29
+ - The worker applies the run body's top-level `_alias_overrides` around `run_job` (register
30
+ before, restore after), so a plugin needs no code for aliases the host re-points at staged
31
+ data. A plugin that applies the same overrides itself keeps working.
32
+ - `PluginConfigStore.exists(config_id)` and `.directory`.
33
+ - Manifest `[runtime] node_routes` documented: the custom routes that must run where the model
34
+ lives when a host with remote-node support runs the plugin on a remote worker; everything
35
+ else stays with the controller's worker. A host without that support ignores the key.
36
+ - Run-body conventions for remote-ready plugins documented: inline `project_config`
37
+ (self-contained params, since a remote worker has no controller-local store), the
38
+ `_alias_overrides` shape, and `run_target` / `prepare_job_ids` as host-owned keys.
39
+ - `plugin-api.d.ts`: optional `PLUGIN_API` members `getRunTarget`, `onRunTargetChange`,
40
+ `checkDataForRunTarget` and `hostChecksTableInputs` for frontends with remote-node support.
41
+ Absent elsewhere, so fragments feature-detect them.
42
+
43
+ ### Changed
44
+ - A job stays registered on the worker for as long as its **thread** runs, not its event
45
+ stream, so a host that restarts mid-run can still cancel it. A cancel the job ignores for
46
+ `TLC_WORKER_CANCEL_GRACE_S` seconds (default 60; `0` disables) makes the worker exit with
47
+ status 3 when that job is the last live one; the supervisor spawns a fresh worker for the
48
+ next job.
49
+ - Job ids must match `[A-Za-z0-9_-]{1,64}` (400 otherwise); a `/jobs/{id}/run` for an id
50
+ still live on the worker answers 409 rather than replacing the running job.
51
+ - When the host's job stream disconnects the worker stops buffering that job's events (the
52
+ thread keeps running, cancellable), so a lost stream no longer grows memory for the rest of
53
+ a training run.
54
+ - A plugin route handler on a reserved path (`/health`, `/ui`, `/compute`, `/busy`,
55
+ `/reclaim`, `/jobs/*`) is not mounted; the collision is logged as an error.
56
+ - `PluginConfigStore` writes its directory `0700` and config files `0600` (they hold keys).
57
+
58
+ ## [0.3.3] - 2026-09-07
59
+
60
+ ### Changed
61
+ - Requires `3lc>=3.3.0`. The SDK carries the `3lc[pandas]` data plane for every plugin venv, so
62
+ raising the floor here moves the whole plugin fleet onto the current 3lc release.
11
63
 
12
64
  ## [0.3.2] - 2026-08-31
13
65
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 3lc-compute-plugin-sdk
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Public Python SDK for building 3LC compute-service plugins.
5
5
  Project-URL: Homepage, https://github.com/3lc-ai/3lc-compute-plugin-sdk
6
6
  Project-URL: Repository, https://github.com/3lc-ai/3lc-compute-plugin-sdk
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.10
21
- Requires-Dist: 3lc[pandas]<4.0.0,>=3.0.0
21
+ Requires-Dist: 3lc[pandas]<4.0.0,>=3.3.0
22
22
  Requires-Dist: litestar<3.0.0,>=2.22.0
23
23
  Requires-Dist: uvicorn>=0.34.0
24
24
  Provides-Extra: shared
@@ -12,6 +12,9 @@ package's own docstrings.
12
12
  .. autoclass:: tlc_plugin_sdk.JobContext
13
13
  :members:
14
14
 
15
+ .. autoclass:: tlc_plugin_sdk.JobIdentity
16
+ :members:
17
+
15
18
  .. autodata:: tlc_plugin_sdk.SDK_CONTRACT_VERSION
16
19
  ```
17
20
 
@@ -126,6 +126,12 @@ isolation = "venv" # "venv" is the only value (and the default
126
126
  entrypoint = "tlc_plugin_my_plugin:MyPlugin" # "pkg.module:ClassName"
127
127
  requires_gpu = false # drives GPU vs CPU classification
128
128
  provision_extra = "my-plugin" # your plugin's dependency group: host runs `uv sync --extra <this>`
129
+ # Custom routes that must run where the model lives when a host with remote-node support
130
+ # runs this plugin on a remote worker (inference, model warm-up/status). Path prefixes
131
+ # relative to your route root. Everything NOT listed stays with the controller's worker —
132
+ # config stores, model catalogs, table reads — so saved configs have one history. A host
133
+ # without remote-node support ignores the key.
134
+ # node_routes = ["/preview", "/model-status"]
129
135
  # The plugin's SocketIO namespace is host-derived as "/<plugin-id>" and registered at
130
136
  # startup — it is NOT declarable in the manifest (a plugin emits via ctx; the host owns
131
137
  # the transport).
@@ -528,6 +534,7 @@ class MyGpuPlugin(ComputePlugin):
528
534
  | `ctx.params` | Job parameters (parsed request body / query). |
529
535
  | `ctx.cancelled` | `True` once cancel is requested — poll at checkpoints. |
530
536
  | `ctx.state_dir` | Writable per-plugin scratch dir (never write inside the package). |
537
+ | `ctx.identity` | Who the job runs for: a `JobIdentity` with `user_id`, `org_id`, `project_id` (canonical id strings, or `None` when the host did not know). Read it for attribution; never set it. |
531
538
  | `ctx.progress(*, percent, label="", timing=None)` | Generic progress bar. `percent=-1` = indeterminate. `timing` = `{elapsed_s, eta_s, avg_step_s, step_label}`. |
532
539
  | `ctx.metric(label, value)` | Scalar metric card on the generic panel. |
533
540
  | `ctx.log(message)` | A log line for the job. |
@@ -543,10 +550,80 @@ The `progress` / `metric` / `result` calls above are what populate that generic
543
550
  translated to the frontend's plugin-agnostic schema by the host, so no plugin-specific
544
551
  field ever reaches the frontend.
545
552
 
553
+ **A cancel always ends the job — poll `ctx.cancelled` so it ends cleanly.** Cancel is
554
+ cooperative first: the worker sets `ctx.cancelled` and your loop returns at its next
555
+ checkpoint (the host marks the job "cancelled"). A loop that never checks is not allowed
556
+ to keep a GPU busy: after `TLC_WORKER_CANCEL_GRACE_S` seconds (default 60; `0` disables)
557
+ the worker process exits with status 3 when the unresponsive job is the last one live in it,
558
+ and the supervisor spawns a fresh worker for the next job — so an
559
+ unhonoured cancel costs you the process, and any partial state you did not flush. The grace
560
+ period includes your cleanup: flush checkpoints before you return from a cancel, not after.
561
+ When other jobs are live in the same worker the unresponsive thread is left running (it ends
562
+ with the process) and reported in the worker log; nothing else is interrupted.
563
+ The worker keeps a job registered for as long as its **thread** runs, not its event
564
+ stream: a host that restarts mid-run can still cancel the job afterwards
565
+ (`POST /jobs/{id}/cancel`, or `POST /jobs/cancel-all` for everything still live), so a lost
566
+ stream never leaves a training running for nobody. Job ids are chosen by the host and must
567
+ match `[A-Za-z0-9_-]{1,64}`; a run for an id that is still live on the worker answers 409.
568
+
546
569
  **Start a job from the UI** with the generic run route — `POST /api/plugins/{id}/run`
547
570
  with the params as the JSON body; it returns `{job_id, status, namespace}`. The easiest
548
571
  way to consume it is `window.PluginJobs` (next section).
549
572
 
573
+ ### Run-body conventions for remote workers
574
+
575
+ A host with remote-node support can run a `requires_gpu` job on a **remote worker** — a
576
+ TCP worker on another machine — over the same stream contract. These conventions make a
577
+ plugin remote-ready; all are optional locally and additive:
578
+
579
+ - **Self-contained params (`project_config`).** A remote worker has none of the
580
+ controller's local state — in particular, nothing saved by a worker-local config or
581
+ project store exists on the node. If your `run_job` resolves an id against such a
582
+ store, also accept the frozen configuration inline (recommended key:
583
+ `project_config`) and prefer it when present; have your fragment always include it in
584
+ the run body (harmless locally, required remotely).
585
+ - **`_alias_overrides` is applied by the worker.** When a table's data has been staged
586
+ somewhere else for this worker (a copy on a remote node), the host puts
587
+ `{"enabled": true, "overrides": [{"token": "MY_DATA", "path": "/staged/…"}]}` at the top
588
+ level of the run body (tokens are bare, no angle brackets). The worker registers those
589
+ aliases before `run_job` and restores them after, so the table resolves to the staged copy
590
+ with no code in the plugin. A plugin that persists its own alias overrides with its saved
591
+ config keeps applying those itself; a plugin that used to read the top-level key can drop
592
+ that code (applying the same override twice is a no-op).
593
+ - **`run_target` is host-owned.** The run body may carry `run_target` (where to run); the
594
+ host consumes it before params reach the worker — never read or set it in a plugin. The
595
+ same goes for `prepare_job_ids` (the data-copy jobs a remote run waits for): the host pops
596
+ it, orders the run behind the copies, and delivers their result as `_alias_overrides`,
597
+ which the worker applies for you.
598
+ - **`_identity` is host-owned and becomes `ctx.identity`.** The host stamps who the job runs
599
+ for (`{"user_id", "org_id", "project_id"}`, canonical id strings) under the top-level
600
+ `_identity` key; the worker pops it before `ctx.params` is built and exposes it as
601
+ `ctx.identity` (a `JobIdentity`; every field `None` when the host did not know it, as on a
602
+ keyless local host). A fragment never sets it — the host overwrites whatever the browser
603
+ sent — and a plugin never persists it with saved params. Read it for attribution; the
604
+ forthcoming credential API leases credentials to *this* identity, never to a plugin.
605
+ - **Custom routes stay with the controller unless declared.** A host with remote-node
606
+ support forwards a custom route to the remote worker only if your manifest lists it under
607
+ `[runtime] node_routes` (e.g. sam3's `/preview`). Config/project stores, model catalogs
608
+ and table reads must NOT be listed: they are controller state, and the remote copy of your
609
+ worker starts with an empty store. Nothing to code; keep the list honest.
610
+ - **Selection-time data notes.** A frontend with remote-node support exposes optional
611
+ `PLUGIN_API` members — `getRunTarget`, `onRunTargetChange`, `checkDataForRunTarget` (see
612
+ `plugin-api.d.ts`) — and may annotate every table input in a GPU fragment with the verdict
613
+ itself, announced by `PLUGIN_API.hostChecksTableInputs`; opt a field in or out with
614
+ `data-run-target-check="table"|"off"`. Feature-detect the members (frontends without
615
+ remote-node support have none of them) and call `checkDataForRunTarget` yourself only for
616
+ bespoke placement, skipping it when `hostChecksTableInputs` is true.
617
+
618
+ Remote TCP workers run token-guarded (`--token` / `TLC_WORKER_TOKEN`: every request must
619
+ carry `Authorization: Bearer <token>`) and may emit `{"event": "ping"}` keepalives on the
620
+ job stream (`TLC_WORKER_STREAM_KEEPALIVE_S`) so provider proxies don't kill quiet
621
+ streams; a host that enables keepalives filters the pings before events reach any
622
+ consumer. Neither affects a local Unix-socket worker. Every worker also answers `GET /busy`
623
+ (`{"active_jobs": n}`, read before a remote worker's machine is torn down) and
624
+ `POST /jobs/cancel-all` (what a host calls after a restart it could not re-attach to);
625
+ plugins implement neither.
626
+
550
627
  ---
551
628
 
552
629
  ## Real-Time Updates: `window.PluginJobs` + custom events
@@ -1027,3 +1104,21 @@ If your entry doesn't show up after that, check that the tester's compute-servic
1027
1104
  Agent-facing guidance for building a plugin end-to-end (reading order, step-by-step,
1028
1105
  code patterns, common mistakes, testing) lives in this repo's
1029
1106
  [`CLAUDE.md`](https://github.com/3lc-ai/3lc-compute-plugin-sdk/blob/main/CLAUDE.md).
1107
+
1108
+ ## Which 3LC project a job belongs to
1109
+
1110
+ The Hub's project page shows a job in its **Queue & Progress** panel only when the host knows the job's
1111
+ 3LC project. The host resolves it once, when the run starts, in this order — nothing comes from the browser
1112
+ address:
1113
+
1114
+ 1. `project_name` in the run body. The SDK's job starter sets it from the launch context; a plugin that knows
1115
+ the project can set it explicitly, and an explicit value always wins.
1116
+ 2. Any table or run URL in the body. The Object Service lays projects out as `…/projects/<project>/…`, so an
1117
+ inline config with `train_table_url` names its project by itself.
1118
+ 3. The plugin's saved project config. A body that carries only `project_id` refers to a record the plugin
1119
+ serves at `GET /projects/{project_id}`; the host reads its `project_name` (the **3LC Project** field every
1120
+ training form has). Keep that route and field if your plugin saves its configs.
1121
+
1122
+ While the job runs, any event whose payload carries `project_name` (or a `run_url` under a project) also sets
1123
+ the project if it was still unknown. Emit `ctx.result(run_url)` as soon as the run exists and the panel
1124
+ catches up even when the start carried nothing.
@@ -7,7 +7,7 @@ name = "3lc-compute-plugin-sdk"
7
7
  # The plugin-contract (ABI) version. This IS tlc_plugin_sdk.SDK_CONTRACT_VERSION
8
8
  # (read back via importlib.metadata) — one source of truth. SemVer MAJOR.MINOR.PATCH;
9
9
  # the 0.x line is additive-only (see README → Status).
10
- version = "0.3.2"
10
+ version = "0.4.0"
11
11
  description = "Public Python SDK for building 3LC compute-service plugins."
12
12
  requires-python = ">=3.10"
13
13
  readme = "README.md"
@@ -43,7 +43,7 @@ classifiers = [
43
43
  dependencies = [
44
44
  "uvicorn>=0.34.0",
45
45
  "litestar>=2.22.0,<3.0.0",
46
- "3lc[pandas]>=3.0.0,<4.0.0",
46
+ "3lc[pandas]>=3.3.0,<4.0.0",
47
47
  ]
48
48
 
49
49
  [project.urls]
@@ -25,7 +25,7 @@ from importlib.metadata import PackageNotFoundError
25
25
  from importlib.metadata import version as _pkg_version
26
26
 
27
27
  from tlc_plugin_sdk.contract import ComputePlugin
28
- from tlc_plugin_sdk.job_context import JobContext, JobFailed
28
+ from tlc_plugin_sdk.job_context import JobContext, JobFailed, JobIdentity
29
29
 
30
30
  # The plugin contract version — one axis, one source of truth: this package's own
31
31
  # version (the ``[project] version`` in pyproject), read via importlib.metadata rather
@@ -39,4 +39,4 @@ try:
39
39
  except PackageNotFoundError: # running from a raw checkout that was never installed
40
40
  SDK_CONTRACT_VERSION = "0.0.0"
41
41
 
42
- __all__ = ["SDK_CONTRACT_VERSION", "ComputePlugin", "JobContext", "JobFailed"]
42
+ __all__ = ["SDK_CONTRACT_VERSION", "ComputePlugin", "JobContext", "JobFailed", "JobIdentity"]
@@ -0,0 +1,194 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Build a plugin's HTTP surface as a Litestar ASGI app.
4
+
5
+ This is the single route-authoring pattern: a plugin exposes its custom routes as
6
+ relative Litestar route handlers via :meth:`ComputePlugin.get_route_handlers`, and
7
+ the worker (``tlc_plugin_sdk.worker``) serves the app with uvicorn on a Unix socket
8
+ (or TCP, for a remote worker); the host reverse-proxies to it.
9
+
10
+ Because the worker runs a real Litestar app, a plugin's routes get a real router,
11
+ request validation, multipart, and binary/streaming responses. Litestar runs
12
+ ``def`` handlers in a threadpool, so a synchronous, CPU-bound custom route (e.g.
13
+ preview inference) does not block the event loop.
14
+
15
+ The app also mounts the host-reserved generic routes (``/health``, ``/ui``,
16
+ ``/compute``) so the worker can answer them over the socket.
17
+
18
+ Litestar is a base dependency of this SDK, but it is imported **here**, not in
19
+ the import-light :mod:`tlc_plugin_sdk` package surface — so
20
+ ``import tlc_plugin_sdk`` stays cheap.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from litestar import Litestar, Request, get
29
+
30
+ from tlc_plugin_sdk.shared.job_tracker import JOB_TRACKER_JS
31
+ from tlc_plugin_sdk.shared.ui_inject import inject_scripts
32
+
33
+ if TYPE_CHECKING:
34
+ from litestar.handlers import BaseRouteHandler
35
+
36
+ from tlc_plugin_sdk.contract import ComputePlugin
37
+
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ def _generic_handlers(plugin: ComputePlugin) -> list[BaseRouteHandler]:
43
+ """The host-reserved generic routes, bound to ``plugin`` (served by the worker)."""
44
+
45
+ @get("/health", sync_to_thread=False)
46
+ def health() -> dict[str, Any]:
47
+ # ``sdk_version`` is the worker half of a handshake: a venv worker imports its
48
+ # *own* install of this SDK, so the host cannot know which contract is live inside
49
+ # the venv unless the worker says so. The host compares it against its own on
50
+ # MAJOR.MINOR and flags skew on the plugin card. One contract axis, one field.
51
+ from tlc_plugin_sdk import SDK_CONTRACT_VERSION
52
+
53
+ return {
54
+ "ok": True,
55
+ "plugin": getattr(plugin, "id", "?"),
56
+ "sdk_version": SDK_CONTRACT_VERSION,
57
+ }
58
+
59
+ # def + sync_to_thread: get_ui_fragment()/compute() are synchronous and may do
60
+ # blocking work, so Litestar runs them in a threadpool, off the event loop.
61
+ @get("/ui", media_type="text/html", sync_to_thread=True)
62
+ def ui() -> str:
63
+ # Auto-inject the PluginJobs client so every fragment can drive the generic job
64
+ # channel without a manual inject_scripts() call. The client is idempotent
65
+ # (``if (window.PluginJobs) return;``), so a plugin that still injects it by hand
66
+ # is harmless. A fragment with no inline <script> has nothing to drive PluginJobs
67
+ # from — inject_scripts() raises there, so serve it unchanged.
68
+ raw = plugin.get_ui_fragment()
69
+ try:
70
+ return inject_scripts(raw, JOB_TRACKER_JS)
71
+ except ValueError:
72
+ return raw
73
+
74
+ @get("/compute", sync_to_thread=True)
75
+ def compute(request: Request[Any, Any, Any]) -> dict[str, Any]:
76
+ params: dict[str, Any] = dict(request.query_params)
77
+ return plugin.compute(params)
78
+
79
+ return [health, ui, compute]
80
+
81
+
82
+ def _bearer_guard(token: str) -> Any:
83
+ """An ASGI middleware factory rejecting requests without ``Authorization: Bearer <token>``.
84
+
85
+ Installed only when a token is configured — a worker on a Unix socket runs with no
86
+ token and never pays for this. Guards every HTTP route, ``/health`` included, and every
87
+ websocket a plugin declares (closed with code 1008 before accept). Callers that own the
88
+ token (the controller's supervisor, the node-agent) send it on probes too.
89
+
90
+ Litestar applies app middleware per matched route, so an unknown path still answers 404
91
+ and a wrong method 405 without the token: the guard protects content, not the route
92
+ table. ``lifespan`` scopes pass through.
93
+ """
94
+ import hmac
95
+
96
+ expected = f"Bearer {token}".encode()
97
+
98
+ def factory(app: Any) -> Any:
99
+ async def guard(scope: Any, receive: Any, send: Any) -> None:
100
+ kind = scope["type"]
101
+ if kind not in ("http", "websocket"):
102
+ await app(scope, receive, send)
103
+ return
104
+ auth = b""
105
+ for name, value in scope.get("headers", []):
106
+ if name == b"authorization":
107
+ auth = value
108
+ break
109
+ # Constant-time compare: a timing oracle on the token would defeat it.
110
+ if hmac.compare_digest(auth, expected):
111
+ await app(scope, receive, send)
112
+ return
113
+ if kind == "websocket":
114
+ # 1008 = policy violation; closing before accept makes the handshake fail.
115
+ await send({"type": "websocket.close", "code": 1008})
116
+ return
117
+ body = b'{"detail":"unauthorized"}'
118
+ await send({
119
+ "type": "http.response.start",
120
+ "status": 401,
121
+ "headers": [
122
+ (b"content-type", b"application/json"),
123
+ (b"content-length", str(len(body)).encode()),
124
+ ],
125
+ })
126
+ await send({"type": "http.response.body", "body": body})
127
+
128
+ return guard
129
+
130
+ return factory
131
+
132
+
133
+ def build_plugin_app(
134
+ plugin: ComputePlugin,
135
+ *,
136
+ extra_handlers: list[BaseRouteHandler] | None = None,
137
+ debug: bool = False,
138
+ token: str | None = None,
139
+ ) -> Litestar:
140
+ """Build the Litestar app serving ``plugin``'s HTTP surface.
141
+
142
+ Args:
143
+ plugin: The plugin instance whose behavior the routes invoke.
144
+ extra_handlers: The worker's job-channel handlers (the ``/jobs/{id}/run``
145
+ stream, ``/jobs/{id}/cancel``, and ``/reclaim``).
146
+ debug: Litestar debug flag.
147
+ token: When set, every request must carry ``Authorization: Bearer <token>``
148
+ (401 otherwise). Set for TCP workers on remote nodes; ``None`` (the
149
+ default) leaves local/UDS behavior untouched — no middleware installed.
150
+
151
+ Returns:
152
+ A Litestar app mounting, in trie-priority order: the plugin's own relative
153
+ route handlers (most specific), the generic reserved routes, and any
154
+ ``extra_handlers``.
155
+
156
+ """
157
+ handlers: list[Any] = [
158
+ *_without_reserved(plugin.get_route_handlers(), plugin),
159
+ *_generic_handlers(plugin),
160
+ *(extra_handlers or []),
161
+ ]
162
+ middleware: list[Any] = [_bearer_guard(token)] if token else []
163
+ # No generated OpenAPI/Swagger routes: a worker is an internal endpoint, and on a node the
164
+ # schema would describe the job channel to anyone who reached the port.
165
+ return Litestar(route_handlers=handlers, debug=debug, middleware=middleware, openapi_config=None)
166
+
167
+
168
+ # Paths the host and the worker own. A plugin handler on one of them would shadow the
169
+ # worker's (``/busy`` shadowed = a node-agent's self-destruct guard answers whatever the
170
+ # plugin says). Plugins mount first for trie priority, so collisions are removed here.
171
+ RESERVED_WORKER_PATHS: frozenset[str] = frozenset({"/health", "/ui", "/compute", "/busy", "/reclaim"})
172
+ RESERVED_WORKER_PREFIXES: tuple[str, ...] = ("/jobs",)
173
+
174
+
175
+ def _without_reserved(handlers: list[Any], plugin: ComputePlugin) -> list[Any]:
176
+ kept: list[Any] = []
177
+ for handler in handlers:
178
+ paths = {"/" + str(p).strip("/") for p in (getattr(handler, "paths", None) or ())}
179
+ clash = [
180
+ p
181
+ for p in paths
182
+ if p in RESERVED_WORKER_PATHS or any(p == r or p.startswith(r + "/") for r in RESERVED_WORKER_PREFIXES)
183
+ ]
184
+ if clash:
185
+ logger.error(
186
+ "Plugin %s declares reserved route(s) %s; the handler is not mounted (host-owned paths: %s, %s/*)",
187
+ getattr(plugin, "id", "?"),
188
+ ", ".join(sorted(clash)),
189
+ ", ".join(sorted(RESERVED_WORKER_PATHS)),
190
+ ", ".join(RESERVED_WORKER_PREFIXES),
191
+ )
192
+ continue
193
+ kept.append(handler)
194
+ return kept
@@ -298,6 +298,40 @@ export interface PluginApi {
298
298
  */
299
299
  computeFetch(path: string, options?: PluginFetchOptions, requiresGpu?: boolean): Promise<Response>;
300
300
 
301
+ /**
302
+ * Where jobs from this page currently execute: the host-owned "Run on:" choice.
303
+ * `{target: 'local', ready: true}` on hosts without remote GPU nodes, when
304
+ * "This machine" is selected, or on older frontends (feature-detect the member).
305
+ * `ready` is false while a chosen node's worker is still being prepared.
306
+ */
307
+ getRunTarget?(): { target: 'local' | 'node'; node_id?: string; ready: boolean };
308
+
309
+ /**
310
+ * Register a callback fired whenever the "Run on:" choice or its readiness
311
+ * changes — re-validate any selected data via `checkDataForRunTarget`.
312
+ * Listeners are cleared when the fragment unmounts.
313
+ */
314
+ onRunTargetChange?(callback: () => void): void;
315
+
316
+ /**
317
+ * Selection-time data verdict for the current run target: can this table work
318
+ * there, and what will happen to its data? `ok: false` means a node run WILL
319
+ * fail (e.g. rows carry local paths with no alias); `notes` are user-facing
320
+ * strings (upcoming copies, copy-vs-stream choices) to render next to the
321
+ * picker. Always `{ok: true, notes: []}` for the local target and on older
322
+ * hosts. Call it from the data picker's change handler — before Train.
323
+ */
324
+ checkDataForRunTarget?(tableUrl: string): Promise<{ ok: boolean; notes: string[] }>;
325
+
326
+ /**
327
+ * `true` when the host itself annotates every table input in the fragment with
328
+ * the `checkDataForRunTarget` verdict (fields whose value has a `/tables/` segment
329
+ * or whose id/name says "table"; `data-run-target-check="table"|"off"` opts a field
330
+ * in or out). A fragment that wires `checkDataForRunTarget` to its own inputs can
331
+ * skip that when this is set, so the note is not shown twice. Absent on older hosts.
332
+ */
333
+ hostChecksTableInputs?: boolean;
334
+
301
335
  /** Reference to `TlcApi.computeService` (currently only `getHealth()`). */
302
336
  compute: TlcComputeService;
303
337
 
@@ -105,8 +105,10 @@ class ComputePlugin(ABC):
105
105
  plugin's own Litestar app in its worker, reverse-proxied by the host (see
106
106
  ``tlc_plugin_sdk/asgi_app.py``); Litestar runs ``def`` handlers in a threadpool,
107
107
  so a synchronous, blocking custom route does not block the event loop. The
108
- reserved routes (``/run``, ``/health``, ``/ui``, ``/compute``, ``/jobs/*``,
109
- and the host admin routes ``/provision`` ``/reload`` ``/venv`` ``/worker/stop``)
110
- are host-owned — a plugin must not define them. Empty by default.
108
+ reserved routes (``/run``, ``/health``, ``/ui``, ``/compute``, ``/busy``,
109
+ ``/reclaim``, ``/jobs/*`` including ``/jobs/cancel-all``, and the host admin routes
110
+ ``/provision`` ``/reload`` ``/venv`` ``/worker/stop``) are host-owned — a plugin must
111
+ not define them; a handler on one of them is not mounted (logged as an error).
112
+ Empty by default.
111
113
  """
112
114
  return []
@@ -15,11 +15,70 @@ Import-light: stdlib only. Must not pull in the server stack.
15
15
  from __future__ import annotations
16
16
 
17
17
  import threading
18
+ from dataclasses import dataclass
18
19
  from pathlib import Path
19
20
  from typing import TYPE_CHECKING, Any, NoReturn
20
21
 
21
22
  if TYPE_CHECKING:
22
- from collections.abc import Callable
23
+ from collections.abc import Callable, Mapping
24
+
25
+ #: The host-owned top-level run-body key that carries :class:`JobIdentity` to the worker.
26
+ #: The worker pops it before ``ctx.params`` is built; a plugin never reads or sets it.
27
+ IDENTITY_KEY = "_identity"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class JobIdentity:
32
+ """Who a job runs for: the tenant identity the host stamped when it started the job.
33
+
34
+ Every field is the canonical string form of the id the host's identity provider uses
35
+ (UUIDs in the hosted service) or ``None`` when the host did not know it — a keyless local
36
+ dev host knows no user, and no host knows a project id yet. Plugins read this for
37
+ attribution and for authorization decisions made on their behalf later (a credential
38
+ lease is issued to a *job's* identity, never to a plugin); they never set it.
39
+
40
+ Attributes:
41
+ user_id: The user who started the job.
42
+ org_id: The organization (tenant) the job belongs to.
43
+ project_id: The project the job belongs to, when the host resolved one.
44
+
45
+ """
46
+
47
+ user_id: str | None = None
48
+ org_id: str | None = None
49
+ project_id: str | None = None
50
+
51
+ @classmethod
52
+ def from_wire(cls, raw: object) -> JobIdentity:
53
+ """Build an identity from the run body's ``_identity`` value, tolerating anything.
54
+
55
+ Unknown keys are ignored and non-string values read as unknown, so a host of any
56
+ version can stamp whatever it knows and an old worker never fails a job over it.
57
+
58
+ Args:
59
+ raw: The value found under :data:`IDENTITY_KEY`, or ``None``.
60
+
61
+ Returns:
62
+ The identity; empty when ``raw`` is not a mapping.
63
+
64
+ """
65
+ if not isinstance(raw, dict):
66
+ return cls()
67
+ mapping: Mapping[str, object] = raw
68
+ return cls(
69
+ user_id=_str_or_none(mapping.get("user_id")),
70
+ org_id=_str_or_none(mapping.get("org_id")),
71
+ project_id=_str_or_none(mapping.get("project_id")),
72
+ )
73
+
74
+ @property
75
+ def known(self) -> bool:
76
+ """Whether the host stamped any identity at all."""
77
+ return any((self.user_id, self.org_id, self.project_id))
78
+
79
+
80
+ def _str_or_none(value: object) -> str | None:
81
+ return value if isinstance(value, str) and value else None
23
82
 
24
83
 
25
84
  class JobFailed(Exception):
@@ -43,6 +102,7 @@ class JobContext:
43
102
  reinstall/reload (plugins must not write inside their package dir).
44
103
  sink: Callable invoked with each emitted event dict.
45
104
  cancel_event: Set by the host/worker to request cooperative cancellation.
105
+ identity: Who the job runs for (see :class:`JobIdentity`); empty when omitted.
46
106
 
47
107
  """
48
108
 
@@ -54,10 +114,12 @@ class JobContext:
54
114
  *,
55
115
  sink: Callable[[dict[str, Any]], None],
56
116
  cancel_event: threading.Event,
117
+ identity: JobIdentity | None = None,
57
118
  ) -> None:
58
119
  self.job_id = job_id
59
120
  self.params = params or {}
60
121
  self.state_dir = state_dir
122
+ self.identity = identity if identity is not None else JobIdentity()
61
123
  self._sink = sink
62
124
  self._cancel = cancel_event
63
125
 
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import json
27
27
  import logging
28
+ import os
28
29
  import shutil
29
30
  import uuid
30
31
  from dataclasses import asdict, is_dataclass
@@ -61,7 +62,9 @@ class PluginConfigStore(Generic[T]):
61
62
  raise TypeError(msg)
62
63
  self._cls = config_cls
63
64
  self._dir = CONFIG_ROOT / plugin_id
65
+ # Configs hold provider keys and tokens: owner-only directory and files.
64
66
  self._dir.mkdir(parents=True, exist_ok=True)
67
+ _restrict(self._dir, 0o700)
65
68
  if legacy_dir is not None:
66
69
  self._migrate_legacy(Path(legacy_dir))
67
70
 
@@ -86,10 +89,17 @@ class PluginConfigStore(Generic[T]):
86
89
  config.id = str(uuid.uuid4()) # type: ignore[attr-defined]
87
90
  if not getattr(config, "created", ""):
88
91
  config.created = datetime.now(timezone.utc).isoformat() # type: ignore[attr-defined]
89
- with open(self._path(config.id), "w") as f: # type: ignore[attr-defined]
92
+ path = self._path(config.id) # type: ignore[attr-defined]
93
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
94
+ with os.fdopen(fd, "w") as f:
90
95
  json.dump(asdict(config), f, indent=2) # type: ignore[call-overload]
96
+ _restrict(path, 0o600) # an existing world-readable file is tightened too
91
97
  return config
92
98
 
99
+ def exists(self, config_id: str) -> bool:
100
+ """True when a config with ``config_id`` is saved (no read, no deserialisation)."""
101
+ return self._path(config_id).exists()
102
+
93
103
  def delete_config(self, config_id: str) -> bool:
94
104
  """Delete a config. Returns True if it existed."""
95
105
  path = self._path(config_id)
@@ -106,6 +116,11 @@ class PluginConfigStore(Generic[T]):
106
116
  self.save_config(config)
107
117
 
108
118
  # ── internals ────────────────────────────────────────────────────────
119
+ @property
120
+ def directory(self) -> Path:
121
+ """Where this plugin's configs live (read-only view for callers that need the path)."""
122
+ return self._dir
123
+
109
124
  def _path(self, config_id: str) -> Path:
110
125
  return self._dir / f"{config_id}.json"
111
126
 
@@ -138,3 +153,11 @@ class PluginConfigStore(Generic[T]):
138
153
  moved += 1
139
154
  if moved:
140
155
  logger.info("Migrated %d config(s) from %s → %s", moved, legacy_dir, self._dir)
156
+
157
+
158
+ def _restrict(path: Path, mode: int) -> None:
159
+ """Best-effort chmod: the store must keep working on filesystems without POSIX modes."""
160
+ try:
161
+ os.chmod(path, mode)
162
+ except OSError:
163
+ logger.debug("Could not chmod %s", path, exc_info=True)