server4agent 0.7.0__tar.gz → 0.9.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 (29) hide show
  1. {server4agent-0.7.0 → server4agent-0.9.0}/CHANGELOG.md +29 -0
  2. {server4agent-0.7.0 → server4agent-0.9.0}/PKG-INFO +4 -14
  3. {server4agent-0.7.0 → server4agent-0.9.0}/README.md +3 -13
  4. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/__init__.py +1 -8
  5. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_async/client.py +4 -146
  6. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_models.py +5 -69
  7. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_sync/client.py +8 -169
  8. server4agent-0.9.0/server4agent/_version.py +1 -0
  9. {server4agent-0.7.0 → server4agent-0.9.0}/tests/test_client.py +18 -93
  10. server4agent-0.7.0/server4agent/_version.py +0 -1
  11. {server4agent-0.7.0 → server4agent-0.9.0}/.gitignore +0 -0
  12. {server4agent-0.7.0 → server4agent-0.9.0}/LICENSE +0 -0
  13. {server4agent-0.7.0 → server4agent-0.9.0}/SECURITY.md +0 -0
  14. {server4agent-0.7.0 → server4agent-0.9.0}/examples/async_quickstart.py +0 -0
  15. {server4agent-0.7.0 → server4agent-0.9.0}/examples/await_input.py +0 -0
  16. {server4agent-0.7.0 → server4agent-0.9.0}/examples/exec_and_files.py +0 -0
  17. {server4agent-0.7.0 → server4agent-0.9.0}/examples/preview_env.py +0 -0
  18. {server4agent-0.7.0 → server4agent-0.9.0}/examples/quickstart.py +0 -0
  19. {server4agent-0.7.0 → server4agent-0.9.0}/examples/webhook_receiver.py +0 -0
  20. {server4agent-0.7.0 → server4agent-0.9.0}/pyproject.toml +0 -0
  21. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_async/__init__.py +0 -0
  22. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_async/transport.py +0 -0
  23. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_sync/__init__.py +0 -0
  24. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_sync/transport.py +0 -0
  25. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/_transport.py +0 -0
  26. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/errors.py +0 -0
  27. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/py.typed +0 -0
  28. {server4agent-0.7.0 → server4agent-0.9.0}/server4agent/webhooks.py +0 -0
  29. {server4agent-0.7.0 → server4agent-0.9.0}/tests/test_webhooks.py +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ Tuning runs are removed. The API no longer has the `/tuning/sessions`
6
+ endpoints, so neither client calls them. This is a breaking change.
7
+
8
+ - `client.tuning` is gone from both clients, with `list()`, `start()`, `get()`
9
+ and `report()`, and so are the `TuningSession` and `AsyncTuningSession`
10
+ handles and the `TuningStart` and `TuningStatus` types.
11
+ - `WEBHOOK_EVENTS` no longer lists `tuning.started` or `tuning.completed`, and
12
+ `webhooks.create()` with either is refused as an unknown event. Nothing
13
+ sends them any more. A webhook created before they were retired may still
14
+ have them stored; `webhooks.list()` leaves them off its `events`.
15
+
16
+ ## 0.8.0
17
+
18
+ Projects can put their URL behind a password.
19
+
20
+ - `ProjectAccess` gains `"password"`: a visitor to the project's URL is asked
21
+ for a password the owner set, and gets in without any account. A project
22
+ read from the API can now report it, so code that branches on `access`
23
+ should handle the new value.
24
+ - `projects.create()` and `project.update()` (sync and async, and the flat
25
+ `client.projects.update(project_id, ...)`) take `access_password`. It is
26
+ required when switching to `access="password"` unless a password is already
27
+ set, and refused with any other access. Sent on its own to a password
28
+ project, it changes the password and signs out everyone who used the old
29
+ one. Switching to another access discards it. It is at least 4 characters
30
+ (a 4 digit code works), stored encrypted, and never returned by the API.
31
+
3
32
  ## 0.7.0
4
33
 
5
34
  The client now covers every operation in the REST API. Everything below is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: server4agent
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Python client for the Server4Agent REST API — sync + async, typed, with retries.
5
5
  Project-URL: Homepage, https://www.server4agent.com
6
6
  Project-URL: Documentation, https://www.server4agent.com/docs/sdks
@@ -99,6 +99,8 @@ server.refresh() # re-fetch in place
99
99
 
100
100
  project = client.projects.get("prj_xyz")
101
101
  project.update(visibility="public")
102
+ # Ask visitors for a password before the URL opens. No account needed to get in.
103
+ project.update(access="password", access_password="a long shared passphrase")
102
104
  ```
103
105
 
104
106
  Binary files (e.g. a generated `.xlsx`) round-trip through `write`/`read_binary`
@@ -227,18 +229,6 @@ if me.usage.blocked:
227
229
  print("new tasks are refused:", me.usage.reason)
228
230
  ```
229
231
 
230
- ## Tuning runs
231
-
232
- A tuning run fixes an agent from an evaluation platform's report, then reports
233
- back what changed. Reading a session is what advances it, so `wait()` polls it
234
- to `reported` or `failed`:
235
-
236
- ```python
237
- start = client.tuning.start("prop_123", project_id=project.id)
238
- session = start.session.wait(on_poll=lambda s: print(s.status))
239
- print(session.change_summary, session.changed_files)
240
- ```
241
-
242
232
  ## Errors
243
233
 
244
234
  Every failure is a subclass of `Server4AgentError`, so you can catch broadly or
