mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a38b835f5 | |||
| 415be00149 | |||
| 346ad2a6aa | |||
| 8003fcbebe | |||
| d4f3711d63 | |||
| cea229206b | |||
| b7b4dcc0df | |||
| 97080e1df9 | |||
| af0cbaaec3 | |||
| b9ce0d388e | |||
| d07d2242aa | |||
| 70514cc406 | |||
| 687a3367c0 | |||
| bfd99c6a81 | |||
| 1a813c8130 | |||
| d6aa85db6d | |||
| c0c7fda7f9 | |||
| 7fae2698d7 | |||
| 4bbe64755e |
+126
@@ -14,6 +14,132 @@ Three release tracks are maintained:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.18]
|
||||
|
||||
Backports the `turnstone-admin` config-loading alignment from `main`
|
||||
plus the accompanying `load_config` permission-warning hardening. No
|
||||
schema changes.
|
||||
|
||||
### Added
|
||||
|
||||
- **`turnstone-admin` reads `config.toml`** — the admin CLI now honors
|
||||
the same `[database]` section that `turnstone-server` does, with the
|
||||
same precedence (`CLI / config.toml > TURNSTONE_DB_* env > defaults`).
|
||||
Operators with DB credentials in `config.toml` no longer need to
|
||||
re-export `TURNSTONE_DB_URL` before every admin invocation. Newly
|
||||
plumbed through to `init_storage`: `pool_size`, `sslmode`,
|
||||
`sslrootcert`, `sslcert`, `sslkey` — previously the admin CLI
|
||||
silently dropped these. A new `--config PATH` flag mirrors the
|
||||
one already on `turnstone-server`.
|
||||
|
||||
### Security
|
||||
|
||||
- **Permissive `config.toml` now warns** — `turnstone.core.config.load_config`
|
||||
logs a single warning when the resolved config file is group- or
|
||||
world-readable (any bit in `0o077`). DB password and TLS key paths
|
||||
live in `[database]`; operators usually want the file at `0600`.
|
||||
|
||||
## [1.5.17]
|
||||
|
||||
Backports a clutch of coordinator-tool clarity fixes plus a watch-delivery
|
||||
correctness fix from `main` to the `stable/1.5` track, plus a previously-
|
||||
latent intent-verdicts persistence bug exposed by the new heuristic-verdict
|
||||
INSERT paths. No schema changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`intent_verdicts` PK collisions on every llm_fallback delivery** —
|
||||
async LLM-tier "llm_fallback" verdicts (`turnstone/core/judge.py` —
|
||||
`_deliver_fallbacks` and the in-loop fallback path) deliberately
|
||||
reuse the heuristic verdict's `verdict_id` so the row gets
|
||||
"upgraded in place" from `tier="heuristic"` → `tier="llm_fallback"`
|
||||
when the LLM judge times out, is cancelled, or returns no content.
|
||||
The consumer `_persist_intent_verdict` was doing a plain INSERT,
|
||||
hitting the `intent_verdicts_pkey` constraint on every fallback
|
||||
delivery; Postgres logged the duplicate-key error, the application
|
||||
try/except swallowed it at `log.debug`, and the row never actually
|
||||
got upgraded — the LLM judge's annotation
|
||||
(`"(LLM judge did not return a verdict)"`) was lost. The collision
|
||||
rate exploded on this release because the new heuristic-INSERT
|
||||
paths in the auto-approve early-return branches of `approve_tools`
|
||||
(introduced below) leave no gap for the fallback to land cleanly
|
||||
into. Fix: new `upsert_intent_verdict` storage method using
|
||||
`ON CONFLICT (verdict_id) DO UPDATE` that updates only `tier`,
|
||||
`reasoning`, `judge_model` — the three fields that genuinely
|
||||
change between heuristic and llm_fallback. Every other column
|
||||
(identity, carried-verbatim, and `user_decision`) is excluded;
|
||||
`user_decision` in particular would otherwise be clobbered back
|
||||
to `"pending"` when a fallback arrives after the operator has
|
||||
already resolved the approval. The bulk-INSERT path stays as
|
||||
plain INSERT — fresh UUIDs in `judge.evaluate` make in-turn dups
|
||||
impossible; the inverse race (fallback wins before bulk lands) is
|
||||
reachable but unchanged in observable behavior by this fix,
|
||||
documented at the bulk site for a future hardening pass.
|
||||
- **Coordinator LLM re-spawn loops on large fan-outs** — the spawn-tool
|
||||
return JSON used `ws_id` as its key, which primed the model's recency
|
||||
bias to feed the spawn result straight back into another
|
||||
`spawn_workstream(ws_id=...)` call instead of progressing to
|
||||
`wait_for_workstream(ws_ids=[...])`. On 10+ child fan-outs this cascaded
|
||||
into self-inflicted re-spawn loops. The LLM-facing tool result now emits
|
||||
`child_ws_id` (the storage column / HTTP API contract is unchanged); the
|
||||
field name is already an existing project term so the rename aligns
|
||||
rather than introduces new vocabulary. Also handles the silent
|
||||
upstream-omits-ws_id success-shape edge that previously emitted
|
||||
`{"child_ws_id": null}` to the LLM — now surfaces a tool error so the
|
||||
model retries rather than chasing a null id.
|
||||
- **`inspect_workstream` blowing the coordinator context budget** — a
|
||||
coord doing a fan-out wave against tool-heavy children could land
|
||||
>100 KB of raw output per inspect call, and the previous safety net
|
||||
(`_truncate_output`'s head+tail strategy) silently dropped *middle*
|
||||
messages — exactly the wrong shape for understanding a child's
|
||||
trajectory (the FIRST sets the brief, the LAST shows the conclusion,
|
||||
the middle is the connective tissue). Output now goes through a
|
||||
three-tier degradation ladder mirroring the search tool's
|
||||
`_format_search_results`: `_tier="full"` (every message verbatim) →
|
||||
`_tier="compact"` (per-message head/tail-snipped content + snipped
|
||||
`tool_calls.arguments`, falling through a `(20,30)` / `(10,20)` /
|
||||
`(5,10)` message-list trim ladder) → `_tier="skeleton"` (counts, role
|
||||
distribution, last-assistant preview). Budget 32 KiB matches the
|
||||
search tool's; the chosen tier is annotated on the response so the
|
||||
model can recall with a tighter `message_limit` if signal was lost.
|
||||
- **Auto-approved verdicts indistinguishable from pending review** —
|
||||
`intent_verdict` rows for auto-approved tool calls landed with
|
||||
`user_decision=""`, which read identically to "still waiting for the
|
||||
operator" in the audit trail and led to a real misdiagnosis incident.
|
||||
The column now carries an explicit vocabulary at insert: `pending` /
|
||||
`approved` / `denied` / `timeout` / `policy` / `blanket` / `skill` /
|
||||
`always` / `auto_approve_tools`. The auto-approve early-return
|
||||
branches in `approve_tools` now persist heuristic verdicts stamped
|
||||
with their reason (previously dropped on the floor), and late LLM-tier
|
||||
verdicts that arrive for an already-auto-approved call_id are stamped
|
||||
via a TTL-pruned lookup map — so the audit row carries the
|
||||
auto-approve reason even when the LLM judge daemon completes after
|
||||
the synchronous approval cycle finished. `resolve_approval` gains a
|
||||
`timeout` kwarg writing `"timeout"` (the previous shape collapsed
|
||||
passive timeouts and active denials into the same column).
|
||||
- **`list_skills` empty `allowed_tools` misread as "no tool access"** —
|
||||
the response previously emitted `"allowed_tools": []` for every skill
|
||||
that hadn't declared an auto-approve allowlist, which a coordinator
|
||||
model read as "this skill can't use any tools" (real misdiagnosis: a
|
||||
code-review child appeared to have been spawned with zero tool
|
||||
access). The field is now omitted entirely when empty — absence
|
||||
carries the unambiguous meaning "no tool is pre-approved for this
|
||||
skill", presence (non-empty list) keeps the standard Claude Code
|
||||
skill-spec shape. The tool description rewrite makes the
|
||||
auto-approve-allowlist semantics explicit so a future reader doesn't
|
||||
re-derive the gating misread.
|
||||
- **Watch terminal-fires silently dropped on backpressure** —
|
||||
delivery now routes terminal events through the same path as
|
||||
normal fires instead of being filtered out when the consumer was
|
||||
saturated.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Storage `LIKE_ESCAPE` contract** — clarify that callers passing
|
||||
`.like(escape=...)` must use the same escape character that the
|
||||
storage helper assumes; previous wording let a reader pass a
|
||||
different escape and silently produce no matches.
|
||||
|
||||
## [1.5.15]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -91,7 +91,7 @@ turnstone/
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.46/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.47/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
|
||||
@@ -110,11 +110,18 @@ owns it; the node is just currently unreachable.
|
||||
|
||||
### Example — `spawn_batch`
|
||||
|
||||
This is the coordinator-tool result shape (the JSON the LLM receives),
|
||||
not an HTTP API response — the table above keys it under "model tool"
|
||||
to distinguish it from the `/v1/api/...` endpoints in the same table.
|
||||
The underlying HTTP spawn endpoint still returns `ws_id`; the tool
|
||||
result re-keys it to `child_ws_id` to defuse a coordinator-LLM recency
|
||||
bias (see `docs/coordinator-skills.md`).
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"0": {"ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
|
||||
"2": {"ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
|
||||
"0": {"child_ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
|
||||
"2": {"child_ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
|
||||
},
|
||||
"denied": [
|
||||
{"idx": 1, "reason": "skill not found: nonexistent-skill"}
|
||||
|
||||
@@ -169,14 +169,21 @@ validates ws_id against `parent_ws_id=coord_ws_id` AND
|
||||
the wait into reporting "complete".
|
||||
|
||||
Pattern: capture each spawn result in the next tool call's input.
|
||||
The JSON tool-result carries `{"ws_id": "...", "name": "...",
|
||||
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
|
||||
"node_id": "...", "routing_strategy": "..."}`; the model should
|
||||
extract the ws_id and pass it to `inspect_workstream` /
|
||||
`wait_for_workstream` / `send_to_workstream` / `close_workstream`
|
||||
verbatim.
|
||||
extract the `child_ws_id` and pass it as `ws_id` (or in the `ws_ids`
|
||||
list) to `inspect_workstream` / `wait_for_workstream` /
|
||||
`send_to_workstream` / `close_workstream` verbatim. The asymmetry
|
||||
— spawn returns `child_ws_id` but the other tools accept `ws_id` /
|
||||
`ws_ids` — is intentional: it defuses a coordinator-LLM recency
|
||||
bias where seeing `ws_id` in a spawn return primed re-spawn loops
|
||||
instead of progression to the wait phase.
|
||||
|
||||
A UI that wants human-readable identifiers should render the `name`
|
||||
field and keep the ws_id as the click-through key.
|
||||
field and keep the workstream id as the click-through key — note
|
||||
that the id *value* is the same regardless of whether it arrived
|
||||
under the `child_ws_id` key (spawn return) or the `ws_id` key
|
||||
(every other tool's input/output); only the field name differs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.5.16"
|
||||
version = "1.5.18"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -82,7 +82,7 @@ include = [
|
||||
"turnstone/console/static/coordinator/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.46/**/*",
|
||||
"turnstone/shared_static/katex-0.16.47/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.15.0/**/*",
|
||||
"turnstone/shared_static/hls-1.6.16/**/*",
|
||||
|
||||
Generated
+75
-75
@@ -74,9 +74,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.129.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
|
||||
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -84,9 +84,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -101,9 +101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -118,9 +118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -135,9 +135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -152,9 +152,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
|
||||
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -169,9 +169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -189,9 +189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -209,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -229,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -249,9 +249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -269,9 +269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -289,9 +289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -306,9 +306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
|
||||
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -325,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -342,9 +342,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -359,9 +359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
|
||||
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -988,14 +988,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
|
||||
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.129.0",
|
||||
"@rolldown/pluginutils": "1.0.0"
|
||||
"@oxc-project/types": "=0.130.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -1004,21 +1004,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0"
|
||||
"@rolldown/binding-android-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1119,16 +1119,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
|
||||
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.0",
|
||||
"rolldown": "1.0.1",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for turnstone-admin DB configuration precedence.
|
||||
|
||||
Locks in the alignment with turnstone-server:
|
||||
CLI / config.toml [database] > TURNSTONE_DB_* env > hardcoded default
|
||||
|
||||
The motivation is to keep DB secrets in config.toml (see
|
||||
feedback_secrets_not_in_env) rather than forcing operators to export
|
||||
TURNSTONE_DB_URL before every admin invocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import turnstone.core.config as config_mod
|
||||
from turnstone.admin import _get_storage
|
||||
|
||||
|
||||
def _reset_cache() -> None:
|
||||
config_mod._cache = None
|
||||
config_mod._config_path = None
|
||||
|
||||
|
||||
def _build_args(config_path: str | None) -> argparse.Namespace:
|
||||
"""Build an args namespace the way admin.main() does.
|
||||
|
||||
Skips ``add_config_arg`` (which reads ``sys.argv``) — the test
|
||||
constructs the args programmatically instead.
|
||||
"""
|
||||
config_mod.set_config_path(config_path or "/nonexistent/turnstone-admin-test.toml")
|
||||
parser = argparse.ArgumentParser()
|
||||
config_mod.apply_config(parser, ["database"])
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
sub.add_parser("list-users")
|
||||
return parser.parse_args(["list-users"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_db_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Clean slate: no TURNSTONE_DB_* env vars unless a test sets them."""
|
||||
for var in (
|
||||
"TURNSTONE_DB_BACKEND",
|
||||
"TURNSTONE_DB_URL",
|
||||
"TURNSTONE_DB_PATH",
|
||||
"TURNSTONE_DB_POOL_SIZE",
|
||||
"TURNSTONE_DB_SSLMODE",
|
||||
"TURNSTONE_DB_SSLROOTCERT",
|
||||
"TURNSTONE_DB_SSLCERT",
|
||||
"TURNSTONE_DB_SSLKEY",
|
||||
"TURNSTONE_CONFIG",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
_reset_cache()
|
||||
yield
|
||||
_reset_cache()
|
||||
|
||||
|
||||
def test_defaults_to_sqlite_when_neither_config_nor_env_set() -> None:
|
||||
args = _build_args(None)
|
||||
with patch("turnstone.core.storage.init_storage") as init:
|
||||
_get_storage(args)
|
||||
assert init.call_args.args == ("sqlite",)
|
||||
assert init.call_args.kwargs["url"] == ""
|
||||
assert init.call_args.kwargs["path"] == ""
|
||||
assert init.call_args.kwargs["pool_size"] == 2
|
||||
|
||||
|
||||
def test_config_toml_database_section_drives_init_storage(tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"[database]\n"
|
||||
'backend = "postgresql"\n'
|
||||
'url = "postgresql+psycopg://fromconfig:x@host/db"\n'
|
||||
"pool_size = 5\n"
|
||||
'sslmode = "verify-full"\n'
|
||||
'sslrootcert = "/etc/ssl/ca.pem"\n'
|
||||
'sslcert = "/etc/ssl/client.pem"\n'
|
||||
'sslkey = "/etc/ssl/client.key"\n'
|
||||
)
|
||||
args = _build_args(str(cfg))
|
||||
with patch("turnstone.core.storage.init_storage") as init:
|
||||
_get_storage(args)
|
||||
assert init.call_args.args == ("postgresql",)
|
||||
kw = init.call_args.kwargs
|
||||
assert kw["url"] == "postgresql+psycopg://fromconfig:x@host/db"
|
||||
assert kw["pool_size"] == 5
|
||||
assert kw["sslmode"] == "verify-full"
|
||||
assert kw["sslrootcert"] == "/etc/ssl/ca.pem"
|
||||
assert kw["sslcert"] == "/etc/ssl/client.pem"
|
||||
assert kw["sslkey"] == "/etc/ssl/client.key"
|
||||
|
||||
|
||||
def test_env_used_as_fallback_when_config_absent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TURNSTONE_DB_BACKEND", "postgresql")
|
||||
monkeypatch.setenv("TURNSTONE_DB_URL", "postgresql+psycopg://fromenv:x@host/db")
|
||||
monkeypatch.setenv("TURNSTONE_DB_POOL_SIZE", "7")
|
||||
monkeypatch.setenv("TURNSTONE_DB_SSLMODE", "require")
|
||||
|
||||
args = _build_args(None)
|
||||
with patch("turnstone.core.storage.init_storage") as init:
|
||||
_get_storage(args)
|
||||
assert init.call_args.args == ("postgresql",)
|
||||
kw = init.call_args.kwargs
|
||||
assert kw["url"] == "postgresql+psycopg://fromenv:x@host/db"
|
||||
assert kw["pool_size"] == 7
|
||||
assert kw["sslmode"] == "require"
|
||||
|
||||
|
||||
def test_config_toml_wins_over_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""config.toml beats env — operators should put secrets in TOML."""
|
||||
monkeypatch.setenv("TURNSTONE_DB_BACKEND", "sqlite")
|
||||
monkeypatch.setenv("TURNSTONE_DB_URL", "postgresql+psycopg://fromenv:x@host/db")
|
||||
monkeypatch.setenv("TURNSTONE_DB_SSLMODE", "require")
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"[database]\n"
|
||||
'backend = "postgresql"\n'
|
||||
'url = "postgresql+psycopg://fromconfig:x@host/db"\n'
|
||||
'sslmode = "verify-full"\n'
|
||||
)
|
||||
args = _build_args(str(cfg))
|
||||
with patch("turnstone.core.storage.init_storage") as init:
|
||||
_get_storage(args)
|
||||
assert init.call_args.args == ("postgresql",)
|
||||
kw = init.call_args.kwargs
|
||||
assert kw["url"] == "postgresql+psycopg://fromconfig:x@host/db"
|
||||
assert kw["sslmode"] == "verify-full"
|
||||
|
||||
|
||||
def test_partial_config_falls_through_to_env_per_key(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A key missing from [database] should fall back to its env var."""
|
||||
monkeypatch.setenv("TURNSTONE_DB_SSLMODE", "require")
|
||||
monkeypatch.setenv("TURNSTONE_DB_POOL_SIZE", "9")
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
'[database]\nbackend = "postgresql"\nurl = "postgresql+psycopg://fromconfig:x@host/db"\n'
|
||||
)
|
||||
args = _build_args(str(cfg))
|
||||
with patch("turnstone.core.storage.init_storage") as init:
|
||||
_get_storage(args)
|
||||
kw = init.call_args.kwargs
|
||||
assert kw["url"] == "postgresql+psycopg://fromconfig:x@host/db"
|
||||
assert kw["sslmode"] == "require"
|
||||
assert kw["pool_size"] == 9
|
||||
|
||||
|
||||
def test_empty_string_in_config_beats_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""`url = ""` in config.toml beats an env var.
|
||||
|
||||
Locks in the `is not None` guard — a falsy-but-present TOML value
|
||||
should NOT silently fall through to the env fallback.
|
||||
"""
|
||||
monkeypatch.setenv("TURNSTONE_DB_URL", "postgresql+psycopg://fromenv:x@host/db")
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[database]\nbackend = "sqlite"\nurl = ""\n')
|
||||
args = _build_args(str(cfg))
|
||||
with patch("turnstone.core.storage.init_storage") as init:
|
||||
_get_storage(args)
|
||||
assert init.call_args.kwargs["url"] == ""
|
||||
|
||||
|
||||
def test_main_threads_config_toml_through_real_argv(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""End-to-end: ``turnstone-admin --config <toml> list-users`` honors TOML.
|
||||
|
||||
Covers the ``add_config_arg`` -> ``apply_config`` -> ``_get_storage``
|
||||
chain that the programmatic ``_build_args`` helper skips.
|
||||
"""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
'[database]\nbackend = "postgresql"\nurl = "postgresql+psycopg://fromcli:x@host/db"\n'
|
||||
)
|
||||
monkeypatch.setattr("sys.argv", ["turnstone-admin", "--config", str(cfg), "list-users"])
|
||||
|
||||
fake_storage = patch("turnstone.core.storage.init_storage").start()
|
||||
fake_storage.return_value.list_users.return_value = []
|
||||
try:
|
||||
from turnstone.admin import main
|
||||
|
||||
main()
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert fake_storage.call_args.args == ("postgresql",)
|
||||
assert fake_storage.call_args.kwargs["url"] == "postgresql+psycopg://fromcli:x@host/db"
|
||||
|
||||
|
||||
def test_get_storage_initializes_real_sqlite_backend(tmp_path: Path) -> None:
|
||||
"""Drives the real ``init_storage`` boundary on a fresh sqlite file.
|
||||
|
||||
Mock-only tests would miss a kwarg-name typo (sslmode -> ssl_mode).
|
||||
This test trips on any such drift because Alembic + the backend
|
||||
actually run.
|
||||
"""
|
||||
from turnstone.core.storage import reset_storage
|
||||
|
||||
db_file = tmp_path / "admin.db"
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(f'[database]\nbackend = "sqlite"\npath = "{db_file}"\n')
|
||||
args = _build_args(str(cfg))
|
||||
|
||||
reset_storage()
|
||||
try:
|
||||
storage = _get_storage(args)
|
||||
assert storage.list_users() == []
|
||||
finally:
|
||||
reset_storage()
|
||||
@@ -50,6 +50,41 @@ def test_load_config_invalid_toml(tmp_path):
|
||||
assert load_config() == {}
|
||||
|
||||
|
||||
def test_load_config_warns_when_world_readable(tmp_path, caplog):
|
||||
"""Secrets in config.toml — warn if anyone but the owner can read it."""
|
||||
import logging
|
||||
import os
|
||||
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[database]\nurl = "postgresql+psycopg://u:secret@h/d"\n')
|
||||
os.chmod(cfg, 0o644)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="turnstone.core.config"):
|
||||
load_config()
|
||||
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert any("group/world-readable" in m for m in messages)
|
||||
|
||||
|
||||
def test_load_config_quiet_when_mode_0600(tmp_path, caplog):
|
||||
import logging
|
||||
import os
|
||||
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[database]\nurl = "postgresql+psycopg://u:secret@h/d"\n')
|
||||
os.chmod(cfg, 0o600)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="turnstone.core.config"):
|
||||
load_config()
|
||||
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert not any("group/world-readable" in m for m in messages)
|
||||
|
||||
|
||||
def test_load_config_caches(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
|
||||
@@ -1228,6 +1228,49 @@ def test_list_skills_hides_interactive_only_skills(tmp_path):
|
||||
assert skill["kind"] in {"coordinator", "any"}
|
||||
|
||||
|
||||
def test_list_skills_omits_allowed_tools_when_empty(tmp_path):
|
||||
"""``allowed_tools`` is the auto-approve allowlist (tools exempt
|
||||
from the operator approval gate), NOT the set of tools the skill
|
||||
can use. An empty list reads as "no tool access" to a model
|
||||
that doesn't know the semantics — real misdiagnosis source: a
|
||||
code-review skill with no auto-approve allowlist looked like it
|
||||
had been spawned with zero tools. Dropping the key when empty
|
||||
removes the ambiguity at the source; absence of the field carries
|
||||
the unambiguous meaning "no tool is pre-approved for this skill"
|
||||
while a tool list reads as "these specific tools bypass the prompt".
|
||||
"""
|
||||
st = SQLiteBackend(str(tmp_path / "skills_empty.db"))
|
||||
st.create_prompt_template(
|
||||
template_id="s-empty",
|
||||
name="empty-skill",
|
||||
category="ops",
|
||||
content="",
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="test",
|
||||
tags="[]",
|
||||
allowed_tools="[]",
|
||||
)
|
||||
st.create_prompt_template(
|
||||
template_id="s-nonempty",
|
||||
name="nonempty-skill",
|
||||
category="ops",
|
||||
content="",
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="test",
|
||||
tags="[]",
|
||||
allowed_tools='["read_file"]',
|
||||
)
|
||||
client = _make_read_client(st)
|
||||
result = client.list_skills()
|
||||
by_name = {s["name"]: s for s in result["skills"]}
|
||||
assert "allowed_tools" not in by_name["empty-skill"]
|
||||
assert by_name["nonempty-skill"]["allowed_tools"] == ["read_file"]
|
||||
|
||||
|
||||
def test_list_skills_projects_allowed_tools_capped_with_sentinel(tmp_path):
|
||||
"""Each row carries the skill's allowed_tools (capped at the projection
|
||||
cap with a +N more sentinel) so coordinators can pick a skill without
|
||||
@@ -2616,3 +2659,362 @@ def test_cleanup_dead_task_child_refs_storage_batch_failure_swallows(populated_s
|
||||
|
||||
populated_storage.get_workstreams_batch = _boom # type: ignore[method-assign]
|
||||
assert client.cleanup_dead_task_child_refs("coord-1") == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_workstream — three-tier output compression
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A coord doing a fan-out wave against tool-heavy children would
|
||||
# otherwise blow the context budget on raw output alone. Mirrors the
|
||||
# search tool's Tier-1/Tier-2/Tier-3 ladder.
|
||||
|
||||
|
||||
def _make_inspect_result(
|
||||
*, ws_id: str = "ws-test", state: str = "running", n_messages: int = 5
|
||||
) -> dict[str, Any]:
|
||||
"""Build an inspect-result dict shaped like ``coordinator_client.inspect()``.
|
||||
|
||||
Production output keys (``ws_id``, ``skill_id``) mirror the storage
|
||||
row that ``inspect()`` spreads from ``get_workstream``. Tests that
|
||||
synthesize an inspect result must match these keys — otherwise a
|
||||
formatter that looks at the production keys silently emits null
|
||||
values against a fixture that uses different ones (real bug-1
|
||||
regression source: skeleton tier read ``skill`` from a fixture
|
||||
that wrote ``skill`` while production wrote ``skill_id``).
|
||||
"""
|
||||
return {
|
||||
"ws_id": ws_id,
|
||||
"state": state,
|
||||
"title": "test workstream",
|
||||
"skill_id": "researcher",
|
||||
"messages": [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i} content"}
|
||||
for i in range(n_messages)
|
||||
],
|
||||
"verdicts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_format_inspect_tiered_full_fits_returns_full_tier():
|
||||
"""Small payloads pass through with `_tier='full'` — no compression."""
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
result = _make_inspect_result(n_messages=3)
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "full"
|
||||
# Every message verbatim.
|
||||
assert len(parsed["messages"]) == 3
|
||||
assert parsed["messages"][0]["content"] == "msg 0 content"
|
||||
|
||||
|
||||
def test_format_inspect_tiered_compact_when_full_exceeds_budget():
|
||||
"""Large messages trigger the compact tier — head/tail-snipped
|
||||
content with the rest of the row intact."""
|
||||
from turnstone.console.coordinator_client import (
|
||||
_INSPECT_MSG_CONTENT_HEAD,
|
||||
_INSPECT_MSG_CONTENT_TAIL,
|
||||
_INSPECT_OUTPUT_BUDGET,
|
||||
_format_inspect_tiered,
|
||||
)
|
||||
|
||||
# Each message ~5KB; with 20 messages, full tier blows the 32KB budget.
|
||||
fat = "X" * 5000
|
||||
result = {
|
||||
"id": "ws-fat",
|
||||
"state": "running",
|
||||
"messages": [{"role": "assistant", "content": fat} for _ in range(20)],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "compact"
|
||||
# Every message preserved (compact keeps the count, just snips content).
|
||||
assert len(parsed["messages"]) == 20
|
||||
# Head/tail snip kicked in.
|
||||
msg_content = parsed["messages"][0]["content"]
|
||||
assert msg_content.startswith("X" * _INSPECT_MSG_CONTENT_HEAD)
|
||||
assert msg_content.endswith("X" * _INSPECT_MSG_CONTENT_TAIL)
|
||||
assert "chars elided" in msg_content
|
||||
# Budget invariant — the load-bearing contract of the formatter.
|
||||
# Without this assertion, a future change to ``_tier_note`` or
|
||||
# ``_compact_message`` could push the output over budget and the
|
||||
# ``_truncate_output`` head+tail safety net would silently mask
|
||||
# the regression, re-introducing the middle-message-drop pathology.
|
||||
assert len(out) <= _INSPECT_OUTPUT_BUDGET
|
||||
|
||||
|
||||
def test_format_inspect_tiered_compact_when_content_below_snip_threshold():
|
||||
"""When per-message content is below the snip threshold but the
|
||||
message COUNT alone overflows the budget, compact tier must still
|
||||
stay within budget — by trimming the message list (head + tail of
|
||||
messages) rather than degrading straight to skeleton. Bug-3
|
||||
regression cover: with 400 × 100-char messages, the original
|
||||
formatter fell through to skeleton because adding ``_tier_note``
|
||||
to an un-snipped tier-2 produced output strictly larger than
|
||||
tier-1 (both over budget). The fix preserves messages from both
|
||||
ends of the list and inserts an ``_omitted`` sentinel."""
|
||||
from turnstone.console.coordinator_client import (
|
||||
_INSPECT_OUTPUT_BUDGET,
|
||||
_format_inspect_tiered,
|
||||
)
|
||||
|
||||
# 400 × ~100 chars → Tier-1 ~53 KB (over budget), per-message
|
||||
# content under the 964-char snip threshold so content-snipping
|
||||
# saves nothing. Without the list-trim rung the formatter would
|
||||
# fall to skeleton and drop all 400 messages.
|
||||
smallish = "S" * 100
|
||||
result = {
|
||||
"ws_id": "ws-many-small",
|
||||
"state": "running",
|
||||
"messages": [
|
||||
{"role": "assistant" if i % 2 == 0 else "user", "content": smallish} for i in range(400)
|
||||
],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
# Should NOT fall through to skeleton — message-list trim preserves
|
||||
# head + tail of the conversation.
|
||||
assert parsed["_tier"] == "compact"
|
||||
assert "messages" in parsed
|
||||
# Some messages must survive; the trim shape is head + tail with an
|
||||
# ``_omitted`` sentinel between them.
|
||||
assert len(parsed["messages"]) > 0
|
||||
assert len(parsed["messages"]) < 400
|
||||
# Budget invariant.
|
||||
assert len(out) <= _INSPECT_OUTPUT_BUDGET
|
||||
|
||||
|
||||
def test_format_inspect_tiered_skeleton_when_compact_also_exceeds_budget():
|
||||
"""Tier 3 fallback: counts + last assistant preview only. Trigger by
|
||||
flooding with messages whose content is a multi-block list — the
|
||||
snipper correctly leaves non-string content unchanged (mirrors
|
||||
Anthropic/OpenAI multi-block content shape), so even after the
|
||||
(5, 10) message-list trim the surviving 15 messages don't fit in
|
||||
the 32 KB budget."""
|
||||
from turnstone.console.coordinator_client import (
|
||||
_INSPECT_OUTPUT_BUDGET,
|
||||
_format_inspect_tiered,
|
||||
)
|
||||
|
||||
# 50 messages × multi-block content (~30 KB each — list-shape
|
||||
# content bypasses the head/tail string snipper because lists
|
||||
# aren't strings). Even (5, 10) trim leaves 15 × 30 KB which
|
||||
# blows the 32 KB budget — forces skeleton.
|
||||
fat_block = {"type": "text", "text": "Y" * 3000}
|
||||
result = {
|
||||
"ws_id": "ws-flood",
|
||||
"state": "running",
|
||||
"title": "flood",
|
||||
"skill_id": "researcher",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant" if i % 2 == 0 else "user",
|
||||
"content": [fat_block] * 10,
|
||||
}
|
||||
for i in range(50)
|
||||
],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "skeleton"
|
||||
assert parsed["message_count"] == 50
|
||||
# Role distribution surfaces — the "what shape of activity" signal.
|
||||
assert parsed["roles"]["assistant"] == 25
|
||||
assert parsed["roles"]["user"] == 25
|
||||
# No `messages` field at skeleton tier — only the aggregate signal.
|
||||
assert "messages" not in parsed
|
||||
# Budget invariant.
|
||||
assert len(out) <= _INSPECT_OUTPUT_BUDGET
|
||||
|
||||
|
||||
def test_format_inspect_tiered_skeleton_keeps_terminal_state_fields():
|
||||
"""``close_reason`` / ``last_error`` survive the skeleton fall — they're
|
||||
small, load-bearing, and the operator needs them to understand WHY
|
||||
a terminal child landed in its state."""
|
||||
from turnstone.console.coordinator_client import (
|
||||
_INSPECT_OUTPUT_BUDGET,
|
||||
_format_inspect_tiered,
|
||||
)
|
||||
|
||||
# Same flood pattern as the bare-skeleton test (multi-block content
|
||||
# bypasses the string snipper) — paired with terminal-state fields
|
||||
# that must survive the skeleton fall.
|
||||
fat_block = {"type": "text", "text": "Z" * 3000}
|
||||
result = {
|
||||
"ws_id": "ws-closed",
|
||||
"state": "closed",
|
||||
"title": "done",
|
||||
"skill_id": "researcher",
|
||||
"messages": [{"role": "user", "content": [fat_block] * 10} for _ in range(50)],
|
||||
"verdicts": [],
|
||||
"close_reason": "task complete: report attached",
|
||||
"live": None, # filtered by truthy check
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "skeleton"
|
||||
assert parsed["close_reason"] == "task complete: report attached"
|
||||
# Falsy ``live`` doesn't bleed through.
|
||||
assert "live" not in parsed
|
||||
assert len(out) <= _INSPECT_OUTPUT_BUDGET
|
||||
|
||||
|
||||
def test_format_inspect_tiered_error_shapes_bypass_tiering():
|
||||
"""Cross-tenant / not-found responses keep their original shape — they
|
||||
carry no messages, are already tiny, and changing them would break
|
||||
callers that key on the ``error`` field."""
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
result = {"error": "workstream not found", "ws_id": "ws-foreign"}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed == {"error": "workstream not found", "ws_id": "ws-foreign"}
|
||||
# No `_tier` annotation — error shapes are self-describing.
|
||||
assert "_tier" not in parsed
|
||||
|
||||
|
||||
def test_format_inspect_tiered_compact_preserves_tool_call_linkage():
|
||||
"""Compact tier keeps ``tool_name`` / ``tool_call_id`` / ``name`` so a
|
||||
model reading the snipped trace can still pair a tool call to its
|
||||
response — the linkage is load-bearing for "what happened" signal."""
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
fat = "Q" * 5000
|
||||
result = {
|
||||
"ws_id": "ws-tools",
|
||||
"state": "running",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": fat,
|
||||
"tool_name": "bash",
|
||||
"tool_call_id": "call-1",
|
||||
}
|
||||
for _ in range(20)
|
||||
],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "compact"
|
||||
first = parsed["messages"][0]
|
||||
assert first["tool_name"] == "bash"
|
||||
assert first["tool_call_id"] == "call-1"
|
||||
|
||||
|
||||
def test_format_inspect_tiered_compact_preserves_assistant_tool_calls():
|
||||
"""Compact tier must preserve the assistant-side ``tool_calls`` list
|
||||
(OpenAI shape: ``[{id, type, function: {name, arguments}}]``) so a
|
||||
model reading the snipped trace can see WHICH tool was called and
|
||||
pair it with the corresponding result row via ``id`` ↔ ``tool_call_id``.
|
||||
Bug-2 regression cover: the pre-fix compactor stripped ``tool_calls``,
|
||||
leaving the audit reader with a tool-result orphan against an
|
||||
invisible call.
|
||||
|
||||
``function.arguments`` strings are snipped head/tail (analogous to
|
||||
content) because they can be multi-KB JSON; ``id`` and
|
||||
``function.name`` are preserved verbatim — they're the linkage."""
|
||||
from turnstone.console.coordinator_client import (
|
||||
_INSPECT_TOOL_ARG_HEAD,
|
||||
_INSPECT_TOOL_ARG_TAIL,
|
||||
_format_inspect_tiered,
|
||||
)
|
||||
|
||||
fat_content = "C" * 5000 # forces compact tier
|
||||
fat_args = "A" * 5000 # forces argument snipping
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call-abc-123",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": fat_args},
|
||||
},
|
||||
{
|
||||
"id": "call-def-456",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": fat_args},
|
||||
},
|
||||
]
|
||||
result = {
|
||||
"ws_id": "ws-tool-calls",
|
||||
"state": "running",
|
||||
"messages": [
|
||||
{"role": "assistant", "content": fat_content, "tool_calls": tool_calls}
|
||||
for _ in range(20)
|
||||
],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "compact"
|
||||
first = parsed["messages"][0]
|
||||
# tool_calls survives compaction.
|
||||
assert "tool_calls" in first
|
||||
assert len(first["tool_calls"]) == 2
|
||||
# Linkage fields verbatim.
|
||||
assert first["tool_calls"][0]["id"] == "call-abc-123"
|
||||
assert first["tool_calls"][0]["function"]["name"] == "bash"
|
||||
assert first["tool_calls"][1]["id"] == "call-def-456"
|
||||
assert first["tool_calls"][1]["function"]["name"] == "read_file"
|
||||
# arguments snipped head/tail — both prefix and suffix preserved.
|
||||
snipped_args = first["tool_calls"][0]["function"]["arguments"]
|
||||
assert snipped_args.startswith("A" * _INSPECT_TOOL_ARG_HEAD)
|
||||
assert snipped_args.endswith("A" * _INSPECT_TOOL_ARG_TAIL)
|
||||
assert "chars elided" in snipped_args
|
||||
|
||||
|
||||
def test_format_inspect_tiered_compact_passes_small_messages_through_unsnipped():
|
||||
"""Messages under the snip threshold pass through verbatim at compact
|
||||
tier — snipping a 100-byte message costs more bytes (the elision
|
||||
marker) than it saves."""
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
# Mix: a few large messages force compact tier; small messages must
|
||||
# not be snipped.
|
||||
big = "B" * 5000
|
||||
small = "S" * 50
|
||||
result = {
|
||||
"id": "ws-mixed",
|
||||
"state": "running",
|
||||
"messages": [{"role": "assistant", "content": big} for _ in range(15)]
|
||||
+ [{"role": "user", "content": small}],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "compact"
|
||||
# The trailing small message is exact, not snipped.
|
||||
assert parsed["messages"][-1]["content"] == small
|
||||
|
||||
|
||||
def test_format_inspect_tiered_emits_tier_note_when_compressed():
|
||||
"""The ``_tier_note`` advisory tells the LLM how to ask for a tighter
|
||||
or fuller view next time — actionable feedback rather than a bare
|
||||
"we compressed your output" signal."""
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
fat = "F" * 5000
|
||||
result = {
|
||||
"id": "ws-noted",
|
||||
"state": "running",
|
||||
"messages": [{"role": "assistant", "content": fat} for _ in range(20)],
|
||||
"verdicts": [],
|
||||
}
|
||||
out = _format_inspect_tiered(result)
|
||||
parsed = json.loads(out)
|
||||
assert "_tier_note" in parsed
|
||||
assert "message_limit" in parsed["_tier_note"]
|
||||
|
||||
|
||||
def test_format_inspect_tiered_full_tier_omits_tier_note():
|
||||
"""When the full tier fits, no note is emitted — the absence of a
|
||||
note is the signal that nothing was compressed."""
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
out = _format_inspect_tiered(_make_inspect_result(n_messages=2))
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "full"
|
||||
assert "_tier_note" not in parsed
|
||||
|
||||
@@ -215,7 +215,12 @@ def test_spawn_exec_does_not_surface_misleading_status_field(coord_session):
|
||||
summary tempted callers to write ``if result["status"] == "idle"``
|
||||
which silently never matched. The summary now omits the field
|
||||
entirely; lifecycle state lives on the workstream row and is read
|
||||
via inspect_workstream."""
|
||||
via inspect_workstream.
|
||||
|
||||
Also asserts the return key is ``child_ws_id`` (not ``ws_id``) so
|
||||
the coordinator LLM doesn't recency-bias toward feeding the spawn
|
||||
output back into another ``spawn_workstream(ws_id=...)`` call.
|
||||
"""
|
||||
sess, coord, _ui = coord_session
|
||||
coord.spawn.return_value = {
|
||||
"ws_id": "child-7",
|
||||
@@ -227,8 +232,9 @@ def test_spawn_exec_does_not_surface_misleading_status_field(coord_session):
|
||||
_call_id, output = sess._exec_spawn_workstream(item)
|
||||
body = json.loads(output)
|
||||
assert "status" not in body
|
||||
assert "ws_id" not in body
|
||||
# The substantive fields are still here.
|
||||
assert body["ws_id"] == "child-7"
|
||||
assert body["child_ws_id"] == "child-7"
|
||||
assert body["node_id"] == "node-1"
|
||||
|
||||
|
||||
@@ -248,6 +254,10 @@ def test_spawn_batch_exec_does_not_surface_misleading_status_field(coord_session
|
||||
body = json.loads(output)
|
||||
assert "0" in body["results"]
|
||||
assert "status" not in body["results"]["0"]
|
||||
# Per-result entries surface ``child_ws_id``, not ``ws_id`` — same
|
||||
# recency-bias rationale as the spawn_workstream test above.
|
||||
assert body["results"]["0"]["child_ws_id"] == "c-x"
|
||||
assert "ws_id" not in body["results"]["0"]
|
||||
|
||||
|
||||
def test_spawn_exec_surfaces_client_error(coord_session):
|
||||
@@ -260,6 +270,21 @@ def test_spawn_exec_surfaces_client_error(coord_session):
|
||||
assert ui.tool_results[-1][3] is True # is_error
|
||||
|
||||
|
||||
def test_spawn_exec_treats_missing_ws_id_on_success_path_as_error(coord_session):
|
||||
"""A malformed upstream response (200-success-shape with no
|
||||
``ws_id``) used to emit ``{"child_ws_id": null}`` to the LLM,
|
||||
which then chased a null id through follow-up tools. Now matches
|
||||
the matching guard in ``_exec_spawn_batch``: surface as a tool
|
||||
error so the model retries instead of acting on garbage."""
|
||||
sess, coord, ui = coord_session
|
||||
# No ``error`` field, but ``ws_id`` is missing — the silent-null path.
|
||||
coord.spawn.return_value = {"name": "c", "node_id": "node-1", "status": 200}
|
||||
item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": "hi"}))
|
||||
_call_id, output = sess._exec_spawn_workstream(item)
|
||||
assert "no ws_id" in output
|
||||
assert ui.tool_results[-1][3] is True # is_error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_workstream
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1410,9 +1435,13 @@ def test_spawn_batch_exec_serialises_spawns_and_returns_results(coord_session):
|
||||
assert body["denied"] == []
|
||||
# Keyed by input index (stringified).
|
||||
assert set(body["results"].keys()) == {"0", "1", "2"}
|
||||
assert body["results"]["0"]["ws_id"] == "child-0"
|
||||
assert body["results"]["0"]["child_ws_id"] == "child-0"
|
||||
assert body["results"]["1"]["node_id"] == "n-1"
|
||||
assert body["results"]["2"]["ws_id"] == "child-2"
|
||||
assert body["results"]["2"]["child_ws_id"] == "child-2"
|
||||
# Confirm we don't leak the old ``ws_id`` key alongside the new
|
||||
# ``child_ws_id`` — see test_spawn_exec_does_not_surface_misleading_status_field
|
||||
# for the rationale on the rename.
|
||||
assert "ws_id" not in body["results"]["0"]
|
||||
|
||||
|
||||
def test_spawn_batch_exec_surfaces_per_item_errors_in_denied(coord_session):
|
||||
|
||||
+122
-1
@@ -51,7 +51,11 @@ class TestIntentVerdictCRUD:
|
||||
assert v["tier"] == "heuristic"
|
||||
assert v["judge_model"] == ""
|
||||
assert v["latency_ms"] == 2
|
||||
assert v["user_decision"] == ""
|
||||
# ``user_decision`` defaults to ``"pending"`` (not the empty
|
||||
# string) so an audit reader can distinguish in-flight rows
|
||||
# from pre-convention legacy rows that carry the column's
|
||||
# server_default of ``""``.
|
||||
assert v["user_decision"] == "pending"
|
||||
assert "created" in v
|
||||
|
||||
def test_get_nonexistent(self, db):
|
||||
@@ -114,6 +118,123 @@ class TestIntentVerdictCRUD:
|
||||
assert ok is False
|
||||
|
||||
|
||||
class TestIntentVerdictUpsert:
|
||||
"""``upsert_intent_verdict`` — the LLM-tier-aware persistence path.
|
||||
|
||||
Backs the heuristic → llm_fallback "upgrade in place" pattern.
|
||||
The async judge's fallback verdicts deliberately reuse the
|
||||
heuristic ``verdict_id``; a plain INSERT would collide on the
|
||||
PK and the upgrade would be lost to a silently-swallowed
|
||||
exception (Postgres logged ``intent_verdicts_pkey`` violations
|
||||
for every fallback delivery on stable/1.5 smoke tests).
|
||||
"""
|
||||
|
||||
def test_upsert_on_fresh_id_inserts(self, db):
|
||||
"""No conflict — behaves like a regular INSERT."""
|
||||
db.upsert_intent_verdict(**_make_verdict_kwargs())
|
||||
v = db.get_intent_verdict("v_001")
|
||||
assert v is not None
|
||||
assert v["tier"] == "heuristic"
|
||||
assert v["user_decision"] == "pending"
|
||||
|
||||
def test_upsert_on_conflict_upgrades_tier_reasoning_judge_model(self, db):
|
||||
"""On PK conflict: tier, reasoning, judge_model update — every
|
||||
other field is preserved. Mirrors what the judge emits when
|
||||
promoting heuristic → llm_fallback."""
|
||||
db.upsert_intent_verdict(
|
||||
**_make_verdict_kwargs(
|
||||
tier="heuristic",
|
||||
reasoning="initial heuristic reasoning",
|
||||
judge_model="",
|
||||
)
|
||||
)
|
||||
db.upsert_intent_verdict(
|
||||
**_make_verdict_kwargs(
|
||||
tier="llm_fallback",
|
||||
reasoning="initial heuristic reasoning (LLM judge did not return a verdict)",
|
||||
judge_model="gpt-5-judge",
|
||||
)
|
||||
)
|
||||
v = db.get_intent_verdict("v_001")
|
||||
assert v is not None
|
||||
# The three fields that should change.
|
||||
assert v["tier"] == "llm_fallback"
|
||||
assert "LLM judge did not return" in v["reasoning"]
|
||||
assert v["judge_model"] == "gpt-5-judge"
|
||||
|
||||
def test_upsert_on_conflict_preserves_user_decision(self, db):
|
||||
"""LOAD-BEARING: a manually-resolved approval (user_decision=
|
||||
``"approved"``) or auto-approve-stamped row (user_decision=
|
||||
``"policy"``/``"blanket"``/etc.) must NOT be clobbered back to
|
||||
``"pending"`` when the late LLM-fallback verdict lands.
|
||||
``IntentVerdict.to_dict()`` doesn't project user_decision, so
|
||||
the upsert's defaulted ``"pending"`` would silently overwrite
|
||||
the real value if user_decision were in the on-conflict
|
||||
SET clause."""
|
||||
db.upsert_intent_verdict(**_make_verdict_kwargs())
|
||||
ok = db.update_intent_verdict("v_001", user_decision="approved")
|
||||
assert ok is True
|
||||
# Simulate the late LLM-fallback delivery — same verdict_id,
|
||||
# default user_decision (the IntentVerdict.to_dict() shape).
|
||||
db.upsert_intent_verdict(
|
||||
**_make_verdict_kwargs(
|
||||
tier="llm_fallback",
|
||||
reasoning="extended (LLM judge did not return a verdict)",
|
||||
judge_model="gpt-5-judge",
|
||||
)
|
||||
)
|
||||
v = db.get_intent_verdict("v_001")
|
||||
assert v is not None
|
||||
assert v["user_decision"] == "approved" # NOT clobbered to "pending"
|
||||
assert v["tier"] == "llm_fallback" # but the upgrade did land
|
||||
|
||||
def test_upsert_on_conflict_preserves_identity_and_carried_fields(self, db):
|
||||
"""Identity columns (ws_id, call_id, func_name, func_args) and
|
||||
carried-verbatim columns (intent_summary, risk_level,
|
||||
confidence, recommendation, evidence, latency_ms) are
|
||||
excluded from the on-conflict SET — verify they aren't
|
||||
changed even when the second upsert passes different values
|
||||
(defensive against a future judge bug that ships divergent
|
||||
carried fields)."""
|
||||
db.upsert_intent_verdict(**_make_verdict_kwargs())
|
||||
db.upsert_intent_verdict(
|
||||
**_make_verdict_kwargs(
|
||||
# Same verdict_id (conflict trigger), divergent everything else.
|
||||
ws_id="ws-different",
|
||||
call_id="tc_different",
|
||||
func_name="bash_v2",
|
||||
func_args='{"command":"rm -rf /"}',
|
||||
intent_summary="totally different summary",
|
||||
risk_level="critical",
|
||||
confidence=0.0,
|
||||
recommendation="deny",
|
||||
evidence='["dangerous"]',
|
||||
latency_ms=99999,
|
||||
# The three fields that DO update.
|
||||
tier="llm_fallback",
|
||||
reasoning="upgraded reasoning",
|
||||
judge_model="judge-v2",
|
||||
)
|
||||
)
|
||||
v = db.get_intent_verdict("v_001")
|
||||
assert v is not None
|
||||
# All preserved from the first upsert (identity + carried).
|
||||
assert v["ws_id"] == "ws-abc"
|
||||
assert v["call_id"] == "tc_001"
|
||||
assert v["func_name"] == "bash"
|
||||
assert v["func_args"] == '{"command":"echo hello"}'
|
||||
assert v["intent_summary"] == "Echo a greeting to stdout"
|
||||
assert v["risk_level"] == "low"
|
||||
assert v["confidence"] == 0.85
|
||||
assert v["recommendation"] == "approve"
|
||||
assert v["evidence"] == '["The command only prints text."]'
|
||||
assert v["latency_ms"] == 2
|
||||
# Only the three updated.
|
||||
assert v["tier"] == "llm_fallback"
|
||||
assert v["reasoning"] == "upgraded reasoning"
|
||||
assert v["judge_model"] == "judge-v2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bulk insert
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
@@ -401,8 +402,10 @@ class TestValidation:
|
||||
|
||||
|
||||
class TestValidUntil:
|
||||
"""``valid_until`` predicate: drain re-checks freshness; falsy /
|
||||
raising predicates drop the entry without delivery.
|
||||
"""``valid_until`` predicate: drain re-checks freshness. Falsy
|
||||
predicates drop the entry without delivery and log at ``info``
|
||||
(normal lifecycle outcome); raising predicates drop the entry and
|
||||
log at ``warning`` with ``exc_info`` (misbehaving predicate).
|
||||
"""
|
||||
|
||||
def test_valid_until_true_delivers(self):
|
||||
@@ -411,26 +414,52 @@ class TestValidUntil:
|
||||
out = q.drain({"any"})
|
||||
assert out == [("a", "1", None)]
|
||||
|
||||
def test_valid_until_false_drops_silently(self):
|
||||
def test_valid_until_false_drops_with_info_log(self, caplog: pytest.LogCaptureFixture):
|
||||
q = NudgeQueue()
|
||||
q.enqueue("a", "1", "any", valid_until=lambda: False)
|
||||
out = q.drain({"any"})
|
||||
with caplog.at_level(logging.INFO, logger="turnstone.core.nudge_queue"):
|
||||
out = q.drain({"any"})
|
||||
assert out == []
|
||||
# Already removed from queue (drain partition removes BEFORE
|
||||
# predicate check — falsy doesn't return to queue).
|
||||
assert len(q) == 0
|
||||
# The drop emits a structured info record so a wiring
|
||||
# regression (a predicate that always returns False) is still
|
||||
# observable, without spamming ``warning`` for the routine
|
||||
# lifecycle case where ``valid_until`` is doing its job.
|
||||
# structlog renders the event name + extras into ``msg`` as a
|
||||
# single rendered string, so substring-match like the
|
||||
# ``watch_dispatch.queue_full`` assertion in
|
||||
# tests/test_watch_dispatch.py.
|
||||
drops = [r for r in caplog.records if "nudge_queue.predicate_dropped" in r.getMessage()]
|
||||
assert len(drops) == 1
|
||||
assert drops[0].levelno == logging.INFO
|
||||
assert "predicate_false" in drops[0].getMessage()
|
||||
assert "'nudge_type': 'a'" in drops[0].getMessage()
|
||||
assert "'channel': 'any'" in drops[0].getMessage()
|
||||
assert "'text_len': 1" in drops[0].getMessage()
|
||||
|
||||
def test_valid_until_exception_drops_silently(self):
|
||||
def test_valid_until_exception_drops_with_warning(self, caplog: pytest.LogCaptureFixture):
|
||||
q = NudgeQueue()
|
||||
|
||||
def boom() -> bool:
|
||||
raise RuntimeError("predicate crash")
|
||||
|
||||
q.enqueue("a", "1", "any", valid_until=boom)
|
||||
out = q.drain({"any"})
|
||||
with caplog.at_level(logging.WARNING, logger="turnstone.core.nudge_queue"):
|
||||
out = q.drain({"any"})
|
||||
assert out == []
|
||||
# Crash-on-predicate is treated as "no longer valid" — drop, not propagate.
|
||||
assert len(q) == 0
|
||||
# Stays at ``warning`` (with ``exc_info``) because a raising
|
||||
# predicate is a bug, not a normal lifecycle outcome.
|
||||
drops = [r for r in caplog.records if "nudge_queue.predicate_dropped" in r.getMessage()]
|
||||
assert len(drops) == 1
|
||||
assert drops[0].levelno == logging.WARNING
|
||||
rendered = drops[0].getMessage()
|
||||
assert "predicate_raised" in rendered
|
||||
assert "RuntimeError" in rendered
|
||||
assert "predicate crash" in rendered
|
||||
|
||||
def test_valid_until_evaluated_outside_lock(self):
|
||||
"""The predicate may do non-trivial work (e.g. storage I/O)
|
||||
|
||||
@@ -167,8 +167,8 @@ def test_on_intent_verdict_persists_verdict_row() -> None:
|
||||
}
|
||||
with _patch_get_storage(storage):
|
||||
ui.on_intent_verdict(verdict)
|
||||
storage.create_intent_verdict.assert_called_once()
|
||||
kwargs = storage.create_intent_verdict.call_args.kwargs
|
||||
storage.upsert_intent_verdict.assert_called_once()
|
||||
kwargs = storage.upsert_intent_verdict.call_args.kwargs
|
||||
assert kwargs["verdict_id"] == "v1"
|
||||
assert kwargs["ws_id"] == "ws-1"
|
||||
assert kwargs["call_id"] == "c1"
|
||||
@@ -352,6 +352,192 @@ def test_resolve_approval_stamps_all_pending_verdicts() -> None:
|
||||
assert ui._last_verdict_decision == "denied"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# user_decision value space — pending / approved / denied / timeout
|
||||
# / auto-approve reasons (policy / blanket / skill / always / auto_approve_tools).
|
||||
# Guards the "user_decision is never empty for new rows" invariant.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_approval_timeout_kwarg_writes_timeout_value() -> None:
|
||||
"""``resolve_approval(False, ..., timeout=True)`` writes
|
||||
``user_decision="timeout"`` so the audit trail can distinguish a
|
||||
passive timeout expiry from an active user denial — the feedback
|
||||
string used to carry this distinction but the column alone could not."""
|
||||
storage = MagicMock()
|
||||
ui = _make_ui()
|
||||
with _patch_get_storage(storage):
|
||||
ui.on_intent_verdict({"verdict_id": "v1", "call_id": "c1"})
|
||||
with _patch_get_storage(storage):
|
||||
ui.resolve_approval(False, "expired", timeout=True)
|
||||
storage.update_intent_verdict.assert_any_call("v1", user_decision="timeout")
|
||||
assert ui._last_verdict_decision == "timeout"
|
||||
|
||||
|
||||
def test_resolve_approval_timeout_with_approved_raises() -> None:
|
||||
"""``timeout=True`` is mutually exclusive with ``approved=True`` —
|
||||
the combination would land a row whose audit column says
|
||||
``"timeout"`` while the SSE event reports ``approved=True``. Fail
|
||||
loud so the inconsistency can't ship silently."""
|
||||
import pytest
|
||||
|
||||
ui = _make_ui()
|
||||
with pytest.raises(ValueError, match="timeout"):
|
||||
ui.resolve_approval(True, timeout=True)
|
||||
|
||||
|
||||
def test_record_auto_approves_populates_reason_lookup() -> None:
|
||||
"""``_record_auto_approves`` must seed
|
||||
``_auto_approve_reasons[call_id]`` with the per-item reason so a
|
||||
late-arriving LLM judge verdict can recover the auto-approve
|
||||
reason via ``on_intent_verdict``."""
|
||||
storage = MagicMock()
|
||||
ui = _make_ui()
|
||||
items = [
|
||||
{
|
||||
"call_id": "c-policy",
|
||||
"func_name": "bash",
|
||||
"auto_approved": True,
|
||||
"auto_approve_reason": "policy",
|
||||
},
|
||||
{
|
||||
"call_id": "c-blanket",
|
||||
"func_name": "list_workstreams",
|
||||
"auto_approved": True,
|
||||
"auto_approve_reason": "blanket",
|
||||
},
|
||||
]
|
||||
with _patch_get_storage(storage):
|
||||
ui._record_auto_approves(items)
|
||||
assert "c-policy" in ui._auto_approve_reasons
|
||||
assert "c-blanket" in ui._auto_approve_reasons
|
||||
assert ui._auto_approve_reasons["c-policy"][0] == "policy"
|
||||
assert ui._auto_approve_reasons["c-blanket"][0] == "blanket"
|
||||
|
||||
|
||||
def test_on_intent_verdict_consumes_auto_approve_reason() -> None:
|
||||
"""A late LLM verdict for a previously auto-approved call_id picks
|
||||
up the reason from ``_auto_approve_reasons``, stamps it on the
|
||||
verdict before persist, and pops the entry so re-use isn't
|
||||
possible. Closes the misdiagnosis bug where auto-approved tools
|
||||
landed verdict rows with ``user_decision=""``."""
|
||||
storage = MagicMock()
|
||||
ui = _make_ui()
|
||||
ui._auto_approve_reasons["c-x"] = ("auto_approve_tools", 0.0)
|
||||
with _patch_get_storage(storage):
|
||||
ui.on_intent_verdict({"verdict_id": "v-x", "call_id": "c-x"})
|
||||
storage.upsert_intent_verdict.assert_called_once()
|
||||
kwargs = storage.upsert_intent_verdict.call_args.kwargs
|
||||
assert kwargs["user_decision"] == "auto_approve_tools"
|
||||
# Consumed on read so the same call_id can't double-stamp later.
|
||||
assert "c-x" not in ui._auto_approve_reasons
|
||||
# Auto-stamped verdicts must NOT join _pending_verdicts — the
|
||||
# row's final decision is already set; appending would let a
|
||||
# later resolve_approval overwrite the auto-reason with the
|
||||
# manual decision (real audit-trail clobber bug).
|
||||
assert ui._pending_verdicts == []
|
||||
|
||||
|
||||
def test_on_intent_verdict_auto_reason_survives_resolve_cycle() -> None:
|
||||
"""Mixed-batch case: one tool was auto-approved (policy), another
|
||||
needs manual approval. The LLM judge fires for the auto-approved
|
||||
sibling DURING the manual-approval wait. The verdict must land
|
||||
with ``user_decision="policy"`` and stay that way even after
|
||||
``resolve_approval`` fires for the pending sibling — the prior
|
||||
bug was that the auto-stamped row got overwritten with
|
||||
``"approved"``/``"denied"`` by the resolve path."""
|
||||
storage = MagicMock()
|
||||
ui = _make_ui()
|
||||
ui._auto_approve_reasons["c-auto"] = ("policy", 0.0)
|
||||
with _patch_get_storage(storage):
|
||||
# LLM verdict fires for the auto-approved sibling.
|
||||
ui.on_intent_verdict({"verdict_id": "v-auto", "call_id": "c-auto"})
|
||||
# Now the pending sibling gets a verdict + manual resolve.
|
||||
ui.on_intent_verdict({"verdict_id": "v-pending", "call_id": "c-pending"})
|
||||
ui.resolve_approval(True, "looks good")
|
||||
# Only the pending verdict should be UPDATEd to "approved" — the
|
||||
# auto-stamped one stays "policy" via its INSERT.
|
||||
update_calls = {
|
||||
c.args[0]: c.kwargs.get("user_decision")
|
||||
for c in storage.update_intent_verdict.call_args_list
|
||||
}
|
||||
assert update_calls == {"v-pending": "approved"}
|
||||
# The auto verdict's INSERT carried the policy reason.
|
||||
insert_calls = {
|
||||
c.kwargs["verdict_id"]: c.kwargs["user_decision"]
|
||||
for c in storage.upsert_intent_verdict.call_args_list
|
||||
}
|
||||
assert insert_calls["v-auto"] == "policy"
|
||||
assert insert_calls["v-pending"] == "pending"
|
||||
|
||||
|
||||
def test_persist_auto_approved_heuristic_verdicts_stamps_reason() -> None:
|
||||
"""The auto-approve early-return branches in ``approve_tools`` used
|
||||
to drop heuristic verdicts on the floor — auditors couldn't tell
|
||||
whether the judge ran or the call was simply silently auto-approved.
|
||||
``_persist_auto_approved_heuristic_verdicts`` closes that gap and
|
||||
stamps each verdict with the item's reason."""
|
||||
storage = MagicMock()
|
||||
ui = _make_ui()
|
||||
items = [
|
||||
{
|
||||
"call_id": "c-1",
|
||||
"auto_approved": True,
|
||||
"auto_approve_reason": "blanket",
|
||||
"_heuristic_verdict": {
|
||||
"verdict_id": "v-1",
|
||||
"call_id": "c-1",
|
||||
"risk_level": "low",
|
||||
"recommendation": "review",
|
||||
},
|
||||
},
|
||||
# No _heuristic_verdict — skipped (judge didn't run for this item).
|
||||
{"call_id": "c-2", "auto_approved": True, "auto_approve_reason": "blanket"},
|
||||
# Not auto_approved — skipped (this helper only handles auto-approved).
|
||||
{
|
||||
"call_id": "c-3",
|
||||
"_heuristic_verdict": {"verdict_id": "v-3", "call_id": "c-3"},
|
||||
},
|
||||
]
|
||||
with _patch_get_storage(storage):
|
||||
ui._persist_auto_approved_heuristic_verdicts(items)
|
||||
storage.create_intent_verdicts_bulk.assert_called_once()
|
||||
rows = storage.create_intent_verdicts_bulk.call_args.args[0]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["verdict_id"] == "v-1"
|
||||
assert rows[0]["user_decision"] == "blanket"
|
||||
|
||||
|
||||
def test_auto_approve_reasons_ttl_prune_drops_stale_entries() -> None:
|
||||
"""Lazy TTL eviction at write time: entries older than
|
||||
``_AUTO_APPROVE_REASON_TTL`` are pruned on the next
|
||||
``_record_auto_approves`` call. Without this, a session with the
|
||||
LLM judge disabled would accumulate entries that never get
|
||||
consumed."""
|
||||
import time as time_module
|
||||
|
||||
storage = MagicMock()
|
||||
ui = _make_ui()
|
||||
# Seed two stale entries (well past the TTL).
|
||||
stale_ts = time_module.time() - ui._AUTO_APPROVE_REASON_TTL - 30.0
|
||||
ui._auto_approve_reasons["c-stale-1"] = ("policy", stale_ts)
|
||||
ui._auto_approve_reasons["c-stale-2"] = ("blanket", stale_ts)
|
||||
items = [
|
||||
{
|
||||
"call_id": "c-fresh",
|
||||
"auto_approved": True,
|
||||
"auto_approve_reason": "skill",
|
||||
"func_name": "bash",
|
||||
}
|
||||
]
|
||||
with _patch_get_storage(storage):
|
||||
ui._record_auto_approves(items)
|
||||
# Stale entries pruned; only the fresh one remains.
|
||||
assert "c-stale-1" not in ui._auto_approve_reasons
|
||||
assert "c-stale-2" not in ui._auto_approve_reasons
|
||||
assert "c-fresh" in ui._auto_approve_reasons
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output guard persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -232,59 +232,52 @@ class TestSoftCap:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# valid_until predicate
|
||||
# Predicate independence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidUntil:
|
||||
"""The ``valid_until`` predicate captured at dispatch time re-checks
|
||||
the watch's ``active`` flag at drain time, so a cancelled watch's
|
||||
last splat doesn't ride out a future wake.
|
||||
class TestPredicateIndependence:
|
||||
"""The watch closure does NOT wire a ``valid_until`` predicate.
|
||||
|
||||
Earlier the closure wired ``_still_active`` (re-reading
|
||||
``is_watch_active`` at drain time). That predicate raced
|
||||
``WatchRunner._poll_watch``'s commit of ``active=False`` and silently
|
||||
dropped every terminal fire. The closure now enqueues without a
|
||||
predicate; entries survive drain regardless of the row's ``active``
|
||||
column state.
|
||||
"""
|
||||
|
||||
def test_valid_until_drops_when_watch_inactive(self, tmp_db, monkeypatch):
|
||||
def test_drain_delivers_even_when_storage_reports_inactive(self, tmp_db, monkeypatch):
|
||||
session = _make_session_for_dispatch()
|
||||
_runner, dispatch = _register_runner(session)
|
||||
|
||||
# Storage stub returns False at drain time.
|
||||
is_active_calls = patch_session_storage(monkeypatch, active=False)
|
||||
|
||||
dispatch(_reminder("body"), "watch-1")
|
||||
# Drain fires the predicate; entry should NOT be delivered.
|
||||
out = session._nudge_queue.drain({"any"})
|
||||
assert out == []
|
||||
# Predicate ran once with the dispatched watch_id.
|
||||
assert is_active_calls == ["watch-1"]
|
||||
|
||||
def test_valid_until_drops_when_storage_raises(self, tmp_db, monkeypatch):
|
||||
"""The closure's broad-except in the predicate translates a
|
||||
storage-layer exception to ``False`` so the drain doesn't
|
||||
propagate; the predicate captured ``watch_id`` correctly
|
||||
(otherwise storage wouldn't even be touched).
|
||||
"""
|
||||
session = _make_session_for_dispatch()
|
||||
_runner, dispatch = _register_runner(session)
|
||||
|
||||
patch_session_storage(monkeypatch, raise_on_is_active=True)
|
||||
|
||||
dispatch(_reminder("body"), "watch-bound-id")
|
||||
out = session._nudge_queue.drain({"any"})
|
||||
assert out == []
|
||||
|
||||
def test_valid_until_delivers_when_watch_active(self, tmp_db, monkeypatch):
|
||||
"""Happy-path counter-test for the predicate above: the entry
|
||||
DOES drain when the watch is still active.
|
||||
"""
|
||||
session = _make_session_for_dispatch()
|
||||
_runner, dispatch = _register_runner(session)
|
||||
|
||||
patch_session_storage(monkeypatch, active=True)
|
||||
# Even if storage reports active=False, the entry should still
|
||||
# drain — no predicate to drop it.
|
||||
patch_session_storage(monkeypatch, active=False)
|
||||
|
||||
dispatch(_reminder("body"), "watch-1")
|
||||
out = session._nudge_queue.drain({"any"})
|
||||
assert len(out) == 1
|
||||
assert out[0][0] == "watch_triggered"
|
||||
|
||||
def test_dispatch_never_calls_is_watch_active(self, tmp_db, monkeypatch):
|
||||
"""Pin the invariant directly: the closure must NOT consult
|
||||
``storage.is_watch_active`` anywhere along the enqueue + drain
|
||||
path. Without this assertion, a future change that re-wires
|
||||
an ``is_watch_active`` predicate would silently bring back the
|
||||
bug that motivates this whole module.
|
||||
"""
|
||||
session = _make_session_for_dispatch()
|
||||
_runner, dispatch = _register_runner(session)
|
||||
|
||||
is_active_calls = patch_session_storage(monkeypatch, active=True)
|
||||
|
||||
dispatch(_reminder("body"), "watch-bound-id")
|
||||
session._nudge_queue.drain({"any"})
|
||||
assert is_active_calls == [], (
|
||||
f"watch closure must not call is_watch_active; got {is_active_calls!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrency
|
||||
|
||||
@@ -24,11 +24,15 @@ the structural integration gate for the watch switchover.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._helpers import patch_session_storage
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.watch import WatchRunner
|
||||
|
||||
|
||||
@@ -272,3 +276,283 @@ def test_watch_dispatch_through_restore_fn_lands_on_rehydrated_session(tmp_db, m
|
||||
# Original session's queue stays empty — the dispatch did NOT
|
||||
# accidentally route back to it.
|
||||
assert len(original._nudge_queue) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stop_on", "max_polls", "label"),
|
||||
[
|
||||
('"HIT" in output', 100, "stop_on_fired"),
|
||||
(None, 1, "max_polls_reached"),
|
||||
],
|
||||
)
|
||||
def test_poll_watch_terminal_fire_survives_drain(
|
||||
tmp_db: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stop_on: str | None,
|
||||
max_polls: int,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""Regression for the dispatch-ordering bug.
|
||||
|
||||
With the broken ordering (``update_watch(active=False)`` before
|
||||
``_dispatch_result``) plus the ``_still_active`` ``valid_until``
|
||||
predicate that re-reads ``is_watch_active`` at drain time, every
|
||||
terminal watch fire was silently dropped — the closure enqueued
|
||||
the entry but the predicate immediately invalidated it because
|
||||
the row's ``active`` flag had already been flipped to ``0`` in
|
||||
the same poll. The model never saw the fire.
|
||||
|
||||
This test drives a REAL ``WatchRunner._poll_watch`` against a real
|
||||
``tmp_db`` watch row (no ``patch_session_storage(active=True)``
|
||||
stub — that stub is exactly what masked the bug in earlier tests).
|
||||
Covers both terminal paths: ``stop_on`` condition matched and
|
||||
``poll_count >= max_polls`` reached.
|
||||
"""
|
||||
session = _make_session()
|
||||
storage = get_storage()
|
||||
|
||||
runner = WatchRunner(storage=storage, node_id="test-node")
|
||||
session.set_watch_runner(runner)
|
||||
|
||||
storage.create_watch(
|
||||
watch_id=f"w-regression-{label}",
|
||||
ws_id=session._ws_id,
|
||||
node_id="test-node",
|
||||
name=f"regression-{label}",
|
||||
command="echo HIT",
|
||||
interval_secs=10.0,
|
||||
stop_on=stop_on,
|
||||
max_polls=max_polls,
|
||||
created_by="model",
|
||||
next_poll="1970-01-01T00:00:00",
|
||||
)
|
||||
|
||||
# Spy ``enqueue`` so the assertion can distinguish "dispatch never
|
||||
# called" (a different bug class) from "dispatch enqueued but the
|
||||
# predicate dropped it at drain" (this bug).
|
||||
enqueue_calls: list[tuple[str, str, str]] = []
|
||||
real_enqueue = session._nudge_queue.enqueue
|
||||
|
||||
def _spy_enqueue(*args: Any, **kwargs: Any) -> None:
|
||||
enqueue_calls.append((args[0], args[1][:40], args[2]))
|
||||
return real_enqueue(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(session._nudge_queue, "enqueue", _spy_enqueue)
|
||||
|
||||
# For the max_polls=1 case the first poll has prev_output=None and
|
||||
# would not normally fire on output change; the max_polls branch
|
||||
# at watch.py:412-414 still marks is_final=True so dispatch runs.
|
||||
due = storage.list_due_watches("2099-01-01T00:00:00")
|
||||
matching = [r for r in due if r["watch_id"] == f"w-regression-{label}"]
|
||||
assert len(matching) == 1, f"watch row not picked up by list_due_watches: {due!r}"
|
||||
runner._poll_watch(matching[0])
|
||||
|
||||
assert len(enqueue_calls) == 1, (
|
||||
f"_poll_watch did not enqueue exactly one fire (got {enqueue_calls!r}); "
|
||||
"this is a different bug from the predicate-drop regression"
|
||||
)
|
||||
assert enqueue_calls[0][0] == "watch_triggered"
|
||||
|
||||
assert storage.is_watch_active(f"w-regression-{label}") is False, (
|
||||
"terminal fire should have committed active=False on the row"
|
||||
)
|
||||
|
||||
# The key assertion: drain delivers the entry. Pre-fix this
|
||||
# returned ``[]`` because the ``_still_active`` predicate re-read
|
||||
# ``active=0``. Post-fix the watch closure no longer wires a
|
||||
# predicate and the entry survives.
|
||||
out = session._nudge_queue.drain({"any"})
|
||||
assert len(out) == 1, (
|
||||
"Watch fire was enqueued but never reached drain — dispatch-ordering "
|
||||
"regression. Check that WatchRunner._poll_watch dispatches BEFORE "
|
||||
"committing active=False, and that the watch closure in "
|
||||
"ChatSession.set_watch_runner does not wire an is_watch_active "
|
||||
"predicate."
|
||||
)
|
||||
nt, text, _meta = out[0]
|
||||
assert nt == "watch_triggered"
|
||||
assert "HIT" in text
|
||||
|
||||
|
||||
def test_cancel_reports_already_completed_for_auto_cancelled_watch(tmp_db: str) -> None:
|
||||
"""After a watch fires and auto-cancels, the cancel-by-name path
|
||||
should report 'already completed' rather than 'not found'.
|
||||
|
||||
Pre-fix, ``_exec_watch`` cancel looked the watch up via
|
||||
``list_watches_for_ws`` which filters ``active==1``, so a recently-
|
||||
auto-cancelled row was invisible and the model got the same
|
||||
'not found' message it would for a typo'd name. Post-fix the
|
||||
cancel path uses ``find_watch_by_name`` (no active filter) and
|
||||
branches on ``row["active"]``.
|
||||
"""
|
||||
session = _make_session()
|
||||
storage = get_storage()
|
||||
|
||||
storage.create_watch(
|
||||
watch_id="w-completed-1",
|
||||
ws_id=session._ws_id,
|
||||
node_id="test-node",
|
||||
name="completed-watch",
|
||||
command="echo x",
|
||||
interval_secs=10.0,
|
||||
stop_on=None,
|
||||
max_polls=100,
|
||||
created_by="model",
|
||||
next_poll="",
|
||||
)
|
||||
# Simulate the post-fire state.
|
||||
storage.update_watch("w-completed-1", active=False, next_poll="")
|
||||
|
||||
_call_id, msg = session._exec_watch(
|
||||
{"call_id": "c1", "action": "cancel", "watch_name": "completed-watch"}
|
||||
)
|
||||
|
||||
assert "not found" not in msg.lower()
|
||||
assert "completed" in msg.lower()
|
||||
|
||||
|
||||
def test_cancel_reports_not_found_for_unknown_watch(tmp_db: str) -> None:
|
||||
"""The 'not found' message still applies when the watch genuinely
|
||||
does not exist — make sure the new ``find_watch_by_name`` path
|
||||
didn't accidentally turn every cancel into 'already completed'.
|
||||
"""
|
||||
session = _make_session()
|
||||
|
||||
_call_id, msg = session._exec_watch(
|
||||
{"call_id": "c1", "action": "cancel", "watch_name": "ghost-watch"}
|
||||
)
|
||||
|
||||
assert "not found" in msg.lower()
|
||||
|
||||
|
||||
def test_poll_watch_retry_deactivate_after_update_watch_failure(
|
||||
tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_terminal_dispatched`` lifecycle: if ``update_watch`` raises
|
||||
AFTER ``_dispatch_result`` shipped the reminder for a terminal
|
||||
fire, the next ``_poll_watch`` tick MUST retry the row write
|
||||
(so the row stops appearing in ``list_due_watches``) and MUST NOT
|
||||
re-dispatch the reminder the model already saw.
|
||||
|
||||
This is the keystone path that prevents duplicate-fire under
|
||||
transient storage failure. Pre-this-test, the entire branch was
|
||||
unexercised.
|
||||
"""
|
||||
session = _make_session()
|
||||
storage = get_storage()
|
||||
runner = WatchRunner(storage=storage, node_id="test-node")
|
||||
session.set_watch_runner(runner)
|
||||
|
||||
watch_id = "w-retry-1"
|
||||
storage.create_watch(
|
||||
watch_id=watch_id,
|
||||
ws_id=session._ws_id,
|
||||
node_id="test-node",
|
||||
name="retry-watch",
|
||||
command="echo HIT",
|
||||
interval_secs=10.0,
|
||||
stop_on='"HIT" in output',
|
||||
max_polls=100,
|
||||
created_by="model",
|
||||
next_poll="1970-01-01T00:00:00",
|
||||
)
|
||||
|
||||
enqueue_calls: list[tuple[str, str]] = []
|
||||
real_enqueue = session._nudge_queue.enqueue
|
||||
|
||||
def _spy_enqueue(*args: Any, **kwargs: Any) -> None:
|
||||
enqueue_calls.append((args[0], args[1][:32]))
|
||||
return real_enqueue(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(session._nudge_queue, "enqueue", _spy_enqueue)
|
||||
|
||||
# Stage 1 — first poll. ``update_watch`` raises AFTER dispatch.
|
||||
real_update = storage.update_watch
|
||||
update_raise = {"armed": True}
|
||||
|
||||
def _failing_update(wid: str, **fields: Any) -> bool:
|
||||
if update_raise["armed"]:
|
||||
raise RuntimeError("simulated transient storage failure")
|
||||
return real_update(wid, **fields)
|
||||
|
||||
monkeypatch.setattr(storage, "update_watch", _failing_update)
|
||||
|
||||
due = storage.list_due_watches("2099-01-01T00:00:00")
|
||||
matching = [r for r in due if r["watch_id"] == watch_id]
|
||||
assert len(matching) == 1
|
||||
# ``_poll_watch`` doesn't catch the storage error; the outer
|
||||
# ``_tick`` would log it. Suppress here so the test owns the
|
||||
# boundary and continues to its assertions.
|
||||
with contextlib.suppress(RuntimeError):
|
||||
runner._poll_watch(matching[0])
|
||||
|
||||
# Dispatch ran exactly once and the watch_id sits in the
|
||||
# terminal-dispatched set awaiting retry.
|
||||
assert len(enqueue_calls) == 1
|
||||
assert enqueue_calls[0][0] == "watch_triggered"
|
||||
assert watch_id in runner._terminal_dispatched
|
||||
|
||||
# The row is still active=1 because update_watch raised. It
|
||||
# would re-appear in list_due_watches on the next tick.
|
||||
assert storage.is_watch_active(watch_id) is True
|
||||
|
||||
# Stage 2 — second poll. Storage now succeeds; retry-deactivate
|
||||
# branch must commit active=False WITHOUT re-dispatching.
|
||||
update_raise["armed"] = False
|
||||
|
||||
due = storage.list_due_watches("2099-01-01T00:00:00")
|
||||
matching = [r for r in due if r["watch_id"] == watch_id]
|
||||
assert len(matching) == 1
|
||||
runner._poll_watch(matching[0])
|
||||
|
||||
# Exactly one dispatch in total — the retry path took the
|
||||
# short-circuit return at the top of _poll_watch.
|
||||
assert len(enqueue_calls) == 1, f"retry-deactivate must not re-dispatch; got {enqueue_calls!r}"
|
||||
# Row is now inactive (the retry path's update_watch landed).
|
||||
assert storage.is_watch_active(watch_id) is False
|
||||
# Set is cleared so future watches with the same id (unlikely) /
|
||||
# process memory doesn't accumulate.
|
||||
assert watch_id not in runner._terminal_dispatched
|
||||
|
||||
|
||||
def test_cancel_clears_pending_terminal_dispatched_entry(
|
||||
tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If ``update_watch`` raised after dispatch, leaving a pending
|
||||
entry in ``_terminal_dispatched``, and the user then cancels the
|
||||
watch out-of-band, the retry-deactivate branch never gets to run
|
||||
(the cancel sets ``next_poll=""`` which removes the row from
|
||||
``list_due_watches``). The cancel path itself must discard the
|
||||
pending entry; otherwise the runner leaks ``watch_id``s for the
|
||||
process lifetime.
|
||||
"""
|
||||
session = _make_session()
|
||||
storage = get_storage()
|
||||
runner = WatchRunner(storage=storage, node_id="test-node")
|
||||
session.set_watch_runner(runner)
|
||||
|
||||
watch_id = "w-leak-1"
|
||||
storage.create_watch(
|
||||
watch_id=watch_id,
|
||||
ws_id=session._ws_id,
|
||||
node_id="test-node",
|
||||
name="leak-watch",
|
||||
command="echo x",
|
||||
interval_secs=10.0,
|
||||
stop_on=None,
|
||||
max_polls=100,
|
||||
created_by="model",
|
||||
next_poll="",
|
||||
)
|
||||
# Simulate: dispatch shipped, update_watch raised, watch_id sits
|
||||
# in the runner's pending set.
|
||||
with runner._terminal_dispatched_lock:
|
||||
runner._terminal_dispatched.add(watch_id)
|
||||
|
||||
# User cancels. Because the cancel writes active=False, next_poll="",
|
||||
# the row leaves list_due_watches and the runner's retry-deactivate
|
||||
# branch never executes for it. The cancel must discard the entry.
|
||||
storage.update_watch(watch_id, active=False, next_poll="")
|
||||
session._exec_watch({"call_id": "c1", "action": "cancel", "watch_name": "leak-watch"})
|
||||
|
||||
assert watch_id not in runner._terminal_dispatched
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import watches as watches_table
|
||||
|
||||
|
||||
def _make_watch_kwargs(**overrides):
|
||||
"""Build default kwargs for create_watch."""
|
||||
@@ -101,6 +105,91 @@ class TestWatchListQueries:
|
||||
db.update_watch("w1", active=False)
|
||||
assert db.list_watches_for_ws("ws-1") == []
|
||||
|
||||
def test_find_by_name_returns_inactive(self, db):
|
||||
"""``find_watch_by_name`` ignores the active filter — that is
|
||||
what lets the cancel-by-name UX distinguish 'already completed'
|
||||
from 'no such watch.'
|
||||
"""
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="completed"))
|
||||
db.update_watch("w1", active=False)
|
||||
|
||||
row = db.find_watch_by_name("ws-1", "completed")
|
||||
assert row is not None
|
||||
assert row["watch_id"] == "w1"
|
||||
assert not row["active"]
|
||||
|
||||
def test_find_by_name_matches_watch_id_prefix(self, db):
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="abcdef123", ws_id="ws-1", name="x"))
|
||||
row = db.find_watch_by_name("ws-1", "abc")
|
||||
assert row is not None
|
||||
assert row["watch_id"] == "abcdef123"
|
||||
|
||||
def test_find_by_name_scoped_to_ws(self, db):
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="shared"))
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w2", ws_id="ws-2", name="shared"))
|
||||
|
||||
row = db.find_watch_by_name("ws-1", "shared")
|
||||
assert row is not None
|
||||
assert row["watch_id"] == "w1"
|
||||
|
||||
def test_find_by_name_returns_none_when_missing(self, db):
|
||||
assert db.find_watch_by_name("ws-1", "ghost") is None
|
||||
|
||||
def test_find_by_name_empty_input_returns_none(self, db):
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="x"))
|
||||
assert db.find_watch_by_name("ws-1", "") is None
|
||||
|
||||
def test_find_by_name_treats_percent_as_literal(self, db):
|
||||
"""A model-supplied '%' must NOT match arbitrary watch_ids.
|
||||
|
||||
Pre-escape, ``watch_id.like(f"{name_or_prefix}%")`` would
|
||||
interpret '%' as 'match anything' and pick up the first row in
|
||||
the workstream regardless of name.
|
||||
"""
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="real-watch"))
|
||||
assert db.find_watch_by_name("ws-1", "%") is None
|
||||
|
||||
def test_find_by_name_treats_underscore_as_literal(self, db):
|
||||
"""Same as the '%' case for the single-char LIKE wildcard."""
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="abcd", ws_id="ws-1", name="real-watch"))
|
||||
# '_' would otherwise match any single char, picking up
|
||||
# watch_ids beginning with 'a', 'b', etc.
|
||||
assert db.find_watch_by_name("ws-1", "_") is None
|
||||
|
||||
def test_find_by_name_prefers_active_over_newer_inactive(self, db):
|
||||
"""If a same-name pair exists where the inactive row is NEWER
|
||||
than the active row, find_watch_by_name must still return the
|
||||
active row. Pre-fix the query was ``ORDER BY created DESC
|
||||
LIMIT 1`` — which would return the newer inactive row and
|
||||
cause the cancel UX to report 'already completed' for a name
|
||||
whose live row is still polling.
|
||||
|
||||
Reachable in practice because storage allows out-of-band
|
||||
writes (e.g. ``delete_watches_for_ws`` cleanup followed by
|
||||
re-create, an admin manually flipping ``active``, or test
|
||||
scaffolding) that bypass the create-time duplicate-name
|
||||
guard.
|
||||
"""
|
||||
# Older active watch.
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w-active", ws_id="ws-1", name="recurring"))
|
||||
# Newer inactive watch with the same name. ``create_watch``
|
||||
# stamps ``created`` to ``now`` at second resolution, so we
|
||||
# bypass the API to give the inactive row a deterministically
|
||||
# later timestamp.
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w-inactive", ws_id="ws-1", name="recurring"))
|
||||
with db._conn() as conn:
|
||||
conn.execute(
|
||||
sa.update(watches_table)
|
||||
.where(watches_table.c.watch_id == "w-inactive")
|
||||
.values(active=0, next_poll="", created="2099-01-01T00:00:00")
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
row = db.find_watch_by_name("ws-1", "recurring")
|
||||
assert row is not None
|
||||
assert row["watch_id"] == "w-active"
|
||||
assert row["active"]
|
||||
|
||||
def test_list_for_node(self, db):
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w1", node_id="n1"))
|
||||
db.create_watch(**_make_watch_kwargs(watch_id="w2", node_id="n1"))
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestVersionHtml:
|
||||
def test_vendored_katex_skipped(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<link rel="stylesheet" href="/shared/katex-0.16.46/katex.min.css">'
|
||||
html = '<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css">'
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
@@ -76,7 +76,7 @@ class TestVersionHtml:
|
||||
|
||||
html = (
|
||||
'<link rel="stylesheet" href="/shared/base.css">\n'
|
||||
'<link rel="stylesheet" href="/shared/katex-0.16.46/katex.min.css">\n'
|
||||
'<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css">\n'
|
||||
'<link rel="stylesheet" href="/static/style.css">\n'
|
||||
'<script src="/shared/utils.js"></script>\n'
|
||||
'<script src="/shared/hljs-11.11.1/highlight.min.js"></script>\n'
|
||||
@@ -88,7 +88,7 @@ class TestVersionHtml:
|
||||
assert f'/shared/utils.js?v={__version__}"' in result
|
||||
assert f'/static/app.js?v={__version__}"' in result
|
||||
# Vendored libs unchanged
|
||||
assert '/shared/katex-0.16.46/katex.min.css"' in result
|
||||
assert '/shared/katex-0.16.47/katex.min.css"' in result
|
||||
assert '/shared/hljs-11.11.1/highlight.min.js"' in result
|
||||
|
||||
def test_version_matches_package(self):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.5.16"
|
||||
__version__ = "1.5.18"
|
||||
|
||||
+40
-14
@@ -12,14 +12,36 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _get_storage() -> Any:
|
||||
"""Initialize and return the storage backend."""
|
||||
def _get_storage(args: argparse.Namespace) -> Any:
|
||||
"""Initialize and return the storage backend.
|
||||
|
||||
Precedence (matches turnstone-server): CLI / config.toml ``[database]``
|
||||
> ``TURNSTONE_DB_*`` env vars > hardcoded defaults.
|
||||
"""
|
||||
from turnstone.core.storage import init_storage
|
||||
|
||||
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
|
||||
db_url = os.environ.get("TURNSTONE_DB_URL", "")
|
||||
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
|
||||
return init_storage(db_backend, path=db_path, url=db_url)
|
||||
def _pick(arg_name: str, env_name: str, default: str = "") -> Any:
|
||||
# `is not None` (not truthy) so a legitimate falsy TOML value
|
||||
# like `pool_size = 0` or `url = ""` still beats the env fallback.
|
||||
val = getattr(args, arg_name, None)
|
||||
if val is not None:
|
||||
return val
|
||||
return os.environ.get(env_name, default)
|
||||
|
||||
db_backend = str(_pick("db_backend", "TURNSTONE_DB_BACKEND", "sqlite"))
|
||||
db_url = str(_pick("db_url", "TURNSTONE_DB_URL"))
|
||||
db_path = str(_pick("db_path", "TURNSTONE_DB_PATH"))
|
||||
db_pool_size = int(_pick("db_pool_size", "TURNSTONE_DB_POOL_SIZE", "2"))
|
||||
return init_storage(
|
||||
db_backend,
|
||||
path=db_path,
|
||||
url=db_url,
|
||||
pool_size=db_pool_size,
|
||||
sslmode=str(_pick("db_sslmode", "TURNSTONE_DB_SSLMODE")),
|
||||
sslrootcert=str(_pick("db_sslrootcert", "TURNSTONE_DB_SSLROOTCERT")),
|
||||
sslcert=str(_pick("db_sslcert", "TURNSTONE_DB_SSLCERT")),
|
||||
sslkey=str(_pick("db_sslkey", "TURNSTONE_DB_SSLKEY")),
|
||||
)
|
||||
|
||||
|
||||
def _cmd_create_user(args: argparse.Namespace) -> None:
|
||||
@@ -37,7 +59,7 @@ def _cmd_create_user(args: argparse.Namespace) -> None:
|
||||
print("Error: invalid username (1-64 chars: letters, digits, . _ -)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
user_id = uuid.uuid4().hex
|
||||
|
||||
# Prompt for password
|
||||
@@ -76,7 +98,7 @@ def _cmd_create_user(args: argparse.Namespace) -> None:
|
||||
def _cmd_create_token(args: argparse.Namespace) -> None:
|
||||
from turnstone.core.auth import generate_token, hash_token, token_prefix
|
||||
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
|
||||
if storage.get_user(args.user) is None:
|
||||
print(f"Error: user {args.user} not found", file=sys.stderr)
|
||||
@@ -110,7 +132,7 @@ def _cmd_create_token(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def _cmd_list_users(args: argparse.Namespace) -> None:
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
users = storage.list_users()
|
||||
if not users:
|
||||
print("No users found.")
|
||||
@@ -120,7 +142,7 @@ def _cmd_list_users(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def _cmd_list_tokens(args: argparse.Namespace) -> None:
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
tokens = storage.list_api_tokens(args.user)
|
||||
if not tokens:
|
||||
print(f"No tokens found for user {args.user}.")
|
||||
@@ -134,7 +156,7 @@ def _cmd_list_tokens(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def _cmd_revoke_token(args: argparse.Namespace) -> None:
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
if storage.delete_api_token(args.token_id):
|
||||
print(f"Revoked token {args.token_id}")
|
||||
else:
|
||||
@@ -297,7 +319,7 @@ def _cmd_list_node_metadata(args: argparse.Namespace) -> None:
|
||||
"""List metadata for a node."""
|
||||
import json
|
||||
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
rows = storage.get_node_metadata(args.node_id)
|
||||
if not rows:
|
||||
print(f"No metadata for node: {args.node_id}")
|
||||
@@ -324,7 +346,7 @@ def _cmd_set_node_metadata(args: argparse.Namespace) -> None:
|
||||
"""Set a metadata key on a node."""
|
||||
import json
|
||||
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
|
||||
# Check for auto-source conflict
|
||||
existing = storage.get_node_metadata(args.node_id)
|
||||
@@ -345,7 +367,7 @@ def _cmd_set_node_metadata(args: argparse.Namespace) -> None:
|
||||
|
||||
def _cmd_delete_node_metadata(args: argparse.Namespace) -> None:
|
||||
"""Delete a metadata key from a node."""
|
||||
storage = _get_storage()
|
||||
storage = _get_storage(args)
|
||||
|
||||
existing = storage.get_node_metadata(args.node_id)
|
||||
for r in existing:
|
||||
@@ -395,6 +417,10 @@ def main() -> None:
|
||||
prog="turnstone-admin",
|
||||
description="Turnstone user and token administration",
|
||||
)
|
||||
from turnstone.core.config import add_config_arg, apply_config
|
||||
|
||||
add_config_arg(parser)
|
||||
apply_config(parser, ["database"])
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_cu = sub.add_parser("create-user", help="Create a new user")
|
||||
|
||||
@@ -1278,21 +1278,29 @@ class CoordinatorClient:
|
||||
allowed_tools: list[str] = [str(t) for t in allowed_full[:_SKILL_TOOLS_PROJECTION_CAP]]
|
||||
if len(allowed_full) > _SKILL_TOOLS_PROJECTION_CAP:
|
||||
allowed_tools.append(f"+{len(allowed_full) - _SKILL_TOOLS_PROJECTION_CAP} more")
|
||||
skills.append(
|
||||
{
|
||||
"name": r.get("name") or "",
|
||||
"category": r.get("category") or "",
|
||||
"tags": tags,
|
||||
"version": r.get("version") or "",
|
||||
"description": r.get("description") or "",
|
||||
"model": r.get("model") or "",
|
||||
"enabled": bool(r.get("enabled")),
|
||||
"risk_level": r.get("risk_level") or "",
|
||||
"activation": r.get("activation") or "",
|
||||
"kind": r["kind"],
|
||||
"allowed_tools": allowed_tools,
|
||||
}
|
||||
)
|
||||
skill_row: dict[str, Any] = {
|
||||
"name": r.get("name") or "",
|
||||
"category": r.get("category") or "",
|
||||
"tags": tags,
|
||||
"version": r.get("version") or "",
|
||||
"description": r.get("description") or "",
|
||||
"model": r.get("model") or "",
|
||||
"enabled": bool(r.get("enabled")),
|
||||
"risk_level": r.get("risk_level") or "",
|
||||
"activation": r.get("activation") or "",
|
||||
"kind": r["kind"],
|
||||
}
|
||||
# Omit ``allowed_tools`` when empty: an empty list reads as
|
||||
# "no tools are usable by this skill" to a model that doesn't
|
||||
# know the semantics, but the actual meaning is "no tools are
|
||||
# pre-approved (auto-approve exemption list)". Real
|
||||
# misdiagnosis happened in testing when a code-review skill
|
||||
# with no auto-approve allowlist looked like it had been
|
||||
# spawned with zero tool access. Dropping the key altogether
|
||||
# when empty removes the ambiguity at the source.
|
||||
if allowed_tools:
|
||||
skill_row["allowed_tools"] = allowed_tools
|
||||
skills.append(skill_row)
|
||||
return {"skills": skills, "truncated": truncated}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1791,6 +1799,293 @@ def _serialize_verdicts(rows: list[Any]) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_workstream — tiered output compression
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A coord doing a fan-out wave of inspect_workstream calls against
|
||||
# tool-heavy children can blow the context budget on raw output alone
|
||||
# (one child with a 100 KB bash result × N children). The previous
|
||||
# safety net was ``_truncate_output``'s head+tail strategy, which
|
||||
# silently drops *middle* messages — exactly the wrong shape for a
|
||||
# coordinator trying to understand a child's trajectory (the LAST
|
||||
# message tells the model what the child concluded; the FIRST sets
|
||||
# the brief; the middle is the connective tissue).
|
||||
#
|
||||
# The three-tier degradation pattern matches the ``search`` tool's
|
||||
# Tier-1/Tier-2/Tier-3 ladder at ``session.py:_format_search_results``.
|
||||
# First tier whose serialized size fits the budget wins; the LLM
|
||||
# learns which tier it got via the ``_tier`` field in the response
|
||||
# (no API change to the coordinator tool).
|
||||
#
|
||||
# Budget chosen well under ``tool_truncation`` (typically 256 KB+) so
|
||||
# the head+tail safety net never fires for inspect_workstream — that
|
||||
# strategy silently drops middle messages, which is exactly the
|
||||
# pathology this formatter exists to avoid.
|
||||
|
||||
_INSPECT_OUTPUT_BUDGET: int = 32_768
|
||||
# Per-message head/tail snip when Tier 2 needs to compress content.
|
||||
# Head dominates because the first ~600 chars of an assistant message
|
||||
# usually contains the conclusion / direction; the tail is the
|
||||
# follow-through. Tool results compress similarly: head shows what
|
||||
# the tool was asked / what it found at the top; tail shows the final
|
||||
# state / error suffix.
|
||||
_INSPECT_MSG_CONTENT_HEAD: int = 600
|
||||
_INSPECT_MSG_CONTENT_TAIL: int = 300
|
||||
# Skeleton-tier preview length on the last assistant message. Single
|
||||
# value because the skeleton wants ONE meaningful signal ("what did
|
||||
# the child last say"), not a head/tail snip.
|
||||
_INSPECT_SKELETON_LAST_PREVIEW: int = 400
|
||||
|
||||
# Snip lengths for tool-call ``function.arguments`` strings on
|
||||
# assistant turns. Tighter than content snipping because tool calls
|
||||
# often appear in clusters (10+ per turn for a fan-out) and the
|
||||
# arguments JSON is dense — keep just enough to see what was invoked
|
||||
# and the head of the args structure.
|
||||
_INSPECT_TOOL_ARG_HEAD: int = 300
|
||||
_INSPECT_TOOL_ARG_TAIL: int = 100
|
||||
|
||||
# Bytes ``_snip_head_tail`` reserves for the elision marker itself
|
||||
# (``\n...[N chars elided]...\n``). A text shorter than
|
||||
# ``head + tail + this margin`` passes through unsnipped — snipping
|
||||
# would cost more bytes (the marker) than it saves.
|
||||
_INSPECT_ELISION_MARGIN: int = 64
|
||||
|
||||
# Message-list trim ladder for the compact tier when per-message
|
||||
# content snipping alone doesn't free enough budget. Each rung is
|
||||
# ``(head_count, tail_count)`` — keep the first N + last M messages,
|
||||
# elide the middle as ``{"_omitted": K}``. Tail-weighted because the
|
||||
# last assistant turn carries the load-bearing "what did the child
|
||||
# conclude" signal (same rationale as ``_inspect_skeleton``'s
|
||||
# last-assistant preview). Tried in order; first rung whose
|
||||
# serialized emission fits the budget wins. Mirrors the per-file
|
||||
# sample ladder in ``_format_search_results`` at session.py:254.
|
||||
_INSPECT_LIST_TRIM_LADDER: tuple[tuple[int, int], ...] = ((20, 30), (10, 20), (5, 10))
|
||||
|
||||
|
||||
def _snip_head_tail(text: str, head: int, tail: int) -> str:
|
||||
"""Head/tail snip with elision marker; passthrough when shorter than threshold."""
|
||||
if not isinstance(text, str) or len(text) <= head + tail + _INSPECT_ELISION_MARGIN:
|
||||
return text
|
||||
elided = len(text) - head - tail
|
||||
return text[:head] + f"\n...[{elided} chars elided]...\n" + text[-tail:]
|
||||
|
||||
|
||||
def _compact_tool_calls(tool_calls: Any) -> Any:
|
||||
"""Snip ``function.arguments`` on each tool-call entry; keep ``id`` and
|
||||
``function.name`` verbatim.
|
||||
|
||||
OpenAI shape: ``[{"id": ..., "type": "function", "function":
|
||||
{"name": ..., "arguments": "<json-string>"}}, ...]``. The
|
||||
arguments string is the dominant size term on a fan-out turn that
|
||||
issued many tool calls with multi-KB JSON arguments each;
|
||||
preserving them verbatim re-opens the same size pressure the
|
||||
compact tier is trying to relieve. Non-list / non-dict entries
|
||||
pass through so a future shape change doesn't crash the formatter.
|
||||
"""
|
||||
if not isinstance(tool_calls, list):
|
||||
return tool_calls
|
||||
out: list[Any] = []
|
||||
for call in tool_calls:
|
||||
if not isinstance(call, dict):
|
||||
out.append(call)
|
||||
continue
|
||||
compact_call: dict[str, Any] = {}
|
||||
for k in ("id", "type"):
|
||||
v = call.get(k)
|
||||
if v:
|
||||
compact_call[k] = v
|
||||
func = call.get("function")
|
||||
if isinstance(func, dict):
|
||||
compact_func: dict[str, Any] = {}
|
||||
name = func.get("name")
|
||||
if name:
|
||||
compact_func["name"] = name
|
||||
args = func.get("arguments", "")
|
||||
if args:
|
||||
compact_func["arguments"] = _snip_head_tail(
|
||||
args, _INSPECT_TOOL_ARG_HEAD, _INSPECT_TOOL_ARG_TAIL
|
||||
)
|
||||
compact_call["function"] = compact_func
|
||||
out.append(compact_call)
|
||||
return out
|
||||
|
||||
|
||||
def _compact_message(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Tier-2 per-message projection: keep role + identifier keys, snip content + tool_calls.
|
||||
|
||||
Tool-call linkage is the load-bearing "what happened" signal:
|
||||
``tool_call_id`` on the result side matches an ``id`` in
|
||||
``tool_calls`` on the issuing assistant turn. Stripping
|
||||
``tool_calls`` (the pre-fix shape) left tool results dangling
|
||||
against an invisible call — the audit reader could see "bash
|
||||
returned X" but not "the assistant asked for ``ls /tmp``". The
|
||||
``arguments`` string is the size offender, so we snip it head/tail
|
||||
rather than dropping the call entirely.
|
||||
"""
|
||||
content = msg.get("content", "")
|
||||
snipped = _snip_head_tail(content, _INSPECT_MSG_CONTENT_HEAD, _INSPECT_MSG_CONTENT_TAIL)
|
||||
compact: dict[str, Any] = {"role": msg.get("role"), "content": snipped}
|
||||
# Tool-result linkage (result-side keys).
|
||||
for k in ("tool_name", "tool_call_id", "name"):
|
||||
v = msg.get(k)
|
||||
if v:
|
||||
compact[k] = v
|
||||
# Tool-call request linkage (issuing-side list), snipped per-call.
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
compact["tool_calls"] = _compact_tool_calls(tool_calls)
|
||||
return compact
|
||||
|
||||
|
||||
def _inspect_skeleton(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Tier-3 fallback: state + counts + last assistant preview + terminal info.
|
||||
|
||||
Drops every message, keeping only aggregate signal: state, message
|
||||
count, role distribution, verdict count + risk distribution, and a
|
||||
short preview of the most recent assistant turn (the "what did this
|
||||
child last say" signal). Terminal-state fields (``close_reason``,
|
||||
``last_error``) and the ``live`` block pass through unchanged
|
||||
because they're already small and load-bearing.
|
||||
"""
|
||||
messages = result.get("messages") or []
|
||||
verdicts = result.get("verdicts") or []
|
||||
role_counts: dict[str, int] = {}
|
||||
for m in messages:
|
||||
role = m.get("role") if isinstance(m, dict) else None
|
||||
if role:
|
||||
role_counts[role] = role_counts.get(role, 0) + 1
|
||||
verdicts_by_risk: dict[str, int] = {}
|
||||
for v in verdicts:
|
||||
if isinstance(v, dict):
|
||||
risk = v.get("risk_level") or "unknown"
|
||||
verdicts_by_risk[risk] = verdicts_by_risk.get(risk, 0) + 1
|
||||
last_preview = ""
|
||||
for m in reversed(messages):
|
||||
if not isinstance(m, dict) or m.get("role") != "assistant":
|
||||
continue
|
||||
c = m.get("content", "")
|
||||
if isinstance(c, str) and c:
|
||||
last_preview = c[:_INSPECT_SKELETON_LAST_PREVIEW]
|
||||
if len(c) > _INSPECT_SKELETON_LAST_PREVIEW:
|
||||
last_preview += "..."
|
||||
break
|
||||
skeleton: dict[str, Any] = {
|
||||
# Storage row keys verbatim from ``get_workstreams_batch``
|
||||
# (the projection backing ``get_workstream`` → ``inspect()``):
|
||||
# ``ws_id``, ``skill_id``. No fallback to ``id`` / ``skill``
|
||||
# — fail loud on storage column drift rather than silently
|
||||
# emitting null.
|
||||
"ws_id": result["ws_id"],
|
||||
"state": result.get("state"),
|
||||
"title": result.get("title"),
|
||||
"skill": result["skill_id"],
|
||||
"message_count": len(messages),
|
||||
"roles": role_counts,
|
||||
"verdict_count": len(verdicts),
|
||||
"verdicts_by_risk": verdicts_by_risk,
|
||||
"last_assistant_preview": last_preview,
|
||||
"_tier": "skeleton",
|
||||
"_tier_note": (
|
||||
"Output exceeded the inspect_workstream budget at both full and compact "
|
||||
"tiers; skeleton-only. Re-call with a smaller ``message_limit`` to fit "
|
||||
"the compact tier, or read individual messages via the storage admin path."
|
||||
),
|
||||
}
|
||||
for k in ("close_reason", "last_error", "live"):
|
||||
v = result.get(k)
|
||||
if v:
|
||||
skeleton[k] = v
|
||||
return skeleton
|
||||
|
||||
|
||||
def _format_inspect_tiered(result: dict[str, Any], *, budget: int = _INSPECT_OUTPUT_BUDGET) -> str:
|
||||
"""Serialize an ``inspect_workstream`` result with tiered degradation.
|
||||
|
||||
Tier 1 (full): every message verbatim — used when the size fits.
|
||||
Tier 2 (compact): per-message ``{role, head/tail-snipped content,
|
||||
tool linkage, snipped tool_calls.arguments}`` for
|
||||
every message, then a head+tail message-list trim
|
||||
ladder when content snipping alone doesn't free
|
||||
enough budget.
|
||||
Tier 3 (skeleton): no messages — counts + last assistant preview only.
|
||||
|
||||
First emission whose JSON serialization fits ``budget`` wins.
|
||||
``_tier`` appears on every non-error emission so the coordinator
|
||||
LLM (and any audit reader) can see which compression rung the
|
||||
output landed on without inferring from length. Error-shape
|
||||
results (missing or cross-tenant ws_id) bypass tiering entirely —
|
||||
they're already small and the ``error`` key signals the shape.
|
||||
|
||||
The intermediate Tier-2 list-trim rungs exist because content
|
||||
snipping alone fails on workloads where many small messages
|
||||
overflow the budget by sheer count (``message_limit=200`` × a few
|
||||
hundred chars each). In that regime, dropping content-snipping
|
||||
saves zero bytes per message, so without the list-trim ladder
|
||||
Tier-2 produces output strictly larger than Tier-1 (added
|
||||
``_tier_note``) and the formatter fell through to skeleton —
|
||||
losing every message when a head+tail message-list trim would
|
||||
have preserved dozens. Mirrors the per-file sample ladder in
|
||||
``_format_search_results`` (session.py:_SEARCH_TIER2_SAMPLE_LADDER).
|
||||
"""
|
||||
if "error" in result:
|
||||
# Cross-tenant guard / not-found responses — pass through.
|
||||
return json.dumps(result, default=str, separators=(",", ":"))
|
||||
tier1 = {**result, "_tier": "full"}
|
||||
out1 = json.dumps(tier1, default=str, separators=(",", ":"))
|
||||
if len(out1) <= budget:
|
||||
return out1
|
||||
messages = result.get("messages") or []
|
||||
compact_msgs = [_compact_message(m) if isinstance(m, dict) else m for m in messages]
|
||||
tier2_note_full = (
|
||||
"Output exceeded the inspect_workstream budget at the full tier; messages "
|
||||
"are head/tail-snipped at "
|
||||
f"{_INSPECT_MSG_CONTENT_HEAD}/{_INSPECT_MSG_CONTENT_TAIL} chars. Re-call "
|
||||
"with a smaller ``message_limit`` for a tighter tail, or include_provider_"
|
||||
"content=False if it was on."
|
||||
)
|
||||
tier2 = {
|
||||
**result,
|
||||
"messages": compact_msgs,
|
||||
"_tier": "compact",
|
||||
"_tier_note": tier2_note_full,
|
||||
}
|
||||
out2 = json.dumps(tier2, default=str, separators=(",", ":"))
|
||||
if len(out2) <= budget:
|
||||
return out2
|
||||
# Tier-2 list-trim ladder: keep head N + tail M, elide the middle.
|
||||
# Tail-weighted because the recent turns carry the load-bearing
|
||||
# signal ("what did the child conclude") — same reason
|
||||
# ``_inspect_skeleton`` keeps a last-assistant preview rather than
|
||||
# a first-user preview.
|
||||
total = len(compact_msgs)
|
||||
for head_n, tail_n in _INSPECT_LIST_TRIM_LADDER:
|
||||
if head_n + tail_n >= total:
|
||||
# Rung doesn't actually trim — would re-emit Tier-2 verbatim.
|
||||
continue
|
||||
omitted = total - head_n - tail_n
|
||||
trimmed: list[Any] = (
|
||||
compact_msgs[:head_n] + [{"_omitted": omitted}] + compact_msgs[-tail_n:]
|
||||
)
|
||||
tier2_trim_note = (
|
||||
f"Output exceeded the inspect_workstream budget at the compact tier; "
|
||||
f"keeping first {head_n} + last {tail_n} of {total} messages, eliding "
|
||||
f"{omitted} middle messages. Re-call with a smaller ``message_limit`` "
|
||||
"to fit the full compact tier."
|
||||
)
|
||||
tier2_trim = {
|
||||
**result,
|
||||
"messages": trimmed,
|
||||
"_tier": "compact",
|
||||
"_tier_note": tier2_trim_note,
|
||||
}
|
||||
out2_trim = json.dumps(tier2_trim, default=str, separators=(",", ":"))
|
||||
if len(out2_trim) <= budget:
|
||||
return out2_trim
|
||||
skeleton = _inspect_skeleton(result)
|
||||
return json.dumps(skeleton, default=str, separators=(",", ":"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wait_for_workstream — last-message extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="stylesheet" href="/shared/base.css">
|
||||
<link rel="stylesheet" href="/shared/ui-base.css">
|
||||
<link rel="stylesheet" href="/shared/chat.css">
|
||||
<link rel="stylesheet" href="/shared/katex-0.16.46/katex.min.css">
|
||||
<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/coordinator/coordinator.css">
|
||||
<style>
|
||||
@@ -634,7 +634,7 @@
|
||||
<script src="/shared/composer_attachments.js"></script>
|
||||
<script src="/shared/composer_queue.js"></script>
|
||||
<script src="/shared/status_bar.js"></script>
|
||||
<script src="/shared/katex-0.16.46/katex.min.js"></script>
|
||||
<script src="/shared/katex-0.16.47/katex.min.js"></script>
|
||||
<script src="/shared/hljs-11.11.1/highlight.min.js"></script>
|
||||
<script src="/shared/renderer.js"></script>
|
||||
<script src="/static/coordinator/coordinator.js"></script>
|
||||
|
||||
@@ -54,6 +54,27 @@ def set_config_path(path: str) -> None:
|
||||
_cache = None # invalidate cache so next load_config() re-reads
|
||||
|
||||
|
||||
def _warn_if_world_readable(cfg_path: Path) -> None:
|
||||
"""Warn once if config.toml is group- or world-readable.
|
||||
|
||||
DB passwords, OIDC client secrets, and TLS key paths live in this
|
||||
file — operators usually want it at 0600. POSIX-only; no-ops where
|
||||
``stat()`` modes are meaningless (Windows).
|
||||
"""
|
||||
try:
|
||||
mode = cfg_path.stat().st_mode & 0o777
|
||||
except OSError:
|
||||
return
|
||||
if mode & 0o077:
|
||||
log.warning(
|
||||
"%s is mode %04o (group/world-readable); secrets live here — "
|
||||
"run `chmod 0600 %s` to restrict access",
|
||||
cfg_path,
|
||||
mode,
|
||||
cfg_path,
|
||||
)
|
||||
|
||||
|
||||
def load_config(section: str | None = None) -> dict[str, Any]:
|
||||
"""Load config.toml and return the full dict or a specific section.
|
||||
|
||||
@@ -66,6 +87,7 @@ def load_config(section: str | None = None) -> dict[str, Any]:
|
||||
cfg_path = _resolve_config_path()
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
_warn_if_world_readable(cfg_path)
|
||||
_cache = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
log.warning("Failed to parse %s: %s", cfg_path, exc)
|
||||
|
||||
@@ -19,11 +19,11 @@ Channels:
|
||||
Drain preserves FIFO order; non-matching entries stay queued. Each
|
||||
entry can carry an optional ``valid_until`` predicate that drain
|
||||
evaluates outside the queue lock; entries whose predicate returns
|
||||
``False`` (or raises) are silently dropped without delivery — used by
|
||||
producers whose payload becomes stale if the underlying state changes
|
||||
between enqueue and drain (e.g. ``idle_children`` re-checks the active
|
||||
child set, dropping the nudge if every child finished while the queue
|
||||
sat). Operations are atomic under an internal :class:`threading.Lock`.
|
||||
``False`` are dropped (logged at ``info`` — normal lifecycle outcome,
|
||||
e.g. ``idle_children`` after every child closed) and entries whose
|
||||
predicate raises are dropped (logged at ``warning`` with ``exc_info``
|
||||
— a misbehaving predicate). Operations are atomic under an internal
|
||||
:class:`threading.Lock`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,9 +32,13 @@ import threading
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
Channel = Literal["user", "tool", "any"]
|
||||
_VALID_CHANNELS: frozenset[str] = frozenset({"user", "tool", "any"})
|
||||
|
||||
@@ -139,7 +143,12 @@ class NudgeQueue:
|
||||
self._items = kept
|
||||
# Predicates evaluate outside the lock — they may do storage
|
||||
# I/O or other work that shouldn't block other producers /
|
||||
# the drain consumer's other queues.
|
||||
# the drain consumer's other queues. Drop-level distinction:
|
||||
# a ``False`` return is a normal lifecycle outcome (the
|
||||
# producer's snapshot is stale — e.g. ``idle_children`` after
|
||||
# every child closed) and logs at ``info``; a raised exception
|
||||
# is a wiring bug (predicate is misbehaving) and stays at
|
||||
# ``warning`` with ``exc_info`` so the traceback surfaces.
|
||||
out: list[tuple[str, str, dict[str, Any] | None]] = []
|
||||
for entry in candidates:
|
||||
if entry.valid_until is None:
|
||||
@@ -148,11 +157,27 @@ class NudgeQueue:
|
||||
try:
|
||||
if entry.valid_until():
|
||||
out.append((entry.nudge_type, entry.text, entry.metadata))
|
||||
continue
|
||||
log.info(
|
||||
"nudge_queue.predicate_dropped",
|
||||
extra={
|
||||
"nudge_type": entry.nudge_type,
|
||||
"channel": entry.channel,
|
||||
"reason": "predicate_false",
|
||||
"text_len": len(entry.text),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# Predicate raising is treated as "no longer valid" —
|
||||
# drop silently rather than letting one bad predicate
|
||||
# poison the whole drain batch.
|
||||
pass
|
||||
log.warning(
|
||||
"nudge_queue.predicate_dropped",
|
||||
extra={
|
||||
"nudge_type": entry.nudge_type,
|
||||
"channel": entry.channel,
|
||||
"reason": "predicate_raised",
|
||||
"text_len": len(entry.text),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
return out
|
||||
|
||||
def __len__(self) -> int:
|
||||
|
||||
+53
-30
@@ -1670,13 +1670,17 @@ class ChatSession:
|
||||
The closure carries:
|
||||
- a soft cap on per-session ``"watch_triggered"`` depth via
|
||||
:data:`_WATCH_QUEUE_SOFT_CAP` + drop-oldest-on-saturation.
|
||||
- a ``valid_until`` predicate that re-checks
|
||||
``storage.is_watch_active(watch_id)`` at drain time so a
|
||||
cancelled watch's last splat doesn't ride out a future wake.
|
||||
- producer-side :func:`sanitize_payload` over the whole
|
||||
formatted message so steering-vector / control-char payloads
|
||||
sourced from arbitrary shell output can't tamper with the
|
||||
envelope at interpolation time.
|
||||
|
||||
No ``valid_until`` predicate is wired: ``WatchRunner._poll_watch``
|
||||
commits ``active=False`` for terminal fires right after dispatch
|
||||
returns, and an ``is_watch_active`` predicate would race that
|
||||
write at drain time and drop the fire the model was meant to see.
|
||||
A user-cancelled watch's last splat is informative (the reminder
|
||||
carries ``is_final=True``), not stale-noise to suppress.
|
||||
"""
|
||||
self._watch_runner = runner
|
||||
nudge_queue = self._nudge_queue
|
||||
@@ -1707,18 +1711,6 @@ class ChatSession:
|
||||
_WATCH_QUEUE_SOFT_CAP,
|
||||
)
|
||||
|
||||
def _still_active() -> bool:
|
||||
# Re-checked at drain time outside the queue lock — if
|
||||
# the watch was cancelled between fire and drain, the
|
||||
# entry gets dropped silently rather than splicing a
|
||||
# stale result onto the user's next turn. Single-column
|
||||
# ``is_watch_active`` avoids the full-row marshal of
|
||||
# ``get_watch`` on this hot path.
|
||||
try:
|
||||
return get_storage().is_watch_active(watch_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _maybe_sanitize(v: Any) -> Any:
|
||||
return sanitize_payload(v) if isinstance(v, str) else v
|
||||
|
||||
@@ -1731,7 +1723,6 @@ class ChatSession:
|
||||
"watch_triggered",
|
||||
sanitized,
|
||||
"any",
|
||||
valid_until=_still_active,
|
||||
metadata=metadata or None,
|
||||
)
|
||||
|
||||
@@ -7014,9 +7005,20 @@ class ChatSession:
|
||||
msg = f"Error: {result['error']}"
|
||||
self._report_tool_result(call_id, "spawn_workstream", msg, is_error=True)
|
||||
return call_id, msg
|
||||
# Successful spawn — surface ws_id + node_id + name + routing
|
||||
# strategy so the coordinator can follow up with inspect / send
|
||||
# and explain why a given node was chosen. ``status`` was
|
||||
# Defensive: absence of ``error`` is the success signal, but
|
||||
# a malformed upstream response could land here with no
|
||||
# ``ws_id``. Without this check the LLM gets
|
||||
# ``{"child_ws_id": null}`` and chases a null id through
|
||||
# follow-up tools. Mirrors the matching guard in
|
||||
# ``_exec_spawn_batch`` (denied row on empty ws_id).
|
||||
child_ws_id = str(result.get("ws_id") or "")
|
||||
if not child_ws_id:
|
||||
msg = "Error: spawn returned no ws_id"
|
||||
self._report_tool_result(call_id, "spawn_workstream", msg, is_error=True)
|
||||
return call_id, msg
|
||||
# Successful spawn — surface child_ws_id + node_id + name +
|
||||
# routing strategy so the coordinator can follow up with inspect
|
||||
# / send and explain why a given node was chosen. ``status`` was
|
||||
# historically included but it was the routing-proxy's HTTP
|
||||
# code (always 200 on this branch); the absence of an
|
||||
# ``error`` field is the success signal. Dropped here to
|
||||
@@ -7027,14 +7029,19 @@ class ChatSession:
|
||||
# ``inspect_workstream``.
|
||||
summary = json.dumps(
|
||||
{
|
||||
"ws_id": result.get("ws_id"),
|
||||
# Key is ``child_ws_id`` (not ``ws_id``) so the coordinator
|
||||
# LLM doesn't recency-bias toward feeding the spawn-return
|
||||
# straight back into another ``spawn_workstream(ws_id=...)``
|
||||
# call. On large fan-outs this cascaded into self-inflicted
|
||||
# re-spawn loops instead of progressing to ``wait_for_workstream``.
|
||||
"child_ws_id": child_ws_id,
|
||||
"name": result.get("name"),
|
||||
"node_id": result.get("node_id"),
|
||||
"routing_strategy": result.get("routing_strategy"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self._report_tool_result(call_id, "spawn_workstream", f"spawned {result.get('ws_id', '?')}")
|
||||
self._report_tool_result(call_id, "spawn_workstream", f"spawned {child_ws_id}")
|
||||
return call_id, summary
|
||||
|
||||
# Cap per batch call. Matches the ``wait_for_workstream`` ws_ids
|
||||
@@ -7165,7 +7172,9 @@ class ChatSession:
|
||||
denied.append({"idx": idx, "reason": "spawn returned no ws_id"})
|
||||
continue
|
||||
results[str(idx)] = {
|
||||
"ws_id": ws_id,
|
||||
# ``child_ws_id`` (not ``ws_id``) — see the matching
|
||||
# comment in ``_exec_spawn_workstream``.
|
||||
"child_ws_id": ws_id,
|
||||
"name": result.get("name", ""),
|
||||
"node_id": result.get("node_id", ""),
|
||||
# ``status`` deliberately omitted — see the matching
|
||||
@@ -7243,6 +7252,8 @@ class ChatSession:
|
||||
}
|
||||
|
||||
def _exec_inspect_workstream(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
from turnstone.console.coordinator_client import _format_inspect_tiered
|
||||
|
||||
call_id = item["call_id"]
|
||||
ws_id = item["ws_id"]
|
||||
try:
|
||||
@@ -7255,8 +7266,13 @@ class ChatSession:
|
||||
msg = f"Error: inspect_workstream failed: {e}"
|
||||
self._report_tool_result(call_id, "inspect_workstream", msg, is_error=True)
|
||||
return call_id, msg
|
||||
output = json.dumps(result, default=str, separators=(",", ":"))
|
||||
# Summary for UI: state + message count
|
||||
# Tiered output: full → compact (head/tail-snipped messages) →
|
||||
# skeleton (counts + last-assistant preview). First tier that
|
||||
# fits the budget wins; the LLM sees a ``_tier`` field on every
|
||||
# non-error response. ``_truncate_output`` remains the safety
|
||||
# net for the (rare) skeleton-exceeds-budget case — guarding
|
||||
# against a single-field blowup we didn't anticipate.
|
||||
output = _format_inspect_tiered(result)
|
||||
desc = f"{result.get('state', '?')} ({len(result.get('messages', []))} msgs)"
|
||||
self._report_tool_result(call_id, "inspect_workstream", desc)
|
||||
return call_id, self._truncate_output(output)
|
||||
@@ -10473,16 +10489,23 @@ class ChatSession:
|
||||
msg = "Error: storage unavailable"
|
||||
self._report_tool_result(call_id, "watch", msg, is_error=True)
|
||||
return call_id, msg
|
||||
watches = storage.list_watches_for_ws(self._ws_id)
|
||||
target = None
|
||||
for w in watches:
|
||||
if w["name"] == name or w["watch_id"].startswith(name):
|
||||
target = w
|
||||
break
|
||||
target = storage.find_watch_by_name(self._ws_id, name)
|
||||
if target is None:
|
||||
msg = f'Watch "{name}" not found.'
|
||||
self._report_tool_result(call_id, "watch", msg, is_error=True)
|
||||
return call_id, msg
|
||||
# In either branch below the row leaves ``list_due_watches``
|
||||
# view (already-inactive or just-cancelled with empty
|
||||
# next_poll), so the runner's retry-deactivate branch will
|
||||
# never reclaim a pending ``_terminal_dispatched`` entry.
|
||||
# Clear it here to bound the lifetime of any leftover from
|
||||
# a previous dispatch-then-failed-row-write.
|
||||
if self._watch_runner is not None:
|
||||
self._watch_runner.forget_terminal_dispatched(target["watch_id"])
|
||||
if not target["active"]:
|
||||
msg = f'Watch "{target["name"]}" already completed (auto-cancelled).'
|
||||
self._report_tool_result(call_id, "watch", msg)
|
||||
return call_id, msg
|
||||
storage.update_watch(target["watch_id"], active=False, next_poll="")
|
||||
msg = f'Watch "{target["name"]}" cancelled.'
|
||||
self._report_tool_result(call_id, "watch", msg)
|
||||
|
||||
@@ -179,6 +179,24 @@ class SessionUIBase:
|
||||
# ``/dashboard`` payload. Capped so a long-running skill
|
||||
# workstream can't fill the live block with stale rows.
|
||||
self._recent_auto_approvals: list[dict[str, Any]] = []
|
||||
# Maps ``call_id`` → ``(auto_approve_reason, inserted_ts)`` for
|
||||
# verdicts that arrive AFTER ``approve_tools`` already returned.
|
||||
# The LLM judge tier is asynchronous: ``on_intent_verdict`` can
|
||||
# fire seconds later for a tool that ``approve_tools``
|
||||
# short-circuited via one of the auto-approve branches. Without
|
||||
# this lookup the late-arriving LLM verdict lands with
|
||||
# ``user_decision="pending"`` and stays that way forever (no
|
||||
# ``resolve_approval`` cycle on the auto-approve path).
|
||||
#
|
||||
# Lifetime is bounded by ``_AUTO_APPROVE_REASON_TTL`` rather
|
||||
# than by a count-cap or by session lifetime: a fixed cap
|
||||
# would silently break the fix on the (N+1)th in-flight
|
||||
# auto-approve; "evict on consume" alone would leak entries
|
||||
# whenever the LLM judge is disabled (no ``on_intent_verdict``
|
||||
# ever fires to drain them). TTL means entries clear lazily
|
||||
# on the next ``_record_auto_approves`` write whether or not
|
||||
# the LLM judge tier is active. Guarded by ``_ws_lock``.
|
||||
self._auto_approve_reasons: dict[str, tuple[str, float]] = {}
|
||||
# Foreground gate — used by the CLI's WorkstreamTerminalUI to
|
||||
# block output when the workstream is in the background.
|
||||
# Starts set so non-CLI UIs can skip any explicit management.
|
||||
@@ -367,6 +385,7 @@ class SessionUIBase:
|
||||
feedback: str | None = None,
|
||||
*,
|
||||
always: bool = False,
|
||||
timeout: bool = False,
|
||||
) -> None:
|
||||
"""Unblock a pending approval with the caller's decision.
|
||||
|
||||
@@ -383,8 +402,22 @@ class SessionUIBase:
|
||||
can label their resolved-status pill correctly). Keyword-only
|
||||
+ default ``False`` so the four pre-existing callers (cancel,
|
||||
timeout, channel adapters) compile unchanged.
|
||||
|
||||
``timeout`` flips the persisted ``user_decision`` from
|
||||
``"denied"`` to ``"timeout"`` so the audit trail can
|
||||
distinguish an active user denial from a passive
|
||||
approval-timeout expiry — the feedback string carries the
|
||||
same information today but operators querying on the
|
||||
``user_decision`` column alone could not tell them apart.
|
||||
Mutually exclusive with ``approved=True`` (a timeout is a
|
||||
passive denial); the combination raises ``ValueError`` so a
|
||||
future caller can't accidentally ship a row whose audit
|
||||
column says ``"timeout"`` while the SSE event reports
|
||||
``approved=True``.
|
||||
"""
|
||||
decision_str = "approved" if approved else "denied"
|
||||
if timeout and approved:
|
||||
raise ValueError("resolve_approval: timeout=True is incompatible with approved=True")
|
||||
decision_str = "timeout" if timeout else ("approved" if approved else "denied")
|
||||
# Swap-and-clear + set decision under lock to avoid racing
|
||||
# with the daemon judge thread's ``on_intent_verdict`` appends.
|
||||
with self._ws_lock:
|
||||
@@ -544,8 +577,15 @@ class SessionUIBase:
|
||||
# the early return — the fall-through
|
||||
# branch never runs on this path, so without
|
||||
# this the policy bypass is invisible to
|
||||
# /dashboard + audit.
|
||||
# /dashboard + audit. ``_record_auto_approves``
|
||||
# MUST run before ``_persist_auto_approved_*``
|
||||
# so the call_id → reason lookup map is
|
||||
# populated before the heuristic INSERTs go
|
||||
# in: otherwise an LLM judge verdict firing
|
||||
# in the gap lands with ``user_decision=
|
||||
# "pending"`` and stays that way.
|
||||
self._record_auto_approves(items)
|
||||
self._persist_auto_approved_heuristic_verdicts(items)
|
||||
self._enqueue(
|
||||
{
|
||||
"type": "tool_info",
|
||||
@@ -608,7 +648,12 @@ class SessionUIBase:
|
||||
self._ws_current_activity = f"⚙ {label}: {preview}" if label else ""
|
||||
self._ws_activity_state = "tool" if label else ""
|
||||
self._broadcast_activity()
|
||||
# ``_record_auto_approves`` runs FIRST so the call_id → reason
|
||||
# lookup is populated before the heuristic INSERT can race
|
||||
# against a concurrent LLM judge verdict — see the matching
|
||||
# comment on the policy-deny branch above.
|
||||
self._record_auto_approves(items)
|
||||
self._persist_auto_approved_heuristic_verdicts(items)
|
||||
self._enqueue({"type": "tool_info", "items": self._serialize_approval_items(items)})
|
||||
return True, None
|
||||
|
||||
@@ -628,19 +673,34 @@ class SessionUIBase:
|
||||
# commit instead of N (was visible as time-to-render-prompt
|
||||
# latency for fan-out turns); the per-item Prometheus call stays
|
||||
# in the loop because it's a lock+increment, not a DB round-trip.
|
||||
#
|
||||
# ``user_decision`` is stamped per-verdict here so the row lands
|
||||
# with a meaningful value at insert: auto-approved items
|
||||
# (mixed-path case: policy allowed some, others still prompt)
|
||||
# carry their auto_approve_reason directly; items still pending
|
||||
# operator decision carry ``"pending"`` and get updated by
|
||||
# ``resolve_approval`` on close. ``_pending_verdicts`` only
|
||||
# tracks the latter — auto-approved verdicts are already final.
|
||||
heuristic_verdicts: list[dict[str, Any]] = []
|
||||
pending_verdicts: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
hv = item.get("_heuristic_verdict")
|
||||
if hv:
|
||||
heuristic_verdicts.append(hv)
|
||||
# Subclass-overridden Prometheus surface: WebUI feeds
|
||||
# the per-node /metrics endpoint, ConsoleCoordinatorUI
|
||||
# feeds the console's /metrics endpoint via ConsoleMetrics.
|
||||
self._record_judge_metric(hv)
|
||||
if not hv:
|
||||
continue
|
||||
if item.get("auto_approved"):
|
||||
hv["user_decision"] = item.get("auto_approve_reason", "") or "pending"
|
||||
else:
|
||||
hv["user_decision"] = "pending"
|
||||
pending_verdicts.append(hv)
|
||||
heuristic_verdicts.append(hv)
|
||||
# Subclass-overridden Prometheus surface: WebUI feeds
|
||||
# the per-node /metrics endpoint, ConsoleCoordinatorUI
|
||||
# feeds the console's /metrics endpoint via ConsoleMetrics.
|
||||
self._record_judge_metric(hv)
|
||||
self._persist_intent_verdicts_bulk(heuristic_verdicts, default_tier="heuristic")
|
||||
|
||||
with self._ws_lock:
|
||||
self._pending_verdicts = heuristic_verdicts
|
||||
self._pending_verdicts = pending_verdicts
|
||||
|
||||
# Record any items the policy block already auto-approved
|
||||
# before falling through to the prompt — without this the
|
||||
@@ -676,8 +736,14 @@ class SessionUIBase:
|
||||
if not self._approval_event.wait(timeout=self._APPROVAL_WAIT_TIMEOUT):
|
||||
# Approval timed out (e.g., user disconnected). Deny via
|
||||
# resolve_approval so verdicts and state are updated consistently.
|
||||
# Feedback string derives from ``_APPROVAL_WAIT_TIMEOUT`` so the
|
||||
# text follows the constant if the timeout knob moves.
|
||||
log.warning("Approval timed out for ws_id=%s", self.ws_id)
|
||||
self.resolve_approval(False, "Approval timed out after 1 hour")
|
||||
self.resolve_approval(
|
||||
False,
|
||||
f"Approval timed out after {self._APPROVAL_WAIT_TIMEOUT}s",
|
||||
timeout=True,
|
||||
)
|
||||
self._pending_approval = None
|
||||
approved, feedback = self._approval_result
|
||||
|
||||
@@ -714,8 +780,17 @@ class SessionUIBase:
|
||||
``user_decision`` immediately (if the approval already
|
||||
resolved) or parks the verdict in ``_pending_verdicts`` for
|
||||
``resolve_approval`` to stamp on close.
|
||||
|
||||
When the verdict arrives for a call_id that ``approve_tools``
|
||||
already auto-approved (the LLM judge is async and can fire
|
||||
seconds after the auto-approve path returned), stamp the
|
||||
``auto_approve_reason`` onto the verdict before persist so
|
||||
the row lands with a meaningful ``user_decision`` instead of
|
||||
the default ``"pending"`` (which would never be updated for
|
||||
this code path).
|
||||
"""
|
||||
call_id = verdict.get("call_id", "")
|
||||
auto_reason = ""
|
||||
if call_id:
|
||||
with self._ws_lock:
|
||||
if (
|
||||
@@ -725,6 +800,14 @@ class SessionUIBase:
|
||||
oldest_key = next(iter(self._llm_verdicts))
|
||||
del self._llm_verdicts[oldest_key]
|
||||
self._llm_verdicts[call_id] = verdict
|
||||
# Pop (not get) — once consumed the entry isn't useful
|
||||
# again; TTL pruning at the writer side keeps the
|
||||
# never-consumed case bounded too.
|
||||
entry = self._auto_approve_reasons.pop(call_id, None)
|
||||
if entry is not None:
|
||||
auto_reason = entry[0]
|
||||
if auto_reason:
|
||||
verdict["user_decision"] = auto_reason
|
||||
self._enqueue({"type": "intent_verdict", **verdict})
|
||||
# Kind-specific cross-stream broadcast — ConsoleCoordinatorUI
|
||||
# overrides to push onto the cluster bus so a coord parent's
|
||||
@@ -743,6 +826,17 @@ class SessionUIBase:
|
||||
# WRONG decision. Storage UPDATE happens outside the lock
|
||||
# on the already-resolved path — no contention with other
|
||||
# ws-scoped work.
|
||||
# If ``auto_reason`` was stamped above, the verdict already
|
||||
# carries the final ``user_decision`` for this row. Neither
|
||||
# path below applies: appending to ``_pending_verdicts`` would
|
||||
# cause ``resolve_approval`` (on the manual-approval sibling
|
||||
# in a mixed batch) to overwrite the auto-reason with
|
||||
# ``"approved"``/``"denied"``/``"timeout"``; the
|
||||
# ``_persist_verdict_decisions`` immediate-stamp path would
|
||||
# overwrite it the same way from a prior cycle's decision.
|
||||
# Skip both so the audit trail keeps the auto-approve reason.
|
||||
if auto_reason:
|
||||
return
|
||||
with self._ws_lock:
|
||||
decision = self._last_verdict_decision
|
||||
if not decision:
|
||||
@@ -811,9 +905,29 @@ class SessionUIBase:
|
||||
"tier": v.get("tier", default_tier),
|
||||
"judge_model": v.get("judge_model", ""),
|
||||
"latency_ms": v.get("latency_ms", 0),
|
||||
"user_decision": v.get("user_decision", "pending"),
|
||||
}
|
||||
for v in verdicts
|
||||
]
|
||||
# Plain INSERT (not UPSERT) at the bulk site. The race
|
||||
# where a daemon-judge verdict lands BEFORE this bulk
|
||||
# write IS reachable today: ``_evaluate_intent``
|
||||
# (session.py) spawns the daemon thread before
|
||||
# ``approve_tools`` is called, and the daemon's first
|
||||
# emission (heuristic-only short batch, fast LLM response,
|
||||
# or cancel-event ``_deliver_fallbacks`` from judge.py)
|
||||
# can fire ``_persist_intent_verdict`` before this bulk
|
||||
# INSERT runs. Outcome of that race is unchanged by the
|
||||
# per-row UPSERT switch: the bulk INSERT statement aborts
|
||||
# on PK collision regardless of whether the colliding row
|
||||
# was planted by INSERT or UPSERT, and the wrapping
|
||||
# ``try/except`` swallows it. Race A (daemon fires AFTER
|
||||
# bulk) IS improved by the fix: heuristic→llm_fallback
|
||||
# upgrade-in-place now lands. Future bulk-side hardening
|
||||
# (``ON CONFLICT DO NOTHING``) would preserve the OTHER
|
||||
# rows in the batch when one collides, but would keep the
|
||||
# daemon's ``tier`` ("llm"/"llm_fallback") for the
|
||||
# colliding row instead of the bulk's heuristic stamp.
|
||||
storage.create_intent_verdicts_bulk(rows)
|
||||
except Exception:
|
||||
log.debug("Failed to bulk-persist intent verdicts", exc_info=True)
|
||||
@@ -824,15 +938,21 @@ class SessionUIBase:
|
||||
*,
|
||||
default_tier: str = "llm",
|
||||
) -> None:
|
||||
"""Persist an intent-judge verdict row.
|
||||
"""Persist an intent-judge verdict row via UPSERT.
|
||||
|
||||
Used by both the async LLM-tier path (``on_intent_verdict``,
|
||||
default tier ``"llm"``) and the synchronous heuristic-tier
|
||||
path (``approve_tools``, caller passes ``default_tier="heuristic"``).
|
||||
Used by the async LLM-tier path (``on_intent_verdict``,
|
||||
default tier ``"llm"``). Routes through ``upsert_intent_verdict``
|
||||
because ``tier="llm_fallback"`` verdicts deliberately reuse the
|
||||
heuristic verdict's ``verdict_id`` (see ``judge.py`` —
|
||||
``_deliver_fallbacks`` and the in-loop fallback path)
|
||||
so the row gets "upgraded in place" from heuristic →
|
||||
llm_fallback. A plain INSERT would collide on the PK and the
|
||||
upgrade would be lost to a silently-swallowed exception.
|
||||
``default_tier`` only matters when the verdict dict doesn't
|
||||
already carry a ``tier`` key — both real producers always set it,
|
||||
but the fallback is the right call-site label so a malformed
|
||||
verdict still lands on the correct row classification.
|
||||
already carry a ``tier`` key — both real producers always set
|
||||
it, but the fallback is the right call-site label so a
|
||||
malformed verdict still lands on the correct row
|
||||
classification.
|
||||
"""
|
||||
try:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
@@ -840,7 +960,7 @@ class SessionUIBase:
|
||||
storage = get_storage()
|
||||
if storage is None:
|
||||
return
|
||||
storage.create_intent_verdict(
|
||||
storage.upsert_intent_verdict(
|
||||
verdict_id=verdict.get("verdict_id", ""),
|
||||
ws_id=self.ws_id,
|
||||
call_id=verdict.get("call_id", ""),
|
||||
@@ -855,6 +975,7 @@ class SessionUIBase:
|
||||
tier=verdict.get("tier", default_tier),
|
||||
judge_model=verdict.get("judge_model", ""),
|
||||
latency_ms=verdict.get("latency_ms", 0),
|
||||
user_decision=verdict.get("user_decision", "pending"),
|
||||
)
|
||||
except Exception:
|
||||
log.debug("Failed to persist intent verdict", exc_info=True)
|
||||
@@ -955,6 +1076,14 @@ class SessionUIBase:
|
||||
# workstreams that auto-approve dozens of tool calls per turn.
|
||||
_RECENT_AUTO_APPROVALS_MAX = 10
|
||||
|
||||
# TTL on the call_id → auto_approve_reason map. Sized to comfortably
|
||||
# cover the LLM judge's worst-case latency (cold start + a slow model
|
||||
# + queue depth). Pruning happens lazily at write time so the cost
|
||||
# is paid only on the next auto-approve event; a session that goes
|
||||
# quiet after auto-approving never pays the prune cost at all but
|
||||
# the resident-set is also tiny.
|
||||
_AUTO_APPROVE_REASON_TTL = 60.0
|
||||
|
||||
@staticmethod
|
||||
def _serialize_approval_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Project each item to the wire shape the SSE event payload uses.
|
||||
@@ -1026,6 +1155,38 @@ class SessionUIBase:
|
||||
else:
|
||||
it["auto_approve_reason"] = reason
|
||||
|
||||
def _persist_auto_approved_heuristic_verdicts(self, items: list[dict[str, Any]]) -> None:
|
||||
"""Persist heuristic verdicts for items the auto-approve path resolved.
|
||||
|
||||
The manual-approval block at the bottom of ``approve_tools``
|
||||
handles its own verdict persistence (and stamps
|
||||
``user_decision`` per item — auto-approved items in a
|
||||
mixed-path batch carry their reason, pending items carry
|
||||
``"pending"``). The auto-approve early-return branches
|
||||
(policy-allow-with-deny, blanket flag, auto_approve_tools
|
||||
match) used to drop heuristic verdicts on the floor — an
|
||||
operator querying ``user_decision`` for the auto-approve
|
||||
reason would find no row at all, conflating "we auto-approved
|
||||
silently" with "the judge didn't run". This helper closes
|
||||
that gap: walk ``items``, persist each auto-approved verdict
|
||||
with its reason stamped, and fan a metric row per verdict.
|
||||
Safe to call with empty items.
|
||||
"""
|
||||
if not items:
|
||||
return
|
||||
verdicts: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
if not it.get("auto_approved"):
|
||||
continue
|
||||
hv = it.get("_heuristic_verdict")
|
||||
if not hv:
|
||||
continue
|
||||
hv["user_decision"] = it.get("auto_approve_reason", "") or "pending"
|
||||
verdicts.append(hv)
|
||||
self._record_judge_metric(hv)
|
||||
if verdicts:
|
||||
self._persist_intent_verdicts_bulk(verdicts, default_tier="heuristic")
|
||||
|
||||
def _record_auto_approves(self, items: list[dict[str, Any]]) -> None:
|
||||
"""Append auto-approved items to the per-ws ring buffer + audit log.
|
||||
|
||||
@@ -1062,6 +1223,32 @@ class SessionUIBase:
|
||||
overflow = len(self._recent_auto_approvals) - self._RECENT_AUTO_APPROVALS_MAX
|
||||
if overflow > 0:
|
||||
self._recent_auto_approvals = self._recent_auto_approvals[overflow:]
|
||||
# Mirror call_id → reason into the lookup map so a late
|
||||
# ``on_intent_verdict`` (LLM judge tier) can stamp the
|
||||
# right ``user_decision`` instead of leaving the verdict
|
||||
# stuck as ``"pending"`` forever. Prune expired entries
|
||||
# first (lazy TTL eviction) so a session with the LLM
|
||||
# judge disabled doesn't accumulate entries that will
|
||||
# never be consumed. Skip the rebuild when the map is
|
||||
# empty or all entries are still fresh — the common case
|
||||
# on a healthy LLM-judge-enabled session where entries
|
||||
# drain via ``on_intent_verdict.pop`` within the TTL.
|
||||
cutoff = ts - self._AUTO_APPROVE_REASON_TTL
|
||||
if self._auto_approve_reasons and any(
|
||||
ins_ts < cutoff for _, ins_ts in self._auto_approve_reasons.values()
|
||||
):
|
||||
self._auto_approve_reasons = {
|
||||
cid: (reason, ins_ts)
|
||||
for cid, (reason, ins_ts) in self._auto_approve_reasons.items()
|
||||
if ins_ts >= cutoff
|
||||
}
|
||||
for entry in appended:
|
||||
cid = entry["call_id"]
|
||||
if cid:
|
||||
self._auto_approve_reasons[cid] = (
|
||||
entry["auto_approve_reason"],
|
||||
ts,
|
||||
)
|
||||
# Audit emission — one row per ``approve_tools`` call (not one
|
||||
# per item) keeps the audit table from blowing up on
|
||||
# tool-heavy turns while still capturing every tool name +
|
||||
|
||||
@@ -72,6 +72,9 @@ from turnstone.core.storage._schema import (
|
||||
from turnstone.core.storage._utils import (
|
||||
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
LIKE_ESCAPE as _LIKE_ESCAPE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
|
||||
)
|
||||
@@ -102,6 +105,9 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
escape_like as _escape_like,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
normalize_search_terms as _normalize_search_terms,
|
||||
)
|
||||
@@ -120,11 +126,6 @@ from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def _escape_ilike(s: str) -> str:
|
||||
"""Escape ILIKE metacharacters for use with ESCAPE '\\\\'."""
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _resolve_pg_listen_url(override: str, sqlalchemy_url: str) -> str:
|
||||
"""Resolve the URL used by the dedicated LISTEN connection.
|
||||
|
||||
@@ -1835,6 +1836,33 @@ class PostgreSQLBackend:
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
|
||||
|
||||
if not name_or_prefix:
|
||||
return None
|
||||
like_pattern = _escape_like(name_or_prefix) + "%"
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(watches)
|
||||
.where(
|
||||
(watches.c.ws_id == ws_id)
|
||||
& (
|
||||
(watches.c.name == name_or_prefix)
|
||||
| watches.c.watch_id.like(like_pattern, escape=_LIKE_ESCAPE)
|
||||
)
|
||||
)
|
||||
# Active rows win over inactive ones with the same name.
|
||||
# _prepare_watch's duplicate-name guard filters active=1,
|
||||
# so a model can recreate a name after the previous one
|
||||
# auto-cancelled; a cancel-by-name request on the live
|
||||
# row must not be shadowed by the older completed row.
|
||||
.order_by(watches.c.active.desc(), watches.c.created.desc())
|
||||
.limit(1)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row._mapping)
|
||||
|
||||
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
with self._conn() as conn:
|
||||
@@ -3334,6 +3362,7 @@ class PostgreSQLBackend:
|
||||
tier: str,
|
||||
judge_model: str,
|
||||
latency_ms: int,
|
||||
user_decision: str = "pending",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
@@ -3354,11 +3383,67 @@ class PostgreSQLBackend:
|
||||
"tier": tier,
|
||||
"judge_model": judge_model,
|
||||
"latency_ms": latency_ms,
|
||||
"user_decision": user_decision,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def upsert_intent_verdict(
|
||||
self,
|
||||
verdict_id: str,
|
||||
ws_id: str,
|
||||
call_id: str,
|
||||
func_name: str,
|
||||
func_args: str,
|
||||
intent_summary: str,
|
||||
risk_level: str,
|
||||
confidence: float,
|
||||
recommendation: str,
|
||||
reasoning: str,
|
||||
evidence: str,
|
||||
tier: str,
|
||||
judge_model: str,
|
||||
latency_ms: int,
|
||||
user_decision: str = "pending",
|
||||
) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = pg_insert(intent_verdicts).values(
|
||||
verdict_id=verdict_id,
|
||||
ws_id=ws_id,
|
||||
call_id=call_id,
|
||||
func_name=func_name,
|
||||
func_args=func_args,
|
||||
intent_summary=intent_summary,
|
||||
risk_level=risk_level,
|
||||
confidence=confidence,
|
||||
recommendation=recommendation,
|
||||
reasoning=reasoning,
|
||||
evidence=evidence,
|
||||
tier=tier,
|
||||
judge_model=judge_model,
|
||||
latency_ms=latency_ms,
|
||||
user_decision=user_decision,
|
||||
created=now,
|
||||
)
|
||||
# On verdict_id conflict, update only the three fields that
|
||||
# genuinely change between heuristic and llm_fallback. See the
|
||||
# protocol docstring for the full exclusion rationale —
|
||||
# ``user_decision`` exclusion in particular is load-bearing.
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[intent_verdicts.c.verdict_id],
|
||||
set_={
|
||||
"tier": tier,
|
||||
"reasoning": reasoning,
|
||||
"judge_model": judge_model,
|
||||
},
|
||||
)
|
||||
with self._conn() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
|
||||
if not verdicts:
|
||||
return
|
||||
@@ -3379,6 +3464,7 @@ class PostgreSQLBackend:
|
||||
"tier": v.get("tier", "heuristic"),
|
||||
"judge_model": v.get("judge_model", ""),
|
||||
"latency_ms": v.get("latency_ms", 0),
|
||||
"user_decision": v.get("user_decision", "pending"),
|
||||
"created": now,
|
||||
}
|
||||
for v in verdicts
|
||||
@@ -3671,7 +3757,7 @@ class PostgreSQLBackend:
|
||||
clauses = []
|
||||
params: dict[str, str] = {}
|
||||
for i, t in enumerate(terms):
|
||||
escaped = _escape_ilike(t)
|
||||
escaped = _escape_like(t)
|
||||
clauses.append(
|
||||
f"(name ILIKE :n{i} ESCAPE '\\' "
|
||||
f"OR description ILIKE :d{i} ESCAPE '\\' "
|
||||
@@ -3750,7 +3836,7 @@ class PostgreSQLBackend:
|
||||
scope_clauses, params = self._build_scope_or_clause(scopes)
|
||||
term_clauses = []
|
||||
for i, t in enumerate(terms):
|
||||
escaped = _escape_ilike(t)
|
||||
escaped = _escape_like(t)
|
||||
term_clauses.append(
|
||||
f"(name ILIKE :n{i} ESCAPE '\\' "
|
||||
f"OR description ILIKE :d{i} ESCAPE '\\' "
|
||||
|
||||
@@ -1003,6 +1003,23 @@ class StorageBackend(Protocol):
|
||||
"""Return active watches for a workstream, ordered by created DESC."""
|
||||
...
|
||||
|
||||
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
|
||||
"""Return a watch in ``ws_id`` whose ``name`` matches
|
||||
``name_or_prefix`` exactly, or whose ``watch_id`` starts with it.
|
||||
|
||||
Unlike :meth:`list_watches_for_ws` this DOES NOT filter on the
|
||||
``active`` flag — callers can inspect ``row["active"]`` to
|
||||
distinguish a still-running watch from one that fired and
|
||||
auto-cancelled. Returns ``None`` if no match.
|
||||
|
||||
When multiple rows match, prefers active rows over inactive
|
||||
ones, then most-recently-created. Without the active
|
||||
preference, a recreated-after-completion name would let the
|
||||
older inactive row shadow the new active one in the cancel
|
||||
path.
|
||||
"""
|
||||
...
|
||||
|
||||
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
|
||||
"""Return all active watches on a node, ordered by created DESC."""
|
||||
...
|
||||
@@ -1590,8 +1607,77 @@ class StorageBackend(Protocol):
|
||||
tier: str,
|
||||
judge_model: str,
|
||||
latency_ms: int,
|
||||
user_decision: str = "pending",
|
||||
) -> None:
|
||||
"""Record an intent validation verdict."""
|
||||
"""Record an intent validation verdict.
|
||||
|
||||
``user_decision`` defaults to ``"pending"`` rather than ``""``
|
||||
so an audit reader can distinguish "in-flight" rows from
|
||||
legacy pre-fix rows (which carry ``""`` from the column's
|
||||
server_default and indicate "convention not yet established
|
||||
when this row was written"). Resolution writers
|
||||
(:meth:`update_intent_verdict`) later overwrite the field with
|
||||
``"approved"`` / ``"denied"`` / ``"timeout"`` (user-driven) or
|
||||
``"policy"`` / ``"blanket"`` / ``"auto_approve_tools"``
|
||||
(auto-approve reason, mirroring :class:`AutoApproveReason`).
|
||||
"""
|
||||
...
|
||||
|
||||
def upsert_intent_verdict(
|
||||
self,
|
||||
verdict_id: str,
|
||||
ws_id: str,
|
||||
call_id: str,
|
||||
func_name: str,
|
||||
func_args: str,
|
||||
intent_summary: str,
|
||||
risk_level: str,
|
||||
confidence: float,
|
||||
recommendation: str,
|
||||
reasoning: str,
|
||||
evidence: str,
|
||||
tier: str,
|
||||
judge_model: str,
|
||||
latency_ms: int,
|
||||
user_decision: str = "pending",
|
||||
) -> None:
|
||||
"""INSERT a verdict row, or UPDATE the judge-output fields on conflict.
|
||||
|
||||
Async LLM judge verdicts with ``tier="llm_fallback"`` deliberately
|
||||
reuse the heuristic verdict's ``verdict_id`` so the row gets
|
||||
"upgraded in place" from heuristic → fallback when the LLM tier
|
||||
doesn't return a real verdict (timeout / cancelled / no-content).
|
||||
A plain INSERT collides on ``intent_verdicts_pkey``; this method
|
||||
``ON CONFLICT (verdict_id) DO UPDATE`` updates only the columns
|
||||
that genuinely change between the two tiers:
|
||||
|
||||
- ``tier`` (the upgrade itself)
|
||||
- ``reasoning`` (gets " (LLM judge did not return a verdict)" appended)
|
||||
- ``judge_model`` (heuristic carries "", fallback carries the model)
|
||||
|
||||
Every other column is EXCLUDED from the on-conflict SET clause:
|
||||
|
||||
- Identity columns (``verdict_id``, ``ws_id``, ``call_id``,
|
||||
``func_name``, ``func_args``) — already the same row.
|
||||
- Carried-verbatim columns (``intent_summary``, ``risk_level``,
|
||||
``confidence``, ``recommendation``, ``evidence``, ``latency_ms``) —
|
||||
the fallback copies them from the heuristic verdict; updating
|
||||
would be a no-op.
|
||||
- ``user_decision`` — LOAD-BEARING exclusion. ``IntentVerdict.to_dict()``
|
||||
doesn't project it, so a fallback verdict reaching this layer
|
||||
defaults the kwarg to ``"pending"``. If the operator already
|
||||
resolved the approval between heuristic INSERT and fallback
|
||||
fire, the row's ``user_decision`` was already updated to
|
||||
``"approved"``/``"denied"``/``"timeout"`` (or stamped to an
|
||||
auto-approve reason at heuristic-INSERT time). Clobbering it
|
||||
back to ``"pending"`` would undo that.
|
||||
- ``created`` — preserve the original timestamp.
|
||||
|
||||
Used by :meth:`SessionUIBase._persist_intent_verdict` for every
|
||||
async LLM-tier delivery; the synchronous heuristic-bulk path
|
||||
(:meth:`create_intent_verdicts_bulk`) stays as plain INSERT
|
||||
since each heuristic UUID is freshly generated per turn.
|
||||
"""
|
||||
...
|
||||
|
||||
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
|
||||
@@ -1601,10 +1687,12 @@ class StorageBackend(Protocol):
|
||||
(``verdict_id`` / ``ws_id`` / ``call_id`` / ``func_name`` /
|
||||
``func_args`` / ``intent_summary`` / ``risk_level`` /
|
||||
``confidence`` / ``recommendation`` / ``reasoning`` / ``evidence`` /
|
||||
``tier`` / ``judge_model`` / ``latency_ms``). Used by the
|
||||
synchronous heuristic-verdict persistence loop in
|
||||
``approve_tools`` so a tool-heavy turn doesn't pay N×commit
|
||||
latency before the approval prompt renders.
|
||||
``tier`` / ``judge_model`` / ``latency_ms`` /
|
||||
``user_decision``). ``user_decision`` defaults to ``"pending"``
|
||||
when absent — see :meth:`create_intent_verdict` for the
|
||||
vocabulary. Used by the synchronous heuristic-verdict
|
||||
persistence loop in ``approve_tools`` so a tool-heavy turn
|
||||
doesn't pay N×commit latency before the approval prompt renders.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ from turnstone.core.storage._schema import (
|
||||
from turnstone.core.storage._utils import (
|
||||
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
LIKE_ESCAPE as _LIKE_ESCAPE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
|
||||
)
|
||||
@@ -102,6 +105,9 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
escape_like as _escape_like,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
normalize_search_terms as _normalize_search_terms,
|
||||
)
|
||||
@@ -120,11 +126,6 @@ from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
"""Escape LIKE metacharacters for use with ESCAPE '\\\\'."""
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _fts5_query(query: str) -> str:
|
||||
"""Convert a plain search string into a safe FTS5 query."""
|
||||
terms = query.split()
|
||||
@@ -1976,6 +1977,33 @@ class SQLiteBackend:
|
||||
).fetchall()
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
|
||||
|
||||
if not name_or_prefix:
|
||||
return None
|
||||
like_pattern = _escape_like(name_or_prefix) + "%"
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(watches)
|
||||
.where(
|
||||
(watches.c.ws_id == ws_id)
|
||||
& (
|
||||
(watches.c.name == name_or_prefix)
|
||||
| watches.c.watch_id.like(like_pattern, escape=_LIKE_ESCAPE)
|
||||
)
|
||||
)
|
||||
# Active rows win over inactive ones with the same name.
|
||||
# _prepare_watch's duplicate-name guard filters active=1,
|
||||
# so a model can recreate a name after the previous one
|
||||
# auto-cancelled; a cancel-by-name request on the live
|
||||
# row must not be shadowed by the older completed row.
|
||||
.order_by(watches.c.active.desc(), watches.c.created.desc())
|
||||
.limit(1)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row._mapping)
|
||||
|
||||
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
with self._conn() as conn:
|
||||
@@ -3496,6 +3524,7 @@ class SQLiteBackend:
|
||||
tier: str,
|
||||
judge_model: str,
|
||||
latency_ms: int,
|
||||
user_decision: str = "pending",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._conn() as conn:
|
||||
@@ -3516,11 +3545,67 @@ class SQLiteBackend:
|
||||
"tier": tier,
|
||||
"judge_model": judge_model,
|
||||
"latency_ms": latency_ms,
|
||||
"user_decision": user_decision,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def upsert_intent_verdict(
|
||||
self,
|
||||
verdict_id: str,
|
||||
ws_id: str,
|
||||
call_id: str,
|
||||
func_name: str,
|
||||
func_args: str,
|
||||
intent_summary: str,
|
||||
risk_level: str,
|
||||
confidence: float,
|
||||
recommendation: str,
|
||||
reasoning: str,
|
||||
evidence: str,
|
||||
tier: str,
|
||||
judge_model: str,
|
||||
latency_ms: int,
|
||||
user_decision: str = "pending",
|
||||
) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = sqlite_insert(intent_verdicts).values(
|
||||
verdict_id=verdict_id,
|
||||
ws_id=ws_id,
|
||||
call_id=call_id,
|
||||
func_name=func_name,
|
||||
func_args=func_args,
|
||||
intent_summary=intent_summary,
|
||||
risk_level=risk_level,
|
||||
confidence=confidence,
|
||||
recommendation=recommendation,
|
||||
reasoning=reasoning,
|
||||
evidence=evidence,
|
||||
tier=tier,
|
||||
judge_model=judge_model,
|
||||
latency_ms=latency_ms,
|
||||
user_decision=user_decision,
|
||||
created=now,
|
||||
)
|
||||
# On verdict_id conflict, update only the three fields that
|
||||
# genuinely change between heuristic and llm_fallback. See the
|
||||
# protocol docstring for the full exclusion rationale —
|
||||
# ``user_decision`` exclusion in particular is load-bearing.
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["verdict_id"],
|
||||
set_={
|
||||
"tier": tier,
|
||||
"reasoning": reasoning,
|
||||
"judge_model": judge_model,
|
||||
},
|
||||
)
|
||||
with self._conn() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
|
||||
if not verdicts:
|
||||
return
|
||||
@@ -3541,6 +3626,7 @@ class SQLiteBackend:
|
||||
"tier": v.get("tier", "heuristic"),
|
||||
"judge_model": v.get("judge_model", ""),
|
||||
"latency_ms": v.get("latency_ms", 0),
|
||||
"user_decision": v.get("user_decision", "pending"),
|
||||
"created": now,
|
||||
}
|
||||
for v in verdicts
|
||||
|
||||
@@ -98,6 +98,37 @@ def sanitize_text(value: str | None) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL LIKE escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The escape character paired with :func:`escape_like`. Callers MUST
|
||||
# pass ``escape=LIKE_ESCAPE`` to SQLAlchemy's ``.like()`` — without
|
||||
# that kwarg, ``.like()`` uses no escape character at all and the
|
||||
# ``\%`` / ``\_`` sequences produced by :func:`escape_like` would be
|
||||
# interpreted as a literal backslash followed by a wildcard. ``\``
|
||||
# is the SQL standard escape character and works identically on SQLite
|
||||
# and PostgreSQL when passed explicitly.
|
||||
LIKE_ESCAPE = "\\"
|
||||
|
||||
|
||||
def escape_like(value: str) -> str:
|
||||
"""Escape ``%`` and ``_`` (and the escape character itself) so the
|
||||
string can be safely embedded in a SQL ``LIKE`` pattern.
|
||||
|
||||
Pair with ``column.like(escape_like(prefix) + "%", escape=LIKE_ESCAPE)``
|
||||
to do a true prefix match against caller-supplied input. Without
|
||||
this, untrusted text containing ``%`` or ``_`` is interpreted as a
|
||||
wildcard — e.g. a model-supplied watch name of ``"%"`` would match
|
||||
every row in the queried partition.
|
||||
"""
|
||||
return (
|
||||
value.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2)
|
||||
.replace("%", LIKE_ESCAPE + "%")
|
||||
.replace("_", LIKE_ESCAPE + "_")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+92
-24
@@ -289,6 +289,17 @@ class WatchRunner:
|
||||
self._dispatch_fns: dict[str, Callable[[dict[str, Any], str], None]] = {}
|
||||
self._dispatch_lock = threading.Lock()
|
||||
|
||||
# Watch ids whose terminal reminder has already been dispatched
|
||||
# but whose row write has not yet been confirmed. Populated
|
||||
# between ``_dispatch_result`` and ``update_watch`` in
|
||||
# :meth:`_poll_watch`; on a subsequent tick the same row will
|
||||
# still appear in ``list_due_watches`` (active=1, next_poll
|
||||
# unchanged) — the guard at the top of ``_poll_watch`` retries
|
||||
# the row write WITHOUT re-dispatching. Bounded by transient
|
||||
# storage failure depth (~MAX_WATCHES_PER_WS × num_ws).
|
||||
self._terminal_dispatched: set[str] = set()
|
||||
self._terminal_dispatched_lock = threading.Lock()
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
@@ -314,14 +325,18 @@ class WatchRunner:
|
||||
def set_dispatch_fn(self, ws_id: str, fn: Callable[[dict[str, Any], str], None]) -> None:
|
||||
"""Register a per-workstream dispatch fn.
|
||||
|
||||
The fn signature is ``(reminder, watch_id)`` — the runner passes
|
||||
the originating ``watch_id`` so dispatch closures can capture
|
||||
per-watch metadata (e.g. a ``valid_until`` predicate that
|
||||
re-checks ``storage.is_watch_active(watch_id)`` before
|
||||
delivering a stale entry). ``reminder`` is the structured dict
|
||||
returned by :func:`build_watch_reminder` — ``text`` carries the
|
||||
formatted body, the remaining fields ride as queue-entry
|
||||
metadata so the frontend can render a ``.msg.watch-result`` card.
|
||||
The fn signature is ``(reminder, watch_id)``. ``reminder`` is
|
||||
the structured dict returned by :func:`build_watch_reminder` —
|
||||
``text`` carries the formatted body, the remaining fields ride
|
||||
as queue-entry metadata so the frontend can render a
|
||||
``.msg.watch-result`` card. ``watch_id`` is passed for
|
||||
closures that need per-watch metadata in their queue plumbing
|
||||
(e.g. correlating a fire back to the originating row in logs);
|
||||
do NOT use it to gate delivery against
|
||||
``storage.is_watch_active(watch_id)`` — see
|
||||
:meth:`ChatSession.set_watch_runner` for why that pattern
|
||||
races :meth:`_poll_watch`'s commit of ``active=False`` and
|
||||
drops fires the model was meant to see.
|
||||
"""
|
||||
with self._dispatch_lock:
|
||||
self._dispatch_fns[ws_id] = fn
|
||||
@@ -339,6 +354,22 @@ class WatchRunner:
|
||||
with self._dispatch_lock:
|
||||
return self._dispatch_fns.get(ws_id)
|
||||
|
||||
def forget_terminal_dispatched(self, watch_id: str) -> None:
|
||||
"""Discard ``watch_id`` from the pending-terminal-dispatched
|
||||
set if present. Called by paths that take a watch out of
|
||||
:meth:`StorageBackend.list_due_watches` view independent of
|
||||
the runner's own poll (most importantly the user-cancel path
|
||||
in :meth:`ChatSession._exec_watch`). Without this, a
|
||||
``_poll_watch`` whose row write failed AFTER dispatch would
|
||||
leak ``watch_id`` in ``_terminal_dispatched`` indefinitely —
|
||||
the user-cancel writes ``next_poll=''`` which excludes the
|
||||
row from ``list_due_watches``, so the retry-deactivate branch
|
||||
at the top of :meth:`_poll_watch` never fires to clear the
|
||||
entry.
|
||||
"""
|
||||
with self._terminal_dispatched_lock:
|
||||
self._terminal_dispatched.discard(watch_id)
|
||||
|
||||
# -- Main loop -----------------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
@@ -383,6 +414,21 @@ class WatchRunner:
|
||||
prev_output = watch_row.get("last_output")
|
||||
created = watch_row.get("created", "")
|
||||
|
||||
# Re-poll of a row whose terminal reminder already shipped but
|
||||
# whose ``active=False`` write didn't land — retry just the row
|
||||
# write so the row stops appearing in ``list_due_watches``; do
|
||||
# NOT re-dispatch the reminder, which the model already saw.
|
||||
with self._terminal_dispatched_lock:
|
||||
already_dispatched = watch_id in self._terminal_dispatched
|
||||
if already_dispatched:
|
||||
try:
|
||||
self._storage.update_watch(watch_id, active=False, next_poll="")
|
||||
with self._terminal_dispatched_lock:
|
||||
self._terminal_dispatched.discard(watch_id)
|
||||
except Exception:
|
||||
log.exception("watch_runner.retry_deactivate_failed", extra={"watch_id": watch_id})
|
||||
return
|
||||
|
||||
# Safety check
|
||||
blocked = is_command_blocked(command)
|
||||
if blocked:
|
||||
@@ -416,22 +462,17 @@ class WatchRunner:
|
||||
now = datetime.now(UTC)
|
||||
now_str = now.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# Update DB
|
||||
update_fields: dict[str, Any] = {
|
||||
"poll_count": poll_count,
|
||||
"last_output": output,
|
||||
"last_exit_code": exit_code,
|
||||
"last_poll": now_str,
|
||||
}
|
||||
if is_final:
|
||||
update_fields["active"] = False
|
||||
update_fields["next_poll"] = ""
|
||||
else:
|
||||
next_poll = now + timedelta(seconds=watch_row["interval_secs"])
|
||||
update_fields["next_poll"] = next_poll.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
self._storage.update_watch(watch_id, **update_fields)
|
||||
|
||||
# Dispatch result if condition fired or final
|
||||
# Dispatch before committing the row update. Belt-and-braces
|
||||
# given the rest of the fix (closure no longer wires a
|
||||
# ``valid_until`` predicate, cancel-by-name uses
|
||||
# :meth:`find_watch_by_name` which ignores the ``active``
|
||||
# filter): either order would deliver the reminder today, but
|
||||
# this ordering preserves the invariant against re-wiring an
|
||||
# ``is_watch_active`` predicate or adding a new
|
||||
# ``active``-filtered read on this hot path. Combined with the
|
||||
# ``_terminal_dispatched`` guard above it also bounds the
|
||||
# duplicate-fire blast radius if the row write fails after the
|
||||
# reminder shipped.
|
||||
if fired or is_final:
|
||||
# Compute elapsed from created time
|
||||
elapsed_secs = 0.0
|
||||
@@ -454,6 +495,33 @@ class WatchRunner:
|
||||
reason=reason,
|
||||
)
|
||||
self._dispatch_result(ws_id, reminder, watch_id)
|
||||
if is_final:
|
||||
# Mark BEFORE the row write so a raise below routes the
|
||||
# next tick into the retry-deactivate branch instead of
|
||||
# re-firing the reminder.
|
||||
with self._terminal_dispatched_lock:
|
||||
self._terminal_dispatched.add(watch_id)
|
||||
|
||||
# Update DB
|
||||
update_fields: dict[str, Any] = {
|
||||
"poll_count": poll_count,
|
||||
"last_output": output,
|
||||
"last_exit_code": exit_code,
|
||||
"last_poll": now_str,
|
||||
}
|
||||
if is_final:
|
||||
update_fields["active"] = False
|
||||
update_fields["next_poll"] = ""
|
||||
else:
|
||||
next_poll = now + timedelta(seconds=watch_row["interval_secs"])
|
||||
update_fields["next_poll"] = next_poll.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
self._storage.update_watch(watch_id, **update_fields)
|
||||
|
||||
if is_final:
|
||||
# Row write committed; the retry-deactivate branch will
|
||||
# never be reached for this watch_id.
|
||||
with self._terminal_dispatched_lock:
|
||||
self._terminal_dispatched.discard(watch_id)
|
||||
|
||||
log.debug(
|
||||
"watch_runner.polled",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inspect_workstream",
|
||||
"description": "Read a workstream's persisted state: state, title, skill, timestamps, last N messages. A `live` block from the owning node merges in when available (current tokens, activity, `pending_approval`) and reflects the node currently holding in-memory state, which can differ from the stored `node_id` if cluster membership shifted since spawn. `close_reason` surfaces when the workstream was closed with one. Provider-native content blocks are stripped by default for compactness; pass `include_provider_content=true` for the full-fidelity payload (replay tooling).",
|
||||
"description": "Read a workstream's persisted state: state, title, skill, timestamps, last N messages. A `live` block from the owning node merges in when available (current tokens, activity, `pending_approval`) and reflects the node currently holding in-memory state, which can differ from the stored `node_id` if cluster membership shifted since spawn. `close_reason` surfaces when the workstream was closed with one. Provider-native content blocks are stripped by default for compactness; pass `include_provider_content=true` for the full-fidelity payload (replay tooling). Output uses three-tier compression to stay within a ~32 KB budget: `_tier=\"full\"` (every message verbatim), `_tier=\"compact\"` (messages head/tail-snipped at 600/300 chars), or `_tier=\"skeleton\"` (counts + last assistant preview only, when even compact didn't fit). Check the `_tier` field to know which shape you got; re-call with a smaller `message_limit` if you landed on skeleton and need more detail.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "list_skills",
|
||||
"description": "List skills (worker profiles) available to coordinators. Use to discover skill names for `spawn_workstream`. Filters: `category` (e.g. 'engineering', 'ops'), `tag` (single tag), `risk_level` (`safe`/`low`/`medium`/`high`/`critical`; omit to include unscanned rows). Results are pre-filtered to coordinator-applicable skills (`kind='coordinator'` or `'any'`); interactive-only skills are hidden. Each row returns `name`, `category`, `tags`, `version`, `description`, model preference, `enabled`, `risk_level`, `activation`, `kind`, and `allowed_tools` (capped at 20 names with a `+N more` sentinel when truncated) — enough for an informed pick.",
|
||||
"description": "List skills (worker profiles) available to coordinators. Use to discover skill names for `spawn_workstream`. Filters: `category` (e.g. 'engineering', 'ops'), `tag` (single tag), `risk_level` (`safe`/`low`/`medium`/`high`/`critical`; omit to include unscanned rows). Results are pre-filtered to coordinator-applicable skills (`kind='coordinator'` or `'any'`); interactive-only skills are hidden. Each row returns `name`, `category`, `tags`, `version`, `description`, model preference, `enabled`, `risk_level`, `activation`, and `kind`. `allowed_tools` is included ONLY when the skill declares an auto-approve allowlist — listing the tool names exempt from the operator approval gate (capped at 20 names with a `+N more` sentinel when truncated). The field is OMITTED when empty: a skill without `allowed_tools` still has access to every tool in its session's toolset; absence of the field means no tool is pre-approved for this skill, not that the skill has no tools.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "spawn_batch",
|
||||
"description": "Create up to 10 child workstreams in one call. Serialised in input order so sibling ordering (by `created_at`) is deterministic. Returns `{results: {idx: {ws_id, name, node_id}}, denied: [{idx, reason}]}` — `results` keyed by stringified input-array index, `denied` collects per-item validation / spawn failures. For >10 children make multiple calls (the batch hard-errors rather than truncating). Pair with `wait_for_workstream(ws_ids=[...], mode='all')` to synthesise the N outputs once every child has finished. Lifecycle state at spawn isn't returned — call inspect_workstream if you need it.",
|
||||
"description": "Create up to 10 child workstreams in one call. Serialised in input order so sibling ordering (by `created_at`) is deterministic. Returns `{results: {idx: {child_ws_id, name, node_id}}, denied: [{idx, reason}]}` — `results` keyed by stringified input-array index, `denied` collects per-item validation / spawn failures. Collect the `child_ws_id` values and pass them as a list to `wait_for_workstream(ws_ids=[...], mode='all')` to synthesise the N outputs once every child has finished. For >10 children make multiple calls (the batch hard-errors rather than truncating). Lifecycle state at spawn isn't returned — call inspect_workstream if you need it.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "spawn_workstream",
|
||||
"description": "Create a new child workstream, optionally dispatching an initial message. Kick off a focused sub-task on a different skill / model / node while the coordinator stays in charge. The child runs independently — drive it with send_to_workstream / inspect_workstream / close_workstream. Returns `{ws_id, name, node_id, routing_strategy}`. `routing_strategy` is `rendezvous` (default placement on the live-node set), `target_node` (your hint was honored), or `resume` (rebound to a still-alive prior owner on rehydrate). The returned `node_id` is the spawn-time binding; subsequent ops re-route via rendezvous over the live-node set, so a node join or drop after spawn can shift the active owner. Conversation state lives in storage (the new owner rehydrates lazily). Don't cache `node_id` for long-running callbacks — re-read with inspect_workstream. Lifecycle state (idle / running / etc.) is not in this response — read it via inspect_workstream.",
|
||||
"description": "Create a new child workstream, optionally dispatching an initial message. Kick off a focused sub-task on a different skill / model / node while the coordinator stays in charge. The child runs independently — drive it with send_to_workstream / inspect_workstream / close_workstream. Returns `{child_ws_id, name, node_id, routing_strategy}` — pass `child_ws_id` into `wait_for_workstream(ws_ids=[...])` and the other `ws_id`-taking tools. `routing_strategy` is `rendezvous` (default placement on the live-node set), `target_node` (your hint was honored), or `resume` (rebound to a still-alive prior owner on rehydrate). The returned `node_id` is the spawn-time binding; subsequent ops re-route via rendezvous over the live-node set, so a node join or drop after spawn can shift the active owner. Conversation state lives in storage (the new owner rehydrates lazily). Don't cache `node_id` for long-running callbacks — re-read with inspect_workstream. Lifecycle state (idle / running / etc.) is not in this response — read it via inspect_workstream.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<link rel="stylesheet" href="/shared/ui-base.css" />
|
||||
<link rel="stylesheet" href="/shared/chat.css" />
|
||||
<link rel="stylesheet" href="/shared/cards.css" />
|
||||
<link rel="stylesheet" href="/shared/katex-0.16.46/katex.min.css" />
|
||||
<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -629,7 +629,7 @@
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/auth.js"></script>
|
||||
<script src="/shared/kb.js"></script>
|
||||
<script src="/shared/katex-0.16.46/katex.min.js"></script>
|
||||
<script src="/shared/katex-0.16.47/katex.min.js"></script>
|
||||
<script src="/shared/hljs-11.11.1/highlight.min.js"></script>
|
||||
<script src="/shared/renderer.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -188,40 +188,42 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -359,6 +361,79 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotlicffi"
|
||||
version = "1.2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
@@ -440,14 +515,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
version = "8.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -636,16 +711,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ddgs"
|
||||
version = "9.14.2"
|
||||
version = "9.14.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "fake-useragent" },
|
||||
{ name = "httpx", extra = ["brotli", "http2", "socks"] },
|
||||
{ name = "lxml" },
|
||||
{ name = "primp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/31/4b8ad86fd97fba7cff52d9d7c59a002ddf9ef0ba8fa4d70b925190471c33/ddgs-9.14.2.tar.gz", hash = "sha256:a9e6ad5bd7357707163d1cf03dbbcc9413a5820738ba5176efe36955b32aab38", size = 57205, upload-time = "2026-05-03T19:45:30.229Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/24/9d29eeb7dd4852c27c3673adcaf30c4dc55ced76b303c1fbb792ce7cae52/ddgs-9.14.4.tar.gz", hash = "sha256:f7b118a2b709a9e9c04a1dca6e96b98c25d4dfaca1a4b0a244d74454fcca48ef", size = 59742, upload-time = "2026-05-15T06:53:45.946Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/5d258f7bfb418a5d33c3a77fba327efd8bd6c5d834d06f07b4f229033c33/ddgs-9.14.2-py3-none-any.whl", hash = "sha256:47f5002ebe72d0e7d342d9ce9c0cd9d1125fa7b9ee38dc47069449f4a8382d37", size = 67058, upload-time = "2026-05-03T19:45:28.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/5f/32de4d99220eb559b7b1cd1c529a1856efa8097f7a3e10b6c207aa95e36c/ddgs-9.14.4-py3-none-any.whl", hash = "sha256:acb084c34bf1110c974caf7e5e5a2c1973beb4bd9e170bfd191fe5ed2d2b2d6c", size = 70638, upload-time = "2026-05-15T06:53:44.761Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -679,6 +756,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fake-useragent"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -850,6 +936,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -878,6 +986,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
brotli = [
|
||||
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
|
||||
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
|
||||
]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
socks = [
|
||||
{ name = "socksio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
@@ -887,6 +1007,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
@@ -1509,86 +1638,86 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.4"
|
||||
version = "2.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/8e/b8041bc719f056afd864478029d52214789341ac6583437b0ee5031e9530/numpy-2.4.5.tar.gz", hash = "sha256:ca670567a5683b7c1670ec03e0ddd5862e10934e92a70751d68d7b7b74ca7f9f", size = 20735669, upload-time = "2026-05-15T20:25:19.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/44/1383ee4d1e916a9e610e46c876b5c83ea023526117d23cd911983929ec34/numpy-2.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3176dc8ff71dbb593606f91a69ad0c3cd3303c7eb546af477370ab9edf760288", size = 16969261, upload-time = "2026-05-15T20:22:23.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/61/54bacfbec7550bc398e6b6d9a861db35d64f75844e1d7920f5722c3cd5e7/numpy-2.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1811150e5148f5a01a7cc282cb2f489b4a3050a773e173adb480e507bad3a3d7", size = 14964009, upload-time = "2026-05-15T20:22:25.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/55/fe86c64561761f185339c26001164a2687bd4787af681e961431abd2d534/numpy-2.4.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0d63a780070871210853ba01e90b88f9b85cf2abf63a7f143d5127189265ddf6", size = 5469106, upload-time = "2026-05-15T20:22:28.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/74/cf29b8317627f0e3aa2c9fb332d386bd734308cecd9e07da9f407d9ce0c3/numpy-2.4.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:0c6919cefafb3b76cd46a89dbb203bf1dd95529d2a6d09fef2d325d95d6a79d8", size = 6798945, upload-time = "2026-05-15T20:22:30.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a9/b61730a17fa87d5abb13ce560a1b4ce3485d37a13e03eb7b414e598e72f8/numpy-2.4.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51efede1e58e8b11877536a5518f60e318d8ff69b89ad7b38ee5e431b24d772", size = 15967025, upload-time = "2026-05-15T20:22:32.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/39/70bcd187eb4d223c21fde02c2bdfbffbffef3288cbb3947c04c74ae39a08/numpy-2.4.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07ce7e74da92d7c71b5df157b9758bcdd53d7fea10602154de3afd2b3ddc34dd", size = 16918685, upload-time = "2026-05-15T20:22:34.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/31/400fd1315bbe228af3937cf8a74e32023df6217af36077919d00adc382e4/numpy-2.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d7828234a13185effb34979e146f9921f2a65dfbbe215e6dbb57d6478fc8e059", size = 17322963, upload-time = "2026-05-15T20:22:37.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/6a/bbbafb657e6f6ee826b4ecdb8722a2e0aae4a981888eaf59eae6a535cc13/numpy-2.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f96083adc3dfc1bbf778f2c79654d88115fa07074c97cb724fe9508f12d91c55", size = 18651594, upload-time = "2026-05-15T20:22:40.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/0c/857a515154a2a18b0dfae04089600d166d352d473ec17a0680d879582d06/numpy-2.4.5-cp311-cp311-win32.whl", hash = "sha256:4ed78c904a638b6e5d7cd4db90c06fca5fc6ec2f28d258305368f454a50e79cf", size = 6233849, upload-time = "2026-05-15T20:22:43.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/66/d215f3fb93541617adb5d58b3b9508e8a6413e499711e0adc0b80bcb445d/numpy-2.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:079b0fad6f2899b23c5da89792b5409d2d83fc83e8bd5c2299cc9c397a264864", size = 12608238, upload-time = "2026-05-15T20:22:45.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c4/611d66d3fcfa931954d37a19ce5575f3283d023e89ff0df6ad43b334ae9c/numpy-2.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:d6c78e260b53affe9b395a9d54fc61f101f9521c4d9452c7e9e3718b19e2215b", size = 10479452, upload-time = "2026-05-15T20:22:47.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/18/3275231e98620002681c922e792db04d72c356e9d8073c387344fc0e4ff1/numpy-2.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:654fb8674b61b1c4bd568f944d13a908566fdcb0d797303521d4149d16da05ef", size = 16689166, upload-time = "2026-05-15T20:22:50.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/000aab6a16bdec53307f0f72546b57a3ac9266a62d8c257bee97d85fd078/numpy-2.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4cd9f6fa7ce10dc4627f2bb81dd9075dab67e94632e04c2b638e12575ddaa862", size = 14699514, upload-time = "2026-05-15T20:22:53.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/cc/ddaf3af9c46966fef5be879256f213d85a0c56c75d07a3b7defec7cf6b4c/numpy-2.4.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4f5bc96d35d94e4ceab8b38a92241b4611e95dc44e63b9f1fa2a331858ee3507", size = 5204601, upload-time = "2026-05-15T20:22:56.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ea/627fadd11959b3c7759008f34c92a35af8ff942dd8284a66ced648bbe516/numpy-2.4.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4bb33e900ee81730ad77a258965134aa8ceac805124f7e5229347beda4b8d0aa", size = 6551360, upload-time = "2026-05-15T20:22:58.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/47/0728b986b8682d742ff68c16baa5af9d185484abfc635c5cc700f44e62be/numpy-2.4.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32f8f852273ef32b291201ac2a2c97629c4a1ee8632bb670e3443eaa09fc2e72", size = 15671157, upload-time = "2026-05-15T20:23:01.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/0b/b905ae82d9419dc38123523862db64978ca2954b69609c3ae8fdaca1084c/numpy-2.4.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685681e956fc8dcb75adc6ff26694e1dfd738b24bd8d4696c51ca0110157f912", size = 16645703, upload-time = "2026-05-15T20:23:04.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/24/e27fc3f5236b4118ed9eed67111675f5c61a07ea333acec87c869c3b359d/numpy-2.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f64dd84b277a737eb59513f6b9bb6195bf41ab11941ef15b2562dbab43fa8ef", size = 17021018, upload-time = "2026-05-15T20:23:07.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/a7/9041af38d527ab80a06a93570a77e29425b41507ad41f6acf5da78cfb4a4/numpy-2.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b42d9496f79e3a728192f05a42d86e36163217b7cdecb3813d0028a0aa6b72d7", size = 18368768, upload-time = "2026-05-15T20:23:09.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/82/326a014442f32c2663434fd424d9298791f47f8a0f17585ad60519a5606e/numpy-2.4.5-cp312-cp312-win32.whl", hash = "sha256:86d980970f5110595ca14855768073b08585fc1acc36895de303e039e7dee4a5", size = 5962819, upload-time = "2026-05-15T20:23:11.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/cbf5d391b0b3a5e8cad264603e2fae256b0bde8ce43566b13b78faedc659/numpy-2.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:3333dba6a4e611d666f69e177ba8fe4140366ff681a5feb2374d3fd4fff3acb6", size = 12321621, upload-time = "2026-05-15T20:23:14.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d0/0f18909d9bc37a5f3f969fc737d2bb5df9f2ff295f71b467e6f52a0d6c4e/numpy-2.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:4593d197270b894efeb538dcbe227e4bcf1c77f88c4c6bf933ead812cfaa4453", size = 10221430, upload-time = "2026-05-15T20:23:16.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a4/fb50657c7cab297bf34edcd60a074cb0647f61771430d6363575274160fe/numpy-2.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ef248460b645c102026b82337cc4e88231909c66dd77b59ec6d6cac7e44f277", size = 16684760, upload-time = "2026-05-15T20:23:19.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/43/87e731299b9408eda705b3b9cb31c7bceb9347d2af9cbb16b2b1e4b5bc0f/numpy-2.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4603622bdcdbf8dccb1d9d5b21d16a7aa4e473ae6c8e14048d846fd4ca2907a0", size = 14694117, upload-time = "2026-05-15T20:23:21.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c7/0b2bb8acea222e9dd6e582afc2bc553b89b8833cbdccc68e68f050fb31f8/numpy-2.4.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6c18d49c67689c562854b53fdc433b93e47c12952aa6fa6d59f185e1a5992419", size = 5199141, upload-time = "2026-05-15T20:23:24.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/60/b6972b5d47033d90000f0097c81a98b9486589a2d7003bf725bff275cb0d/numpy-2.4.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b1c663ddc641f4192e90511bec61a09bc231e3bbdb996cdc6edbcaa0e528d685", size = 6546954, upload-time = "2026-05-15T20:23:26.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/e9/ed667cb12c11ca0adde431f685d3a5dd78e6f78b27228c581c8415198e9e/numpy-2.4.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93793222b524f692f12b2f8752ce8b1d9d9125b2bfd5dbf0fb69c92c5e1ce86c", size = 15669430, upload-time = "2026-05-15T20:23:28.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/e5/679f6ffeb01294b0008e5ada4a113cb47617bc0e1819a529fd7973c6d7f4/numpy-2.4.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1616bde34b2bcba2fa9bde06217ce00da4f3d1bdfb264d54525a99e8fe170d83", size = 16633390, upload-time = "2026-05-15T20:23:31.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/46/42bfffc9a780ec902ccd7470d3219192ee82b7b442710307dd85b4d121b0/numpy-2.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09d7d97da1c2c62f4818b3e150a57572ff8dcf1cf5ac501aac832ffd4ebd9566", size = 17020709, upload-time = "2026-05-15T20:23:34.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/00/3e840bfee0cc6cec22209f2c97057f26eeb30de031e4933b4dfc0395416c/numpy-2.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d68d0b355ab2e39fe0de59001d7151dfdbbb880ef67baeed806661e03df5097", size = 18357818, upload-time = "2026-05-15T20:23:36.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/cb/3447b400b9da84134575486f0f656541559b00d4b262477bce9b678bbca8/numpy-2.4.5-cp313-cp313-win32.whl", hash = "sha256:fe28b64777ddfa0eca9b5f51474034ebe3dcb8324f48f27b28f479085673ae33", size = 5961114, upload-time = "2026-05-15T20:23:39.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f9/a90d2220ffcdc0798f5d55bb5d5463cd6254ec9ef43f384dae80217d7a2f/numpy-2.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:fb4a6c9c537d6ccec9cc4aeae4261bd3cc79b070c67ddc0646f5b1c07fddde42", size = 12318553, upload-time = "2026-05-15T20:23:41.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c9/96f531fb3234545315152d34efdf3de7daee81254448447eb619e8d16967/numpy-2.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d7df2da2e7ea0624a43aa368104b3a3ce14aae98ad4bb2c9a93fecef76f1c97", size = 10222200, upload-time = "2026-05-15T20:23:43.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f4/a291caab5a3c520babf93ff77c54fd5fdb1ebbc3296cee2eb2146ce773b1/numpy-2.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:2a235607a18df941760a695927051af4b1cd5d3ee85840d0e2af816785771feb", size = 14821438, upload-time = "2026-05-15T20:23:45.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/26/13dbb1159b864370568e7309063fd72667984df89db74e9caeb175d067c7/numpy-2.4.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:58dcf64969d870f36bc7fbd557d2617e997db7dc06261b6e3327148ea460d0a4", size = 5326663, upload-time = "2026-05-15T20:23:48.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/99/d233408072a0e019e2288e27edd23f7d572ccd4a73d1539baa3270ede85d/numpy-2.4.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:235f54b0156274d8fa3155db3ed6d2f401c7e8f3367c90db0a12f02a58fde6ed", size = 6646874, upload-time = "2026-05-15T20:23:49.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/00/eeb6f193dfe767725e952e0464f3e51f44145c5dd261cd7389aa36ac0713/numpy-2.4.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3b5bb65437a3555c648e706475db01c645559ca80dc8b03e4f202ea757e0d6", size = 15728147, upload-time = "2026-05-15T20:23:51.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/c9/b8ed039f1fde1b13a8807c893e7e2f9432a379f4d6401edecf0028da5b2c/numpy-2.4.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f09a7e5f017d7098c66522097c96257411c9620c0926212200d66bc8cee3976", size = 16681770, upload-time = "2026-05-15T20:23:53.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/5b/0198ef6cb7016eca6d895d392106012138127fab23f46637e76d5e25c9f5/numpy-2.4.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:993a88d8fdd8554466a8765cd8bacd97ba56b70ca6b0a04bcdca77f5afed4222", size = 17086218, upload-time = "2026-05-15T20:23:56.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/fe/8821f3cfc660ae84c92ee158505941874b62c56a42e035a41425228cd8cf/numpy-2.4.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:84f58bed609b5669f5ad3d597901a4f1f86ee5b3c3708aaa55f05b4fe6e0f656", size = 18403542, upload-time = "2026-05-15T20:23:59.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/00/e64ecaf498865e7b091f57658b2c522503e5d1b70e43b807f5f8247e1d88/numpy-2.4.5-cp313-cp313t-win32.whl", hash = "sha256:7200c58f3f933ca61e66346667dcc8510bb111995e9ce15398a731e6a4afa4bb", size = 6084903, upload-time = "2026-05-15T20:24:01.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c0/354997dedaf74e8311c2cf9a6027b476fd8d424cb92189cc0ae2b25f501c/numpy-2.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c26c71080d35db5002102f5d9ff614d45de02aa1f7802943e691e063e5ee93bc", size = 12458420, upload-time = "2026-05-15T20:24:03.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/dc/917ee5ea4a31ca1a6e4c9a85386477efa318dcc60db257c5ef4adda096c1/numpy-2.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:2caa576d1707b275cba1aeb60a5c50daa6fa2a3f28ecb08123bc05fd439005db", size = 10291826, upload-time = "2026-05-15T20:24:06.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/c1/3be0bf102fc17cff5bd142e3be0bfffabec6fa46da0a462396c76b0765d0/numpy-2.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:889ca2c072315de638a5194a772aa1fa2df92bdd6175f6a222d4784040424b61", size = 16683455, upload-time = "2026-05-15T20:24:08.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/3e/0742d724901fa36bc54b338c6e62e463a7601180da896aa44978f0adf004/numpy-2.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:89e89304fb1f8c3f0ecfa4a7d48f311dd79771336a940e920159d643d1307e77", size = 14704577, upload-time = "2026-05-15T20:24:11.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:144fcc5a3a17679b2b82543b4a2d8dd29937230a7af13232b5f753872feb6361", size = 5209756, upload-time = "2026-05-15T20:24:14.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/c0/23fb1bc506f774e03db66219a2830e720f4d3dbcaaddf855a7ff7bb6d96f/numpy-2.4.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:398bb16772b265b9fa5c07b07072646ea97137c10ffb62a9a087b277fc825c29", size = 6543937, upload-time = "2026-05-15T20:24:16.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/49/db4662c26e68520afcc84d672a6f9f5294063dee0e57a46d61afdaa7f9ed/numpy-2.4.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb352e7b8876da1249e72254736d6c58c505fa4e58a3d7e30efca241ca9ca9ce", size = 15685292, upload-time = "2026-05-15T20:24:17.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7341b08ff8124d7353939778e2707b8732d03c78c1c30e0815aba2dacbe1245a", size = 16638528, upload-time = "2026-05-15T20:24:20.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/81/364388600932618fe735d97fdd2437cb8dd87a23377ac11d8b9d5db098b7/numpy-2.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:deb01226f012539f3945261ffe1c10aec081a0fa0a5c925419933c70f3ae2d23", size = 17036709, upload-time = "2026-05-15T20:24:22.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4a/a1185b18a94a6d9587e54b437e7d0ba36ecf6e614f1bea03f5249912c64e/numpy-2.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d888bdf7335f76878c3c7b264ac1ff089863e211ec81249f9fb5795c2183dc25", size = 18363254, upload-time = "2026-05-15T20:24:25.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/8e/95c1d2ed15ae97750ede8c8a0ac487c9c01207afff430f47078b1d9d7dc5/numpy-2.4.5-cp314-cp314-win32.whl", hash = "sha256:15f90d1256e9b2320aff24fde44815b787ab6d7c49a1a11bfd8138b321c5f080", size = 6010184, upload-time = "2026-05-15T20:24:27.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4bd2cd4ef9c0afa87de73723c0a33c0edff62143e1432917458e26d3d195d87f", size = 12450344, upload-time = "2026-05-15T20:24:29.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/64/c0ae481f7c3b2f85869bcd8fc5d30aa7c96b394162eef9c9315957f115c5/numpy-2.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:db304568c650e9d7039744d3575d0d287754debb2057d7c7b8cdfdc2c487a957", size = 10495674, upload-time = "2026-05-15T20:24:32.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/89/c5a4c677acf17aa50ba09a15e61812f90baac42bb6ca38d112e005858351/numpy-2.4.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de2883e0d2c63eae1bab1a84b390dca74aabb3d20ea1f5d58f360853c83abf3", size = 14824078, upload-time = "2026-05-15T20:24:34.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/52/57e7144284f6b51ba93523e495ff239260b1ecd5257e3700a436332e5688/numpy-2.4.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:06760fe73ae5005008748d182de612c733542af3cde063d532cd2127561b27be", size = 5329246, upload-time = "2026-05-15T20:24:36.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b3/09dbce80fd4a7db4318f2fc01eec0ae76f29306442b5a32d4b811d082cdf/numpy-2.4.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:4b51a01745cb04cc19278482207444b4d30728ce91c28d27a3bfae5fc6ff24c7", size = 6649877, upload-time = "2026-05-15T20:24:38.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c2/dbdb23e82d540b757690ef13f011c386fca6a63848eec6136baf8ce7cbed/numpy-2.4.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a05636d7937d0936f271e5ba957fa8d746b5be3c2025caa1a2508f4fe521d40", size = 15730534, upload-time = "2026-05-15T20:24:41.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bd/68f6e9b3c20decf40ac06708a7b506757e3a8588efed32988d1b747316be/numpy-2.4.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b86f56048ed09c3bbe48962a7dff077c2fd3274f8cf981800f3b38eac49cc3", size = 16679741, upload-time = "2026-05-15T20:24:44.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/1d/0fcac0b6b4ea1b50ca8fca05a34bed5c8d56e34c1cb5ffb04cf76109ac3c/numpy-2.4.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:130d58151c4db23e9fa860b84784e219a3aa3e030acc88a493ea37006c4dfd4c", size = 17085598, upload-time = "2026-05-15T20:24:47.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e8/a472b2564cf6cc498ad7aa9741d9832648221b8ab8cc0dbef41faa248ede/numpy-2.4.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d475afc8cbe935ff5944f753d863bba774d7f4e1feaaa4102901e3e053ca5963", size = 18403855, upload-time = "2026-05-15T20:24:50.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/a4/da82196f8cc4bd28ecf17bd57008c84f3d4696caf06753d9bad45e4ad749/numpy-2.4.5-cp314-cp314t-win32.whl", hash = "sha256:27f4a6dc26353a860b348961b9aa9e009835688b435cfa105e873b8dc2c726f5", size = 6156900, upload-time = "2026-05-15T20:24:53.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/31/860959b91a73d9a085006554fa3850da51a7ffab64599bac5097243438ab/numpy-2.4.5-cp314-cp314t-win_amd64.whl", hash = "sha256:76ac6e90f5e226011c88f9b7040a4bcae612518bc7e9adc127e697a13b28ad1a", size = 12638906, upload-time = "2026-05-15T20:24:55.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2a/bbd3097913083ad07c0f28fc9629666221fc18923e17ce97ae22a5dccdd6/numpy-2.4.5-cp314-cp314t-win_arm64.whl", hash = "sha256:7c392e2c1bf596701d3c6832be7567eab5d5b0a13865036c33365ee097d37f8b", size = 10565875, upload-time = "2026-05-15T20:24:57.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5d/9a644cfb841bc76b584afc3af1708b3bf6c5cb51fc84a7008246cd93b7b7/numpy-2.4.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6bf0bfc1c2e1db972e30b6cd3d4861f477f3af908b27799b239dc3cbe3eb4b95", size = 16847544, upload-time = "2026-05-15T20:24:59.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/8f/4fe5e3ba76d858dae1fe79078818c0520447335be0082c0dedf82719cc08/numpy-2.4.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:73d664413fb97229149c4711ef56531a6fe8c15c1c2626b0bbe497b84c287e70", size = 14889039, upload-time = "2026-05-15T20:25:03.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/6f/79f195abf922ecc43e7d0eb6cc969462a71b524a35bcd1fa26b4a1d7406a/numpy-2.4.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:b35bee5ef99e8d227a07829bee2e864fcb65f7c157646fcd8ec8b4b45dd8b88f", size = 5394106, upload-time = "2026-05-15T20:25:05.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6f/79cd6247205802bcbd10b40ea087e20ded526e10e9be224d34de832b216e/numpy-2.4.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:02981d0fc9f9ce147643d552966d47f329a02f7ecb3b113e84207242f20dfa83", size = 6708718, upload-time = "2026-05-15T20:25:08.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/22/5f378a9d4633c98f28c4709d4144b1a4630c5c09e109d2e781e2d26c8fe1/numpy-2.4.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e63caf31a1df06338ae63d999f7a33a675ced62eea9c9b02db4b1c1f45cff38", size = 15798292, upload-time = "2026-05-15T20:25:10.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/1c/cec582febef798c99888892d92dc1d28dfe29cb427c41f44d13d0dec208f/numpy-2.4.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8fc52b85a7b45e474be53eddf08e006d22e381a4e41bcde8e4aa08da0e7d198", size = 16747406, upload-time = "2026-05-15T20:25:13.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/dc/d358a16a6fec86cf736b8fbe67386044b3fa2aded1a80cff90e836799301/numpy-2.4.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:40c71d50a4da1a7c317af419461052d3911a5770bfc5fd55baf52cc45e7a2c20", size = 12504085, upload-time = "2026-05-15T20:25:16.667Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.36.0"
|
||||
version = "2.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1600,9 +1729,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706, upload-time = "2026-05-15T22:30:35.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238, upload-time = "2026-05-15T22:30:32.767Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2066,23 +2195,23 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-frontmatter"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/21/88aefb4f1de6661b5a003175e21e4a5ad94f5e52b2abf4170a11883c7d81/python_frontmatter-1.2.0.tar.gz", hash = "sha256:5b26ccd3cb85af77feb11d83b922c7bb5aeccb0c9d3fb236b938c600b6322984", size = 16890, upload-time = "2026-05-17T23:42:05.493Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/b1/ec12e3e746234006b77dec69d53878253b1da09dbb55fa3cb456083d9069/python_frontmatter-1.2.0-py3-none-any.whl", hash = "sha256:e1ee1d4300450a2f84e778eb4f70edf573da6cd7d463801066f05edc4e819c78", size = 10396, upload-time = "2026-05-17T23:42:04.637Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.28"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2283,27 +2412,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.12"
|
||||
version = "0.15.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2416,6 +2545,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socksio"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.49"
|
||||
@@ -2584,7 +2722,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.5.16"
|
||||
version = "1.5.18"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -2731,15 +2869,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.46.0"
|
||||
version = "0.47.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user