deepline 0.3.148 → 0.3.150

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.
@@ -561,6 +561,24 @@
561
561
  "failureAlert": "vercel_route_wrapper",
562
562
  "domainReportRequired": true
563
563
  },
564
+ {
565
+ "id": "vercel.public-tool-execution-blob-cleanup",
566
+ "name": "Public tool execution blob cleanup",
567
+ "scheduler": "vercel_cron",
568
+ "cadence": "*/15 * * * *",
569
+ "timezone": "UTC",
570
+ "source": "apps/deepline-api/vercel.json",
571
+ "locator": "/api/v2/cron/public-tool-execution-blob-cleanup",
572
+ "owner": "integrations",
573
+ "criticality": "maintenance",
574
+ "purpose": "Delete expired public execution response and launch blobs while retaining idempotency key and fingerprint tombstones.",
575
+ "successDefinition": "Every claimed expired receipt's recorded blobs are confirmed absent before their references are cleared; no eligible receipts is a valid no-op.",
576
+ "failureDefinition": "An R2 deletion/absence check fails or the fenced tombstone cleanup acknowledgement cannot complete.",
577
+ "startGraceMs": 300000,
578
+ "runTimeoutMs": 1800000,
579
+ "failureAlert": "vercel_route_wrapper",
580
+ "domainReportRequired": true
581
+ },
564
582
  {
565
583
  "id": "vercel.scheduled-work-health-snapshot",
566
584
  "name": "Scheduled-work health snapshot",
@@ -144,6 +144,39 @@ export async function r2ObjectExists(
144
144
  }
145
145
  }
146
146
 
