Python SDK: typed /v1/bridge queue-mode client bindings (executors + jobs) - #3
Conversation
Give the SDK a shared REST client/auth core and, on top of it, typed bindings for the Local Bridge queue-mode surface: the four executor endpoints (register/list/heartbeat/stop) and the seven job endpoints (feed/detail/claim/start/progress/fulfill/fail). The SDK had no HTTP layer at all, so `alissa.sdk.api` introduces one — stdlib only, matching the core's zero-dependency contract. `ApiClient` owns the base URL, the bearer token, JSON encoding and the error envelope; a binding module owns paths and shapes and nothing else, so there is never a second HTTP stack. The transport is a one-method seam, which is what keeps every test offline. Bindings only: no polling loop, no claim state machine, no nonce minting, no tmux. The executor daemon is the Node `alissa` CLI's, and a second implementation of those rules in another language is a divergence waiting to happen. This is plumbing for observers and tooling. The design doc's §5.3 codes each get an exception class, so callers branch on a code rather than on the prose of `message`. The four 409s share a `RetryAfterReadError` base because they all mean "re-read the row and retry" rather than "give up", and they carry their structured detail (`currentClaimSeq`, `stickyExecutorId`, the observed `status`, `held`/`cap`) as typed properties. An unmodelled code degrades to a plain `ApiError` holding the code verbatim rather than crashing a deployed reader. Response models are frozen stdlib dataclasses mirroring the zod schemas in studio.alissa.app's `api/src/schemas/bridge.ts`, modelled against what the wire actually returns: the feed carries the whole spec including the prompt (`summarizeJobForFeed` projects `spec` whole) and it is the *result* side — result, error, timing, cancelRequested — that only `GET /jobs/:jobId` has. Tests replay recorded response bodies through an injected transport, covering all eleven endpoints, both halves of each binding (request shape and typed response), the noop/absorb and cancel paths, and one branch per §5.3 error family including STALE_CONSUMER. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: alissa-app <support@alissa.app>
|
Review-loop activity — mechanical spawn/round log; the daemon appends a line each time it queues (or defers) a reviewer round on this PR.
|
alissa-app
left a comment
There was a problem hiding this comment.
Review — round 1 of 22 (TASK-1026651236)
Fresh-instance review per the alissa-code-review rubric (CR1–CR9). Reviewer posture: read and comment only.
Verdict: request_changes — 0 blockers, 0 majors, 3 minors, 1 nit, 1 open [question]. The question is the merge gate the author themselves asked for (the irreversible PyPI publish), and per CR5 a load-bearing unanswered question blocks approval. Nothing here is a defect on the main path.
To be clear about the shape of this verdict: this is a strong PR. I verified the bindings against the actual source of truth rather than the PR description — api/src/schemas/bridge.ts, api/src/routes/bridge.ts, api/src/lib/errors.ts and convex/apiBridgeOps.ts at studio.alissa.app@main — and the wire modelling is accurate field-for-field. The path back to approve is short: answer the version question and dispose of three minors.
What I verified against the server source (not the PR body)
- The author's correction of the issue is right.
summarizeJobForFeed(convex/apiBridgeOps.ts:442-454) projectsspecwhole, prompt included; what the feed omits is the result side, whichsummarizeJobDetailadds. Modelling a fullJobSpeconJobFeedItemfollows the wire, and modelling a promptless feed spec would have been the invented projection. The judgment call was correct. - Comma-joined
?status=is valid.parseFeedStatuses(routes/bridge.ts:79) splits on,— the schema's own description says "Repeat the key or comma-join." The binding's choice is supported, not a guess. - The §5.3 status table is right.
PRECONDITION_FAILED→ 412 viaQUEUE_STATUS_OVERRIDES(routes/bridge.ts:49),CAPACITY_EXCEEDED→ 429, the four re-read codes → 409 (lib/errors.ts:12-36). Correctly documented as "branch on the code, not the status." - The error envelope and its detail keys match exactly.
CODE_DETAIL_KEYS(lib/errors.ts:53-59) whitelistsstatus,currentClaimSeq,stickyExecutorId,held/capas top-level keys besideerror/message— which is precisely whaterror_from_envelopeharvests intodetailand what the typed properties expose. The fixtures match the whitelist. - All 21 response fields on
JobDetail, and every executor/ack model, line up with their zod counterparts including optionality.failure_kindasstrrather than an enum is correct: the server writes five kinds a daemon may not claim, andBridgeJobDetailSchematypes itz.string().
Rubric report (CR4 — all five dimensions)
Correctness — satisfies the origin task's definition-of-done: all eleven endpoints bound, typed §5.3 errors branchable by code, stdlib dataclass models, fixture-based offline tests for every endpoint plus error branches, docs example, CI green on all four checks. Findings: the get_job envelope unwrap (minor). No scope excursions — the diff is inside the declared alissa/*, README.md autonomous scope, no existing source file modified beyond the one-line version bump.
Coding practices — matches repo idiom (frozen slots dataclasses like alissa.utils.version, zero third-party deps per requirements.txt, src/main ↔ src/test mirror). Commit carries the alissa-app <support@alissa.app> co-author trailer (C1), branch is TASK-1594104710-BRIDGE-BINDINGS (C2), PR body carries Closes #2 and both task refs (C3), review requested from alissa-app (R12). No dead code, no debug leftovers, no untracked TODOs. Findings: one inaccurate comment (minor), one inconsistent field default (nit).
Security — no secrets in source, fixtures or tests; the only token-shaped string is the fake alissa_test_token. encode_path percent-encodes with safe="" so an id cannot inject a path segment, and that is directly tested. Bearer auth is applied per-request rather than stored on a shared session. JobSpec.env correctly carries names only. One finding: Authorization survives an HTTP redirect (minor, below).
Performance — nothing found. Single round trip per call, no N+1, no unbounded buffering beyond one response body, no accidental quadratics. The finite default timeout (DEFAULT_TIMEOUT = 30.0) is the right call for an observer loop and is locked by a test. Paging is left to the server's 1–50 limit, correctly not pre-validated client-side.
Side-effects / blast radius — this is where the one open question sits. Merging publishes 0.2.0 to PyPI irreversibly (.github/workflows/package-publish.yaml, pull_request: closed + merged == true, and the workflow's own header says a version number can never be reused). New public surface only; no existing API changed, so no consumer breaks. alissa.sdk.api re-exports do not leak the bridge models, which is a deliberate layering and is fine — alissa.sdk.api.bridge exports them.
Shortest path to approve
- Answer the
[question]onalissa/src/main/alissa/sdk/version— confirm the 0.2.0 release, or drop that line. - Triage the three minors (
[triage:pursue]/[triage:ignore]with reasoning /[triage:later]with a task, per CR8). None require code changes I would insist on; the env-var comment is the one I would actually fix.
The nit is discretionary.
|
|
||
| #: Environment variables the client falls back to, matching the `alissa` CLI. | ||
| ENV_TOKEN = "ALISSA_API_TOKEN" | ||
| ENV_BASE_URL = "ALISSA_BASE" |
There was a problem hiding this comment.
[minor] The comment above these two says "matching the alissa CLI", and for the token that holds — but the CLI does not read ALISSA_BASE. It reads ALISSA_API_BASE:
// studio.alissa.app cli/src/config.ts:204-206
/** Resolve the API base: $ALISSA_API_BASE → config → default. */
return process.env.ALISSA_API_BASE || loadConfig().apiBase || DEFAULT_API_BASE;The name is defensible — ALISSA_BASE is what the existing Python sibling uses (alissa-tools-github-orcloop's alissa_client.py:555), and matching the Python consumer this SDK is meant to serve is a reasonable call. The problem is that the comment asserts the opposite of what is true, and alissa/README.md publishes ALISSA_BASE as the configuration knob with no mention of the split.
Concrete failure: an operator points their machine at a staging deployment the documented way (export ALISSA_API_BASE=…), the Node CLI follows, and the Python SDK silently keeps talking to https://api.alissa.app with a live bearer token. Cross-environment misroute, no error, no log line.
Worth resolving before this ships to PyPI, because the variable name becomes public API on publish. Cheapest fix that closes it in both directions:
ENV_BASE_URL = "ALISSA_BASE"
#: The Node `alissa` CLI's spelling, honoured as a fallback so one variable
#: configures both. (cli/src/config.ts resolves $ALISSA_API_BASE.)
ENV_BASE_URL_CLI = "ALISSA_API_BASE"…checked after ENV_BASE_URL in the constructor. At minimum, correct the comment and say in alissa/README.md which variable the CLI uses.
There was a problem hiding this comment.
[triage:pursue] Correct — the comment asserted the opposite of what the CLI does, and the misroute you describe is the real cost: export ALISSA_API_BASE=… moves the Node CLI and leaves this SDK on production with a live token, silently.
Took your first option rather than the comment-only fix, because only the fallback actually closes it in both directions:
ENV_BASE_URL = "ALISSA_BASE"stays first (the Python sibling's spelling, and whatalissa/README.mddocuments).ENV_BASE_URL_CLI = "ALISSA_API_BASE"is read after it, so either exported variable configures both.- The comment now says which tool spells it which way instead of claiming they match.
alissa/README.mdcarries a note on the split and states the precedence.
Four tests lock it: the fallback, the precedence when both are set, an explicit base_url= beating both, and the default when neither is set.
Fixed in 051adb4.
| method=request.method, | ||
| ) | ||
| try: | ||
| with urllib.request.urlopen(req, timeout=self.timeout) as response: |
There was a problem hiding this comment.
[minor] Security: urllib.request.urlopen uses the default opener, which follows 3xx redirects — and CPython's redirect handler copies every request header except content-length/content-type onto the new request, including Authorization, and including when the redirect crosses to a different host. Verified on this repo's target interpreter (3.12.13):
# urllib/request.py — HTTPRedirectHandler.redirect_request
CONTENT_HEADERS = ("content-length", "content-type")
newheaders = {k: v for k, v in req.headers.items()
if k.lower() not in CONTENT_HEADERS}
return Request(newurl, headers=newheaders, ...)So a redirect from the configured base to any other origin forwards the user's bearer token to that origin. requests strips Authorization on a cross-host redirect specifically to prevent this; stdlib urllib does not, and choosing stdlib means inheriting the footgun.
I am filing this [minor] rather than higher because reaching it needs either a misconfigured $ALISSA_BASE or a redirect from api.alissa.app itself, and neither is the expected path today. But this module is explicitly the one HTTP stack every future alissa.sdk.api.* binding will share, so it is much cheaper to close here than in each binding later.
The API is a fixed JSON surface that has no reason to redirect, so the tightest fix is to not follow them at all:
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None # a 3xx becomes an HTTPError → a normal HttpResponse
# in __init__:
self._opener = urllib.request.build_opener(_NoRedirect)
# in send(): self._opener.open(req, timeout=self.timeout)That also makes an unexpected 3xx visible as a HTTP_302 ApiError instead of silently succeeding somewhere else. If you would rather keep redirects, drop Authorization when the target's scheme/host differs from base_url.
There was a problem hiding this comment.
[triage:pursue] Agreed, and took the tighter of your two fixes — not following redirects at all rather than stripping Authorization on a cross-origin hop.
The reasoning that decided it is yours: this is the one HTTP stack every future alissa.sdk.api.* binding inherits, so the cost of leaving it is paid once per binding later. And the API is a fixed JSON surface with no reason to issue a 3xx, so refusing loses nothing real while making an unexpected one visible — it comes back as an HTTP_302 ApiError instead of a request that quietly succeeded against another host.
UrllibTransport now builds its own opener with a _NoRedirectHandler whose redirect_request returns None, and send() goes through self.opener.open rather than the module-level urlopen. Three tests: the opener carries no stock HTTPRedirectHandler, the handler refuses rather than rewriting, and a 302 arrives as a response carrying its status. The existing transport tests moved from monkeypatching urllib.request.urlopen to stubbing the instance's opener, so the seam is still the only one and nothing opens a socket.
Fixed in 051adb4.
| ``resumed``, or a job you lost track of mid-run. Carries | ||
| ``cancel_requested`` and the full timing the feed omits. | ||
| """ | ||
| return JobDetail.from_wire(self.client.get(f"{BRIDGE_PREFIX}/jobs/{encode_path(job_id)}")["job"]) |
There was a problem hiding this comment.
[minor] ["job"] on the decoded payload is the one place in this module where a server shape drift escapes as something that is not an AlissaError: a missing key raises KeyError, and a non-dict payload raises TypeError. Both bypass the typed hierarchy errors.py exists to guarantee, so a caller that wraps its binding calls in except AlissaError — the pattern the module docstring and README teach — will not catch it.
This matters more than it normally would given the PR body's own operator gate: "The fixture bodies are hand-authored from the schemas, not captured from a real response … a drift between schema and runtime would not be caught here." Shape drift is the acknowledged residual risk, and this is the line where it surfaces first and least legibly.
list_executors two methods up already does the defensive thing:
rows = payload.get("executors") if isinstance(payload, Mapping) else NoneThe same treatment here, raising TransportError (which is already the "the response was not what the contract says" error) when job is absent, would make the two consistent and keep every failure inside AlissaError.
The same reasoning applies more broadly to the required-field indexing in models.py (payload["jobId"], payload["claimSeq"], …) — a KeyError from deep inside a from_wire. I am not asking you to wrap 21 fields; a single try/except KeyError → TransportError at the from_wire boundary, or just this one call site, would cover the realistic case. Reasonable to take as [triage:later] with a task if you would rather not grow the diff.
There was a problem hiding this comment.
[triage:pursue] Fixed now rather than deferred — you tied it to the operator gate this PR already carries (hand-authored fixtures, drift not observable here), and a residual risk that surfaces outside the error hierarchy is worse than the risk itself. Deferring the one place the acknowledged risk lands first would have been the wrong trade.
Took the boundary version you suggested rather than wrapping 21 fields:
_decoded(build, payload, endpoint)inbridge/client.py— rejects a non-Mappingpayload, and convertsKeyError(andTypeError/ValueError, for models that grow coercions) from insidefrom_wireintoTransportError, naming the endpoint and the missing key._unwrapped(payload, "job", endpoint)for the envelope key, soget_jobno longer indexes["job"].- Every one of the eleven call sites goes through
_decoded, including each row oflist_executorsand the feed's rows — otherwise the two would have stayed inconsistent in the other direction.
TransportError was already the right home: it means "the response was not what the contract says". Eight tests in a new test_bridge_shape_drift.py cover the missing envelope, a non-object job, a missing required field, a feed row and an executor row missing their ids, a non-object response body, and that the result is catchable as plain AlissaError — which is the property the docs actually promise.
Fixed in 051adb4.
| @@ -1 +1 @@ | |||
| 0.1.0 No newline at end of file | |||
| 0.2.0 No newline at end of file | |||
There was a problem hiding this comment.
[question] Flagging this as the reviewer so the decision is recorded on the PR rather than only in the PR body — you already raised it, and you are right that it is the one item needing a human.
Confirmed from the workflow: merging this PR publishes to PyPI, and it is irreversible.
# .github/workflows/package-publish.yaml
# Publishing is irreversible: a version number, once on PyPI, can never be
# reused even if the release is deleted.
on:
pull_request:
paths: [.github/workflows/package-publish.yaml, alissa/**]
types: [closed]
branches: [main]
...
if: github.event.pull_request.merged == trueSo merge → 0.2.0 on PyPI, permanently, and --skip-existing means a later re-run cannot correct it either. Your reasoning for bumping is sound — an unbumped merge does not deliver the pip install the issue is premised on.
The question is whether 0.2.0 should be cut from this commit or a later one, and only a maintainer can answer it. It is load-bearing for two specific reasons, both of which resolve in this PR:
- The
ALISSA_BASEenv-var name (see the comment onclient.py:35) becomes public API the moment this publishes. Renaming it after 0.2.0 is a breaking change; before, it is a one-line edit. - Nothing here has touched the live API — by your own gate, every field name is read off the schemas rather than observed on the wire. I independently verified the modelling against
schemas/bridge.ts,routes/bridge.ts,lib/errors.tsandconvex/apiBridgeOps.tsand found it accurate, which substantially de-risks it, but a source read is still not a live call.
Either answer is fine and neither needs code from me:
- Ship 0.2.0 now — reply
[triage:answer]confirming the release is intended, ideally after settling the env-var name. - Merge without releasing — drop the version line as you offered; nothing else depends on it. Then bump in a follow-up once someone has made one real call against the bridge surface.
Answer on this thread and, if the answer is "ship", this question closes and the remaining minors are all discretionary.
There was a problem hiding this comment.
[triage:answer] Answering it the second way: the bump is dropped. alissa/src/main/alissa/sdk/version is back to 0.1.0 in 051adb4, so merging this PR publishes nothing and the merge gate is gone.
Why that rather than "ship 0.2.0": I raised the question because the publish is irreversible, and an irreversible outward-facing release is not a call this actor should make unilaterally — no human has confirmed it, and the reversible option leaves the decision intact for whoever does. Merging without the bump costs a maintainer one line later; merging with it costs a version number that can never be reused.
Your two reasons for it being load-bearing both got stronger during this round, which settled it:
- The env-var name did not just stay unverified, it changed.
ENV_BASE_URL_CLI(ALISSA_API_BASE) is new public surface added in this same commit for yourclient.py:35finding. Freezing a configuration contract on PyPI in the same round that contract moved is exactly the sequencing to avoid. - Still no live call. Your independent read of
schemas/bridge.ts,routes/bridge.ts,lib/errors.tsandapiBridgeOps.tsde-risks the modelling substantially — and I do not think a second source read is what is missing. One real request against the bridge surface is, and this environment cannot make it (queue mode is Ultra-gated and wants a resident executor).
So the release sequencing I would suggest, entirely for the maintainer to accept or ignore: merge this, make one real call, then cut 0.2.0 from a one-line follow-up. Nothing in the SDK depends on the bump — the pip install story the origin issue is premised on is delivered by that follow-up, one merge later, with the surface confirmed rather than assumed.
Registered as TASK-1471774461 so the release does not get lost: bump to 0.2.0 and publish, once someone has exercised the bindings live.
| executor_id=payload["executorId"], | ||
| found=bool(payload.get("found")), | ||
| already_ended=bool(payload.get("alreadyEnded")), | ||
| released_jobs=payload.get("releasedJobs", 0), |
There was a problem hiding this comment.
[nit] releasedJobs is required in BridgeExecutorStopResponseSchema (z.number(), not .optional()), but this is the only required field in the module read with a .get(…, default) instead of payload[…]. found and already_ended on the two lines above use bool(payload.get(...)), which is fine because they are booleans, but released_jobs is typed int and a silent 0 reads as "nothing was released" — which is a meaningful, wrong answer rather than an obvious failure.
payload["releasedJobs"] would match how every other required field in this file is read. Entirely your call; the server always sends it.
There was a problem hiding this comment.
[triage:pursue] Taken — it is payload["releasedJobs"] now.
Answering it because your reasoning is not really a style point: found and already_ended can absorb a .get default because False is an honest "no", but a defaulted 0 on a required int reports nothing was released — a specific, wrong fact a caller could act on — where the missing key should have been a failure. That is the same argument as the shape-drift minor two files over, at one field's scale, so fixing it separately would have left the module arguing with itself.
With the _decoded boundary added for that finding, a stop response missing releasedJobs now raises TransportError rather than KeyError, and there is a test for exactly that case.
Fixed in 051adb4.
|
Dev-loop activity — the daemon's mechanical spawn/retry/deferral log; one line is appended each time it queues (or defers) a worker session for this item. Not an operator page — escalations are separate comments.
|
Four dispositions from the round-1 review, plus the version answer. Honour the CLI's `$ALISSA_API_BASE` as a base-URL fallback. The comment above `ENV_BASE_URL` claimed the pair matched the Node `alissa` CLI; for the token that holds, but the CLI resolves `$ALISSA_API_BASE` (cli/src/config.ts), not `$ALISSA_BASE`. Keeping `ALISSA_BASE` first preserves the Python sibling's spelling, and reading the CLI's as a fallback closes the misroute where an operator points a machine at staging the documented CLI way and this SDK keeps talking to production with a live token. Both READMEs now say which is which. Stop following redirects. `urlopen`'s default opener rebuilds a redirected request with every header copied across — `Authorization` included, and across hosts — so a 3xx from the configured base would hand the bearer token to another origin. `requests` strips it; stdlib does not. The API is a fixed JSON surface with no reason to redirect, so the transport builds an opener that refuses them: an unexpected 3xx surfaces as a visible `HTTP_3xx` `ApiError` rather than silently succeeding somewhere else. Keep server shape drift inside `AlissaError`. `get_job` unwrapped `["job"]` directly, and the models index their required fields, so a drift between the schemas these were written from and the runtime escaped as a bare `KeyError` or `TypeError` — outside the hierarchy `errors.py` exists to guarantee, and past the `except AlissaError` the docs teach. Every `from_wire` call now goes through one boundary that converts those into `TransportError`, which already means "the response was not what the contract says". Read the required `releasedJobs` as a required field. `.get(..., 0)` made a missing count report "nothing was released" — a wrong answer rather than a failure — and it was the only required field in the module read that way. Drop the 0.1.0 → 0.2.0 bump. Merging publishes to PyPI irreversibly, and a version number can never be reused; that call belongs to a maintainer, not to this branch, and it is now cheaper to make after a real call has been made against the bridge surface. Nothing here depends on the bump, and this commit also adds public surface (`ENV_BASE_URL_CLI`) that a release would freeze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: alissa-app <support@alissa.app>
alissa-app
left a comment
There was a problem hiding this comment.
Round 2 of the review loop (cap 22), fresh instance — no context from round 1 beyond this PR's own threads and the prior verdict. Reconciled all five round-1 findings, then swept the full rubric over the round-2 delta (c1ea006..051adb4 — 6 files, +292/−35).
Verdict: approve. No blockers, no majors, no open questions. Two [minor]s below, neither merge-gating; both are registered as TASK-904320987 so they cannot evaporate.
Prior-finding reconciliation — 5 of 5 closed, none still open
| round-1 finding | triage | disposition |
|---|---|---|
[minor] ENV_BASE_URL comment contradicts the CLI's $ALISSA_API_BASE |
pursue | fixed (051adb4) — verified |
[minor] default opener follows 3xx and forwards Authorization cross-host |
pursue | fixed (051adb4) — verified end-to-end |
[minor] get_job's ["job"] unwrap escapes the AlissaError hierarchy |
pursue | fixed (051adb4) — verified, and generalised to all 11 call sites |
[nit] releasedJobs read with a .get(…, 0) default |
pursue | fixed (051adb4) — verified safe against the server schema |
[question] does 0.2.0 get cut from this commit? |
answer | answered — bump dropped, deferred to TASK-1471774461 (exists, open) |
Every thread carries exactly one reasoned [triage:*] reply. Triage is complete under CR8; nothing bounced.
What I checked, rather than took on the reply's word
- Env-var split.
cli/src/config.ts:206does resolveprocess.env.ALISSA_API_BASE— the round-1 quote holds atstudio.alissa.app@main. The chain isbase_url=→$ALISSA_BASE→$ALISSA_API_BASE→ default, with four tests pinning each rung. Note this changes behaviour for machines that already export the CLI's variable — deliberately, and both READMEs now state the precedence. - Redirects — the one I actually exercised. Two loopback servers, base pointed at the first, which 302s to the second; a bearer token set. On
051adb4the call raisesApiErrorHTTP_302and the second origin logs zero requests. Re-run againstc1ea006, the same probe returns{"ok": "LEAKED"}and the second origin logsBearer alissa_fake_token. The fix closes a real leak, and the README'sHTTP_302claim is accurate. That retires the operator-gate line "the redirect behaviour is asserted against CPython's opener wiring … no actual 3xx was followed or refused" — one has now been refused. - Shape drift. All eleven bindings route through
_decoded; I grepped everyfrom_wirecall site to confirm none was missed.pyflakesclean over the new modules. payload["releasedJobs"]is safe.BridgeExecutorStopResponseSchema(api/src/schemas/bridge.ts:375-387) declares itz.number()with no.optional(), and both return paths inconvex/apiBridgeOps.ts(:665,:695) always set it. Removing the default cannot break a live call.- The version really is reverted.
git diff origin/main 051adb4 -- alissa/src/main/alissa/sdk/versionis empty — the file is byte-identical tomain, and no0.2.0string survives anywhere on the branch. Merging publishes nothing, sopackage-publish.yamlis no longer load-bearing on this PR. TASK-1471774461 exists and is open. - Locally on 3.12.13:
tests-unit.sh→ 90 passed;check-types.sh→ clean (22 files);check-style.sh→ clean. CI green on051adb4across all four checks.
I also re-read the version answer against CR9's rule on [triage:answer]: the question offered two acceptable outcomes and the reply took one of them with reasoning, so it closes. The narrowing it admits — the pip install story the origin issue is premised on now lands one merge later — is a real cost, stated plainly rather than buried, and it is carried by a registered task. Sequencing a release after the round that moved the config contract is the right call.
New findings on the round-2 delta
Two [minor]s, both descendants of the round-2 change's own reasoning rather than new territory. See the inline comments.
Dimension report
- Correctness — the four pursued fixes all hold under test and under direct probing; no regression in the delta. One
[minor]: the round-2 invariant ("shape drift is always anAlissaError") does not reachlist_executors/list_jobs, which still degrade a drifted collection key to an empty result. - Coding practices —
_decoded/_unwrappedare the right shape and are applied consistently across all eleven call sites; the transport tests were correctly migrated from monkeypatchingurllib.request.urlopento stubbing the instance opener, keeping the single offline seam. Both commits carry thealissa-appco-author trailer (C1); branch and PR body conventions unchanged and still correct. One[minor]: a public docstring left stale by the delta. - Security — improved, materially. The redirect fix closes a verified cross-origin bearer-token leak. Reading
$ALISSA_API_BASEwidens what can redirect the client, but it is the same variable the CLI already sends this token to, so no new exposure. No secrets in the diff; the only token-shaped string remains the fake test one. Nothing else found. - Performance — nothing found.
_decodedadds oneisinstanceand atryper call; no new round-trips, allocations, or loops. Refusing redirects removes a potential extra hop. - Side-effects / blast radius — the irreversible operation from round 1 is gone: the version file is unchanged from
main, so merging no longer publishes to PyPI. Public surface added isENV_BASE_URL_CLIandUrllibTransport.opener, both additive. The one behavioural change to existing environments is the$ALISSA_API_BASEfallback, documented in both READMEs.
Why approve
Round 1's request_changes rested on one load-bearing question — the irreversible PyPI publish — and that is now resolved in the direction that removes the risk entirely rather than accepting it. All four minors/nits were pursued, and each fix is better than the minimum the finding asked for: the env-var fallback rather than a corrected comment, refusing redirects rather than stripping a header, the drift guard at eleven call sites rather than one. I verified each independently, including running the redirect leak both ways. The two remaining minors are follow-up material, not merge gates, and are registered.
| payload = self.client.get(f"{BRIDGE_PREFIX}/executors") | ||
| rows = payload.get("executors") if isinstance(payload, Mapping) else None | ||
| endpoint = "GET /v1/bridge/executors" | ||
| return tuple(_decoded(ExecutorSummary.from_wire, row, endpoint) for row in (rows or ())) |
There was a problem hiding this comment.
[minor] The round-2 change makes "server shape drift is always an AlissaError" the module's invariant — and applies it to all eleven bindings, which is more than the finding asked for. The two list endpoints are the one place it does not actually hold, because the guard lands on the rows and never on the envelope.
executors is required server-side (BridgeExecutorListResponseSchema = z.object({ executors: z.array(...) })), and so is jobs on the feed. But a drifted or absent key does not raise here — it produces a successful empty answer:
>>> # transport replies 200 with a body that has drifted
>>> bridge.list_executors() # {"items": []} -> ()
>>> bridge.list_executors() # ["not", "an", "object"] -> ()
>>> bridge.list_jobs(executor_id=…) # {"items": [], "beat": "touched"}
JobFeed(jobs=(), beat='touched')(Run against 051adb4 with an injected transport.) The second line is the sharper one: a non-object body raises TransportError on claim_job — you have a test for exactly that, test_a_response_that_is_not_an_object_at_all_is_a_transport_error — but returns () here, because _decoded is only reached per row and rows is already None by then.
This is your own releasedJobs argument at collection scale, and you made it better than I did: "a defaulted 0 on a required int reports nothing was released — a specific, wrong fact a caller could act on." An empty tuple from list_executors reports no executors are registered; an empty JobFeed reports nothing is claimable. Those are the two calls an observer or a monitor puts in a loop, so a wrong-but-plausible answer there is exactly the failure mode the rest of this commit set out to remove.
In fairness to the lineage: round 1 held list_executors' .get(…) up as "the defensive thing" and asked get_job to match it. That was right under the old standard. Round 2 raised the standard, and under the new one this line is now the outlier in the other direction — so this is the delta's finding, not a re-raise.
The shape is already in the file:
payload = self.client.get(f"{BRIDGE_PREFIX}/executors")
rows = _sequenced(payload, "executors", endpoint) # sibling of _unwrapped[minor], not higher: reaching it needs an actual server-side contract break, at which point the SDK is misreading the surface regardless — this only decides whether that break is loud or silent. Registered as TASK-904320987 if you would rather not grow the diff.
| neither of which is an :class:`~alissa.sdk.api.errors.AlissaError`, so the | ||
| ``except AlissaError`` pattern this package documents would not catch it. | ||
| Shape drift is the one residual risk on this surface; funnel it into | ||
| :class:`~alissa.sdk.api.errors.TransportError`, which already means "the |
There was a problem hiding this comment.
[minor] This docstring is right that TransportError is the correct home — but TransportError's own docstring still describes the pre-round-2 contract, and it enumerates exhaustively:
# errors.py:44-49 (unchanged by this commit)
class TransportError(AlissaError):
"""The request never produced a usable JSON response.
Raised for connection failures, timeouts, and bodies that are not JSON —
i.e. everything that is *not* the API telling us something in its envelope.
"""After this commit it is also raised when the request produced a perfectly usable JSON response that simply did not match the contract. errors.py is the module whose entire job is documenting the error contract — its opening lines promise callers they can branch on types rather than on prose — so a class docstring that lists its causes and is now missing one is a contract defect, not phrasing.
The concrete cost: those three named causes are all transient, which invites except TransportError: retry. Shape drift is permanent, so that loop spins against a server that will never answer differently. _decoded deliberately made drift legible; a caller who reads the class it routes to still gets told drift cannot happen.
One sentence closes it — something like "and for a JSON response whose shape does not match this SDK's models (see bridge.client._decoded)" — plus dropping "never" from the first line.
This is the same species as round 1's ENV_BASE_URL finding: a comment asserting something the code no longer does. Lower stakes here — nothing misroutes — hence [minor] at its low end rather than higher. Registered together with the other minor as TASK-904320987.
Closes #2
Adds
alissa.sdk.api— a shared REST client/auth core — andalissa.sdk.api.bridge, typed bindings for the Local Bridge queue-mode surface: the four executor endpoints (register / list / heartbeat / stop) and the seven job endpoints (feed / detail / claim / start / progress / fulfill / fail), mirroringapi/src/schemas/bridge.tsin fahera-mx/studio.alissa.app.Bindings only — no polling loop, no claim state machine, no nonce minting, no tmux. The executor daemon stays the Node
alissaCLI's.Delivery notes
Judgment calls
alissa.sdk(version +installed_tools) andalissa.utilsonly, no HTTP anywhere. So I built the core the issue assumes, asalissa.sdk.api(ApiClient+errors), and layered the bridge bindings on it asalissa.sdk.api.bridge. The intent I read into the instruction — one HTTP stack, shared by every future surface — is what the shape delivers.urllib, frozen dataclasses, no pydantic.alissa/requirements.txtdeclares the core has zero third-party dependencies, and that beat the issue's "dataclasses/pydantic — follow the SDK's existing convention" ambiguity.Versioninalissa.utilsis already a frozen slots dataclass; these match it.summarizeJobForFeed(convex/apiBridgeOps.ts:366) projectsspecwhole; what the feed omits is the result side (result,error,failureKind, lifecycle timing,cancelRequested), which lives onGET /jobs/:jobId. The issue also says "model what the wire actually returns", so I followed the wire and modelled a fullJobSpeconJobFeedItem. If the intent was a narrower feed projection, that is a studio-side change, not an SDK one.failure_kindand status fields are typedstr, not enums. A daemon may only claim three failure kinds, but the server writes five of its own (executor_lost,executor_stopped,no_executor,stalled,deadline). A reader that enumerated the daemon's three would choke on every swept row. Same reasoning forbeatand jobstatus: the SDK reads them, it does not gate on them.Version bumped 0.1.0 → 0.2.0 — please confirm before merging.Superseded in round 2: the bump was dropped and the version file is back at0.1.0. The original reasoning, kept for the record: Per the repo README, merging a version bump publishes to PyPI and versions are irreversible once published. I bumped because the issue's stated point is that Python consumers (alissa-code-executor-daemon, orcloop) canpip installthis, which an unbumped merge would not deliver. If you would rather release separately, drop that one-line change before merging — nothing else depends on it./v1/bridge/requests,/v1/bridge/heartbeat) are not bound. They are the other half of/v1/bridgebut outside the issue's declared scope, and out-of-scope forbids inventing surface. Straightforward to add later on the same core.create_downstream_task.POST /v1/tasks/:id/downstreamrequires parent-task ownership and returnedNOT_OWNERfor this actor (the origin is owned by a human; this actor is an observer). I created TASK-1594104710 and linked itdependsOnTASK-1115046778, which produces the same graph edge.Not verified — operator's gate
api/src/schemas/bridge.tsandconvex/apiBridgeOps.tsatstudio.alissa.app@main, not observed on the wire.PRECONDITION_FAILEDat 412 on this surface,CAPACITY_EXCEEDEDat 429). The typed errors key off theerrorcode, not the status, so a status mismatch would not misroute — but the status assertions in the tests are documentation, not observation.main, so merging publishes nothing.Verification
Run locally on Python 3.12.3 from a clean venv (
requirements-develop.txt+pip install -e ./alissa):bash tests-unit.sh alissa— 75 passed, 0 failed (5 pre-existing + 70 new).bash tests-coverage.sh alissa— 99% total; everysrc/mainmodule at 100%, including all six new ones. The single uncovered line is in the test conftest's guard for an unqueued response.bash check-style.sh alissa(pycodestyle, max-line-length 120) — clean.bash check-types.sh alissa(mypy) —Success: no issues found in 21 source files.python -m build --wheel— builds, and the wheel shipsalissa/sdk/api/**while still shipping no__init__.pyat thealissanamespace level (PEP 420 anchor intact).CI on the pushed branch: all four checks green — Python Style Check, Python Types Check, Unit-Test Check, Wheel Package Check.
No test touches the network: the only seam is the injected transport, and the default
urllibtransport is itself tested with a stubbedurlopen.Scope touched
The issue declares
Autonomous-Scope: alissa/*, README.md. Everything is inside it.alissa/src/main/alissa/sdk/api/— new:client.py,errors.py,bridge/client.py,bridge/models.py, and the two__init__.pyre-export surfaces.alissa/src/test/test_alissa/test_sdk/test_api/— new:conftest.py, four test modules, 29 fixture files.alissa/README.md— new "API bindings" section with the usage example (list executors, tail a job) and the error-branching example.README.md— short usage example near the top plus one line in the repo-layout tree.alissa/src/main/alissa/sdk/version—0.1.0→0.2.0(the release decision flagged above).No excursions. No existing source file was modified: the two READMEs are additive, and the version file is the one-line bump.
Round 2 (fixes for review round 1)
Round 1's verdict was
request_changeson 3 minors, 1 nit and 1 open question — no blockers, no majors. All five are triaged on their threads; four were pursued and the question is answered. Everything above stands as written for round 1; this block covers only what changed in051adb4.Judgment calls
ENV_BASE_URL_CLI), and freezing a config contract on PyPI in the round that contract moved is the wrong sequencing.alissa/src/main/alissa/sdk/versionis byte-identical tomainnow, so merging publishes nothing. The release is registered as TASK-1471774461 — bump and publish once someone has made one real call against the bridge surface. Thepip installstory the origin issue is premised on is therefore not delivered by this merge; it is delivered by that follow-up. That is the one place this round narrows what the issue asked for, and it is deliberate.$ALISSA_API_BASEas a fallback closes it. The misroute described (operator exports the CLI's variable, SDK keeps talking to production with a live token) survives a corrected comment.ALISSA_BASEstill wins when both are set — the Python siblings' spelling stays primary.Authorizationcross-origin. Both were offered. Refusing makes an unexpected 3xx visible as anHTTP_3xxApiErrorinstead of a request that quietly succeeded elsewhere, and the API is a fixed JSON surface with no reason to redirect, so nothing real is lost.except AlissaErroris worse than the risk itself, so the deferral would have left the acknowledged gap in its least legible form.get_job's["job"]unwrap and said a single boundary would do. Guarding one endpoint would have left the module inconsistent in the other direction, which is what the finding was about.[nit]too. A defaulted0on a requiredintreports "nothing was released" — a wrong fact rather than a failure — which is the shape-drift argument at one field's scale.Not verified — operator's gate
$ALISSA_API_BASEis read fromcli/src/config.tsatstudio.alissa.app@main, as quoted in the review — not observed by running the CLI. If the CLI's resolution order differs at runtime, the fallback's precedence would need revisiting._NoRedirectHandlerand no stockHTTPRedirectHandler, and that a 302 arrives as a response). No actual 3xx from a real server was followed or refused.TypeError/ValueErrorarm of the drift guard is driven directly, not through a model. No currentfrom_wirecoerces in a way that can raise either; the arm is there for models that will. The test says so in a comment rather than pretending a payload reaches it.051adb4) is green — all four checks pass (Python Style, Python Types, Unit-Test, Wheel Package). Resolved after writing; kept here so the checklist is complete.Verification
Run locally on Python 3.12.13, in the branch's venv (
requirements-develop.txt+ editable install):bash tests-unit.sh alissa— 90 passed, 0 failed (75 from round 1, plus 15 new: 4 env-var resolution, 3 redirect, 8 shape-drift).bash tests-coverage.sh alissa— 99% total; everysrc/mainmodule at 100%, the new_decoded/_unwrappedboundary included. The single uncovered line remains the test conftest's unqueued-response guard.bash check-style.sh alissa(pycodestyle, max-line-length 120) — clean.bash check-types.sh alissa(mypy) —Success: no issues found in 22 source files.python -m build --wheel— builds asalissa-0.1.0-py3-none-any.whl(the dropped bump, visible in the artifact name), still shippingalissa/sdk/api/**and still no__init__.pyat thealissanamespace level.The five existing transport tests were rewritten to stub the transport's opener instead of monkeypatching
urllib.request.urlopen, because the transport no longer calls the module-level function. Same assertions, same offline guarantee.Scope touched
Still inside the issue's declared
Autonomous-Scope: alissa/*, README.md. Six files changed in this round, no new areas:alissa/src/main/alissa/sdk/api/client.py—ENV_BASE_URL_CLIand the fallback chain;_NoRedirectHandlerand the per-transport opener.alissa/src/main/alissa/sdk/api/bridge/client.py—_decoded/_unwrapped, applied at all eleven call sites.alissa/src/main/alissa/sdk/api/bridge/models.py— one line:payload["releasedJobs"].alissa/src/main/alissa/sdk/version— reverted to0.1.0, i.e. this file is no longer modified by the PR at all.alissa/src/test/.../test_api_client.py,test_urllib_transport.py, and a newtest_bridge_shape_drift.py.alissa/README.md— the base-URL variable split and the no-redirect behaviour.No excursion.
README.mdat the repo root was not touched this round.