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.
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/CHANGELOG.md +53 -1
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/PKG-INFO +2 -2
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/api.md +3 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/plugin-guide.md +95 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/pyproject.toml +2 -2
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/__init__.py +2 -2
- 3lc_compute_plugin_sdk-0.4.0/src/tlc_plugin_sdk/asgi_app.py +194 -0
- {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
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/contract.py +5 -3
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/job_context.py +63 -1
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/config_store.py +24 -1
- 3lc_compute_plugin_sdk-0.4.0/src/tlc_plugin_sdk/worker.py +568 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_job_context.py +30 -1
- 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_alias_overrides.py +104 -0
- 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_cancel_lifecycle.py +143 -0
- 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_control_routes.py +202 -0
- 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_identity.py +59 -0
- 3lc_compute_plugin_sdk-0.4.0/tests/test_worker_token_and_keepalive.py +139 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/uv.lock +19 -19
- 3lc_compute_plugin_sdk-0.3.2/src/tlc_plugin_sdk/asgi_app.py +0 -103
- 3lc_compute_plugin_sdk-0.3.2/src/tlc_plugin_sdk/worker.py +0 -298
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.github/workflows/ci.yml +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.github/workflows/docs.yml +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.github/workflows/release.yml +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/.gitignore +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/1.0-READINESS.md +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/CLAUDE.md +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/LICENSE +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/README.md +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/conf.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/index.md +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/package-lock.json +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/package.json +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/docs/requirements.txt +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/py.typed +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/__init__.py +0 -0
- {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
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/alias_ui.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/aliases.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/config_ui.py +0 -0
- {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
- {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
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/generic_job.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/images.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/job_tracker.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/labels.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/modality.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/model_storage.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/naming.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/ui_inject.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/shared/url_utils.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_data_source_routes.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_import_light.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_js_contract_surface.py +0 -0
- {3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/tests/test_url_utils.py +0 -0
- {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
|
-
|
|
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
|
+
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.
|
|
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
|
|
@@ -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.
|
|
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.
|
|
46
|
+
"3lc[pandas]>=3.3.0,<4.0.0",
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
[project.urls]
|
{3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/__init__.py
RENAMED
|
@@ -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
|
|
{3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/contract.py
RENAMED
|
@@ -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``, ``/
|
|
109
|
-
and the host admin routes
|
|
110
|
-
are host-owned — a plugin must
|
|
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 []
|
{3lc_compute_plugin_sdk-0.3.2 → 3lc_compute_plugin_sdk-0.4.0}/src/tlc_plugin_sdk/job_context.py
RENAMED
|
@@ -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
|
-
|
|
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)
|