147
+ /**
148
+ * Confirm an R2 object is absent without confusing a HEAD/auth/network failure
149
+ * with a successful deletion.
150
+ */
151
+ export async function confirmR2ObjectAbsent(
152
+ key: string,
153
+ kind: R2BucketKind = 'default',
154
+ ): Promise<boolean> {
155
+ const config = getR2Config(kind);
156
+ const client = getR2Client(config);
157
+ try {
158
+ await client.send(
159
+ new HeadObjectCommand({ Bucket: config.bucket, Key: key }),
160
+ );
161
+ return false;
162
+ } catch (error) {
163
+ const details = error as {
164
+ name?: string;
165
+ Code?: string;
166
+ $metadata?: { httpStatusCode?: number };
167
+ };
168
+ if (
169
+ details.$metadata?.httpStatusCode === 404 ||
170
+ details.name === 'NotFound' ||
171
+ details.name === 'NoSuchKey' ||
172
+ details.Code === 'NotFound' ||
173
+ details.Code === 'NoSuchKey'
174
+ )
175
+ return true;
176
+ throw error;
177
+ }
178
+ }
179
+
147
180
  export async function uploadBufferToR2(input: {
148
181
  key: string;
149
182
  body: Buffer | Uint8Array;
@@ -757,6 +757,9 @@ function isRetryableAppRuntimeResponse(input: {
757
757
  // than reducing every 4xx to permanent at the worker boundary.
758
758
  const explicitRetryable = appRuntimeExplicitRetryable(input.body);
759
759
  if (explicitRetryable !== null) return explicitRetryable;
760
+ if (isComputeBillingTooManyWritesResponse(input)) {
761
+ return true;
762
+ }
760
763
  if (
761
764
  input.action === 'append_run_events' &&
762
765
  input.status === 500 &&
@@ -809,6 +812,43 @@ function appRuntimeExplicitRetryable(body: string): boolean | null {
809
812
  }
810
813
  }
811
814
 
815
+ function isComputeBillingTooManyWritesResponse(input: {
816
+ action: RuntimeApiRequest['action'];
817
+ status: number;
818
+ body: string;
819
+ }): boolean {
820
+ if (
821
+ input.status !== 500 ||
822
+ (input.action !== 'compute_billing_upsert' &&
823
+ input.action !== 'compute_billing_record_item' &&
824
+ input.action !== 'compute_billing_finalize')
825
+ ) {
826
+ return false;
827
+ }
828
+ // The runtime route's unexpected-error envelope exposes the Convex error
829
+ // name and redacted message but has no retryable flag. A TooManyWrites
830
+ // conflict is transient; treating this 500 as permanent blocks a durable
831
+ // billing job. Only the replay-safe compute actions accept this signature.
832
+ try {
833
+ const parsed = JSON.parse(input.body) as unknown;
834
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
835
+ return false;
836
+ }
837
+ const error = parsed as Record<string, unknown>;
838
+ if (error.action != null && error.action !== input.action) {
839
+ return false;
840
+ }
841
+ return (
842
+ error.errorName === 'TooManyWrites' ||
843
+ error.code === 'TooManyWrites' ||
844
+ (typeof error.debug_error === 'string' &&
845
+ /\bTooManyWrites\b/.test(error.debug_error))
846
+ );
847
+ } catch {
848
+ return false;
849
+ }
850
+ }
851
+
812
852
  function isRetryableAppRuntimeResponseStatus(status: number): boolean {
813
853
  return (
814
854
  status === 429 ||
@@ -5,6 +5,7 @@ import {
5
5
  lowerBound,
6
6
  notApplicable,
7
7
  unavailable,
8
+ runtimeQueueItemRevision,
8
9
  type QueueDefinition,
9
10
  type QueueHealth,
10
11
  type QueueObservation,
@@ -236,7 +237,42 @@ function operatorRetirementCandidates(
236
237
  itemId = candidate.itemId;
237
238
  }
238
239
  const ageMs = numberValue(candidate.ageMs as string | number | undefined);
239
- if (itemId !== undefined) candidates.push({ itemId, bucket, ageMs });
240
+ const revisionFields = candidate.revisionFields;
241
+ let sourceRevision: string | undefined;
242
+ if (
243
+ typeof revisionFields === 'object' &&
244
+ revisionFields !== null &&
245
+ !Array.isArray(revisionFields)
246
+ ) {
247
+ const fields = revisionFields as Record<string, unknown>;
248
+ const sourceDate = fields.updatedAt ?? fields.createdAt;
249
+ const requiresState = kind === 'absurd';
250
+ const requiresStatus =
251
+ kind === 'compute_billing' || kind === 'sandbox_cleanup';
252
+ if (
253
+ (typeof sourceDate === 'string' || typeof sourceDate === 'number') &&
254
+ Number.isFinite(new Date(sourceDate).getTime()) &&
255
+ (!requiresState || typeof fields.state === 'string') &&
256
+ (!requiresStatus || typeof fields.status === 'string')
257
+ ) {
258
+ sourceRevision = runtimeQueueItemRevision({
259
+ updatedAt: fields.updatedAt,
260
+ createdAt: fields.createdAt,
261
+ state: fields.state,
262
+ status: fields.status,
263
+ leaseExpiresAt: fields.leaseExpiresAt,
264
+ failedAt: fields.failedAt,
265
+ });
266
+ }
267
+ }
268
+ if (itemId !== undefined) {
269
+ candidates.push({
270
+ itemId,
271
+ bucket,
272
+ ageMs,
273
+ ...(sourceRevision ? { sourceRevision } : {}),
274
+ });
275
+ }
240
276
  }
241
277
  };
242
278
  append(row.blocked_retirement_candidates, 'blocked');
@@ -517,45 +553,53 @@ function absurdProjectionQuery(
517
553
  candidateSampleLimit: number,
518
554
  ): string {
519
555
  const table = `absurd.${quoteIdentifier(queueTable, 'queue table')}`;
556
+ const revisionFields = includeRetirementCandidates
557
+ ? `json_build_object(
558
+ 'createdAt', created_at,
559
+ 'state', state,
560
+ 'leaseExpiresAt', claim_expires_at,
561
+ 'failedAt', failed_at
562
+ )`
563
+ : 'NULL::json';
520
564
  const retirementColumns = includeRetirementCandidates
521
565
  ? `,
522
566
  COALESCE((
523
- SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}))
524
- FROM (SELECT item_id, age_at FROM sampled WHERE bucket = 'blocked' ORDER BY age_at ASC, item_id ASC LIMIT $4::int) candidates
567
+ SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}, 'revisionFields', revision_fields))
568
+ FROM (SELECT item_id, age_at, revision_fields FROM sampled WHERE bucket = 'blocked' ORDER BY age_at ASC, item_id ASC LIMIT $4::int) candidates
525
569
  ), '[]'::json) AS blocked_retirement_candidates,
526
570
  COALESCE((
527
- SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}))
528
- FROM (SELECT item_id, age_at FROM sampled WHERE bucket = 'dead_lettered' ORDER BY age_at ASC, item_id ASC LIMIT $4::int) candidates
571
+ SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}, 'revisionFields', revision_fields))
572
+ FROM (SELECT item_id, age_at, revision_fields FROM sampled WHERE bucket = 'dead_lettered' ORDER BY age_at ASC, item_id ASC LIMIT $4::int) candidates
529
573
  ), '[]'::json) AS dead_lettered_retirement_candidates`
530
574
  : '';
531
575
  return `
532
576
  WITH sampled AS (
533
- (SELECT 'ready'::text AS bucket, run_id::text AS item_id, available_at AS age_at
577
+ (SELECT 'ready'::text AS bucket, run_id::text AS item_id, available_at AS age_at, NULL::json AS revision_fields
534
578
  FROM ${table}
535
579
  WHERE state = 'pending' AND available_at <= $1::timestamptz
536
580
  ORDER BY available_at, run_id
537
581
  LIMIT $2::int)
538
582
  UNION ALL
539
- (SELECT 'delayed'::text, run_id::text, available_at
583
+ (SELECT 'delayed'::text, run_id::text, available_at, NULL::json
540
584
  FROM ${table}
541
585
  WHERE state = 'sleeping'
542
586
  ORDER BY available_at, run_id
543
587
  LIMIT $2::int)
544
588
  UNION ALL
545
- (SELECT 'delayed'::text, run_id::text, available_at
589
+ (SELECT 'delayed'::text, run_id::text, available_at, NULL::json
546
590
  FROM ${table}
547
591
  WHERE state = 'pending' AND available_at > $1::timestamptz
548
592
  ORDER BY available_at, run_id
549
593
  LIMIT $2::int)
550
594
  UNION ALL
551
- (SELECT 'in_flight'::text, run_id::text, coalesce(started_at, created_at)
595
+ (SELECT 'in_flight'::text, run_id::text, coalesce(started_at, created_at), NULL::json
552
596
  FROM ${table}
553
597
  WHERE state = 'running'
554
598
  AND (claim_expires_at IS NULL OR claim_expires_at > $1::timestamptz)
555
599
  ORDER BY available_at, run_id
556
600
  LIMIT $2::int)
557
601
  UNION ALL
558
- (SELECT 'blocked'::text, run_id::text, coalesce(claim_expires_at, started_at, created_at)
602
+ (SELECT 'blocked'::text, run_id::text, coalesce(claim_expires_at, started_at, created_at), ${revisionFields}
559
603
  FROM ${table}
560
604
  WHERE state = 'running'
561
605
  AND claim_expires_at IS NOT NULL
@@ -563,7 +607,7 @@ function absurdProjectionQuery(
563
607
  ORDER BY claim_expires_at, run_id
564
608
  LIMIT $2::int)
565
609
  UNION ALL
566
- (SELECT 'dead_lettered'::text, run_id::text, coalesce(failed_at, created_at)
610
+ (SELECT 'dead_lettered'::text, run_id::text, coalesce(failed_at, created_at), ${revisionFields}
567
611
  FROM ${table}
568
612
  WHERE state = 'failed'
569
613
  ORDER BY available_at, run_id
@@ -602,15 +646,22 @@ function outboxProjectionQuery(
602
646
  includeRetirementCandidates: boolean,
603
647
  ): string {
604
648
  const table = `${schema}."outbox"`;
649
+ const revisionFields = includeRetirementCandidates
650
+ ? `json_build_object(
651
+ 'createdAt', created_at,
652
+ 'leaseExpiresAt', lease_expires_at,
653
+ 'failedAt', failed_at
654
+ )`
655
+ : 'NULL::json';
605
656
  const retirementColumns = includeRetirementCandidates
606
657
  ? `,
607
658
  COALESCE((
608
- SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}))
609
- FROM (SELECT item_id, age_at FROM blocked ORDER BY age_at ASC, item_id ASC LIMIT $3::int) candidates
659
+ SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}, 'revisionFields', revision_fields))
660
+ FROM (SELECT item_id, age_at, revision_fields FROM blocked ORDER BY age_at ASC, item_id ASC LIMIT $3::int) candidates
610
661
  ), '[]'::json) AS blocked_retirement_candidates,
