pushary 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pushary-2.2.0 → pushary-2.3.0}/PKG-INFO +6 -1
- {pushary-2.2.0 → pushary-2.3.0}/README.md +5 -0
- {pushary-2.2.0 → pushary-2.3.0}/examples/dify/README.md +61 -7
- {pushary-2.2.0 → pushary-2.3.0}/pyproject.toml +1 -1
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/__init__.py +3 -1
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/client.py +5 -3
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/keys.py +4 -2
- pushary-2.3.0/src/pushary/reminders.py +25 -0
- {pushary-2.2.0 → pushary-2.3.0}/tests/test_client.py +2 -2
- pushary-2.3.0/tests/test_reminders.py +17 -0
- {pushary-2.2.0 → pushary-2.3.0}/.gitignore +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/LICENSE +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/examples/agno/README.md +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/examples/llamaindex/README.md +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/examples/microsoft/README.md +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/examples/strands/README.md +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/_types.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/_util.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/adapters.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/decisions.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/errors.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/py.typed +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/src/pushary/webhook.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/tests/test_adapters.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/tests/test_agent.py +0 -0
- {pushary-2.2.0 → pushary-2.3.0}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pushary
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: The decision layer for AI agents. Your agent asks, your user decides on their phone.
|
|
5
5
|
Project-URL: Homepage, https://pushary.com
|
|
6
6
|
Project-URL: Documentation, https://pushary.com/docs
|
|
@@ -291,3 +291,8 @@ These keys intentionally differ from 2.0. Do not replay completed operations dur
|
|
|
291
291
|
### Upgrading to 2.2
|
|
292
292
|
|
|
293
293
|
`ApprovalAsk` accepts `context`. The shared approval gate sends it with the decision, so the approver can read what the notification cannot fit, such as a tool's full arguments. Context is part of the approval identity when set. An ask without context keeps the key it had in 2.1, so pending approvals carry over.
|
|
294
|
+
## Personal reminders
|
|
295
|
+
|
|
296
|
+
On an active Agent workspace, `client.reminders.schedule("Check the deploy", in_minutes=30)` saves a one-time reminder that survives the agent exiting. Use `at` with an explicit UTC offset for calendar times, up to 30 days away. `client.reminders.list()` lists your pending reminders; `client.reminders.cancel(reminder_id)` cancels one before dispatch starts. Confirm the returned `reminder.fireAt`. Delivery requires a connected phone and can be about a minute late. These personal reminders do not schedule agent work or Partner customer campaigns.
|
|
297
|
+
|
|
298
|
+
Personal reminder bodies must fit 180 UTF-16 units after secret redaction (most emoji use two); titles must fit 100. Oversized text is rejected. Listing and cancelling accepted reminders remain available when billing access closes.
|
|
@@ -263,3 +263,8 @@ These keys intentionally differ from 2.0. Do not replay completed operations dur
|
|
|
263
263
|
### Upgrading to 2.2
|
|
264
264
|
|
|
265
265
|
`ApprovalAsk` accepts `context`. The shared approval gate sends it with the decision, so the approver can read what the notification cannot fit, such as a tool's full arguments. Context is part of the approval identity when set. An ask without context keeps the key it had in 2.1, so pending approvals carry over.
|
|
266
|
+
## Personal reminders
|
|
267
|
+
|
|
268
|
+
On an active Agent workspace, `client.reminders.schedule("Check the deploy", in_minutes=30)` saves a one-time reminder that survives the agent exiting. Use `at` with an explicit UTC offset for calendar times, up to 30 days away. `client.reminders.list()` lists your pending reminders; `client.reminders.cancel(reminder_id)` cancels one before dispatch starts. Confirm the returned `reminder.fireAt`. Delivery requires a connected phone and can be about a minute late. These personal reminders do not schedule agent work or Partner customer campaigns.
|
|
269
|
+
|
|
270
|
+
Personal reminder bodies must fit 180 UTF-16 units after secret redaction (most emoji use two); titles must fit 100. Oversized text is rejected. Listing and cancelling accepted reminders remain available when billing access closes.
|
|
@@ -83,14 +83,20 @@ export DIFY_NODE_ID='review'
|
|
|
83
83
|
second workflow. A pending result is reported as `blocked` with the SDK's reason.
|
|
84
84
|
After approval, the result contains the released local order. Inspect `orders.db`
|
|
85
85
|
inside the state directory: the business idempotency key is unique. Re-running
|
|
86
|
-
`resume` after
|
|
86
|
+
`resume` after a committed order returns that order with `"recovered": true`, using
|
|
87
|
+
read-only backend evidence even when the native form is closed or approval has expired.
|
|
88
|
+
This reports an existing effect; it does not authorize another one.
|
|
87
89
|
|
|
88
90
|
For your backend, import `start` and `resume` from [review.py](review.py). Supply
|
|
89
91
|
tenant/customer IDs and the action from authenticated application state, never
|
|
90
92
|
model output. The CLI's `demo-store` tenant and test order are demonstration data.
|
|
91
93
|
Pass your business callback to `resume`; it receives a copy of the exact approved
|
|
92
94
|
parameters and a stable idempotency key. Your downstream service must also enforce
|
|
93
|
-
that key if it can retry internally.
|
|
95
|
+
that key if it can retry internally. Optionally supply a trusted, read-only `lookup`
|
|
96
|
+
callback with the same arguments: return the verified committed result, return `None`
|
|
97
|
+
when completion is unconfirmed, or raise when evidence is unavailable or contradictory.
|
|
98
|
+
Without `lookup`, the original stop-and-reconcile behavior is unchanged. Never use a
|
|
99
|
+
Dify success flag, approval answer, or worker-local success cache as backend evidence.
|
|
94
100
|
|
|
95
101
|
## Trust and recovery limits
|
|
96
102
|
|
|
@@ -107,14 +113,57 @@ that key if it can retry internally.
|
|
|
107
113
|
the one-hour Pushary request; both deadlines are checked before the effect.
|
|
108
114
|
- The SDK consumes the remote one-use permit **before** native submission. Dify
|
|
109
115
|
also makes forms one-shot. A lost submission response, native resume taking over
|
|
110
|
-
30 seconds, worker crash or uncertain business result needs
|
|
111
|
-
The
|
|
116
|
+
30 seconds, worker crash or uncertain business result needs reconciliation.
|
|
117
|
+
The SQLite demo automatically reads back a committed order and its matching backend
|
|
118
|
+
receipt. Without that evidence it stops for operator reconciliation. The spent
|
|
119
|
+
permit is not reset and the effect is not automatically retried.
|
|
112
120
|
- If initial workflow creation fails before the pause is saved, inspect Dify's run
|
|
113
121
|
logs. Do not delete the state directory and blindly start another operation.
|
|
114
122
|
- Dify workspace administrators and anyone who controls your backend remain trusted.
|
|
115
123
|
This example does not intercept arbitrary agent tools or secure a separate effect
|
|
116
124
|
placed directly in Dify.
|
|
117
125
|
|
|
126
|
+
## Recovery evidence and interruption checkpoints
|
|
127
|
+
|
|
128
|
+
The demo's backend is `orders.db`, separate from Dify and the Pushary permit service.
|
|
129
|
+
`release_order` commits the order and a receipt containing **all** approved parameters
|
|
130
|
+
under the same binding in one SQLite transaction. `lookup_order` opens that database
|
|
131
|
+
read-only and reads both rows in one snapshot. Both must match before it reports
|
|
132
|
+
`released`; a missing row, mismatched parameters, corrupt database or legacy schema
|
|
133
|
+
cannot become recovered success. Existing databases without the new receipt table
|
|
134
|
+
require manual reconciliation; do not delete them to force a fresh execution.
|
|
135
|
+
|
|
136
|
+
The binding includes tenant, customer, native run, workflow version, form, parameters
|
|
137
|
+
and deadline. Caller identity and saved operation integrity are checked before lookup.
|
|
138
|
+
Only the permit-protected callback writes orders. Lookup never submits a form, consumes
|
|
139
|
+
a permit, executes an effect or changes a receipt. A late callback exception triggers
|
|
140
|
+
another readback before the SDK reports an outcome. Verified completion therefore
|
|
141
|
+
survives that exception. There is no mutable local completion status for an older
|
|
142
|
+
worker to overwrite; the committed order and receipt remain the source of truth.
|
|
143
|
+
|
|
144
|
+
| Interruption | Backend evidence | Restart outcome | New effect |
|
|
145
|
+
| --- | --- | --- | --- |
|
|
146
|
+
| Permit consumed, response lost | No order | Blocked/unconfirmed permit | None |
|
|
147
|
+
| Native form committed, response lost | No order | Stop for reconciliation | None |
|
|
148
|
+
| Worker dies before backend effect | No order | Stop for reconciliation | None |
|
|
149
|
+
| Order and receipt commit, worker dies before returning | Matching order and receipt | `released`, `recovered: true` | None |
|
|
150
|
+
| Worker B verifies completion before worker A's delayed exception | Matching order and receipt | B recovers; A reads back success; subsequent resumes recover | None |
|
|
151
|
+
| SDK receipt reporting is unavailable | Matching order and receipt | Backend success remains recoverable | None |
|
|
152
|
+
| Backend evidence is missing, inconsistent or unreadable | Unverified | Stop; never invent success | None |
|
|
153
|
+
|
|
154
|
+
The backend receipt is distinct from Pushary's hosted execution receipt. A process
|
|
155
|
+
killed before SDK reporting can leave the hosted permit marked running; this example
|
|
156
|
+
**does not repair that hosted receipt** or claim that its status proves the business
|
|
157
|
+
outcome. After a recoverable delayed callback exception, the still-running worker can
|
|
158
|
+
report SDK success normally. An operator must reconcile a receipt left by a dead worker.
|
|
159
|
+
|
|
160
|
+
This is a local synthetic comparison with [Mission's proposed checkpoint](https://github.com/langgenius/dify/discussions/42319#discussioncomment-18625206): backend
|
|
161
|
+
commit → lost worker response → fresh-process lookup. It is not a Mission integration,
|
|
162
|
+
a distributed exactly-once guarantee, or proof of live Dify/phone-service recovery.
|
|
163
|
+
For a remote backend, implement lookup using that provider's authoritative operation
|
|
164
|
+
identifier, exact subject and durable committed evidence; an absent lookup result alone
|
|
165
|
+
must never authorize resending an uncertain operation.
|
|
166
|
+
|
|
118
167
|
## Verification
|
|
119
168
|
|
|
120
169
|
```bash
|
|
@@ -126,9 +175,14 @@ The checks use Dify's **real Graphon 0.7.0** Human Input node, graph engine and
|
|
|
126
175
|
serialized runtime snapshots, plus the published Pushary SDK. They run fresh
|
|
127
176
|
processes across pause/resume and cover pending, approval, denial, expiry,
|
|
128
177
|
cancellation, customer/version/parameter changes, duplicate workers, a competing
|
|
129
|
-
native submitter, lost permit/submission responses and
|
|
130
|
-
|
|
131
|
-
|
|
178
|
+
native submitter, lost permit/submission responses and crashes before/after the effect.
|
|
179
|
+
They use the actual SQLite order writer and readback, compare restart with and without
|
|
180
|
+
lookup, assert one order and one matching receipt, and exercise worker B's recovery
|
|
181
|
+
before worker A's delayed error. They also reject corrupt, incomplete, legacy and
|
|
182
|
+
mismatched evidence and verify readback after approval expiry.
|
|
183
|
+
|
|
184
|
+
Both hosted HTTP services are simulated; the test backend uses real local SQLite
|
|
185
|
+
transactions and the demo's order writer/readback.
|
|
132
186
|
Dify Cloud import and a Studio pause → approve → resume smoke test were also
|
|
133
187
|
verified on 2026-09-15. The automated checks do **not** prove its database/Celery
|
|
134
188
|
delivery or live phone delivery. Run the test-phone procedure above against your deployment
|
|
@@ -19,15 +19,17 @@ from ._util import deterministic_key, is_approved
|
|
|
19
19
|
from .client import PusharyServer
|
|
20
20
|
from .decisions import DecisionsResource
|
|
21
21
|
from .keys import KeysResource
|
|
22
|
+
from .reminders import RemindersResource
|
|
22
23
|
from .errors import PusharyError
|
|
23
24
|
from .webhook import SIGNATURE_HEADER, parse_decision_callback, verify_webhook_signature
|
|
24
25
|
|
|
25
|
-
__version__ = "2.
|
|
26
|
+
__version__ = "2.3.0"
|
|
26
27
|
|
|
27
28
|
__all__ = [
|
|
28
29
|
"PusharyServer",
|
|
29
30
|
"DecisionsResource",
|
|
30
31
|
"KeysResource",
|
|
32
|
+
"RemindersResource",
|
|
31
33
|
"PusharyError",
|
|
32
34
|
"verify_webhook_signature",
|
|
33
35
|
"parse_decision_callback",
|
|
@@ -15,6 +15,7 @@ from urllib.parse import quote, urlencode
|
|
|
15
15
|
|
|
16
16
|
from .decisions import DecisionsResource
|
|
17
17
|
from .keys import KeysResource
|
|
18
|
+
from .reminders import RemindersResource
|
|
18
19
|
from .errors import PusharyError
|
|
19
20
|
|
|
20
21
|
DEFAULT_BASE_URL = "https://pushary.com/api/v1/server"
|
|
@@ -24,14 +25,14 @@ DEFAULT_BASE_URL = "https://pushary.com/api/v1/server"
|
|
|
24
25
|
# little above it to leave room for the round trip without hanging forever.
|
|
25
26
|
DEFAULT_TIMEOUT_SECONDS = 65.0
|
|
26
27
|
|
|
27
|
-
_SETTINGS_URL = "https://pushary.com/dashboard/settings"
|
|
28
|
+
_SETTINGS_URL = "https://pushary.com/dashboard/agent/settings"
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
class PusharyServer:
|
|
31
32
|
"""Client for the Pushary human-in-the-loop decisions API.
|
|
32
33
|
|
|
33
34
|
Args:
|
|
34
|
-
api_key: The full API key in the form ``pk_xxx.
|
|
35
|
+
api_key: The full API key in the form ``pk_xxx.xxx``.
|
|
35
36
|
base_url: Override the API base URL. Defaults to the public endpoint.
|
|
36
37
|
|
|
37
38
|
Raises:
|
|
@@ -49,6 +50,7 @@ class PusharyServer:
|
|
|
49
50
|
self._timeout = DEFAULT_TIMEOUT_SECONDS
|
|
50
51
|
self.decisions = DecisionsResource(self._request)
|
|
51
52
|
self.keys = KeysResource(self._request)
|
|
53
|
+
self.reminders = RemindersResource(self._request)
|
|
52
54
|
|
|
53
55
|
def evaluate_authorization(
|
|
54
56
|
self,
|
|
@@ -244,7 +246,7 @@ class PusharyServer:
|
|
|
244
246
|
)
|
|
245
247
|
if "." not in api_key:
|
|
246
248
|
raise ValueError(
|
|
247
|
-
"Invalid API key format. Use the full API key (pk_xxx.
|
|
249
|
+
"Invalid API key format. Use the full API key (pk_xxx.xxx). "
|
|
248
250
|
f"Get your API key from {_SETTINGS_URL}"
|
|
249
251
|
)
|
|
250
252
|
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
For multi-tenant Partners. Call ``issue`` from your backend with your own
|
|
4
4
|
(unbound, Partner-plan) key to mint a child key bound to a single end-user, hand
|
|
5
5
|
that key to the agent runtime acting for that user, then ``revoke`` it when the
|
|
6
|
-
session ends. A bound key can only create
|
|
7
|
-
end-user, so a prompt-injected agent can never reach another of your users.
|
|
6
|
+
session ends. A bound key can only create, read and cancel decisions for that
|
|
7
|
+
exact end-user, so a prompt-injected agent can never reach another of your users.
|
|
8
|
+
It cannot enroll devices or answer a decision; use your server-side full-access
|
|
9
|
+
key for those operations.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
from __future__ import annotations
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Callable, Dict, Optional
|
|
3
|
+
|
|
4
|
+
RequestFn = Callable[..., Dict[str, object]]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RemindersResource:
|
|
8
|
+
def __init__(self, request: RequestFn) -> None:
|
|
9
|
+
self._request = request
|
|
10
|
+
|
|
11
|
+
def schedule(
|
|
12
|
+
self, body: str, *, in_minutes: Optional[int] = None, at: Optional[str] = None,
|
|
13
|
+
title: Optional[str] = None, agent_name: Optional[str] = None,
|
|
14
|
+
session_id: Optional[str] = None, machine_id: Optional[str] = None,
|
|
15
|
+
env: Optional[str] = None,
|
|
16
|
+
) -> Dict[str, object]:
|
|
17
|
+
values = {"body": body, "inMinutes": in_minutes, "at": at, "title": title,
|
|
18
|
+
"agentName": agent_name, "sessionId": session_id, "machineId": machine_id, "env": env}
|
|
19
|
+
return self._request("POST", "/reminders", body={key: value for key, value in values.items() if value is not None})
|
|
20
|
+
|
|
21
|
+
def list(self) -> Dict[str, object]:
|
|
22
|
+
return self._request("GET", "/reminders")
|
|
23
|
+
|
|
24
|
+
def cancel(self, reminder_id: str) -> Dict[str, object]:
|
|
25
|
+
return self._request("POST", "/reminders", body={"cancelReminderId": reminder_id})
|
|
@@ -38,12 +38,12 @@ class ApiKeyValidationTests(unittest.TestCase):
|
|
|
38
38
|
def test_empty_key_raises_value_error(self):
|
|
39
39
|
with self.assertRaises(ValueError) as ctx:
|
|
40
40
|
PusharyServer(api_key="")
|
|
41
|
-
self.assertIn("dashboard/settings", str(ctx.exception))
|
|
41
|
+
self.assertIn("dashboard/agent/settings", str(ctx.exception))
|
|
42
42
|
|
|
43
43
|
def test_key_without_dot_raises_value_error(self):
|
|
44
44
|
with self.assertRaises(ValueError) as ctx:
|
|
45
45
|
PusharyServer(api_key="pk_live_only")
|
|
46
|
-
self.assertIn("pk_xxx.
|
|
46
|
+
self.assertIn("pk_xxx.xxx", str(ctx.exception))
|
|
47
47
|
|
|
48
48
|
def test_valid_key_constructs(self):
|
|
49
49
|
client = PusharyServer(api_key=VALID_KEY)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest import mock
|
|
3
|
+
from pushary import PusharyServer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReminderTests(unittest.TestCase):
|
|
7
|
+
def test_schedule_list_and_cancel_use_authenticated_reminder_resource(self):
|
|
8
|
+
with mock.patch.object(PusharyServer, '_request', return_value={'pending': []}) as request:
|
|
9
|
+
client = PusharyServer('pk_test.secret')
|
|
10
|
+
client.reminders.schedule('Check deploy', in_minutes=30)
|
|
11
|
+
client.reminders.list()
|
|
12
|
+
client.reminders.cancel('id')
|
|
13
|
+
self.assertEqual(request.call_args_list, [
|
|
14
|
+
mock.call('POST', '/reminders', body={'body': 'Check deploy', 'inMinutes': 30}),
|
|
15
|
+
mock.call('GET', '/reminders'),
|
|
16
|
+
mock.call('POST', '/reminders', body={'cancelReminderId': 'id'}),
|
|
17
|
+
])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|