deepline 0.2.39 → 0.2.41

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.
@@ -36,6 +36,7 @@
36
36
  import { resolveConfig } from './config.js';
37
37
  import { DeeplineError } from './errors.js';
38
38
  import { HttpClient } from './http.js';
39
+ import { PRODUCT_NOTIFICATION_SLACK_OAUTH_SCOPES } from '../../shared_libs/product-notifications/contract.js';
39
40
  import {
40
41
  STREAM_HEALTHY_CONNECTION_MS,
41
42
  isTransientPlayStreamError,
@@ -58,6 +59,7 @@ import type {
58
59
  PlayRunPackage,
59
60
  PlayLiveEvent,
60
61
  PlayListItem,
62
+ ProductNotificationSettings,
61
63
  PlayDescription,
62
64
  StopPlayRunResult,
63
65
  StopAllPlayRunsResult,
@@ -84,10 +86,7 @@ import type { PlayStagedFileRef } from './plays/local-file-discovery.js';
84
86
  import type { PlayCompilerManifest } from '../../shared_libs/plays/compiler-manifest.js';
85
87
  import type { EnrichCompiledConfig } from './cli/enrich-play-compiler.js';
86
88
  import { RUNTIME_ENVIRONMENT_TOKEN_HEADER } from '../../shared_libs/play-runtime/coordinator-headers.js';
87
- import {
88
- THEIRSTACK_SLOW_JOB_SEARCH_CLIENT_TIMEOUT_MS,
89
- usesExtendedTheirstackJobSearchBudget,
90
- } from '../../shared_libs/integrations/theirstack-execution-policy.js';
89
+ import { resolveTheirstackClientTimeoutMs } from '../../shared_libs/integrations/theirstack-execution-policy.js';
91
90
  import {
92
91
  normalizePlayRuntimeEnvironment,
93
92
  normalizePlayRuntimeNamespace,
@@ -422,9 +421,11 @@ function resolveToolExecuteTimeoutMs(
422
421
  // Provider-specific slow-request policies live in shared modules so the SDK
423
422
  // and server classify the same payloads. If more providers need this, replace
424
423
  // these individual checks with a shared policy registry, not a wider default.
425
- if (usesExtendedTheirstackJobSearchBudget(normalized, input)) {
426
- return THEIRSTACK_SLOW_JOB_SEARCH_CLIENT_TIMEOUT_MS;
427
- }
424
+ const theirstackTimeoutMs = resolveTheirstackClientTimeoutMs(
425
+ normalized,
426
+ input,
427
+ );
428
+ if (theirstackTimeoutMs !== null) return theirstackTimeoutMs;
428
429
  return normalized === 'deeplineagent' ||
429
430
  normalized === 'deeplineagent_deeplineagent' ||
430
431
  normalized === 'ai_inference' ||
@@ -3742,6 +3743,145 @@ export class DeeplineClient {
3742
3743
  return response.plays ?? [];
3743
3744
  }
3744
3745
 
3746
+ /** Read product-notification destinations, subscriptions, event catalog, and DLQ health. */
3747
+ async getNotificationSettings(): Promise<ProductNotificationSettings> {
3748
+ return this.http.get('/api/v2/settings/notifications');
3749
+ }
3750
+
3751
+ /** Start the Slack OAuth flow required by product notifications. */
3752
+ async connectNotificationSlack(options?: {
3753
+ successUrl?: string;
3754
+ failureUrl?: string;
3755
+ }): Promise<{ ok: boolean; redirect_url: string }> {
3756
+ return this.http.post('/api/v2/integrations/connect', {
3757
+ provider: 'slack',
3758
+ scopes: [...PRODUCT_NOTIFICATION_SLACK_OAUTH_SCOPES],
3759
+ ...(options?.successUrl ? { success_url: options.successUrl } : {}),
3760
+ ...(options?.failureUrl ? { failure_url: options.failureUrl } : {}),
3761
+ });
3762
+ }
3763
+
3764
+ /** List Slack channels visible to the connected Deepline Slack app. */
3765
+ async listNotificationSlackChannels(query?: string): Promise<{
3766
+ identity: { teamId: string; teamName?: string };
3767
+ channels: Array<{ id: string; name: string; isPrivate: boolean }>;
3768
+ }> {
3769
+ const suffix = query ? `?query=${encodeURIComponent(query)}` : '';
3770
+ return this.http.get(`/api/v2/settings/notifications/channels${suffix}`);
3771
+ }
3772
+
3773
+ /** Select the Slack channel used for product notifications. */
3774
+ async setNotificationSlack(channel: string) {
3775
+ return this.http.put('/api/v2/settings/notifications', { channel });
3776
+ }
3777
+
3778
+ /** Send one synchronous test ping and return Slack's delivery result. */
3779
+ async testNotificationSlack(): Promise<{
3780
+ ok: boolean;
3781
+ deliveryId: string;
3782
+ state: string;
3783
+ message: string;
3784
+ }> {
3785
+ return this.http.post('/api/v2/settings/notifications/test', {});
3786
+ }
3787
+
3788
+ /** Disable Slack product notifications without deleting the OAuth connection. */
3789
+ async disableNotificationSlack() {
3790
+ return this.http.delete('/api/v2/settings/notifications');
3791
+ }
3792
+
3793
+ /** Enable or disable event IDs from the server-provided notification catalog. */
3794
+ async setNotificationSubscriptions(eventTypes: string[], enabled: boolean) {
3795
+ return this.http.patch('/api/v2/settings/notifications/subscriptions', {
3796
+ eventTypes,
3797
+ enabled,
3798
+ });
3799
+ }
3800
+
3801
+ /** List exhausted deliveries. Dead-lettered messages never replay automatically. */
3802
+ async listNotificationDlq(limit = 25) {
3803
+ return this.http.get(
3804
+ `/api/v2/settings/notifications/dlq?limit=${encodeURIComponent(String(limit))}`,
3805
+ );
3806
+ }
3807
+
3808
+ /** Inspect one exhausted notification delivery. */
3809
+ async getNotificationDlqDelivery(deliveryId: string) {
3810
+ return this.http.get(
3811
+ `/api/v2/settings/notifications/dlq/${encodeURIComponent(deliveryId)}`,
3812
+ );
3813
+ }
3814
+
3815
+ /** Explicitly retry or archive one dead-lettered notification delivery. */
3816
+ async updateNotificationDlqDelivery(
3817
+ deliveryId: string,
3818
+ action: 'retry' | 'archive',
3819
+ ) {
3820
+ return this.http.post(
3821
+ `/api/v2/settings/notifications/dlq/${encodeURIComponent(deliveryId)}`,
3822
+ { action },
3823
+ );
3824
+ }
3825
+
3826
+ /** List the workspace's named notification rules. */
3827
+ async getNotifications(): Promise<ProductNotificationSettings> {
3828
+ return this.http.get('/api/v2/notifications');
3829
+ }
3830
+
3831
+ /** List Slack channels available to an already-connected Slack integration. */
3832
+ async listNotificationChannels(query?: string): Promise<{
3833
+ identity: { teamId: string; teamName?: string };
3834
+ channels: Array<{ id: string; name: string; isPrivate: boolean }>;
3835
+ }> {
3836
+ const suffix = query ? `?search=${encodeURIComponent(query)}` : '';
3837
+ return this.http.get(`/api/v2/notifications/slack/channels${suffix}`);
3838
+ }
3839
+
3840
+ /** Create a named notification routed through an existing provider integration. */
3841
+ async createNotification(input: {
3842
+ name: string;
3843
+ provider: 'slack';
3844
+ channel: string;
3845
+ eventTypes: string[];
3846
+ }) {
3847
+ return this.http.post('/api/v2/notifications', input);
3848
+ }
3849
+
3850
+ /** Update a notification's target, event selection, or enabled state. */
3851
+ async updateNotification(
3852
+ notificationId: string,
3853
+ input:
3854
+ | { enabled: boolean }
3855
+ | { name: string; channel: string; eventTypes: string[] },
3856
+ ) {
3857
+ return this.http.patch(
3858
+ `/api/v2/notifications/${encodeURIComponent(notificationId)}`,
3859
+ input,
3860
+ );
3861
+ }
3862
+
3863
+ /** Send a validation ping to one notification. */
3864
+ async testNotification(notificationId: string): Promise<{
3865
+ ok: boolean;
3866
+ deliveryId: string;
3867
+ state: string;
3868
+ message: string;
3869
+ }> {
3870
+ return this.http.post(
3871
+ `/api/v2/notifications/${encodeURIComponent(notificationId)}/test`,
3872
+ {},
3873
+ );
3874
+ }
3875
+
3876
+ /** Archive one notification without touching its provider integration. */
3877
+ async deleteNotification(
3878
+ notificationId: string,
3879
+ ): Promise<{ deleted: boolean; id: string }> {
3880
+ return this.http.delete(
3881
+ `/api/v2/notifications/${encodeURIComponent(notificationId)}`,
3882
+ );
3883
+ }
3884
+
3745
3885
  /**
3746
3886
  * Search callable plays and return compact play descriptions.
3747
3887
  *
@@ -720,6 +720,14 @@ export class HttpClient {
720
720
  });
721
721
  }
722
722
 
723
+ async put<T = unknown>(
724
+ path: string,
725
+ body?: unknown,
726
+ headers?: Record<string, string>,
727
+ ): Promise<T> {
728
+ return this.request<T>(path, { method: 'PUT', body, headers });
729
+ }
730
+
723
731
  /**
724
732
  * Send a DELETE request.
725
733
  *
@@ -160,7 +160,7 @@ export const SDK_RELEASE = {
160
160
  // 0.2.0 makes Dataset Handles uniformly async-only after 0.1.320 briefly
161
161
  // exposed storage-dependent synchronous access. This deliberate minor
162
162
  // release keeps lazy paging semantics independent of row residency.
163
- version: '0.2.39',
163
+ version: '0.2.41',
164
164
  contracts: {
165
165
  api: {
166
166
  name: 'sdk-http-api',
@@ -1084,6 +1084,48 @@ export interface PlayListItem {
1084
1084
  currentRevision?: PlayRevisionSummary | null;
1085
1085
  liveRevision?: PlayRevisionSummary | null;
1086
1086
  aliases?: string[];
1087
+ triggerStatus?: {
1088
+ cron: string | null;
1089
+ webhook: string | null;
1090
+ blockedReason: string | null;
1091
+ };
1092
+ }
1093
+
1094
+ export interface ProductNotificationEventDefinition {
1095
+ id: string;
1096
+ label: string;
1097
+ description: string;
1098
+ source: 'cron' | 'webhook';
1099
+ outcome: 'succeeded' | 'failed';
1100
+ defaultEnabled: boolean;
1101
+ }
1102
+
1103
+ export interface ProductNotificationSettings {
1104
+ contractVersion: number;
1105
+ catalog: ProductNotificationEventDefinition[];
1106
+ notifications?: ProductNotification[];
1107
+ providers?: Array<{ kind: string; targetLabel: string }>;
1108
+ destinations: Array<Record<string, unknown>>;
1109
+ subscriptions: Array<{
1110
+ eventType: string;
1111
+ enabled: boolean;
1112
+ destinationId: string;
1113
+ }>;
1114
+ dlq: { count: number; capped: boolean };
1115
+ slackConnection?: { connected: boolean; status: string };
1116
+ }
1117
+
1118
+ export interface ProductNotification {
1119
+ id: string;
1120
+ name: string;
1121
+ provider: string;
1122
+ target: { id: string; name: string };
1123
+ enabled: boolean;
1124
+ status: string;
1125
+ eventTypes: string[];
1126
+ lastTestedAt?: number;
1127
+ lastTestStatus?: string;
1128
+ lastErrorMessage?: string;
1087
1129
  }
1088
1130
 
1089
1131
  export interface PlayDescription {
@@ -23,6 +23,14 @@ export const THEIRSTACK_SLOW_JOB_SEARCH_PROVIDER_TIMEOUT_MS = 135_000;
23
23
  export const THEIRSTACK_SLOW_JOB_SEARCH_CLIENT_TIMEOUT_MS =
24
24
  60_000 + THEIRSTACK_SLOW_JOB_SEARCH_PROVIDER_TIMEOUT_MS + 15_000;
25
25
 
26
+ /**
27
+ * TheirStack company search is synchronous but can spend the full upstream
28
+ * request window before returning a large result set. The SDK default is only
29
+ * 60 seconds, so keep it alive through the server's 120-second provider
30
+ * deadline plus the same permit and response grace used for slow job search.
31
+ */
32
+ export const THEIRSTACK_COMPANY_SEARCH_CLIENT_TIMEOUT_MS = 210_000;
33
+
26
34
  const LONG_JOB_SEARCH_WINDOW_MS = 365 * 24 * 60 * 60 * 1_000;
27
35
  const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
28
36
 
@@ -95,3 +103,16 @@ export function usesExtendedTheirstackJobSearchBudget(
95
103
  ? true
96
104
  : usesLongExplicitDateWindow(payload);
97
105
  }
106
+
107
+ /** SDK-only timeout selection. Provider request timeouts remain action-local. */
108
+ export function resolveTheirstackClientTimeoutMs(
109
+ endpointId: string,
110
+ payload: Record<string, unknown>,
111
+ ): number | null {
112
+ if (endpointId === 'theirstack_company_search') {
113
+ return THEIRSTACK_COMPANY_SEARCH_CLIENT_TIMEOUT_MS;
114
+ }
115
+ return usesExtendedTheirstackJobSearchBudget(endpointId, payload)
116
+ ? THEIRSTACK_SLOW_JOB_SEARCH_CLIENT_TIMEOUT_MS
117
+ : null;
118
+ }
@@ -77,7 +77,16 @@ export function workReceiptClaimConflictPredicateSql(input: {
77
77
  const forceFailedRefreshSql = input.forceFailedRefreshSql ?? 'false';
78
78
  return `${table}.status = ANY(${input.claimableStatusesSql}::smallint[])
79
79
  AND (
80
- ${input.forceRefreshSql}::boolean
80
+ (
81
+ ${input.forceRefreshSql}::boolean
82
+ AND (
83
+ ${table}.status NOT IN (
84
+ ${RECEIPT_STATUS_CODE.completed}::smallint,
85
+ ${RECEIPT_STATUS_CODE.skipped}::smallint
86
+ )
87
+ OR ${table}.run_id IS DISTINCT FROM ${input.claimantRunIdSql}
88
+ )
89
+ )
81
90
  OR (
82
91
  ${forceFailedRefreshSql}::boolean
83
92
  AND ${table}.status = ${RECEIPT_STATUS_CODE.failed}::smallint
@@ -111,6 +120,7 @@ export function workReceiptClaimConflictPredicateSql(input: {
111
120
  ${RECEIPT_STATUS_CODE.completed}::smallint,
112
121
  ${RECEIPT_STATUS_CODE.skipped}::smallint
113
122
  )
123
+ AND ${table}.run_id IS DISTINCT FROM ${input.claimantRunIdSql}
114
124
  AND (
115
125
  (
116
126
  ${table}.lease_id IS NULL
@@ -4402,8 +4402,8 @@ export async function claimRuntimeWorkReceipt(
4402
4402
  }
4403
4403
  if (
4404
4404
  latest &&
4405
- input.forceRefresh !== true &&
4406
- isReusableWorkReceipt(latest)
4405
+ isReusableWorkReceipt(latest) &&
4406
+ (input.forceRefresh !== true || latest.runId === input.runId)
4407
4407
  ) {
4408
4408
  return { disposition: 'reused', receipt: latest };
4409
4409
  }
@@ -4697,7 +4697,10 @@ export async function claimRuntimeWorkReceipts(
4697
4697
  });
4698
4698
  continue;
4699
4699
  }
4700
- if (input.forceRefresh !== true && isReusableWorkReceipt(receipt)) {
4700
+ if (
4701
+ isReusableWorkReceipt(receipt) &&
4702
+ (input.forceRefresh !== true || receipt.runId === input.runId)
4703
+ ) {
4701
4704
  claimsByKey.set(receipt.key, {
4702
4705
  disposition: 'reused',
4703
4706
  receipt,
@@ -277,6 +277,22 @@ export function decideWorkReceiptClaim(input: {
277
277
  }): WorkReceiptClaimDecision {
278
278
  if (!input.receipt) return { kind: 'claim' };
279
279
  const state = classifyWorkReceiptState(input.receipt, input.nowMs);
280
+ const sameLogicalRun =
281
+ normalize(input.claimantRunId) !== null &&
282
+ normalize(input.claimantRunId) === normalize(input.receipt.runId);
283
+ const sameRunNewerLeaseAttempt =
284
+ state.reusable &&
285
+ state.lease.kind === 'active' &&
286
+ state.lease.ownerRunId === normalize(input.claimantRunId) &&
287
+ state.lease.ownerAttempt < normalizeAttempt(input.claimantRunAttempt);
288
+ // Force refresh means new intent across runs. Within one run it is a replay,
289
+ // so reuse terminal output instead of creating a second terminal payload
290
+ // under the run's existing Runtime Sheet write version. A newer lease
291
+ // generation is the exception: it is a new attempt and retains the existing
292
+ // stale-owner fence semantics.
293
+ if (state.reusable && sameLogicalRun && !sameRunNewerLeaseAttempt) {
294
+ return { kind: 'reuse_terminal' };
295
+ }
280
296
  if (state.reusable && input.forceRefresh !== true) {
281
297
  return { kind: 'reuse_terminal' };
282
298
  }
@@ -0,0 +1,134 @@
1
+ export const PRODUCT_NOTIFICATION_CONTRACT_VERSION = 1 as const;
2
+
3
+ export const PRODUCT_NOTIFICATION_EVENT_CATALOG = [
4
+ {
5
+ id: 'play.cron.succeeded',
6
+ label: 'Cron success',
7
+ description: 'A scheduled Play run completed successfully.',
8
+ source: 'cron',
9
+ outcome: 'succeeded',
10
+ defaultEnabled: true,
11
+ },
12
+ {
13
+ id: 'play.cron.failed',
14
+ label: 'Cron failure',
15
+ description:
16
+ 'A scheduled Play could not start or its run reached a failed terminal state.',
17
+ source: 'cron',
18
+ outcome: 'failed',
19
+ defaultEnabled: true,
20
+ },
21
+ {
22
+ id: 'play.webhook.succeeded',
23
+ label: 'Webhook success',
24
+ description: 'An accepted webhook-triggered Play completed successfully.',
25
+ source: 'webhook',
26
+ outcome: 'succeeded',
27
+ defaultEnabled: true,
28
+ },
29
+ {
30
+ id: 'play.webhook.failed',
31
+ label: 'Webhook failure',
32
+ description:
33
+ 'An accepted webhook-triggered Play reached a failed terminal state.',
34
+ source: 'webhook',
35
+ outcome: 'failed',
36
+ defaultEnabled: true,
37
+ },
38
+ ] as const;
39
+
40
+ export type ProductNotificationEventDefinition =
41
+ (typeof PRODUCT_NOTIFICATION_EVENT_CATALOG)[number];
42
+ export type ProductNotificationEventType =
43
+ ProductNotificationEventDefinition['id'];
44
+ export type ProductNotificationSource =
45
+ ProductNotificationEventDefinition['source'];
46
+ export type ProductNotificationOutcome =
47
+ ProductNotificationEventDefinition['outcome'];
48
+
49
+ const PRODUCT_NOTIFICATION_EVENT_TYPE_SET = new Set<string>(
50
+ PRODUCT_NOTIFICATION_EVENT_CATALOG.map((event) => event.id),
51
+ );
52
+
53
+ export function isProductNotificationEventType(
54
+ value: unknown,
55
+ ): value is ProductNotificationEventType {
56
+ return (
57
+ typeof value === 'string' && PRODUCT_NOTIFICATION_EVENT_TYPE_SET.has(value)
58
+ );
59
+ }
60
+
61
+ export function getProductNotificationEventDefinition(
62
+ eventType: string,
63
+ ): ProductNotificationEventDefinition | null {
64
+ return (
65
+ PRODUCT_NOTIFICATION_EVENT_CATALOG.find(
66
+ (definition) => definition.id === eventType,
67
+ ) ?? null
68
+ );
69
+ }
70
+
71
+ export function defaultProductNotificationEventTypes(): ProductNotificationEventType[] {
72
+ return PRODUCT_NOTIFICATION_EVENT_CATALOG.filter(
73
+ (definition) => definition.defaultEnabled,
74
+ ).map((definition) => definition.id);
75
+ }
76
+
77
+ export const PRODUCT_NOTIFICATION_DESTINATION_KINDS = ['slack'] as const;
78
+ export const PRODUCT_NOTIFICATION_SLACK_OAUTH_SCOPES = [
79
+ 'channels:read',
80
+ 'chat:write',
81
+ 'groups:read',
82
+ ] as const;
83
+ export type ProductNotificationDestinationKind =
84
+ (typeof PRODUCT_NOTIFICATION_DESTINATION_KINDS)[number];
85
+
86
+ export const PRODUCT_NOTIFICATION_DELIVERY_STATES = [
87
+ 'pending',
88
+ 'delivering',
89
+ 'delivered',
90
+ 'retry_scheduled',
91
+ 'dead_lettered',
92
+ 'expired',
93
+ 'suppressed',
94
+ ] as const;
95
+ export type ProductNotificationDeliveryState =
96
+ (typeof PRODUCT_NOTIFICATION_DELIVERY_STATES)[number];
97
+
98
+ /** Immediate send plus four bounded retries. */
99
+ export const PRODUCT_NOTIFICATION_RETRY_DELAYS_MS = [
100
+ 0,
101
+ 60_000,
102
+ 5 * 60_000,
103
+ 15 * 60_000,
104
+ 60 * 60_000,
105
+ ] as const;
106
+ export const PRODUCT_NOTIFICATION_MAX_ATTEMPTS =
107
+ PRODUCT_NOTIFICATION_RETRY_DELAYS_MS.length;
108
+ /** A claimed delivery must finish within this window or a recovery attempt may claim it. */
109
+ export const PRODUCT_NOTIFICATION_DELIVERY_LEASE_MS = 2 * 60_000;
110
+
111
+ export const PRODUCT_NOTIFICATION_PENDING_LIMIT_PER_DESTINATION = 100;
112
+ export const PRODUCT_NOTIFICATION_DLQ_REPLAY_LIMIT = 25;
113
+ export const PRODUCT_NOTIFICATION_SUCCESS_TTL_MS = 15 * 60_000;
114
+ export const PRODUCT_NOTIFICATION_FAILURE_TTL_MS = 24 * 60 * 60_000;
115
+
116
+ export function productNotificationEventTypeFor(input: {
117
+ source: ProductNotificationSource;
118
+ outcome: ProductNotificationOutcome;
119
+ }): ProductNotificationEventType {
120
+ return `play.${input.source}.${input.outcome}` as ProductNotificationEventType;
121
+ }
122
+
123
+ export function productNotificationEventExpiresAt(input: {
124
+ eventType: ProductNotificationEventType;
125
+ occurredAt: number;
126
+ }): number {
127
+ const definition = getProductNotificationEventDefinition(input.eventType);
128
+ return (
129
+ input.occurredAt +
130
+ (definition?.outcome === 'succeeded'
131
+ ? PRODUCT_NOTIFICATION_SUCCESS_TTL_MS
132
+ : PRODUCT_NOTIFICATION_FAILURE_TTL_MS)
133
+ );
134
+ }