611
662
  COALESCE((
612
- SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}))
613
- FROM (SELECT item_id, age_at FROM dead_lettered ORDER BY age_at ASC, item_id ASC LIMIT $3::int) candidates
663
+ SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}, 'revisionFields', revision_fields))
664
+ FROM (SELECT item_id, age_at, revision_fields FROM dead_lettered ORDER BY age_at ASC, item_id ASC LIMIT $3::int) candidates
614
665
  ), '[]'::json) AS dead_lettered_retirement_candidates`
615
666
  : '';
616
667
  return `
@@ -642,7 +693,8 @@ function outboxProjectionQuery(
642
693
  ORDER BY lease_expires_at, created_at, event_id
643
694
  LIMIT $2::int
644
695
  ), blocked AS (
645
- SELECT event_id::text AS item_id, coalesce(lease_expires_at, created_at) AS age_at
696
+ SELECT event_id::text AS item_id, coalesce(lease_expires_at, created_at) AS age_at,
697
+ ${revisionFields} AS revision_fields
646
698
  FROM ${table}
647
699
  WHERE delivered_at IS NULL
648
700
  AND failed_at IS NULL
@@ -651,7 +703,8 @@ function outboxProjectionQuery(
651
703
  ORDER BY coalesce(lease_expires_at, created_at), created_at, event_id
652
704
  LIMIT $2::int
653
705
  ), dead_lettered AS (
654
- SELECT event_id::text AS item_id, coalesce(failed_at, created_at) AS age_at
706
+ SELECT event_id::text AS item_id, coalesce(failed_at, created_at) AS age_at,
707
+ ${revisionFields} AS revision_fields
655
708
  FROM ${table}
656
709
  WHERE delivered_at IS NULL AND failed_at IS NOT NULL
657
710
  ORDER BY failed_at, event_id
@@ -689,11 +742,19 @@ function cleanupProjectionQuery(
689
742
  includeRetirementCandidates: boolean,
690
743
  ): string {
691
744
  const table = `${schema}."sandbox_cleanup_jobs_v2"`;
745
+ const revisionFields = includeRetirementCandidates
746
+ ? `json_build_object(
747
+ 'updatedAt', updated_at,
748
+ 'createdAt', created_at,
749
+ 'status', status,
750
+ 'leaseExpiresAt', lease_expires_at
751
+ )`
752
+ : 'NULL::json';
692
753
  const retirementColumns = includeRetirementCandidates
693
754
  ? `,
694
755
  COALESCE((
695
- SELECT json_agg(json_build_object('provider', provider, 'sandboxId', sandbox_id, 'routingDomainKey', routing_domain_key, 'ageMs', ${intervalExpr('age_at')}))
696
- FROM (SELECT provider, sandbox_id, routing_domain_key, created_at, age_at FROM blocked ORDER BY age_at ASC, created_at ASC, provider ASC, sandbox_id ASC LIMIT $3::int) candidates
756
+ SELECT json_agg(json_build_object('provider', provider, 'sandboxId', sandbox_id, 'routingDomainKey', routing_domain_key, 'ageMs', ${intervalExpr('age_at')}, 'revisionFields', revision_fields))
757
+ FROM (SELECT provider, sandbox_id, routing_domain_key, created_at, age_at, revision_fields FROM blocked ORDER BY age_at ASC, created_at ASC, provider ASC, sandbox_id ASC LIMIT $3::int) candidates
697
758
  ), '[]'::json) AS blocked_retirement_candidates,
698
759
  '[]'::json AS dead_lettered_retirement_candidates`
699
760
  : '';
@@ -736,7 +797,8 @@ function cleanupProjectionQuery(
736
797
  CASE
737
798
  WHEN blocked_at IS NOT NULL THEN blocked_at
738
799
  ELSE coalesce(lease_expires_at, created_at)
739
- END AS age_at
800
+ END AS age_at,
801
+ ${revisionFields} AS revision_fields
740
802
  FROM ${table}
741
803
  WHERE status <> 'deleted'
742
804
  AND (
@@ -779,11 +841,19 @@ function computeBillingProjectionQuery(
779
841
  includeRetirementCandidates: boolean,
780
842
  ): string {
781
843
  const table = `${schema}."compute_billing_jobs_v2"`;
844
+ const revisionFields = includeRetirementCandidates
845
+ ? `json_build_object(
846
+ 'updatedAt', updated_at,
847
+ 'createdAt', created_at,
848
+ 'status', status,
849
+ 'leaseExpiresAt', lease_expires_at
850
+ )`
851
+ : 'NULL::json';
782
852
  const retirementColumns = includeRetirementCandidates
783
853
  ? `,
784
854
  COALESCE((
785
- SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}))
786
- FROM (SELECT item_id, age_at FROM blocked ORDER BY age_at ASC, item_id ASC LIMIT $3::int) candidates
855
+ SELECT json_agg(json_build_object('itemId', item_id, 'ageMs', ${intervalExpr('age_at')}, 'revisionFields', revision_fields))
856
+ FROM (SELECT item_id, age_at, revision_fields FROM blocked ORDER BY age_at ASC, item_id ASC LIMIT $3::int) candidates
787
857
  ), '[]'::json) AS blocked_retirement_candidates,
788
858
  '[]'::json AS dead_lettered_retirement_candidates`
789
859
  : '';
@@ -816,7 +886,8 @@ function computeBillingProjectionQuery(
816
886
  ORDER BY lease_expires_at, created_at, run_id
817
887
  LIMIT $2::int
818
888
  ), blocked AS (
819
- SELECT run_id::text AS item_id, coalesce(blocked_at, lease_expires_at, created_at) AS age_at
889
+ SELECT run_id::text AS item_id, coalesce(blocked_at, lease_expires_at, created_at) AS age_at,
890
+ ${revisionFields} AS revision_fields
820
891
  FROM ${table}
821
892
  WHERE status <> 'settled'
822
893
  AND (