@@ -296,7 +286,7 @@ Runnable scripts in [`examples/`](examples/):
296
286
  ## API surface
297
287
 
298
288
  `servers`, `projects`, `templates`, `tasks`, `builds`, `files`, `secrets`,
299
- `tuning`, `keys`, `webhooks`, and `client.me()`. Every REST operation has a
289
+ `keys`, `webhooks`, and `client.me()`. Every REST operation has a
300
290
  method on both clients; see the
301
291
  [REST API docs](https://www.server4agent.com/docs/api) for the endpoints each
302
292
  one wraps.
@@ -73,6 +73,8 @@ server.refresh() # re-fetch in place
73
73
 
74
74
  project = client.projects.get("prj_xyz")
75
75
  project.update(visibility="public")
76
+ # Ask visitors for a password before the URL opens. No account needed to get in.
77
+ project.update(access="password", access_password="a long shared passphrase")
76
78
  ```
77
79
 
78
80
  Binary files (e.g. a generated `.xlsx`) round-trip through `write`/`read_binary`
@@ -201,18 +203,6 @@ if me.usage.blocked:
201
203
  print("new tasks are refused:", me.usage.reason)
202
204
  ```
203
205
 
204
- ## Tuning runs
205
-
206
- A tuning run fixes an agent from an evaluation platform's report, then reports
207
- back what changed. Reading a session is what advances it, so `wait()` polls it
208
- to `reported` or `failed`:
209
-
210
- ```python
211
- start = client.tuning.start("prop_123", project_id=project.id)
212
- session = start.session.wait(on_poll=lambda s: print(s.status))
213
- print(session.change_summary, session.changed_files)
214
- ```
215
-
216
206
  ## Errors
217
207
 
218
208
  Every failure is a subclass of `Server4AgentError`, so you can catch broadly or
@@ -270,7 +260,7 @@ Runnable scripts in [`examples/`](examples/):
270
260
  ## API surface
271
261
 
272
262
  `servers`, `projects`, `templates`, `tasks`, `builds`, `files`, `secrets`,
273
- `tuning`, `keys`, `webhooks`, and `client.me()`. Every REST operation has a
263
+ `keys`, `webhooks`, and `client.me()`. Every REST operation has a
274
264
  method on both clients; see the
275
265
  [REST API docs](https://www.server4agent.com/docs/api) for the endpoints each
276
266
  one wraps.
@@ -4,7 +4,6 @@ from ._async.client import (
4
4
  AsyncProject,
5
5
  AsyncServer,
6
6
  AsyncTask,
7
- AsyncTuningSession,
8
7
  )
9
8
  from ._models import (
10
9
  AccountUsage,
@@ -29,12 +28,10 @@ from ._models import (
29
28
  ProjectVisibility,
30
29
  Secret,
31
30
  SecretInput,
32
- TuningStart,
33
- TuningStatus,
34
31
  WEBHOOK_EVENTS,
35
32
  Webhook,
36
33
  )
37
- from ._sync.client import Build, Project, Server, Server4Agent, Task, TuningSession
34
+ from ._sync.client import Build, Project, Server, Server4Agent, Task
38
35
  from ._version import __version__
39
36
  from .errors import (
40
37
  APIConnectionError,
@@ -61,13 +58,11 @@ __all__ = [
61
58
  "Project",
62
59
  "Task",
63
60
  "Build",
64
- "TuningSession",
65
61
  # async handles
66
62
  "AsyncServer",
67
63
  "AsyncProject",
68
64
  "AsyncTask",
69
65
  "AsyncBuild",
70
- "AsyncTuningSession",
71
66
  # data types
72
67
  "ApiKey",
73
68
  "CreatedApiKey",
@@ -78,7 +73,6 @@ __all__ = [
78
73
  "AppStart",
79
74
  "Me",
80
75
  "AccountUsage",
81
- "TuningStart",
82
76
  "ExecResult",
83
77
  "BuildStep",
84
78
  "ProjectTemplate",
@@ -93,7 +87,6 @@ __all__ = [
93
87
  "ProjectVisibility",
94
88
  "ProjectAccess",
95
89
  "ProjectInitPhase",
96
- "TuningStatus",
97
90
  "WEBHOOK_EVENTS",
98
91
  # errors
99
92
  "Server4AgentError",
@@ -27,9 +27,6 @@ from .._models import (
27
27
  ServerKind,
28
28
  ServerTier,
29
29
  TASK_TERMINAL_STATES,
30
- TUNING_TERMINAL_STATES,
31
- TuningStart,
32
- TuningStatus,
33
30
  Webhook,
34
31
  _Record,
35
32
  bundle_b64,
@@ -37,7 +34,6 @@ from .._models import (
37
34
  parse_project,
38
35
  parse_server,
39
36
  parse_task,
40
- parse_tuning_session,
41
37
  secret_inputs,
42
38
  )
43
39
  from .._transport import quote_segment as _seg
@@ -174,6 +170,7 @@ class AsyncProject(_Record):
174
170
  description: Optional[str] = None,
175
171
  visibility: Optional[str] = None,
176
172
  access: Optional[str] = None,
173
+ access_password: Optional[str] = None,
177
174
  lifecycle: Optional[str] = None,
178
175
  run_plan: Optional[Mapping[str, Any]] = None,
179
176
  ) -> "AsyncProject":
@@ -184,6 +181,7 @@ class AsyncProject(_Record):
184
181
  "description": description,
185
182
  "visibility": visibility,
186
183
  "access": access,
184
+ "access_password": access_password,
187
185
  "lifecycle": lifecycle,
188
186
  "run_plan": dict(run_plan) if run_plan is not None else None,
189
187
  }.items()
@@ -315,74 +313,6 @@ class AsyncBuild(_Record):
315
313
  return self
316
314
 
317
315
 
318
- class AsyncTuningSession(_Record):
319
- """A tuning run. Reading it advances it; ``wait()`` polls until it is
320
- ``reported`` or ``failed``."""
321
- id: str
322
- server_id: str
323
- project_id: str
324
- run_id: Optional[str]
325
- build_run_id: Optional[str]
326
- proposal_id: str
327
- evaluation_api_base: str
328
- key_secret: str
329
- brief_path: Optional[str]
330
- brief_revision: Optional[str]
331
- status: str
332
- change_summary: Optional[str]
333
- changed_files: List[str]
334
- deployment_url: Optional[str]
335
- reported_at: Optional[str]
336
- report_error: Optional[str]
337
- created_at: str
338
- updated_at: str
339
- run: Optional["AsyncTask"]
340
-
341
- def __init__(self, transport: AsyncTransport, raw: dict, run: Optional[dict] = None) -> None:
342
- self._t = transport
343
- self.__dict__.update(parse_tuning_session(raw))
344
- self.run = AsyncTask(transport, run) if run else None
345
-
346
- async def refresh(self) -> "AsyncTuningSession":
347
- r = await self._t.request("GET", f"/tuning/sessions/{_seg(self.id)}")
348
- self.__dict__.update(parse_tuning_session(r["session"]))
349
- self.run = AsyncTask(self._t, r["run"]) if r.get("run") else None
350
- return self
351
-
352
- async def wait(
353
- self,
354
- *,
355
- poll_interval: float = 2.0,
356
- timeout: float = 1800.0,
357
- on_poll: Optional[Callable[["AsyncTuningSession"], Union[None, Awaitable[None]]]] = None,
358
- ) -> "AsyncTuningSession":
359
- """Await until the session is reported or failed. ``on_poll`` (sync or
360
- async) is called with ``self`` after each refresh."""
361
- deadline = time.monotonic() + timeout
362
- while self.status not in TUNING_TERMINAL_STATES:
363
- if time.monotonic() >= deadline:
364
- raise APITimeoutError(f"Tuning session {self.id} still {self.status!r} after {timeout}s.")
365
- await asyncio.sleep(poll_interval)
366
- await self.refresh()
367
- if on_poll is not None:
368
- res = on_poll(self)
369
- if res is not None:
370
- await res
371
- return self
372
-
373
- async def report(
374
- self,
375
- *,
376
- change_summary: Optional[str] = None,
377
- changed_files: Optional[Sequence[str]] = None,
378
- deployment_url: Optional[str] = None,
379
- failed: Optional[bool] = None,
380
- ) -> "AsyncTuningSession":
381
- r = await _report_tuning(self._t, self.id, change_summary, changed_files, deployment_url, failed)
382
- self.__dict__.update(parse_tuning_session(r["session"]))
383
- return self
384
-
385
-
386
316
  # =============================================================================
387
317
  # Scoped sub-resources
388
318
  # =============================================================================
@@ -403,6 +333,7 @@ class _ScopedProjects:
403
333
  description: Optional[str] = None,
404
334
  visibility: Optional[str] = None,
405
335
  access: Optional[str] = None,
336
+ access_password: Optional[str] = None,
406
337
  lifecycle: Optional[str] = None,
407
338
  template: Optional[str] = None,
408
339
  source: Optional[Mapping[str, Any]] = None,
@@ -416,6 +347,7 @@ class _ScopedProjects:
416
347
  "description": description,
417
348
  "visibility": visibility,
418
349
  "access": access,
350
+ "access_password": access_password,
419
351
  "lifecycle": lifecycle,
420
352
  "template": template,
421
353
  "source": dict(source) if source is not None else None,
@@ -582,27 +514,6 @@ async def _logs(
582
514
  return LogsSnapshot.parse(r["logs"]) if r.get("logs") is not None else None
583
515
 
584
516
 
585
- async def _report_tuning(
586
- t: AsyncTransport,
587
- session_id: str,
588
- change_summary: Optional[str],
589
- changed_files: Optional[Sequence[str]],
590
- deployment_url: Optional[str],
591
- failed: Optional[bool],
592
- ) -> dict:
593
- body = {
594
- k: v
595
- for k, v in {
596
- "change_summary": change_summary,
597
- "changed_files": list(changed_files) if changed_files is not None else None,
598
- "deployment_url": deployment_url,
599
- "failed": failed,
600
- }.items()
601
- if v is not None
602
- }
603
- return await t.request("PATCH", f"/tuning/sessions/{_seg(session_id)}", body=body)
604
-
605
-
606
517
  # =============================================================================
607
518
  # Top-level resources
608
519
  # =============================================================================
@@ -822,58 +733,6 @@ class SecretsResource:
822
733
  return await _ScopedSecrets(self._t, f"/projects/{_seg(project_id)}/secrets").set(secrets)
823
734
 
824
735
 
825
- class TuningResource:
826
- def __init__(self, transport: AsyncTransport) -> None:
827
- self._t = transport
828
-
829
- async def list(
830
- self, *, project_id: Optional[str] = None, status: Optional[TuningStatus] = None
831
- ) -> List[AsyncTuningSession]:
832
- r = await self._t.request("GET", "/tuning/sessions", query={"project_id": project_id, "status": status})
833
- return [AsyncTuningSession(self._t, s) for s in r["sessions"]]
834
-
835
- async def start(
836
- self,
837
- proposal_id: str,
838
- *,
839
- project_id: Optional[str] = None,
840
- server_id: Optional[str] = None,
841
- evaluation_api_base: Optional[str] = None,
842
- key_secret: Optional[str] = None,
843
- ) -> TuningStart[AsyncTuningSession]:
844
- body = {
845
- k: v
846
- for k, v in {
847
- "proposal_id": proposal_id,
848
- "project_id": project_id,
849
- "server_id": server_id,
850
- "evaluation_api_base": evaluation_api_base,
851
- "key_secret": key_secret,
852
- }.items()
853
- if v is not None
854
- }
855
- r = await self._t.request("POST", "/tuning/sessions", body=body)
856
- return TuningStart.parse(AsyncTuningSession(self._t, r["session"]), r)
857
-
858
- async def get(self, tuning_session_id: str) -> AsyncTuningSession:
859
- r = await self._t.request("GET", f"/tuning/sessions/{_seg(tuning_session_id)}")
860
- return AsyncTuningSession(self._t, r["session"], r.get("run"))
861
-
862
- async def report(
863
- self,
864
- tuning_session_id: str,
865
- *,
866
- change_summary: Optional[str] = None,
867
- changed_files: Optional[Sequence[str]] = None,
868
- deployment_url: Optional[str] = None,
869
- failed: Optional[bool] = None,
870
- ) -> AsyncTuningSession:
871
- r = await _report_tuning(
872
- self._t, tuning_session_id, change_summary, changed_files, deployment_url, failed
873
- )
874
- return AsyncTuningSession(self._t, r["session"])
875
-
876
-
877
736
  class KeysResource:
878
737
  def __init__(self, transport: AsyncTransport) -> None:
879
738
  self._t = transport
@@ -961,7 +820,6 @@ class AsyncServer4Agent:
961
820
  self.builds = BuildsResource(self._t)
962
821
  self.files = FilesResource(self._t)
963
822
  self.secrets = SecretsResource(self._t)
964
- self.tuning = TuningResource(self._t)
965
823
  self.keys = KeysResource(self._t)
966
824
  self.webhooks = WebhooksResource(self._t)
967
825
 
@@ -21,9 +21,10 @@ ServerStatus = Literal["running", "sleeping", "building"]
21
21
  ServerAction = Literal["start", "stop", "restart"]
22
22
  ProjectVisibility = Literal["public", "private"]
23
23
  #: Who may open a public project's URL. "team" puts it behind a Server4Agent
24
- #: sign-in and membership of the owning account; "public" is anyone with the
25
- #: link, which is the default and how every project behaved before this.
26
- ProjectAccess = Literal["public", "team"]
24
+ #: sign-in and membership of the owning account; "password" asks for a password
25
+ #: the owner set (``access_password``) and needs no account; "public" is anyone
26
+ #: with the link, which is the default and how every project behaved before this.
27
+ ProjectAccess = Literal["public", "team", "password"]
27
28
  ProjectLifecycle = Literal["persistent", "ephemeral"]
28
29
  ProjectRuntimeStatus = Literal["unbuilt", "running", "paused"]
29
30
  #: Where a project's bring-up has got to, from seeding the workspace to a
@@ -37,15 +38,10 @@ BuildStatus = Literal["queued", "running", "completed"]
37
38
  #: ``"live"`` when the project's URL answered within the deploy's wait,
38
39
  #: ``"publishing"`` when it is public but had not answered yet.
39
40
  DeployStatus = Literal["live", "publishing"]
40
- #: ``"building"`` while the agent's code is brought up (a run started with
41
- #: ``server_id``), ``"fixing"`` while the agent works on the brief, then
42
- #: ``"reported"`` or ``"failed"``.
43
- TuningStatus = Literal["building", "fixing", "reported", "failed"]
44
41
 
45
42
  # A task poll stops here: nothing further happens without you (input) or it's over.
46
43
  TASK_TERMINAL_STATES = frozenset({"completed", "failed", "cancelled", "awaiting_input"})
47
44
  BUILD_TERMINAL_STATES = frozenset({"completed"})
48
- TUNING_TERMINAL_STATES = frozenset({"reported", "failed"})
49
45
 
50
46
  WEBHOOK_EVENTS = (
51
47
  "task.created",
@@ -59,8 +55,6 @@ WEBHOOK_EVENTS = (
59
55
  "quota.threshold",
60
56
  "quota.cap_reached",
61
57
  "quota.egress_cap_reached",
62
- "tuning.started",
63
- "tuning.completed",
64
58
  )
65
59
 
66
60
 
@@ -477,40 +471,6 @@ class ProjectImport(Generic[P]):
477
471
  )
478
472
 
479
473
 
480
- @dataclass
481
- class TuningStart(Generic[P]):
482
- """What ``tuning.start()`` answered.
483
-
484
- Starting twice for one proposal never starts a second agent: the API hands
485
- back the run already under way, with ``started`` False and a ``message``.
486
- """
487
- session: P
488
- #: False when a run already existed for this proposal and ``session`` is it.
489
- started: bool
490
- message: Optional[str] = None
491
- #: False when the brief was issued before the platform finished its
492
- #: analysis, so it carries per-case judgements but no summarised diagnosis.
493
- analysis_ready: Optional[bool] = None
494
- #: Set when the run started but the evaluation platform could not be told.
495
- #: The run is unaffected; the next status report retries.
496
- warning: Optional[str] = None
497
- #: Set when this call built the agent's code into a new project
498
- #: (``server_id``): ``projectId``, ``source`` and ``envKeys``.
499
- built: Optional[dict] = None
500
-
501
- @staticmethod
502
- def parse(session: Any, raw: dict) -> "TuningStart[Any]":
503
- return TuningStart(
504
- session=session,
505
- # The API answers an existing run with a message and a new one without.
506
- started="message" not in raw,
507
- message=raw.get("message"),
508
- analysis_ready=raw.get("analysisReady", raw.get("analysis_ready")),
509
- warning=raw.get("warning"),
510
- built=raw.get("built"),
511
- )
512
-
513
-
514
474
  # --- request-body helpers ------------------------------------------------------
515
475
 
516
476
  def secret_inputs(secrets: SecretsArg) -> List[dict]:
@@ -584,8 +544,7 @@ def parse_project(raw: dict) -> Dict[str, Any]:
584
544
 
585
545
  def parse_task(raw: dict) -> Dict[str, Any]:
586
546
  return dict(
587
- # A tuning session's run arrives in the run shape, keyed run_id.
588
- id=raw["id"] if "id" in raw else raw["run_id"],
547
+ id=raw["id"],
589
548
  server_id=raw.get("serverId", raw.get("server_id")),
590
549
  project_id=raw.get("projectId", raw.get("project_id")),
591
550
  goal=raw["goal"],
@@ -613,26 +572,3 @@ def parse_build(raw: dict) -> Dict[str, Any]:
613
572
  url=raw.get("url"),
614
573
  created_at=raw.get("createdAt", raw.get("created_at")),
615
574
  )
616
-
617
-
618
- def parse_tuning_session(raw: dict) -> Dict[str, Any]:
619
- return dict(
620
- id=raw["id"],
621
- server_id=raw.get("serverId", raw.get("server_id")),
622
- project_id=raw.get("projectId", raw.get("project_id")),
623
- run_id=raw.get("runId", raw.get("run_id")),
624
- build_run_id=raw.get("buildRunId", raw.get("build_run_id")),
625
- proposal_id=raw.get("proposalId", raw.get("proposal_id")),
626
- evaluation_api_base=raw.get("evaluationApiBase", raw.get("evaluation_api_base")),
627
- key_secret=raw.get("keySecretKey", raw.get("key_secret")),
628
- brief_path=raw.get("briefPath", raw.get("brief_path")),
629
- brief_revision=raw.get("briefRevision", raw.get("brief_revision")),
630
- status=raw["status"],
631
- change_summary=raw.get("changeSummary", raw.get("change_summary")),
632
- changed_files=raw.get("changedFiles", raw.get("changed_files")) or [],
633
- deployment_url=raw.get("deploymentUrl", raw.get("deployment_url")),
634
- reported_at=raw.get("reportedAt", raw.get("reported_at")),
635
- report_error=raw.get("reportError", raw.get("report_error")),
636
- created_at=raw.get("createdAt", raw.get("created_at")),
637
- updated_at=raw.get("updatedAt", raw.get("updated_at")),
638
- )
@@ -26,9 +26,6 @@ from .._models import (
26
26
  ServerKind,
27
27
  ServerTier,
28
28
  TASK_TERMINAL_STATES,
29
- TUNING_TERMINAL_STATES,
30
- TuningStart,
31
- TuningStatus,
32
29
  Webhook,
33
30
  _Record,
34
31
  bundle_b64,
@@ -36,7 +33,6 @@ from .._models import (
36
33
  parse_project,
37
34
  parse_server,
38
35
  parse_task,
39
- parse_tuning_session,
40
36
  secret_inputs,
41
37
  )
42
38
  from .._transport import quote_segment as _seg
@@ -193,11 +189,15 @@ class Project(_Record):
193
189
  description: Optional[str] = None,
194
190
  visibility: Optional[str] = None,
195
191
  access: Optional[str] = None,
192
+ access_password: Optional[str] = None,
196
193
  lifecycle: Optional[str] = None,
197
194
  run_plan: Optional[Mapping[str, Any]] = None,
198
195
  ) -> "Project":
199
196
  """``run_plan`` replaces the stored plan wholesale and is recorded for the
200
- next bring-up, not acted on: ``init()`` is how you ask for the rebuild."""
197
+ next bring-up, not acted on: ``init()`` is how you ask for the rebuild.
198
+ ``access_password`` sets or changes the password for ``access="password"``;
199
+ sent alone it changes the password and signs out everyone who used the
200
+ old one. It is never returned."""
201
201
  body = {
202
202
  k: v
203
203
  for k, v in {
@@ -205,6 +205,7 @@ class Project(_Record):
205
205
  "description": description,
206
206
  "visibility": visibility,
207
207
  "access": access,
208
+ "access_password": access_password,
208
209
  "lifecycle": lifecycle,
209
210
  "run_plan": dict(run_plan) if run_plan is not None else None,
210
211
  }.items()
@@ -366,86 +367,6 @@ class Build(_Record):
366
367
  return self
367
368
 
368
369
 
369
- class TuningSession(_Record):
370
- """A run that fixes an agent from an evaluation platform's report.
371
-
372
- Reading it is what advances it: each ``refresh()`` checks the agent run
373
- behind it, and the first one after that run finishes reports the outcome
374
- back to the platform. ``wait()`` polls until it is reported or failed.
375
- """
376
- id: str
377
- server_id: str
378
- project_id: str
379
- #: The agent run doing the fixing (a task on the server). None while building.
380
- run_id: Optional[str]
381
- #: The bring-up run, when this session built the project from the agent's source.
382
- build_run_id: Optional[str]
383
- proposal_id: str
384
- evaluation_api_base: str
385
- #: Name of the secret holding the evaluation API key, never its value.
386
- key_secret: str
387
- brief_path: Optional[str]
388
- brief_revision: Optional[str]
389
- status: str
390
- change_summary: Optional[str]
391
- changed_files: List[str]
392
- deployment_url: Optional[str]
393
- reported_at: Optional[str]
394
- #: Why the last report to the platform failed. The next read retries.
395
- report_error: Optional[str]
396
- created_at: str
397
- updated_at: str
398
- #: The agent run behind the session, as of the last ``get()`` or ``refresh()``.
399
- run: Optional["Task"]
400
-
401
- def __init__(self, transport: SyncTransport, raw: dict, run: Optional[dict] = None) -> None:
402
- self._t = transport
403
- self.__dict__.update(parse_tuning_session(raw))
404
- self.run = Task(transport, run) if run else None
405
-
406
- def refresh(self) -> "TuningSession":
407
- r = self._t.request("GET", f"/tuning/sessions/{_seg(self.id)}")
408
- self.__dict__.update(parse_tuning_session(r["session"]))
409
- self.run = Task(self._t, r["run"]) if r.get("run") else None
410
- return self
411
-
412
- def wait(
413
- self,
414
- *,
415
- poll_interval: float = 2.0,
416
- timeout: float = 1800.0,
417
- on_poll: Optional[Callable[["TuningSession"], None]] = None,
418
- ) -> "TuningSession":
419
- """Block until the session is ``reported`` or ``failed``. Raises
420
- ``APITimeoutError`` if ``timeout`` elapses first.
421
-
422
- ``on_poll`` is called with ``self`` after each refresh."""
423
- deadline = time.monotonic() + timeout
424
- while self.status not in TUNING_TERMINAL_STATES:
425
- if time.monotonic() >= deadline:
426
- raise APITimeoutError(f"Tuning session {self.id} still {self.status!r} after {timeout}s.")
427
- time.sleep(poll_interval)
428
- self.refresh()
429
- if on_poll is not None:
430
- on_poll(self)
431
- return self
432
-
433
- def report(
434
- self,
435
- *,
436
- change_summary: Optional[str] = None,
437
- changed_files: Optional[Sequence[str]] = None,
438
- deployment_url: Optional[str] = None,
439
- failed: Optional[bool] = None,
440
- ) -> "TuningSession":
441
- """Send your own account of the fix to the evaluation platform, rather
442
- than letting it be read off the run's final message. ``failed=True``
443
- closes the run as failed."""
444
- r = _report_tuning(self._t, self.id, change_summary, changed_files, deployment_url, failed)
445
- self.__dict__.update(parse_tuning_session(r["session"]))
446
- return self
447
-
448
-
449
370
  # =============================================================================
450
371
  # Scoped sub-resources — reached via a Server handle (server.tasks, ...).
451
372
  # =============================================================================
@@ -466,6 +387,7 @@ class _ScopedProjects:
466
387
  description: Optional[str] = None,
467
388
  visibility: Optional[str] = None,
468
389
  access: Optional[str] = None,
390
+ access_password: Optional[str] = None,
469
391
  lifecycle: Optional[str] = None,
470
392
  template: Optional[str] = None,
471
393
  source: Optional[Mapping[str, Any]] = None,
@@ -483,6 +405,7 @@ class _ScopedProjects:
483
405
  "description": description,
484
406
  "visibility": visibility,
485
407
  "access": access,
408
+ "access_password": access_password,
486
409
  "lifecycle": lifecycle,
487
410
  "template": template,
488
411
  "source": dict(source) if source is not None else None,
@@ -662,27 +585,6 @@ def _logs(
662
585
  return LogsSnapshot.parse(r["logs"]) if r.get("logs") is not None else None
663
586
 
664
587
 
665
- def _report_tuning(
666
- t: SyncTransport,
667
- session_id: str,
668
- change_summary: Optional[str],
669
- changed_files: Optional[Sequence[str]],
670
- deployment_url: Optional[str],
671
- failed: Optional[bool],
672
- ) -> dict:
673
- body = {
674
- k: v
675
- for k, v in {
676
- "change_summary": change_summary,
677
- "changed_files": list(changed_files) if changed_files is not None else None,
678
- "deployment_url": deployment_url,
679
- "failed": failed,
680
- }.items()
681
- if v is not None
682
- }
683
- return t.request("PATCH", f"/tuning/sessions/{_seg(session_id)}", body=body)
684
-
685
-
686
588
  # =============================================================================
687
589
  # Top-level resources — the flat client.servers.* API.
688
590
  # =============================================================================
@@ -909,68 +811,6 @@ class SecretsResource:
909
811
  return _ScopedSecrets(self._t, f"/projects/{_seg(project_id)}/secrets").set(secrets)
910
812
 
911
813
 
912
- class TuningResource:
913
- """Tuning runs: fix an agent from an evaluation platform's report, then
914
- report back what changed so the platform can score the fixed agent."""
915
-
916
- def __init__(self, transport: SyncTransport) -> None:
917
- self._t = transport
918
-
919
- def list(
920
- self, *, project_id: Optional[str] = None, status: Optional[TuningStatus] = None
921
- ) -> List[TuningSession]:
922
- """Newest first."""
923
- r = self._t.request("GET", "/tuning/sessions", query={"project_id": project_id, "status": status})
924
- return [TuningSession(self._t, s) for s in r["sessions"]]
925
-
926
- def start(
927
- self,
928
- proposal_id: str,
929
- *,
930
- project_id: Optional[str] = None,
931
- server_id: Optional[str] = None,
932
- evaluation_api_base: Optional[str] = None,
933
- key_secret: Optional[str] = None,
934
- ) -> TuningStart[TuningSession]:
935
- """Start fixing the agent behind an improvement proposal.
936
-
937
- Pass ``project_id`` when the agent's code is already a project here, or
938
- ``server_id`` to build it into a new project on that server first. The
939
- evaluation API key is read from the secret named ``key_secret``
940
- (``AGENTX_API_KEY`` unless you name another); a scoped key may not set
941
- ``key_secret`` or ``evaluation_api_base``."""
942
- body = {
943
- k: v
944
- for k, v in {
945
- "proposal_id": proposal_id,
946
- "project_id": project_id,
947
- "server_id": server_id,
948
- "evaluation_api_base": evaluation_api_base,
949
- "key_secret": key_secret,
950
- }.items()
951
- if v is not None
952
- }
953
- r = self._t.request("POST", "/tuning/sessions", body=body)
954
- return TuningStart.parse(TuningSession(self._t, r["session"]), r)
955
-
956
- def get(self, tuning_session_id: str) -> TuningSession:
957
- """Read a session, which also advances it (see ``TuningSession``)."""
958
- r = self._t.request("GET", f"/tuning/sessions/{_seg(tuning_session_id)}")
959
- return TuningSession(self._t, r["session"], r.get("run"))
960
-
961
- def report(
962
- self,
963
- tuning_session_id: str,
964
- *,
965
- change_summary: Optional[str] = None,
966
- changed_files: Optional[Sequence[str]] = None,
967
- deployment_url: Optional[str] = None,
968
- failed: Optional[bool] = None,
969
- ) -> TuningSession:
970
- r = _report_tuning(self._t, tuning_session_id, change_summary, changed_files, deployment_url, failed)
971
- return TuningSession(self._t, r["session"])
972
-
973
-
974
814
  class KeysResource:
975
815
  def __init__(self, transport: SyncTransport) -> None:
976
816
  self._t = transport
@@ -1061,7 +901,6 @@ class Server4Agent:
1061
901
  self.builds = BuildsResource(self._t)
1062
902
  self.files = FilesResource(self._t)
1063
903
  self.secrets = SecretsResource(self._t)
1064
- self.tuning = TuningResource(self._t)
1065
904
  self.keys = KeysResource(self._t)
1066
905
  self.webhooks = WebhooksResource(self._t)
1067
906
 
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -24,7 +24,6 @@ from server4agent import (
24
24
  Server,
25
25
  Server4Agent,
26
26
  Server4AgentError,
27
- TuningStart,
28
27
  WEBHOOK_EVENTS,
29
28
  )
30
29
  from server4agent._sync.client import (
@@ -804,97 +803,6 @@ def test_me(api):
804
803
  assert me.authenticated_key == {"id": "key_1", "label": "ci"}
805
804
 
806
805
 
807
- # --- tuning -------------------------------------------------------------------
808
-
809
- def _tuning_raw(**over):
810
- base = {
811
- "id": "tun_1", "serverId": "srv_1", "projectId": "prj_1", "runId": "tsk_5",
812
- "buildRunId": None, "proposalId": "prop_1",
813
- "evaluationApiBase": "https://api.evals.example", "keySecretKey": "EVAL_API_KEY",
814
- "briefPath": ".tuning/brief.md", "briefRevision": "r1", "status": "fixing",
815
- "changeSummary": None, "changedFiles": [], "deploymentUrl": None,
816
- "reportedAt": None, "reportError": None,
817
- "createdAt": "2026-10-11T00:00:00Z", "updatedAt": "2026-10-11T00:00:00Z",
818
- }
819
- base.update(over)
820
- return base
821
-
822
-
823
- def test_tuning_list_with_filters(api):
824
- c = api.client(_routes({("GET", "/tuning/sessions"): (200, {"sessions": [_tuning_raw()]})}))
825
- sessions = api.call(c.tuning.list(project_id="prj_1", status="fixing"))
826
- assert api.last["query"] == {"project_id": "prj_1", "status": "fixing"}
827
- s = sessions[0]
828
- assert (s.id, s.server_id, s.project_id, s.run_id, s.proposal_id) == ("tun_1", "srv_1", "prj_1", "tsk_5", "prop_1")
829
- assert (s.key_secret, s.evaluation_api_base, s.status) == ("EVAL_API_KEY", "https://api.evals.example", "fixing")
830
- assert s.run is None
831
-
832
- api.call(c.tuning.list())
833
- assert api.last["query"] == {}
834
-
835
-
836
- def test_tuning_start_new_and_already_running(api):
837
- responses = iter([
838
- (201, {"session": _tuning_raw(), "analysisReady": False, "warning": "platform not told"}),
839
- (200, {"session": _tuning_raw(), "message": "A tuning run already exists for this proposal."}),
840
- ])
841
-
842
- def handler(req):
843
- status, body = next(responses)
844
- return httpx.Response(status, json=body)
845
-
846
- c = api.client(handler)
847
- first = api.call(c.tuning.start("prop_1", project_id="prj_1", key_secret="EVAL_API_KEY"))
848
- assert api.last["method"] == "POST" and api.last["path"] == "/tuning/sessions"
849
- assert api.last["body"] == {"proposal_id": "prop_1", "project_id": "prj_1", "key_secret": "EVAL_API_KEY"}
850
- assert isinstance(first, TuningStart)
851
- assert first.started is True and first.analysis_ready is False and first.warning == "platform not told"
852
- assert first.session.id == "tun_1"
853
-
854
- again = api.call(c.tuning.start("prop_1", server_id="srv_1"))
855
- assert api.last["body"] == {"proposal_id": "prop_1", "server_id": "srv_1"}
856
- assert again.started is False and again.message.startswith("A tuning run already exists")
857
-
858
-
859
- def test_tuning_get_carries_the_run_and_wait_polls_to_reported(api):
860
- run = { # the run arrives in the run shape, keyed run_id
861
- "run_id": "tsk_5", "server_id": "srv_1", "project_id": "prj_1", "goal": "Fix the agent",
862
- "message_type": "system", "status": "running", "created_at": "t", "log": "reading brief",
863
- "result": None, "question": None, "change": None, "usage": None,
864
- }
865
- states = iter(["fixing", "reported"])
866
-
867
- def handler(req):
868
- return httpx.Response(200, json={"session": _tuning_raw(status=next(states)), "run": run})
869
-
870
- c = api.client(handler)
871
- s = api.call(c.tuning.get(HOSTILE))
872
- assert api.last["path"] == f"/tuning/sessions/{HOSTILE_SEG}"
873
- assert s.status == "fixing"
874
- assert (s.run.id, s.run.server_id, s.run.project_id, s.run.log) == ("tsk_5", "srv_1", "prj_1", "reading brief")
875
-
876
- polled = []
877
- done = api.call(s.wait(poll_interval=0, on_poll=lambda x: polled.append(x.status)))
878
- assert done.status == "reported" and polled == ["reported"]
879
-
880
-
881
- def test_tuning_report(api):
882
- reported = _tuning_raw(status="reported", changeSummary="Grounded answers in the docs", changedFiles=["agent.py"])
883
- c = api.client(_routes({
884
- ("PATCH", f"/tuning/sessions/{HOSTILE_SEG}"): (200, {"session": reported}),
885
- ("GET", "/tuning/sessions"): (200, {"sessions": [_tuning_raw(id=HOSTILE)]}),
886
- }))
887
- s = api.call(c.tuning.report(HOSTILE, change_summary="Grounded answers in the docs", changed_files=("agent.py",)))
888
- assert api.last["method"] == "PATCH"
889
- assert api.last["body"] == {"change_summary": "Grounded answers in the docs", "changed_files": ["agent.py"]}
890
- assert s.status == "reported" and s.changed_files == ["agent.py"]
891
-
892
- handle = api.call(c.tuning.list())[0]
893
- api.call(handle.report(failed=True))
894
- assert api.last["body"] == {"failed": True}
895
- assert handle.status == "reported"
896
-
897
-
898
806
  # --- drift fixed in existing methods ------------------------------------------
899
807
 
900
808
  def test_deployments_parse_with_project_id(api):
@@ -947,7 +855,8 @@ def test_webhooks_create_without_events_omits_them(api):
947
855
  })}))
948
856
  api.call(c.webhooks.create("https://hooks.northstar.example/s4a"))
949
857
  assert api.last["body"] == {"url": "https://hooks.northstar.example/s4a"}
950
- assert "tuning.completed" in WEBHOOK_EVENTS and "quota.egress_cap_reached" in WEBHOOK_EVENTS
858
+ assert "quota.egress_cap_reached" in WEBHOOK_EVENTS
859
+ assert "tuning.started" not in WEBHOOK_EVENTS and "tuning.completed" not in WEBHOOK_EVENTS
951
860
 
952
861
 
953
862
  def test_exec_in_a_project_folder(api):
@@ -1028,6 +937,22 @@ def test_project_update_run_plan_and_refresh_keeps_init_run_id(api):
1028
937
  assert project.init_run_id == "tsk_3" # only the create response carries it
1029
938
 
1030
939
 
940
+ def test_project_access_password_goes_out_as_access_password(api):
941
+ c = api.client(_routes({
942
+ ("POST", "/servers/srv_1/projects"): (201, _project_raw(access="password")),
943
+ ("PATCH", "/projects/prj_1"): (200, _project_raw(access="password")),
944
+ }))
945
+ project = api.call(c.projects.create(
946
+ "srv_1", name="web", access="password", access_password="correct horse battery",
947
+ ))
948
+ assert api.last["body"] == {
949
+ "name": "web", "access": "password", "access_password": "correct horse battery",
950
+ }
951
+ assert project.access == "password"
952
+ api.call(project.update(access_password="a new passphrase"))
953
+ assert api.last["method"] == "PATCH" and api.last["body"] == {"access_password": "a new passphrase"}
954
+
955
+
1031
956
  def test_template_source_kind_and_failed_build_steps(api):
1032
957
  c = api.client(_routes({
1033
958
  ("GET", "/templates"): (200, {"templates": [{
@@ -1 +0,0 @@
1
- __version__ = "0.7.0"
File without changes
File without changes
File without changes