@purposeinplay/payload-ai-translate 0.1.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.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
@@ -0,0 +1,87 @@
1
+ import type { Payload, TaskConfig } from 'payload';
2
+ import type { BulkTranslateConfig } from '../types.js';
3
+ export declare const BULK_TRANSLATE_JANITOR_SLUG = "bulk-translate-janitor";
4
+ /**
5
+ * Janitor task — sweeps stale `running` units back into `pending` so
6
+ * the cron picks them up again. Cron-driven (recommended cadence
7
+ * `*\/5 * * * *`), but the task is also idempotent for ad-hoc admin
8
+ * triggering.
9
+ *
10
+ * Threshold: `max(2 × p99-of-recent-LLM-latency, 10 min)` per Decision
11
+ * #27 + F-DA-JANITOR-P99. P99 is computed from the
12
+ * `translation-usage.durationMs` column over the last 1000 rows.
13
+ * Self-tuning — if real translations start taking 4 minutes the
14
+ * threshold drifts up automatically.
15
+ *
16
+ * The P99 read is cached for 5 minutes per process. Janitor's
17
+ * threshold can lag actual P99 by up to 5 min; acceptable for a
18
+ * stale-row reset task.
19
+ */
20
+ export interface BulkJanitorOptions {
21
+ /** Slug override for the units collection. */
22
+ unitsCollectionSlug?: string;
23
+ /** Slug override for the batches collection. */
24
+ batchesCollectionSlug?: string;
25
+ /** Slug override for the usage collection used to compute P99. */
26
+ usageCollectionSlug?: string;
27
+ /** Batch lifecycle callbacks, forwarded to `maybeTransitionBatch`. */
28
+ callbacks?: Pick<BulkTranslateConfig, 'onBatchComplete' | 'onBatchFailed'>;
29
+ /**
30
+ * Floor for the staleness threshold. Even if recent P99 is fast,
31
+ * never reset rows newer than this. Default 10 * 60_000 (10 min).
32
+ */
33
+ minThresholdMs?: number;
34
+ /**
35
+ * Multiplier applied to recent P99 to compute the threshold.
36
+ * Default 2 — gives slow translations a full 2× headroom over
37
+ * historical P99 before the janitor steps in.
38
+ */
39
+ p99Multiplier?: number;
40
+ /**
41
+ * Maximum attempts before a stale `running` row is marked
42
+ * permanently failed (rather than re-queued). Default 3.
43
+ */
44
+ maxAttempts?: number;
45
+ /**
46
+ * P99 cache TTL. Default 5 minutes. Per-process cache; multi-process
47
+ * deployments compute independently which is fine — the threshold
48
+ * change is gradual.
49
+ */
50
+ p99CacheTtlMs?: number;
51
+ }
52
+ type JanitorTaskInput = Record<string, never>;
53
+ export declare function buildBulkTranslateJanitor(options?: BulkJanitorOptions): TaskConfig<{
54
+ input: JanitorTaskInput;
55
+ output: {
56
+ ok: true;
57
+ reset: number;
58
+ failed: number;
59
+ requeued: number;
60
+ thresholdMs: number;
61
+ };
62
+ }>;
63
+ export type JanitorSweepParams = {
64
+ payload: Payload;
65
+ unitsSlug: string;
66
+ usageSlug: string;
67
+ /** Slug of the batches collection — used to unstick finished batches. */
68
+ batchesSlug?: string;
69
+ minThresholdMs: number;
70
+ p99Multiplier: number;
71
+ maxAttempts: number;
72
+ p99CacheTtlMs: number;
73
+ /** Batch lifecycle callbacks, forwarded to `maybeTransitionBatch`. */
74
+ callbacks?: Pick<BulkTranslateConfig, 'onBatchComplete' | 'onBatchFailed'>;
75
+ /** Test override for the current time. */
76
+ now?: () => number;
77
+ };
78
+ export type JanitorSweepResult = {
79
+ reset: number;
80
+ failed: number;
81
+ requeued: number;
82
+ thresholdMs: number;
83
+ };
84
+ /** Reset for tests. */
85
+ export declare function resetJanitorP99Cache(payload?: Payload): void;
86
+ export declare function runJanitorSweep(params: JanitorSweepParams): Promise<JanitorSweepResult>;
87
+ export {};
@@ -0,0 +1,311 @@
1
+ import { DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from '../bulk-translate-batches-collection.js';
2
+ import { DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from '../bulk-translate-units-collection.js';
3
+ import { DEFAULT_USAGE_COLLECTION_SLUG } from '../defaults.js';
4
+ import { BULK_TRANSLATE_DOC_TASK_SLUG, maybeTransitionBatch } from './bulk-translate-doc-task.js';
5
+ export const BULK_TRANSLATE_JANITOR_SLUG = 'bulk-translate-janitor';
6
+ // ---------------------------------------------------------------------------
7
+ // Task config
8
+ // ---------------------------------------------------------------------------
9
+ const taskInputSchema = [];
10
+ export function buildBulkTranslateJanitor(options = {}) {
11
+ const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
12
+ const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
13
+ const usageSlug = options.usageCollectionSlug ?? DEFAULT_USAGE_COLLECTION_SLUG;
14
+ const minThresholdMs = options.minThresholdMs ?? 10 * 60_000;
15
+ const p99Multiplier = options.p99Multiplier ?? 2;
16
+ const maxAttempts = options.maxAttempts ?? 3;
17
+ const p99CacheTtlMs = options.p99CacheTtlMs ?? 5 * 60_000;
18
+ return {
19
+ slug: BULK_TRANSLATE_JANITOR_SLUG,
20
+ label: 'Bulk translate — janitor',
21
+ inputSchema: taskInputSchema,
22
+ retries: 0,
23
+ handler: async ({ req })=>{
24
+ const result = await runJanitorSweep({
25
+ payload: req.payload,
26
+ unitsSlug,
27
+ batchesSlug,
28
+ usageSlug,
29
+ minThresholdMs,
30
+ p99Multiplier,
31
+ maxAttempts,
32
+ p99CacheTtlMs,
33
+ callbacks: options.callbacks
34
+ });
35
+ return {
36
+ output: {
37
+ ok: true,
38
+ ...result
39
+ }
40
+ };
41
+ }
42
+ };
43
+ }
44
+ /**
45
+ * Module-level P99 cache. WeakMap keyed by the Payload instance so a
46
+ * test that creates two payload mocks doesn't see cache pollution.
47
+ */ const P99_CACHE = new WeakMap();
48
+ /** Reset for tests. */ export function resetJanitorP99Cache(payload) {
49
+ if (payload) {
50
+ P99_CACHE.delete(payload);
51
+ return;
52
+ }
53
+ // WeakMap has no .clear() — tests can pass a payload reference to
54
+ // evict its entry. Skipping payload deletes nothing; callers wanting
55
+ // a fresh cache for a brand-new mock just rebuild the mock.
56
+ }
57
+ export async function runJanitorSweep(params) {
58
+ const { payload, unitsSlug, usageSlug, minThresholdMs, p99Multiplier, maxAttempts, p99CacheTtlMs } = params;
59
+ const now = params.now ?? (()=>Date.now());
60
+ const p99Ms = await readP99Cached(payload, usageSlug, now, p99CacheTtlMs);
61
+ const thresholdMs = Math.max(minThresholdMs, p99Multiplier * p99Ms);
62
+ const cutoffIso = new Date(now() - thresholdMs).toISOString();
63
+ const result = await payload.find({
64
+ collection: unitsSlug,
65
+ where: {
66
+ and: [
67
+ {
68
+ status: {
69
+ equals: 'running'
70
+ }
71
+ },
72
+ {
73
+ startedAt: {
74
+ less_than: cutoffIso
75
+ }
76
+ }
77
+ ]
78
+ },
79
+ // Bound the per-tick work. 200 stale rows per sweep is plenty —
80
+ // the next cron tick picks up anything left.
81
+ limit: 200,
82
+ depth: 0,
83
+ overrideAccess: true
84
+ });
85
+ const stale = result.docs;
86
+ const batchesSlug = params.batchesSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
87
+ const affectedBatchIds = new Set();
88
+ const noteBatch = (u)=>{
89
+ const raw = typeof u.batchId === 'object' && u.batchId !== null ? u.batchId.id : u.batchId;
90
+ if (raw !== undefined && raw !== null && String(raw).length > 0) {
91
+ affectedBatchIds.add(String(raw));
92
+ }
93
+ };
94
+ // A unit's worker job is queued exactly once (at enumeration, or by the
95
+ // worker's own deferral re-queue). The worker task has `retries: 0`, and
96
+ // the coordinator never re-runs after enumeration finishes — so a reset
97
+ // WITHOUT a replacement job leaves the unit `pending` forever and its
98
+ // batch stuck in `running`/`cancelling` for eternity. Every reset must
99
+ // therefore queue a fresh job. Cancelled batches are safe to re-queue
100
+ // into: the worker's cancel gate marks their units `skipped` on entry.
101
+ const requeueUnit = async (unitId)=>{
102
+ await payload.jobs.queue({
103
+ task: BULK_TRANSLATE_DOC_TASK_SLUG,
104
+ input: {
105
+ unitId: String(unitId)
106
+ }
107
+ });
108
+ };
109
+ let reset = 0;
110
+ let failed = 0;
111
+ for (const u of stale){
112
+ const attempts = u.attempts ?? 0;
113
+ try {
114
+ if (attempts < maxAttempts) {
115
+ await payload.update({
116
+ collection: unitsSlug,
117
+ id: u.id,
118
+ data: {
119
+ status: 'pending',
120
+ failureCode: 'transient.crashed',
121
+ failureMessage: `janitor reset: running > ${Math.round(thresholdMs / 1000)}s`,
122
+ // Cleared so the orphan-recovery pass below can tell "fresh
123
+ // pending awaiting its queued job" (startedAt null) apart from
124
+ // "previously claimed, job chain dead" (startedAt set).
125
+ startedAt: null
126
+ },
127
+ overrideAccess: true
128
+ });
129
+ await requeueUnit(u.id);
130
+ reset += 1;
131
+ } else {
132
+ await payload.update({
133
+ collection: unitsSlug,
134
+ id: u.id,
135
+ data: {
136
+ status: 'failed',
137
+ failureCode: 'transient.crashed',
138
+ failureMessage: `janitor reset: exceeded ${maxAttempts} attempts`,
139
+ completedAt: new Date(now()).toISOString()
140
+ },
141
+ overrideAccess: true
142
+ });
143
+ failed += 1;
144
+ }
145
+ noteBatch(u);
146
+ } catch (err) {
147
+ payload.logger?.warn?.(`[ai-translate] janitor: failed to update unit ${u.id}: ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ }
150
+ // ----- Orphan-recovery pass -----------------------------------------
151
+ // Units whose job chain is dead and that nothing will ever pick up:
152
+ // (a) `pending` with a stale `startedAt` (janitor-reset rows that
153
+ // kept their last claim's timestamp), and
154
+ // (b) `pending` with NO `startedAt` but a stale `updatedAt` — a
155
+ // previous recovery cleared the marker and queued a job that
156
+ // died before claiming (or the queue write itself failed). The
157
+ // original one-shot predicate matched only (a), so a single
158
+ // failed recovery made the unit permanently invisible
159
+ // (observed: 4 units stuck `pending` >25 min on blog-wild prod,
160
+ // 2026-06-11). `updatedAt` refreshes on every touch, so retries
161
+ // self-pace at one attempt per threshold window.
162
+ // Re-queue FIRST, then clear the marker — if the queue write throws,
163
+ // the unit stays matchable by branch (a) instead of burning its
164
+ // marker. Duplicate jobs are harmless either way — the worker's
165
+ // atomic claim turns a second fire into a no-op.
166
+ let requeued = 0;
167
+ try {
168
+ const orphans = await payload.find({
169
+ collection: unitsSlug,
170
+ where: {
171
+ and: [
172
+ {
173
+ status: {
174
+ equals: 'pending'
175
+ }
176
+ },
177
+ {
178
+ or: [
179
+ {
180
+ startedAt: {
181
+ less_than: cutoffIso
182
+ }
183
+ },
184
+ {
185
+ and: [
186
+ {
187
+ startedAt: {
188
+ exists: false
189
+ }
190
+ },
191
+ {
192
+ updatedAt: {
193
+ less_than: cutoffIso
194
+ }
195
+ }
196
+ ]
197
+ }
198
+ ]
199
+ }
200
+ ]
201
+ },
202
+ limit: 200,
203
+ depth: 0,
204
+ overrideAccess: true
205
+ });
206
+ for (const u of orphans.docs){
207
+ try {
208
+ await requeueUnit(u.id);
209
+ await payload.update({
210
+ collection: unitsSlug,
211
+ id: u.id,
212
+ data: {
213
+ startedAt: null
214
+ },
215
+ overrideAccess: true
216
+ });
217
+ requeued += 1;
218
+ noteBatch(u);
219
+ } catch (err) {
220
+ payload.logger?.warn?.(`[ai-translate] janitor: orphan re-queue failed for unit ${u.id}: ${err instanceof Error ? err.message : String(err)}`);
221
+ }
222
+ }
223
+ } catch (err) {
224
+ payload.logger?.warn?.(`[ai-translate] janitor: orphan-recovery pass failed: ${err instanceof Error ? err.message : String(err)}`);
225
+ }
226
+ // ----- Stuck-batch unsticker -----------------------------------------
227
+ // `maybeTransitionBatch` only ever ran from a finishing worker, so a
228
+ // batch whose last open unit was resolved by anything else (cancel
229
+ // sweep races, janitor `failed` transitions above, crashed workers)
230
+ // stayed `running`/`cancelling` forever. Re-evaluate every batch this
231
+ // sweep touched — plus every non-terminal batch, so batches that were
232
+ // already stuck before this sweep (all units terminal, transition never
233
+ // fired) get unstuck even when the unit passes above found nothing.
234
+ try {
235
+ const open = await payload.find({
236
+ collection: batchesSlug,
237
+ where: {
238
+ status: {
239
+ in: [
240
+ 'running',
241
+ 'cancelling'
242
+ ]
243
+ }
244
+ },
245
+ limit: 20,
246
+ depth: 0,
247
+ overrideAccess: true
248
+ });
249
+ for (const b of open.docs){
250
+ affectedBatchIds.add(String(b.id));
251
+ }
252
+ } catch (err) {
253
+ payload.logger?.warn?.(`[ai-translate] janitor: open-batch scan failed: ${err instanceof Error ? err.message : String(err)}`);
254
+ }
255
+ for (const batchId of affectedBatchIds){
256
+ try {
257
+ await maybeTransitionBatch(payload, batchesSlug, unitsSlug, batchId, params.callbacks);
258
+ } catch (err) {
259
+ payload.logger?.warn?.(`[ai-translate] janitor: batch transition check failed for ${batchId}: ${err instanceof Error ? err.message : String(err)}`);
260
+ }
261
+ }
262
+ return {
263
+ reset,
264
+ failed,
265
+ requeued,
266
+ thresholdMs
267
+ };
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // P99 read
271
+ // ---------------------------------------------------------------------------
272
+ async function readP99Cached(payload, usageSlug, now, ttlMs) {
273
+ const cached = P99_CACHE.get(payload);
274
+ if (cached && now() - cached.computedAt < ttlMs) {
275
+ return cached.p99Ms;
276
+ }
277
+ const p99Ms = await computeRecentP99(payload, usageSlug);
278
+ P99_CACHE.set(payload, {
279
+ p99Ms,
280
+ computedAt: now()
281
+ });
282
+ return p99Ms;
283
+ }
284
+ /**
285
+ * Read the most recent 1000 `translation-usage` rows and return the
286
+ * P99 of their `durationMs` values. Falls back to 0 (which lets the
287
+ * threshold floor dominate) when no data is available — fresh
288
+ * installs and test fixtures.
289
+ */ async function computeRecentP99(payload, usageSlug) {
290
+ try {
291
+ const result = await payload.find({
292
+ collection: usageSlug,
293
+ where: {
294
+ durationMs: {
295
+ exists: true
296
+ }
297
+ },
298
+ limit: 1000,
299
+ sort: '-createdAt',
300
+ depth: 0,
301
+ overrideAccess: true
302
+ });
303
+ const rows = result.docs;
304
+ const durations = rows.map((r)=>r.durationMs ?? 0).filter((n)=>typeof n === 'number' && n > 0).sort((a, b)=>a - b);
305
+ if (durations.length === 0) return 0;
306
+ const idx = Math.min(durations.length - 1, Math.floor(durations.length * 0.99));
307
+ return durations[idx];
308
+ } catch {
309
+ return 0;
310
+ }
311
+ }
@@ -0,0 +1,51 @@
1
+ import type { TaskConfig } from 'payload';
2
+ import type { TargetPolicy } from '../types.js';
3
+ export declare const TRANSLATE_DOCUMENT_TASK_SLUG = "ai-translate-document";
4
+ export declare const TRANSLATE_GLOBAL_TASK_SLUG = "ai-translate-global";
5
+ type TranslateDocumentTaskInput = {
6
+ collection: string;
7
+ documentId: string;
8
+ jobId?: string;
9
+ targetLocales?: string[] | null;
10
+ previousDoc?: Record<string, unknown> | null;
11
+ draft?: boolean;
12
+ targetPolicy?: TargetPolicy;
13
+ };
14
+ type TranslateGlobalTaskInput = {
15
+ global: string;
16
+ jobId?: string;
17
+ targetLocales?: string[] | null;
18
+ previousDoc?: Record<string, unknown> | null;
19
+ targetPolicy?: TargetPolicy;
20
+ };
21
+ /**
22
+ * Builds the Payload task config for translating a collection
23
+ * document. Consumers don't see this — the plugin auto-registers it
24
+ * when `persistJobs: true`.
25
+ *
26
+ * `allowedCollections` is the plugin's configured `collections` list.
27
+ * Task inputs are queryable via Payload's REST API, so without this
28
+ * allowlist an attacker with write access to `payload-jobs` could
29
+ * enqueue a task targeting any collection (e.g. `users`) and the
30
+ * worker would execute it with `overrideAccess: true`. Empty list
31
+ * disables the check for back-compat with consumers wiring tasks
32
+ * manually.
33
+ */
34
+ export declare function buildTranslateDocumentTask(allowedCollections?: readonly string[]): TaskConfig<{
35
+ input: TranslateDocumentTaskInput;
36
+ output: {
37
+ ok: true;
38
+ };
39
+ }>;
40
+ /**
41
+ * Sibling task config for translating a global. Same persistence
42
+ * contract — survives server restart. Same allowlist rationale as
43
+ * `buildTranslateDocumentTask`.
44
+ */
45
+ export declare function buildTranslateGlobalTask(allowedGlobals?: readonly string[]): TaskConfig<{
46
+ input: TranslateGlobalTaskInput;
47
+ output: {
48
+ ok: true;
49
+ };
50
+ }>;
51
+ export {};
@@ -0,0 +1,154 @@
1
+ import { translateDocument, translateGlobal } from '../api.js';
2
+ export const TRANSLATE_DOCUMENT_TASK_SLUG = 'ai-translate-document';
3
+ export const TRANSLATE_GLOBAL_TASK_SLUG = 'ai-translate-global';
4
+ /**
5
+ * Payload jobs task that runs `translateDocument` from a durable queue
6
+ * entry rather than the in-memory coalescing closure. Surviving a
7
+ * server restart works because the task's input lives in the
8
+ * `payload-jobs` collection — when the process restarts, Payload's
9
+ * cron picks up un-completed jobs and re-runs the handler.
10
+ *
11
+ * The original after-change closure captured `req`, `doc`, and
12
+ * `previousDoc` directly. We can't serialize those across a restart,
13
+ * so the task input carries (collection, documentId, previousDoc-as-
14
+ * JSON, targetLocales, jobId, draft). The handler reads the live doc
15
+ * via the plugin's existing `findByIdNoFallback` inside
16
+ * `translateDocument`, so it always operates on current state.
17
+ */ const taskInputSchema = [
18
+ {
19
+ name: 'collection',
20
+ type: 'text',
21
+ required: true
22
+ },
23
+ {
24
+ name: 'documentId',
25
+ type: 'text',
26
+ required: true
27
+ },
28
+ {
29
+ name: 'jobId',
30
+ type: 'text'
31
+ },
32
+ /**
33
+ * Serialized as JSON so it round-trips through the jobs collection
34
+ * cleanly. May be `null` when the after-change hook detected a
35
+ * publish-transition and asked for a full re-translate.
36
+ */ {
37
+ name: 'targetLocales',
38
+ type: 'json'
39
+ },
40
+ {
41
+ name: 'previousDoc',
42
+ type: 'json'
43
+ },
44
+ {
45
+ name: 'draft',
46
+ type: 'checkbox',
47
+ defaultValue: true
48
+ },
49
+ {
50
+ name: 'targetPolicy',
51
+ type: 'text'
52
+ }
53
+ ];
54
+ const taskInputSchemaGlobal = [
55
+ {
56
+ name: 'global',
57
+ type: 'text',
58
+ required: true
59
+ },
60
+ {
61
+ name: 'jobId',
62
+ type: 'text'
63
+ },
64
+ {
65
+ name: 'targetLocales',
66
+ type: 'json'
67
+ },
68
+ {
69
+ name: 'previousDoc',
70
+ type: 'json'
71
+ },
72
+ {
73
+ name: 'targetPolicy',
74
+ type: 'text'
75
+ }
76
+ ];
77
+ /**
78
+ * Builds the Payload task config for translating a collection
79
+ * document. Consumers don't see this — the plugin auto-registers it
80
+ * when `persistJobs: true`.
81
+ *
82
+ * `allowedCollections` is the plugin's configured `collections` list.
83
+ * Task inputs are queryable via Payload's REST API, so without this
84
+ * allowlist an attacker with write access to `payload-jobs` could
85
+ * enqueue a task targeting any collection (e.g. `users`) and the
86
+ * worker would execute it with `overrideAccess: true`. Empty list
87
+ * disables the check for back-compat with consumers wiring tasks
88
+ * manually.
89
+ */ export function buildTranslateDocumentTask(allowedCollections = []) {
90
+ const allowed = new Set(allowedCollections);
91
+ return {
92
+ slug: TRANSLATE_DOCUMENT_TASK_SLUG,
93
+ label: 'AI translate — collection document',
94
+ inputSchema: taskInputSchema,
95
+ // No retries: the per-locale work inside translateDocument already
96
+ // applies the configured `retry` config to provider calls. A second
97
+ // outer attempt would double-bill on transient errors that already
98
+ // got their inner retries.
99
+ retries: 0,
100
+ handler: async ({ input, req })=>{
101
+ const typed = input;
102
+ if (allowed.size > 0 && !allowed.has(typed.collection)) {
103
+ throw new Error(`[ai-translate] Task input rejected: collection '${typed.collection}' is not in the plugin's allowed collections list.`);
104
+ }
105
+ await translateDocument(req.payload, {
106
+ collection: typed.collection,
107
+ id: typed.documentId,
108
+ jobId: typed.jobId,
109
+ targetLocales: typed.targetLocales ?? undefined,
110
+ previousDoc: typed.previousDoc ?? undefined,
111
+ draft: typed.draft ?? true,
112
+ targetPolicy: typed.targetPolicy,
113
+ req
114
+ });
115
+ return {
116
+ output: {
117
+ ok: true
118
+ }
119
+ };
120
+ }
121
+ };
122
+ }
123
+ /**
124
+ * Sibling task config for translating a global. Same persistence
125
+ * contract — survives server restart. Same allowlist rationale as
126
+ * `buildTranslateDocumentTask`.
127
+ */ export function buildTranslateGlobalTask(allowedGlobals = []) {
128
+ const allowed = new Set(allowedGlobals);
129
+ return {
130
+ slug: TRANSLATE_GLOBAL_TASK_SLUG,
131
+ label: 'AI translate — global',
132
+ inputSchema: taskInputSchemaGlobal,
133
+ retries: 0,
134
+ handler: async ({ input, req })=>{
135
+ const typed = input;
136
+ if (allowed.size > 0 && !allowed.has(typed.global)) {
137
+ throw new Error(`[ai-translate] Task input rejected: global '${typed.global}' is not in the plugin's allowed globals list.`);
138
+ }
139
+ await translateGlobal(req.payload, {
140
+ global: typed.global,
141
+ jobId: typed.jobId,
142
+ targetLocales: typed.targetLocales ?? undefined,
143
+ previousDoc: typed.previousDoc ?? undefined,
144
+ targetPolicy: typed.targetPolicy,
145
+ req
146
+ });
147
+ return {
148
+ output: {
149
+ ok: true
150
+ }
151
+ };
152
+ }
153
+ };
154
+ }