@pushary/server 1.5.0 → 2.0.0

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.
package/README.md CHANGED
@@ -140,8 +140,8 @@ const { approved, value, status } = await pushary.decisions.ask({
140
140
  if (approved) await issueRefund()
141
141
  ```
142
142
 
143
- `ask()` creates the decision, derives a collision-safe idempotency key, and polls
144
- durably until the human answers or the deadline passes (default 55s, serverless-safe).
143
+ `ask()` creates a fresh decision per call and polls
144
+ durably until the human answers or the deadline passes (default 55s for a positive local wait budget).
145
145
  `approved` is true only when the person actually said yes, so a declined, expired, or
146
146
  unanswered decision safely blocks the action.
147
147
 
@@ -195,8 +195,8 @@ import {
195
195
  const idempotencyKey = deterministicKey(['run-abc', 'step-3', 'user-123'])
196
196
 
197
197
  // isApproved(decision: { status, type?, value }): boolean
198
- // Fail-closed: true ONLY for an answered confirm whose value is yes. Pending,
199
- // expired, declined and free-text answers all return false.
198
+ // For confirm: true only for an affirmative answer. For select/input: true
199
+ // means answered, not permission to act. Check the actual value for those types.
200
200
  isApproved({ status: 'answered', type: 'confirm', value: 'yes' }) // true
201
201
  isApproved({ status: 'answered', type: 'confirm', value: 'no' }) // false
202
202
  isApproved({ status: 'pending', type: 'confirm', value: null }) // false
@@ -394,3 +394,13 @@ import {
394
394
  ## License
395
395
 
396
396
  MIT. See `LICENSE`.
397
+
398
+ ## Retries and deadlines
399
+
400
+ For retries of one business operation, pass `idempotencyKey: deterministicKey([runId, stepId, userId])`. Independent asks get fresh keys, even with identical questions. Shared adapter durable creates require this key; missing keys fail before a request is sent. Keep a run ID unique per operation and stable across its retries.
401
+
402
+ A positive `timeoutMs` covers creation and polling. `timeoutMs: 0` retains create-only behavior. A timeout during creation throws; retry with the same explicit key to recover the decision. A timeout during polling returns the known pending decision ID for later `get()` or a webhook. `signal` cancels the local wait and throws; it does not cancel the remote decision. Other HTTP calls use `requestTimeoutMs` (65 seconds by default).
403
+
404
+ ### Upgrading to 2.0
405
+
406
+ Upgrade the framework adapter alongside this SDK. Durable helpers now require an explicit `idempotencyKey` tied to the run and step. The 0.3 LangGraph, Mastra and OpenAI adapters support this contract. Earlier adapters must keep the 1.x SDK until migrated. Independent blocking calls no longer deduplicate by question text.
package/dist/adapters.cjs CHANGED
@@ -42,6 +42,7 @@ __export(adapters_exports, {
42
42
  verifyWebhookSignature: () => verifyWebhookSignature
43
43
  });
44
44
  module.exports = __toCommonJS(adapters_exports);
45
+ var import_node_crypto = require("crypto");
45
46
 
46
47
  // src/error.ts
47
48
  var PusharyApiError = class _PusharyApiError extends Error {
@@ -92,11 +93,12 @@ var handleResponse = async (response) => {
92
93
  }
93
94
  return response.json();
94
95
  };
95
- var createRequest = (ctx) => async (method, path, body) => {
96
+ var createRequest = (ctx) => async (method, path, body, options) => {
96
97
  const isGet = method === "GET";
97
98
  const url = isGet && body ? buildUrl(ctx.baseUrl, path, body) : `${ctx.baseUrl}${path}`;
98
99
  const response = await fetch(url, {
99
100
  method,
101
+ signal: options?.signal ?? AbortSignal.timeout(ctx.timeoutMs ?? 65e3),
100
102
  headers: ctx.headers,
101
103
  body: !isGet && body ? JSON.stringify(body) : void 0
102
104
  });
@@ -179,64 +181,80 @@ var DEFAULT_ASK_TIMEOUT_MS = 55e3;
179
181
  var createDecisionsResource = (request) => {
180
182
  const ask = async (data) => {
181
183
  const idempotencyKey = data.idempotencyKey ?? (0, import_crypto2.randomUUID)();
182
- const created = await request("POST", "/decisions", {
183
- question: data.question,
184
- type: data.type,
185
- options: data.options,
186
- externalId: data.externalId,
187
- email: data.email,
188
- agentName: data.agentName,
189
- context: data.context,
190
- placeholder: data.placeholder,
191
- toolName: data.toolName,
192
- toolTarget: data.toolTarget,
193
- actor: data.actor,
194
- environment: data.environment,
195
- parameters: data.parameters,
196
- // Its changes name parameters rather than carrying values, so what the
197
- // person sees is read from the same numbers a rule compares.
198
- presentation: data.presentation,
199
- approvalUrl: data.approvalUrl,
200
- expiresInSeconds: data.expiresInSeconds,
201
- poweredBy: data.poweredBy,
202
- callbackUrl: data.callbackUrl,
203
- requireReachable: data.requireReachable,
204
- // Read only by a key issued on your workspace's sandbox site, where the
205
- // simulator answers instead of a person. Ignored in production.
206
- sandboxOutcome: data.sandboxOutcome,
207
- idempotencyKey,
208
- wait: false
209
- });
210
- const type = created.type ?? data.type ?? "confirm";
211
- let status = created.status;
212
- let answered = created.answered;
213
- let value = created.value ?? null;
214
- const deadline = Date.now() + Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
215
- while (status === "pending" && Date.now() < deadline) {
216
- const waitSeconds = Math.max(
217
- 1,
218
- Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
219
- );
220
- const polled = await request("GET", `/decisions/${created.decisionId}`, {
221
- wait: waitSeconds
222
- });
223
- status = polled.status;
224
- answered = polled.answered;
225
- value = polled.value;
184
+ const timeoutMs = Math.max(0, data.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
185
+ const deadline = Date.now() + timeoutMs;
186
+ const controller = new AbortController();
187
+ const abort = () => controller.abort(data.signal?.reason);
188
+ if (data.signal?.aborted) abort();
189
+ else data.signal?.addEventListener("abort", abort, { once: true });
190
+ const timer = setTimeout(() => controller.abort(new DOMException("Pushary ask timed out", "TimeoutError")), timeoutMs || 65e3);
191
+ try {
192
+ const created = await request("POST", "/decisions", {
193
+ question: data.question,
194
+ type: data.type,
195
+ options: data.options,
196
+ externalId: data.externalId,
197
+ email: data.email,
198
+ agentName: data.agentName,
199
+ context: data.context,
200
+ placeholder: data.placeholder,
201
+ toolName: data.toolName,
202
+ toolTarget: data.toolTarget,
203
+ actor: data.actor,
204
+ environment: data.environment,
205
+ parameters: data.parameters,
206
+ // Its changes name parameters rather than carrying values, so what the
207
+ // person sees is read from the same numbers a rule compares.
208
+ presentation: data.presentation,
209
+ approvalUrl: data.approvalUrl,
210
+ expiresInSeconds: data.expiresInSeconds,
211
+ poweredBy: data.poweredBy,
212
+ callbackUrl: data.callbackUrl,
213
+ requireReachable: data.requireReachable,
214
+ // Read only by a key issued on your workspace's sandbox site, where the
215
+ // simulator answers instead of a person. Ignored in production.
216
+ sandboxOutcome: data.sandboxOutcome,
217
+ idempotencyKey,
218
+ wait: false
219
+ }, { signal: controller.signal });
220
+ const type = created.type ?? data.type ?? "confirm";
221
+ let status = created.status;
222
+ let answered = created.answered;
223
+ let value = created.value ?? null;
224
+ while (status === "pending" && Date.now() < deadline) {
225
+ const waitSeconds = Math.max(
226
+ 1,
227
+ Math.min(POLL_WINDOW_SECONDS, Math.ceil((deadline - Date.now()) / 1e3))
228
+ );
229
+ try {
230
+ const polled = await request("GET", `/decisions/${created.decisionId}`, {
231
+ wait: waitSeconds
232
+ }, { signal: controller.signal });
233
+ status = polled.status;
234
+ answered = polled.answered;
235
+ value = polled.value;
236
+ } catch (error) {
237
+ if (controller.signal.aborted && !data.signal?.aborted) break;
238
+ throw error;
239
+ }
240
+ }
241
+ return {
242
+ decisionId: created.decisionId,
243
+ status,
244
+ answered,
245
+ value,
246
+ type,
247
+ approved: isApproved({ status, type, value }),
248
+ // Surfaced from the create response so a caller can tell an unanswered
249
+ // decision that reached nobody (reachable:false) apart from a real decline.
250
+ reachable: created.reachable,
251
+ reachableChannels: created.reachableChannels,
252
+ deviceCount: created.deviceCount
253
+ };
254
+ } finally {
255
+ clearTimeout(timer);
256
+ data.signal?.removeEventListener("abort", abort);
226
257
  }
227
- return {
228
- decisionId: created.decisionId,
229
- status,
230
- answered,
231
- value,
232
- type,
233
- approved: isApproved({ status, type, value }),
234
- // Surfaced from the create response so a caller can tell an unanswered
235
- // decision that reached nobody (reachable:false) apart from a real decline.
236
- reachable: created.reachable,
237
- reachableChannels: created.reachableChannels,
238
- deviceCount: created.deviceCount
239
- };
240
258
  };
241
259
  return Object.freeze({
242
260
  create: (data) => request("POST", "/decisions", data),
@@ -366,6 +384,7 @@ var createContext = (config) => {
366
384
  validateApiKey(config.apiKey);
367
385
  return Object.freeze({
368
386
  baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
387
+ timeoutMs: config.requestTimeoutMs ?? 65e3,
369
388
  headers: Object.freeze({
370
389
  "Content-Type": "application/json",
371
390
  "Authorization": `Bearer ${config.apiKey}`
@@ -424,9 +443,8 @@ var parseDecisionCallback = (rawBody) => {
424
443
  };
425
444
 
426
445
  // src/adapters.ts
427
- var DEFAULT_NODE = "ask-human";
428
446
  var defaultProtectQuestion = (action, target) => `Approve ${action}${target ? ` ${target}` : ""}?`;
429
- var idempotencyKeyFor = (input) => deterministicKey([input.externalId, input.node ?? DEFAULT_NODE, input.question]);
447
+ var idempotencyKeyFor = (input) => input.idempotencyKey ?? (0, import_node_crypto.randomUUID)();
430
448
  var isAffirmative = (answer) => isApproved({ status: "answered", type: "confirm", value: answer ?? null });
431
449
  var describeAnswer = (type, result) => {
432
450
  if (!result.answered) {
@@ -633,6 +651,9 @@ var createAdapterKernel = (helpersLabel) => {
633
651
  idempotencyKey: idempotencyKeyFor(input)
634
652
  });
635
653
  const createDurableDecision = async (config, input) => {
654
+ if (!input.idempotencyKey?.trim()) {
655
+ throw new Error("Pushary: durable decisions require an idempotencyKey tied to the run and step.");
656
+ }
636
657
  const created = await client(config).decisions.create({
637
658
  question: input.question,
638
659
  type: input.type,