@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.
- package/LICENSE +21 -0
- package/README.md +714 -0
- package/dist/alerts-collection.d.ts +21 -0
- package/dist/alerts-collection.js +159 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +918 -0
- package/dist/bulk-translate-batches-collection.d.ts +29 -0
- package/dist/bulk-translate-batches-collection.js +404 -0
- package/dist/bulk-translate-units-collection.d.ts +35 -0
- package/dist/bulk-translate-units-collection.js +310 -0
- package/dist/client/estimated-cost-cell.d.ts +6 -0
- package/dist/client/estimated-cost-cell.js +12 -0
- package/dist/client/excluded-fields-field.d.ts +45 -0
- package/dist/client/excluded-fields-field.js +553 -0
- package/dist/client/field-translate-button.d.ts +6 -0
- package/dist/client/field-translate-button.js +199 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +6 -0
- package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
- package/dist/client/lib/use-global-kill-switches.js +58 -0
- package/dist/client/translate-button.d.ts +2 -0
- package/dist/client/translate-button.js +228 -0
- package/dist/client/translate-modal.d.ts +16 -0
- package/dist/client/translate-modal.js +549 -0
- package/dist/client/translation-progress.d.ts +10 -0
- package/dist/client/translation-progress.js +297 -0
- package/dist/components/TranslationNavGroup.d.ts +45 -0
- package/dist/components/TranslationNavGroup.js +104 -0
- package/dist/defaults.d.ts +11 -0
- package/dist/defaults.js +16 -0
- package/dist/endpoints/client-config.d.ts +44 -0
- package/dist/endpoints/client-config.js +145 -0
- package/dist/endpoints/estimate.d.ts +5 -0
- package/dist/endpoints/estimate.js +237 -0
- package/dist/endpoints/progress.d.ts +2 -0
- package/dist/endpoints/progress.js +314 -0
- package/dist/endpoints/translate.d.ts +11 -0
- package/dist/endpoints/translate.js +376 -0
- package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
- package/dist/endpoints/translation-hub/_helpers.js +297 -0
- package/dist/endpoints/translation-hub/active.d.ts +21 -0
- package/dist/endpoints/translation-hub/active.js +220 -0
- package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
- package/dist/endpoints/translation-hub/cancel.js +233 -0
- package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
- package/dist/endpoints/translation-hub/enqueue.js +529 -0
- package/dist/endpoints/translation-hub/failures.d.ts +12 -0
- package/dist/endpoints/translation-hub/failures.js +67 -0
- package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
- package/dist/endpoints/translation-hub/force-reset.js +144 -0
- package/dist/endpoints/translation-hub/index.d.ts +21 -0
- package/dist/endpoints/translation-hub/index.js +20 -0
- package/dist/endpoints/translation-hub/list.d.ts +40 -0
- package/dist/endpoints/translation-hub/list.js +182 -0
- package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
- package/dist/endpoints/translation-hub/preflight.js +141 -0
- package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
- package/dist/endpoints/translation-hub/retry-failed.js +235 -0
- package/dist/endpoints/translation-hub/revert.d.ts +88 -0
- package/dist/endpoints/translation-hub/revert.js +405 -0
- package/dist/endpoints/translation-hub/status.d.ts +45 -0
- package/dist/endpoints/translation-hub/status.js +391 -0
- package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
- package/dist/endpoints/translation-hub/usage-summary.js +481 -0
- package/dist/exports/client.d.ts +6 -0
- package/dist/exports/client.js +6 -0
- package/dist/exports/components.d.ts +6 -0
- package/dist/exports/components.js +5 -0
- package/dist/exports/index.d.ts +8 -0
- package/dist/exports/index.js +7 -0
- package/dist/exports/providers.d.ts +9 -0
- package/dist/exports/providers.js +5 -0
- package/dist/exports/views-client.d.ts +23 -0
- package/dist/exports/views-client.js +22 -0
- package/dist/exports/views.d.ts +30 -0
- package/dist/exports/views.js +29 -0
- package/dist/hooks/after-change-global.d.ts +4 -0
- package/dist/hooks/after-change-global.js +109 -0
- package/dist/hooks/after-change.d.ts +16 -0
- package/dist/hooks/after-change.js +205 -0
- package/dist/hooks/after-delete.d.ts +30 -0
- package/dist/hooks/after-delete.js +95 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/jobs-collection.d.ts +17 -0
- package/dist/jobs-collection.js +139 -0
- package/dist/lexical/classifier.d.ts +3 -0
- package/dist/lexical/classifier.js +108 -0
- package/dist/lexical/deserializer.d.ts +4 -0
- package/dist/lexical/deserializer.js +263 -0
- package/dist/lexical/placeholder-integrity.d.ts +6 -0
- package/dist/lexical/placeholder-integrity.js +21 -0
- package/dist/lexical/placeholders.d.ts +21 -0
- package/dist/lexical/placeholders.js +117 -0
- package/dist/lexical/serializer.d.ts +21 -0
- package/dist/lexical/serializer.js +233 -0
- package/dist/lexical/types.d.ts +32 -0
- package/dist/lexical/types.js +1 -0
- package/dist/lib/auth-diagnostics.d.ts +14 -0
- package/dist/lib/auth-diagnostics.js +19 -0
- package/dist/lib/batch-counts.d.ts +58 -0
- package/dist/lib/batch-counts.js +105 -0
- package/dist/lib/bulk-translate-migrations.d.ts +92 -0
- package/dist/lib/bulk-translate-migrations.js +153 -0
- package/dist/lib/coalescing-queue.d.ts +38 -0
- package/dist/lib/coalescing-queue.js +69 -0
- package/dist/lib/content-extractor.d.ts +16 -0
- package/dist/lib/content-extractor.js +410 -0
- package/dist/lib/content-hash.d.ts +1 -0
- package/dist/lib/content-hash.js +19 -0
- package/dist/lib/content-patcher.d.ts +15 -0
- package/dist/lib/content-patcher.js +293 -0
- package/dist/lib/cost-guards.d.ts +2 -0
- package/dist/lib/cost-guards.js +18 -0
- package/dist/lib/daily-spend-cap.d.ts +58 -0
- package/dist/lib/daily-spend-cap.js +233 -0
- package/dist/lib/effective-locales.d.ts +181 -0
- package/dist/lib/effective-locales.js +302 -0
- package/dist/lib/error-messages.d.ts +245 -0
- package/dist/lib/error-messages.js +626 -0
- package/dist/lib/events.d.ts +39 -0
- package/dist/lib/events.js +146 -0
- package/dist/lib/exclude-fields.d.ts +3 -0
- package/dist/lib/exclude-fields.js +64 -0
- package/dist/lib/field-breadcrumb.d.ts +31 -0
- package/dist/lib/field-breadcrumb.js +227 -0
- package/dist/lib/field-diff.d.ts +1 -0
- package/dist/lib/field-diff.js +25 -0
- package/dist/lib/field-empty.d.ts +2 -0
- package/dist/lib/field-empty.js +68 -0
- package/dist/lib/field-resolver.d.ts +3 -0
- package/dist/lib/field-resolver.js +164 -0
- package/dist/lib/group-soft-skips.d.ts +39 -0
- package/dist/lib/group-soft-skips.js +45 -0
- package/dist/lib/locale-merge.d.ts +44 -0
- package/dist/lib/locale-merge.js +357 -0
- package/dist/lib/locale-row-check.d.ts +30 -0
- package/dist/lib/locale-row-check.js +64 -0
- package/dist/lib/logger.d.ts +74 -0
- package/dist/lib/logger.js +97 -0
- package/dist/lib/manual-edit-guard.d.ts +128 -0
- package/dist/lib/manual-edit-guard.js +393 -0
- package/dist/lib/output-validation.d.ts +48 -0
- package/dist/lib/output-validation.js +148 -0
- package/dist/lib/payload-read.d.ts +16 -0
- package/dist/lib/payload-read.js +51 -0
- package/dist/lib/per-doc-claim.d.ts +90 -0
- package/dist/lib/per-doc-claim.js +140 -0
- package/dist/lib/per-doc-lock.d.ts +94 -0
- package/dist/lib/per-doc-lock.js +119 -0
- package/dist/lib/persist-usage.d.ts +91 -0
- package/dist/lib/persist-usage.js +116 -0
- package/dist/lib/progress-store.d.ts +103 -0
- package/dist/lib/progress-store.js +314 -0
- package/dist/lib/rate-limiter.d.ts +3 -0
- package/dist/lib/rate-limiter.js +53 -0
- package/dist/lib/snapshot-select.d.ts +43 -0
- package/dist/lib/snapshot-select.js +108 -0
- package/dist/lib/translate-prompt.d.ts +31 -0
- package/dist/lib/translate-prompt.js +66 -0
- package/dist/lib/translation-token-bucket.d.ts +57 -0
- package/dist/lib/translation-token-bucket.js +365 -0
- package/dist/lib/truncate-source-value.d.ts +1 -0
- package/dist/lib/truncate-source-value.js +27 -0
- package/dist/manual-edit-collection.d.ts +22 -0
- package/dist/manual-edit-collection.js +124 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +934 -0
- package/dist/providers/ai-sdk-adapter.d.ts +35 -0
- package/dist/providers/ai-sdk-adapter.js +100 -0
- package/dist/providers/anthropic.d.ts +31 -0
- package/dist/providers/anthropic.js +66 -0
- package/dist/providers/custom.d.ts +36 -0
- package/dist/providers/custom.js +24 -0
- package/dist/providers/gemini.d.ts +20 -0
- package/dist/providers/gemini.js +48 -0
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.js +29 -0
- package/dist/providers/openai.d.ts +28 -0
- package/dist/providers/openai.js +69 -0
- package/dist/settings-global.d.ts +74 -0
- package/dist/settings-global.js +216 -0
- package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
- package/dist/tasks/bulk-translate-coordinator.js +708 -0
- package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
- package/dist/tasks/bulk-translate-doc-task.js +1000 -0
- package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
- package/dist/tasks/bulk-translate-janitor.js +311 -0
- package/dist/tasks/translate-job-task.d.ts +51 -0
- package/dist/tasks/translate-job-task.js +154 -0
- package/dist/translate.d.ts +113 -0
- package/dist/translate.js +911 -0
- package/dist/translation-daily-spend-collection.d.ts +24 -0
- package/dist/translation-daily-spend-collection.js +133 -0
- package/dist/translation-rate-limits-collection.d.ts +30 -0
- package/dist/translation-rate-limits-collection.js +144 -0
- package/dist/types.d.ts +672 -0
- package/dist/types.js +1 -0
- package/dist/usage-collection.d.ts +14 -0
- package/dist/usage-collection.js +377 -0
- package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
- package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
- package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
- package/dist/views/BulkRunsHub/BucketRow.js +982 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
- package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
- package/dist/views/BulkRunsHub/EmptyState.js +64 -0
- package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
- package/dist/views/BulkRunsHub/FilterBar.js +284 -0
- package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
- package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
- package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
- package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
- package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
- package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
- package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
- package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
- package/dist/views/BulkRunsHub/index.d.ts +17 -0
- package/dist/views/BulkRunsHub/index.js +80 -0
- package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
- package/dist/views/BulkRunsHub/urlFilters.js +50 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
- package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
- package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
- package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
- package/dist/views/TranslationHub/ActiveJobs.js +320 -0
- package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
- package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
- package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
- package/dist/views/TranslationHub/AlertBanner.js +568 -0
- package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
- package/dist/views/TranslationHub/AuditPanel.js +1367 -0
- package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
- package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
- package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
- package/dist/views/TranslationHub/Hub.client.js +357 -0
- package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
- package/dist/views/TranslationHub/ModelCombobox.js +415 -0
- package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
- package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
- package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
- package/dist/views/TranslationHub/SettingsRail.js +382 -0
- package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
- package/dist/views/TranslationHub/StatusStrip.js +451 -0
- package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
- package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
- package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
- package/dist/views/TranslationHub/UsageTable.js +1240 -0
- package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
- package/dist/views/TranslationHub/alertGrouping.js +99 -0
- package/dist/views/TranslationHub/index.d.ts +20 -0
- package/dist/views/TranslationHub/index.js +109 -0
- package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
- package/dist/views/TranslationHub/tabNavigation.js +74 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
- package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
- package/dist/views/TranslationHub/useFocusTrap.js +81 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
- package/dist/views/shared/EditorError.d.ts +97 -0
- package/dist/views/shared/EditorError.js +205 -0
- package/dist/views/shared/ModelCell.d.ts +18 -0
- package/dist/views/shared/ModelCell.js +31 -0
- package/dist/views/shared/docHref.d.ts +16 -0
- package/dist/views/shared/docHref.js +26 -0
- package/dist/views/shared/fetch-error-body.d.ts +25 -0
- package/dist/views/shared/fetch-error-body.js +42 -0
- package/dist/views/shared/filterPillStyle.d.ts +35 -0
- package/dist/views/shared/filterPillStyle.js +40 -0
- package/dist/views/shared/format.d.ts +75 -0
- package/dist/views/shared/format.js +131 -0
- package/package.json +141 -0
|
@@ -0,0 +1,1000 @@
|
|
|
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 { resolveTranslatableFields } from '../lib/field-resolver.js';
|
|
4
|
+
import { createScopedLogger } from '../lib/logger.js';
|
|
5
|
+
import { findByIdNoFallback, findGlobalNoFallback } from '../lib/payload-read.js';
|
|
6
|
+
import { tryClaimAsLock } from '../lib/per-doc-claim.js';
|
|
7
|
+
import { captureLocalizedSnapshot } from '../lib/snapshot-select.js';
|
|
8
|
+
export const BULK_TRANSLATE_DOC_TASK_SLUG = 'bulk-translate-doc';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Task config
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const taskInputSchema = [
|
|
13
|
+
{
|
|
14
|
+
name: 'unitId',
|
|
15
|
+
type: 'text',
|
|
16
|
+
required: true
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
export function buildBulkTranslateDocTask(options = {}) {
|
|
20
|
+
const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
|
|
21
|
+
const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
|
|
22
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
23
|
+
const callbacks = options.callbacks;
|
|
24
|
+
const allowedCollections = options.allowedCollections ?? [];
|
|
25
|
+
const allowedGlobals = options.allowedGlobals ?? [];
|
|
26
|
+
return {
|
|
27
|
+
slug: BULK_TRANSLATE_DOC_TASK_SLUG,
|
|
28
|
+
label: 'Bulk translate — per-doc worker',
|
|
29
|
+
inputSchema: taskInputSchema,
|
|
30
|
+
// We manage our own retry semantics on the unit row. Payload's
|
|
31
|
+
// built-in retry would double-count attempts.
|
|
32
|
+
retries: 0,
|
|
33
|
+
handler: async ({ input, req })=>{
|
|
34
|
+
const typed = input;
|
|
35
|
+
const translateApi = options.translateApi ?? await loadDefaultTranslateApi();
|
|
36
|
+
const result = await runWorkerForUnit({
|
|
37
|
+
payload: req.payload,
|
|
38
|
+
unitId: typed.unitId,
|
|
39
|
+
unitsSlug,
|
|
40
|
+
batchesSlug,
|
|
41
|
+
maxAttempts,
|
|
42
|
+
translateApi,
|
|
43
|
+
callbacks,
|
|
44
|
+
allowedCollections,
|
|
45
|
+
allowedGlobals,
|
|
46
|
+
// Forward the originating request so the locale write carries
|
|
47
|
+
// `req.context = { disableRevalidate: true }` — same shape as
|
|
48
|
+
// the per-doc retry endpoint sets.
|
|
49
|
+
req: {
|
|
50
|
+
...req,
|
|
51
|
+
context: {
|
|
52
|
+
...req.context ?? {},
|
|
53
|
+
disableRevalidate: true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
output: {
|
|
59
|
+
ok: true,
|
|
60
|
+
status: result.status
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function runWorkerForUnit(params) {
|
|
67
|
+
const { payload, unitId, unitsSlug, batchesSlug, maxAttempts, translateApi, callbacks } = params;
|
|
68
|
+
const allowedCollections = new Set(params.allowedCollections ?? []);
|
|
69
|
+
const allowedGlobals = new Set(params.allowedGlobals ?? []);
|
|
70
|
+
const now = params.now ?? (()=>new Date());
|
|
71
|
+
// -------------------------------------------------------------------
|
|
72
|
+
// 1. Load the unit row.
|
|
73
|
+
// -------------------------------------------------------------------
|
|
74
|
+
const unit = await payload.findByID({
|
|
75
|
+
collection: unitsSlug,
|
|
76
|
+
id: unitId,
|
|
77
|
+
overrideAccess: true,
|
|
78
|
+
depth: 0
|
|
79
|
+
});
|
|
80
|
+
if (!unit) {
|
|
81
|
+
return {
|
|
82
|
+
status: 'failed',
|
|
83
|
+
failureCode: 'permanent.deleted',
|
|
84
|
+
failureMessage: 'unit not found'
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Terminal-state guards. Re-running a finished unit must be a no-op
|
|
88
|
+
// — Payload re-fires the task if it sees ambiguous state. `reverted`
|
|
89
|
+
// surfaces as `skipped` to the caller since both mean "do not retry".
|
|
90
|
+
if (unit.status === 'success') return {
|
|
91
|
+
status: 'success'
|
|
92
|
+
};
|
|
93
|
+
if (unit.status === 'skipped' || unit.status === 'reverted') {
|
|
94
|
+
return {
|
|
95
|
+
status: 'skipped'
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// -------------------------------------------------------------------
|
|
99
|
+
// 1c. Load the parent batch row to read `mode`. Threaded into the
|
|
100
|
+
// translate call as `force: batchMode === 'force'` so the UI's
|
|
101
|
+
// "Force re-translate (includes docs already up to date)" checkbox
|
|
102
|
+
// actually bypasses the field-level hash-skip + manual-edit guards
|
|
103
|
+
// in translate.ts. Without this, force-mode bulk runs silently
|
|
104
|
+
// preserve every prior translation while still paying for tokens.
|
|
105
|
+
//
|
|
106
|
+
// Tolerant on miss: if the batch row can't be loaded (deleted /
|
|
107
|
+
// orphan unit / db hiccup), fall through with `force: false` and
|
|
108
|
+
// let the per-locale guards take their default conservative path.
|
|
109
|
+
// That's the same behaviour as pre-v1.2.5 and never makes things
|
|
110
|
+
// worse.
|
|
111
|
+
// -------------------------------------------------------------------
|
|
112
|
+
let batchMode;
|
|
113
|
+
let batchStatus;
|
|
114
|
+
try {
|
|
115
|
+
const batch = await payload.findByID({
|
|
116
|
+
collection: batchesSlug,
|
|
117
|
+
id: unit.batchId,
|
|
118
|
+
overrideAccess: true,
|
|
119
|
+
depth: 0
|
|
120
|
+
});
|
|
121
|
+
batchMode = batch?.scope?.mode ?? batch?.mode;
|
|
122
|
+
batchStatus = batch?.status;
|
|
123
|
+
} catch {
|
|
124
|
+
batchMode = undefined;
|
|
125
|
+
batchStatus = undefined;
|
|
126
|
+
}
|
|
127
|
+
const isForceMode = batchMode === 'force';
|
|
128
|
+
// -------------------------------------------------------------------
|
|
129
|
+
// 1d. Cancel gate. The cancel endpoint sweeps `pending` units exactly
|
|
130
|
+
// once; units whose job was already queued (or claimed between the
|
|
131
|
+
// sweep's find and its updates) would otherwise still translate —
|
|
132
|
+
// and bill — after the admin pressed Cancel. Checking the batch
|
|
133
|
+
// status at worker entry closes that window. Also covers terminal
|
|
134
|
+
// batches: a janitor-reset unit whose batch already ended must not
|
|
135
|
+
// run again.
|
|
136
|
+
// -------------------------------------------------------------------
|
|
137
|
+
if (batchStatus === 'cancelling' || batchStatus === 'cancelled' || batchStatus === 'reverted') {
|
|
138
|
+
// Only rewrite units that are still open — a terminal unit (e.g.
|
|
139
|
+
// `failed`) re-fired by a duplicate job must keep its real outcome.
|
|
140
|
+
if (unit.status === 'pending' || unit.status === 'running') {
|
|
141
|
+
await payload.update({
|
|
142
|
+
collection: unitsSlug,
|
|
143
|
+
id: unitId,
|
|
144
|
+
data: {
|
|
145
|
+
status: 'skipped',
|
|
146
|
+
failureMessage: 'cancelled_by_admin',
|
|
147
|
+
completedAt: now().toISOString()
|
|
148
|
+
},
|
|
149
|
+
overrideAccess: true
|
|
150
|
+
});
|
|
151
|
+
await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'skippedUnits');
|
|
152
|
+
}
|
|
153
|
+
// The skip we just wrote may have been the last open unit — give the
|
|
154
|
+
// batch its chance to leave `cancelling`.
|
|
155
|
+
await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks);
|
|
156
|
+
return {
|
|
157
|
+
status: 'skipped',
|
|
158
|
+
failureMessage: 'cancelled_by_admin'
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// -------------------------------------------------------------------
|
|
162
|
+
// 1b. Security allowlist (Decision #11 / N5). A `bulk-translate-units`
|
|
163
|
+
// row's `collection` field is free-text. Without this guard, an
|
|
164
|
+
// attacker with write access to that collection could craft a
|
|
165
|
+
// unit targeting `users` and the worker would call translateDocument
|
|
166
|
+
// with overrideAccess: true. The plugin auto-wires the allowlist
|
|
167
|
+
// from `options.collections` + `options.globals`.
|
|
168
|
+
// -------------------------------------------------------------------
|
|
169
|
+
const isGlobalForGate = isGlobalSlug(payload, unit.collection);
|
|
170
|
+
if (!isGlobalForGate && allowedCollections.size > 0 && !allowedCollections.has(unit.collection)) {
|
|
171
|
+
await payload.update({
|
|
172
|
+
collection: unitsSlug,
|
|
173
|
+
id: unitId,
|
|
174
|
+
data: {
|
|
175
|
+
status: 'failed',
|
|
176
|
+
failureCode: 'permanent.config',
|
|
177
|
+
failureMessage: `Collection '${unit.collection}' not in plugin allowlist`,
|
|
178
|
+
completedAt: now().toISOString()
|
|
179
|
+
},
|
|
180
|
+
overrideAccess: true
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
status: 'failed',
|
|
184
|
+
failureCode: 'permanent.config',
|
|
185
|
+
failureMessage: `Collection '${unit.collection}' not in plugin allowlist`
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (isGlobalForGate && allowedGlobals.size > 0 && !allowedGlobals.has(unit.collection)) {
|
|
189
|
+
await payload.update({
|
|
190
|
+
collection: unitsSlug,
|
|
191
|
+
id: unitId,
|
|
192
|
+
data: {
|
|
193
|
+
status: 'failed',
|
|
194
|
+
failureCode: 'permanent.config',
|
|
195
|
+
failureMessage: `Global '${unit.collection}' not in plugin allowlist`,
|
|
196
|
+
completedAt: now().toISOString()
|
|
197
|
+
},
|
|
198
|
+
overrideAccess: true
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
status: 'failed',
|
|
202
|
+
failureCode: 'permanent.config',
|
|
203
|
+
failureMessage: `Global '${unit.collection}' not in plugin allowlist`
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// -------------------------------------------------------------------
|
|
207
|
+
// 1d. Per-doc serialization via atomic UPDATE (2026-06-05 rewrite).
|
|
208
|
+
//
|
|
209
|
+
// Previously we used `pg_try_advisory_lock` + polling. That held one
|
|
210
|
+
// pool connection per acquired lock for the full LLM round-trip,
|
|
211
|
+
// starving Payload's connection pool (default 10) when bulk runs
|
|
212
|
+
// touched many docs concurrently — admin UI + status endpoints
|
|
213
|
+
// stalled because every pool slot was tied up by a lock holder.
|
|
214
|
+
//
|
|
215
|
+
// New design: a single atomic UPDATE that conditionally transitions
|
|
216
|
+
// `pending → running` IFF no sibling unit for the same
|
|
217
|
+
// `(collection, documentId)` is already `running`. Winners proceed
|
|
218
|
+
// with no held connection. Losers see `0 rowsAffected` → we
|
|
219
|
+
// re-enqueue this unit's Payload job so a later cron tick retries.
|
|
220
|
+
//
|
|
221
|
+
// `concurrency.perDocument: 1` in plugin config only gates the
|
|
222
|
+
// in-process `translateDocument` API and does NOT cover the bulk-
|
|
223
|
+
// translate worker pool — this claim is what actually serialises
|
|
224
|
+
// per-doc work for bulk runs.
|
|
225
|
+
//
|
|
226
|
+
// On non-Postgres adapters the claim is a no-op (returns `'noop'`)
|
|
227
|
+
// and we fall through to the legacy mark-running path below.
|
|
228
|
+
// -------------------------------------------------------------------
|
|
229
|
+
const earlyLog = createScopedLogger(payload, {
|
|
230
|
+
component: 'bulk.worker',
|
|
231
|
+
batchId: unit.batchId,
|
|
232
|
+
unitId,
|
|
233
|
+
collection: unit.collection,
|
|
234
|
+
documentId: unit.documentId,
|
|
235
|
+
locale: unit.locale
|
|
236
|
+
});
|
|
237
|
+
const claim = await tryClaimAsLock({
|
|
238
|
+
payload,
|
|
239
|
+
unitsCollectionSlug: unitsSlug,
|
|
240
|
+
unitId,
|
|
241
|
+
collection: unit.collection,
|
|
242
|
+
documentId: unit.documentId
|
|
243
|
+
});
|
|
244
|
+
if (claim.acquired === false) {
|
|
245
|
+
// Couldn't claim — sibling unit for the same doc is running, OR
|
|
246
|
+
// the unit was already moved out of `pending` by a competing fire
|
|
247
|
+
// of the same job. Re-enqueue this unit so a later cron tick
|
|
248
|
+
// retries; don't bump attempts (deferral isn't a translation
|
|
249
|
+
// failure).
|
|
250
|
+
try {
|
|
251
|
+
await payload.jobs.queue({
|
|
252
|
+
task: BULK_TRANSLATE_DOC_TASK_SLUG,
|
|
253
|
+
input: {
|
|
254
|
+
unitId
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
} catch (enqueueErr) {
|
|
258
|
+
payload.logger?.warn?.(`[ai-translate] per-doc claim deferred but re-enqueue failed for ${unit.collection}/${String(unit.documentId)} locale=${unit.locale}: ${String(enqueueErr)}`);
|
|
259
|
+
}
|
|
260
|
+
earlyLog.event('info', 'bulk.worker.unit.per-doc-busy-deferred', {});
|
|
261
|
+
return {
|
|
262
|
+
status: 'pending'
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const releaseLock = claim.release;
|
|
266
|
+
try {
|
|
267
|
+
// -------------------------------------------------------------------
|
|
268
|
+
// 2. Mark running + increment attempts.
|
|
269
|
+
//
|
|
270
|
+
// On Postgres the atomic claim above ALREADY transitioned the row
|
|
271
|
+
// to `running` and bumped `attempts`, so this block is a no-op for
|
|
272
|
+
// that path. It runs as the fallback when the claim returned
|
|
273
|
+
// `'noop'` (non-Postgres adapter) — preserving the legacy code path
|
|
274
|
+
// for those callers.
|
|
275
|
+
// -------------------------------------------------------------------
|
|
276
|
+
const isPgClaim = claim.acquired === true;
|
|
277
|
+
const nextAttempts = isPgClaim ? claim.attempts : (unit.attempts ?? 0) + 1;
|
|
278
|
+
const log = earlyLog;
|
|
279
|
+
const unitStartedAtMs = Date.now();
|
|
280
|
+
if (!isPgClaim) {
|
|
281
|
+
await payload.update({
|
|
282
|
+
collection: unitsSlug,
|
|
283
|
+
id: unitId,
|
|
284
|
+
data: {
|
|
285
|
+
status: 'running',
|
|
286
|
+
attempts: nextAttempts,
|
|
287
|
+
startedAt: now().toISOString()
|
|
288
|
+
},
|
|
289
|
+
overrideAccess: true
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
log.event('info', 'bulk.worker.unit.started', {
|
|
293
|
+
collection: unit.collection,
|
|
294
|
+
documentId: unit.documentId,
|
|
295
|
+
locale: unit.locale,
|
|
296
|
+
attempt: nextAttempts
|
|
297
|
+
});
|
|
298
|
+
// -------------------------------------------------------------------
|
|
299
|
+
// 3. F-DA-TOCTOU scenario B: another batch's unit may be racing this
|
|
300
|
+
// one. Round-5 pr-reviewer caught the dual-skip bug — naive "skip if
|
|
301
|
+
// any other pending/running" caused BOTH racing workers to mark
|
|
302
|
+
// themselves running, query, see each other, and skip. Zero
|
|
303
|
+
// translation for the doc.
|
|
304
|
+
//
|
|
305
|
+
// Fix: deterministic tiebreaker on `batchId`. Among all concurrently-
|
|
306
|
+
// active units (this one + any others found), the LOWEST batchId
|
|
307
|
+
// wins. The losers skip. Guarantees exactly one winner per
|
|
308
|
+
// (collection, doc, locale) tuple under any number of concurrent
|
|
309
|
+
// batches.
|
|
310
|
+
// -------------------------------------------------------------------
|
|
311
|
+
const concurrent = await payload.find({
|
|
312
|
+
collection: unitsSlug,
|
|
313
|
+
where: {
|
|
314
|
+
and: [
|
|
315
|
+
{
|
|
316
|
+
collection: {
|
|
317
|
+
equals: unit.collection
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
documentId: {
|
|
322
|
+
equals: unit.documentId
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
locale: {
|
|
327
|
+
equals: unit.locale
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
status: {
|
|
332
|
+
in: [
|
|
333
|
+
'pending',
|
|
334
|
+
'running'
|
|
335
|
+
]
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: {
|
|
340
|
+
not_equals: unitId
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
]
|
|
344
|
+
},
|
|
345
|
+
limit: 100,
|
|
346
|
+
overrideAccess: true,
|
|
347
|
+
depth: 0
|
|
348
|
+
});
|
|
349
|
+
if (concurrent.totalDocs > 0) {
|
|
350
|
+
const competingBatchIds = concurrent.docs.map((d)=>d.batchId).filter((b)=>typeof b === 'string' && b.length > 0);
|
|
351
|
+
const allBatchIds = [
|
|
352
|
+
unit.batchId,
|
|
353
|
+
...competingBatchIds
|
|
354
|
+
].sort();
|
|
355
|
+
if (allBatchIds[0] !== unit.batchId) {
|
|
356
|
+
// A different batch has the lower id — they win. We skip.
|
|
357
|
+
await payload.update({
|
|
358
|
+
collection: unitsSlug,
|
|
359
|
+
id: unitId,
|
|
360
|
+
data: {
|
|
361
|
+
status: 'skipped',
|
|
362
|
+
failureMessage: 'concurrent_batch',
|
|
363
|
+
completedAt: now().toISOString()
|
|
364
|
+
},
|
|
365
|
+
overrideAccess: true
|
|
366
|
+
});
|
|
367
|
+
await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'skippedUnits');
|
|
368
|
+
await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
|
|
369
|
+
return {
|
|
370
|
+
status: 'skipped',
|
|
371
|
+
failureMessage: 'concurrent_batch'
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
// We have the lowest batchId — we win. Continue.
|
|
375
|
+
payload.logger?.debug?.(`[ai-translate] worker: ${concurrent.totalDocs} concurrent unit(s) lost the tiebreaker to batch ${unit.batchId}; proceeding`);
|
|
376
|
+
}
|
|
377
|
+
// -------------------------------------------------------------------
|
|
378
|
+
// 4. Dispatch to the translate API. Globals route through
|
|
379
|
+
// `translateGlobal`, collections through `translateDocument`.
|
|
380
|
+
// Detection: presence of the slug in `payload.config.globals`.
|
|
381
|
+
// -------------------------------------------------------------------
|
|
382
|
+
const isGlobal = isGlobalSlug(payload, unit.collection);
|
|
383
|
+
const targetLocales = [
|
|
384
|
+
unit.locale
|
|
385
|
+
];
|
|
386
|
+
// -------------------------------------------------------------------
|
|
387
|
+
// 3c. Per-locale pre-write snapshot (v1.2.12). Captures the TARGET
|
|
388
|
+
// locale's current localized-translatable values onto the unit
|
|
389
|
+
// row BEFORE any write, so Revert restores exactly what this run
|
|
390
|
+
// overwrote. Replaces the coordinator-era snapshot, which was
|
|
391
|
+
// captured from the SOURCE-locale enumeration read and reused for
|
|
392
|
+
// every locale — replaying it stamped source-language content
|
|
393
|
+
// over translated locales (2026-06-11 prod incident).
|
|
394
|
+
//
|
|
395
|
+
// First capture wins: a retry inside the same batch must not
|
|
396
|
+
// overwrite the true pre-batch state with the previous attempt's
|
|
397
|
+
// machine output. Absent paths are stored as explicit nulls so a
|
|
398
|
+
// revert of a fresh locale clears the run's writes back to empty.
|
|
399
|
+
// Best-effort: on capture failure the unit has no locale-tagged
|
|
400
|
+
// snapshot and Revert refuses it (safe default) while translation
|
|
401
|
+
// proceeds normally.
|
|
402
|
+
// -------------------------------------------------------------------
|
|
403
|
+
if (unit.snapshotLocale !== unit.locale) {
|
|
404
|
+
try {
|
|
405
|
+
const surfaceFields = isGlobal ? (payload.config.globals ?? []).find((g)=>g.slug === unit.collection)?.fields : (payload.config.collections ?? []).find((c)=>c.slug === unit.collection)?.fields;
|
|
406
|
+
if (surfaceFields) {
|
|
407
|
+
const topLevelPaths = Array.from(new Set(resolveTranslatableFields(surfaceFields).filter((f)=>f.localized).map((f)=>f.path.split('.')[0])));
|
|
408
|
+
const targetDoc = isGlobal ? await findGlobalNoFallback(payload, unit.collection, unit.locale) : await findByIdNoFallback(payload, unit.collection, unit.documentId, unit.locale);
|
|
409
|
+
const snapshot = captureLocalizedSnapshot(targetDoc ?? {}, topLevelPaths);
|
|
410
|
+
for (const path of topLevelPaths){
|
|
411
|
+
if (!(path in snapshot)) snapshot[path] = null;
|
|
412
|
+
}
|
|
413
|
+
await payload.update({
|
|
414
|
+
collection: unitsSlug,
|
|
415
|
+
id: unitId,
|
|
416
|
+
data: {
|
|
417
|
+
preRunSnapshot: snapshot,
|
|
418
|
+
snapshotLocale: unit.locale
|
|
419
|
+
},
|
|
420
|
+
overrideAccess: true
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
payload.logger?.warn?.(`[ai-translate] worker: pre-write snapshot capture failed for ${unit.collection}/${unit.documentId} locale=${unit.locale} — unit will not be revertable: ${err instanceof Error ? err.message : String(err)}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
let costUsd;
|
|
429
|
+
// `providerLatencyMs` is the SUM of per-batch LLM round-trip times
|
|
430
|
+
// reported by the provider — measured around the SDK call, so it
|
|
431
|
+
// excludes token-bucket wait + provider rate-limit backoff. That's
|
|
432
|
+
// the "how long did the AI actually take" signal the editor wants.
|
|
433
|
+
//
|
|
434
|
+
// The delta between this unit's `startedAt` and `completedAt`
|
|
435
|
+
// includes throttle wait and is wallclock-in-worker — still useful
|
|
436
|
+
// for engineering (it answers "how long was this unit blocked by
|
|
437
|
+
// the rate limiter?") but not what the editor needs.
|
|
438
|
+
let providerLatencyMs = 0;
|
|
439
|
+
let outcome;
|
|
440
|
+
if (isGlobal) {
|
|
441
|
+
outcome = await translateApi.translateGlobal(payload, {
|
|
442
|
+
global: unit.collection,
|
|
443
|
+
targetLocales,
|
|
444
|
+
req: params.req,
|
|
445
|
+
force: isForceMode
|
|
446
|
+
});
|
|
447
|
+
} else {
|
|
448
|
+
outcome = await translateApi.translateDocument(payload, {
|
|
449
|
+
collection: unit.collection,
|
|
450
|
+
id: unit.documentId,
|
|
451
|
+
targetLocales,
|
|
452
|
+
req: params.req,
|
|
453
|
+
force: isForceMode
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
costUsd = requireCostUsd(outcome);
|
|
457
|
+
providerLatencyMs = outcome?.providerLatencyMs ?? 0;
|
|
458
|
+
// ---------------------------------------------------------------------
|
|
459
|
+
// Unit-status truth check (v1.2.5 + v1.2.7 refinement).
|
|
460
|
+
//
|
|
461
|
+
// The translate API returns `{ succeeded, preserved, failed }` where
|
|
462
|
+
// `failed` mixes two outcome classes:
|
|
463
|
+
// - `status: 'failed'` — HARD failure (provider error, no response,
|
|
464
|
+
// schema rejection). These are real failures the editor must see.
|
|
465
|
+
// - `status: 'skipped'` — SOFT skip (verbatim echo, too-short ratio,
|
|
466
|
+
// placeholder mismatch). The output validator rejected the value
|
|
467
|
+
// but it wasn't a hardware error — the model gave us something we
|
|
468
|
+
// deliberately won't write. Common case: a 1-word `alt` field
|
|
469
|
+
// containing a brand name the model correctly returns unchanged.
|
|
470
|
+
//
|
|
471
|
+
// v1.2.5 lumped both into `failedCount` and stamped every all-rejected
|
|
472
|
+
// unit as `permanent.schema` with an "AI response was unreadable"
|
|
473
|
+
// editor message — which lies when the actual cause was an
|
|
474
|
+
// intentional echo on a proper noun. v1.2.7 splits them:
|
|
475
|
+
//
|
|
476
|
+
// - succeeded > 0 → 'success' (at least one write landed)
|
|
477
|
+
// - hardFailed > 0 && succeeded === 0 && preserved === 0
|
|
478
|
+
// → 'failed' / 'permanent.schema'
|
|
479
|
+
// (genuine output-validation failure)
|
|
480
|
+
// - softSkipped > 0 && hardFailed === 0 && succeeded === 0
|
|
481
|
+
// && preserved === 0
|
|
482
|
+
// → 'skipped' / `soft-skip.no-translation-needed`
|
|
483
|
+
// (model returned source unchanged for every
|
|
484
|
+
// field — typically proper nouns / short labels)
|
|
485
|
+
// - preserved > 0 → 'success' (manual-edit guard kept everything)
|
|
486
|
+
// - all zero → 'success' (empty source, no-op)
|
|
487
|
+
// ---------------------------------------------------------------------
|
|
488
|
+
const succeededCount = outcome?.succeeded?.length ?? 0;
|
|
489
|
+
const preservedCount = outcome?.preserved?.length ?? 0;
|
|
490
|
+
const failedFields = outcome?.failed ?? [];
|
|
491
|
+
const hardFailed = failedFields.filter((f)=>f.status === 'failed');
|
|
492
|
+
const softSkipped = failedFields.filter((f)=>f.status === 'skipped');
|
|
493
|
+
if (succeededCount === 0 && preservedCount === 0 && hardFailed.length === 0 && softSkipped.length > 0) {
|
|
494
|
+
const paths = softSkipped.map((f)=>f.fieldPath ?? '?').slice(0, 5).join(', ');
|
|
495
|
+
const message = `${softSkipped.length} field(s) returned unchanged from source${paths ? `: ${paths}` : ''} — kept as-is`;
|
|
496
|
+
await payload.update({
|
|
497
|
+
collection: unitsSlug,
|
|
498
|
+
id: unitId,
|
|
499
|
+
data: {
|
|
500
|
+
status: 'skipped',
|
|
501
|
+
failureMessage: message,
|
|
502
|
+
processingDurationMs: providerLatencyMs,
|
|
503
|
+
completedAt: now().toISOString()
|
|
504
|
+
},
|
|
505
|
+
overrideAccess: true
|
|
506
|
+
});
|
|
507
|
+
await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'skippedUnits');
|
|
508
|
+
await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
|
|
509
|
+
log.event('info', 'bulk.worker.unit.all-soft-skipped', {
|
|
510
|
+
durationMs: Date.now() - unitStartedAtMs,
|
|
511
|
+
softSkippedCount: softSkipped.length,
|
|
512
|
+
attempt: nextAttempts
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
status: 'skipped',
|
|
516
|
+
failureMessage: message
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (succeededCount === 0 && preservedCount === 0 && hardFailed.length > 0) {
|
|
520
|
+
const paths = hardFailed.map((f)=>f.fieldPath ?? '?').slice(0, 5).join(', ');
|
|
521
|
+
const message = `${hardFailed.length} field(s) failed validation${paths ? `: ${paths}` : ''}`;
|
|
522
|
+
await payload.update({
|
|
523
|
+
collection: unitsSlug,
|
|
524
|
+
id: unitId,
|
|
525
|
+
data: {
|
|
526
|
+
status: 'failed',
|
|
527
|
+
failureCode: 'permanent.schema',
|
|
528
|
+
failureMessage: message,
|
|
529
|
+
processingDurationMs: providerLatencyMs,
|
|
530
|
+
completedAt: now().toISOString()
|
|
531
|
+
},
|
|
532
|
+
overrideAccess: true
|
|
533
|
+
});
|
|
534
|
+
await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'failedUnits');
|
|
535
|
+
await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
|
|
536
|
+
log.event('warn', 'bulk.worker.unit.all-fields-failed', {
|
|
537
|
+
durationMs: Date.now() - unitStartedAtMs,
|
|
538
|
+
failedCount: hardFailed.length,
|
|
539
|
+
attempt: nextAttempts,
|
|
540
|
+
failureCode: 'permanent.schema'
|
|
541
|
+
});
|
|
542
|
+
return {
|
|
543
|
+
status: 'failed',
|
|
544
|
+
failureCode: 'permanent.schema',
|
|
545
|
+
failureMessage: message
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
await payload.update({
|
|
549
|
+
collection: unitsSlug,
|
|
550
|
+
id: unitId,
|
|
551
|
+
data: {
|
|
552
|
+
status: 'success',
|
|
553
|
+
costUsd,
|
|
554
|
+
processingDurationMs: providerLatencyMs,
|
|
555
|
+
completedAt: now().toISOString()
|
|
556
|
+
},
|
|
557
|
+
overrideAccess: true
|
|
558
|
+
});
|
|
559
|
+
await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'completedUnits', costUsd);
|
|
560
|
+
await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
|
|
561
|
+
log.event('info', 'bulk.worker.unit.succeeded', {
|
|
562
|
+
durationMs: Date.now() - unitStartedAtMs,
|
|
563
|
+
costUsd,
|
|
564
|
+
attempt: nextAttempts
|
|
565
|
+
});
|
|
566
|
+
return {
|
|
567
|
+
status: 'success',
|
|
568
|
+
costUsd
|
|
569
|
+
};
|
|
570
|
+
} catch (err) {
|
|
571
|
+
const code = classifyError(err);
|
|
572
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
573
|
+
// Transient errors: send back to `pending` for the cron tick to
|
|
574
|
+
// retry, UNLESS we've exhausted maxAttempts. Past that, mark
|
|
575
|
+
// failed terminally with `transient.crashed`.
|
|
576
|
+
const isTransient = code === 'transient.rate_limited' || code === 'transient.provider' || code === 'transient.crashed';
|
|
577
|
+
if (isTransient && nextAttempts < maxAttempts) {
|
|
578
|
+
await payload.update({
|
|
579
|
+
collection: unitsSlug,
|
|
580
|
+
id: unitId,
|
|
581
|
+
data: {
|
|
582
|
+
status: 'pending',
|
|
583
|
+
failureCode: code,
|
|
584
|
+
failureMessage: message
|
|
585
|
+
},
|
|
586
|
+
overrideAccess: true
|
|
587
|
+
});
|
|
588
|
+
log.event('warn', 'bulk.worker.unit.transient', {
|
|
589
|
+
err,
|
|
590
|
+
attempt: nextAttempts,
|
|
591
|
+
maxAttempts,
|
|
592
|
+
failureCode: code,
|
|
593
|
+
durationMs: Date.now() - unitStartedAtMs
|
|
594
|
+
});
|
|
595
|
+
return {
|
|
596
|
+
status: 'pending',
|
|
597
|
+
failureCode: code,
|
|
598
|
+
failureMessage: message
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const terminalCode = isTransient ? 'transient.crashed' : code;
|
|
602
|
+
await payload.update({
|
|
603
|
+
collection: unitsSlug,
|
|
604
|
+
id: unitId,
|
|
605
|
+
data: {
|
|
606
|
+
status: 'failed',
|
|
607
|
+
failureCode: terminalCode,
|
|
608
|
+
failureMessage: message,
|
|
609
|
+
completedAt: now().toISOString()
|
|
610
|
+
},
|
|
611
|
+
overrideAccess: true
|
|
612
|
+
});
|
|
613
|
+
await bumpBatchCounter(payload, batchesSlug, unit.batchId, 'failedUnits');
|
|
614
|
+
await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks, log);
|
|
615
|
+
log.event('error', 'bulk.worker.unit.failed', {
|
|
616
|
+
err,
|
|
617
|
+
attempt: nextAttempts,
|
|
618
|
+
maxAttempts,
|
|
619
|
+
failureCode: terminalCode,
|
|
620
|
+
durationMs: Date.now() - unitStartedAtMs
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
status: 'failed',
|
|
624
|
+
failureCode: terminalCode,
|
|
625
|
+
failureMessage: message
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
} finally{
|
|
629
|
+
// Per-doc lock acquired above the try/catch. Release in every exit
|
|
630
|
+
// path (success / failed / pending) so the next sibling locale for
|
|
631
|
+
// this doc can proceed. No-op on non-Postgres adapters.
|
|
632
|
+
await releaseLock();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// Error classification
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
/**
|
|
639
|
+
* Maps a thrown error to a `failureCode`. Order matters — more
|
|
640
|
+
* specific patterns win. Defaults to `unknown` for anything we can't
|
|
641
|
+
* recognise so the row stays visible in the admin drill-down rather
|
|
642
|
+
* than disappearing into a generic 'failed'.
|
|
643
|
+
*
|
|
644
|
+
* Classes per design doc Decision §worker §error-classification:
|
|
645
|
+
* - transient.rate_limited: 429 / explicit rate-limit
|
|
646
|
+
* - transient.provider: 5xx / fetch timeout
|
|
647
|
+
* - permanent.schema: NoObjectGeneratedError (Vercel AI SDK
|
|
648
|
+
* throws this when the model returns
|
|
649
|
+
* malformed structured output)
|
|
650
|
+
* - permanent.too_large: cost-guard PER_DOC_CEILING/PER_CALL_LIMIT
|
|
651
|
+
* - permanent.config: 401 / 403 / auth
|
|
652
|
+
* - permanent.deleted: "Document not found" from findByIdNoFallback
|
|
653
|
+
* - unknown: default
|
|
654
|
+
*/ export function classifyError(err) {
|
|
655
|
+
if (err === null || err === undefined) return 'unknown';
|
|
656
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
657
|
+
const lower = message.toLowerCase();
|
|
658
|
+
const name = err instanceof Error ? err.name : '';
|
|
659
|
+
const codeProp = err?.code;
|
|
660
|
+
const codeStr = typeof codeProp === 'string' ? codeProp : '';
|
|
661
|
+
// Cost-guard: check first — its error messages contain '429' fragments
|
|
662
|
+
// in some surfaces.
|
|
663
|
+
if (codeStr === 'PER_DOC_CEILING' || codeStr === 'PER_CALL_LIMIT') {
|
|
664
|
+
return 'permanent.too_large';
|
|
665
|
+
}
|
|
666
|
+
if (lower.includes('per_doc_ceiling') || lower.includes('per_call_limit')) {
|
|
667
|
+
return 'permanent.too_large';
|
|
668
|
+
}
|
|
669
|
+
// Decision #29 / NEW-12 (v1.2.6): undefined / non-finite cost is a
|
|
670
|
+
// provider-flake signal — we hard-throw COST_UNDEFINED inside the
|
|
671
|
+
// worker so the bad cost row never persists as $0 spend. In practice
|
|
672
|
+
// (batches #5, #6 on blog-wild prod) 124 / 168 + 129 / 168 units in
|
|
673
|
+
// the same batch succeeded with the same model + same config, so
|
|
674
|
+
// "config broken" is the wrong story. Classify as transient.provider
|
|
675
|
+
// so the cron retries it on the next tick and the UI doesn't
|
|
676
|
+
// misdirect editors to the settings page. If retries exhaust, the
|
|
677
|
+
// worker still escalates to `transient.crashed` (terminal) — the
|
|
678
|
+
// unit doesn't silently disappear.
|
|
679
|
+
if (codeStr === 'COST_UNDEFINED') {
|
|
680
|
+
return 'transient.provider';
|
|
681
|
+
}
|
|
682
|
+
// Defense in depth: when COST_UNDEFINED is rethrown by an outer
|
|
683
|
+
// wrapper that drops the `.code` property, fall back to message
|
|
684
|
+
// matching. The literal text is set in `throwCostUndefined` below
|
|
685
|
+
// and is stable across versions.
|
|
686
|
+
if (lower.includes('provider returned undefined / non-finite estimatedcostusd') || lower.includes('cost instrumentation broken')) {
|
|
687
|
+
return 'transient.provider';
|
|
688
|
+
}
|
|
689
|
+
// Schema-invalid (AI SDK throws NoObjectGeneratedError when the
|
|
690
|
+
// model produces malformed JSON against the requested schema).
|
|
691
|
+
if (name === 'NoObjectGeneratedError' || lower.includes('noobjectgeneratederror')) {
|
|
692
|
+
return 'permanent.schema';
|
|
693
|
+
}
|
|
694
|
+
// Document-not-found from the plugin's `findByIdNoFallback` path.
|
|
695
|
+
// NEW-12 (v1.2.6): also catch the bare "Not Found" payload throws when
|
|
696
|
+
// a doc is hard-deleted between enqueue and worker pickup — observed
|
|
697
|
+
// on prod (batches #5/6 had `failure_message='Not Found'` rows that
|
|
698
|
+
// were silently classified as `unknown` and rendered as "Translation
|
|
699
|
+
// failed unexpectedly" instead of the more accurate "document was
|
|
700
|
+
// deleted" copy.
|
|
701
|
+
if (/document not found|global not found|not found in payload/.test(lower) || /^not found$/i.test(message.trim())) {
|
|
702
|
+
return 'permanent.deleted';
|
|
703
|
+
}
|
|
704
|
+
// Auth / config — distinct from transient 5xx.
|
|
705
|
+
if (/\b401\b|\b403\b|unauthorized|forbidden|api[_-]?key/i.test(message)) {
|
|
706
|
+
return 'permanent.config';
|
|
707
|
+
}
|
|
708
|
+
// Rate-limit BEFORE provider — both can include '429' in the
|
|
709
|
+
// message but rate-limit is the more specific reason.
|
|
710
|
+
if (/\b429\b|rate[_-]?limit/i.test(message)) {
|
|
711
|
+
return 'transient.rate_limited';
|
|
712
|
+
}
|
|
713
|
+
// Provider 5xx / fetch timeout / network.
|
|
714
|
+
if (/\b5\d\d\b|timeout|timed out|fetch failed|econnreset|enotfound|network/i.test(message) || name === 'AbortError' || name === 'FetchError') {
|
|
715
|
+
return 'transient.provider';
|
|
716
|
+
}
|
|
717
|
+
return 'unknown';
|
|
718
|
+
}
|
|
719
|
+
async function bumpBatchCounter(payload, batchesSlug, batchId, counter, costUsd = 0) {
|
|
720
|
+
const log = createScopedLogger(payload, {
|
|
721
|
+
component: 'bulk.worker',
|
|
722
|
+
batchId
|
|
723
|
+
});
|
|
724
|
+
// Atomic increment via raw SQL — `payload.update()` with a fetched
|
|
725
|
+
// value would race under concurrent workers (read-then-write).
|
|
726
|
+
// Postgres `column = column + N` is the standard fix and lets us
|
|
727
|
+
// bump the counter + actualCostUsd in a single statement.
|
|
728
|
+
//
|
|
729
|
+
// The column-name map mirrors Payload-Drizzle's camelCase →
|
|
730
|
+
// snake_case convention. Hard-coded rather than computed so a
|
|
731
|
+
// future column rename surfaces as a build/test failure here
|
|
732
|
+
// rather than a silent drop.
|
|
733
|
+
const columnByCounter = {
|
|
734
|
+
completedUnits: 'completed_units',
|
|
735
|
+
failedUnits: 'failed_units',
|
|
736
|
+
skippedUnits: 'skipped_units'
|
|
737
|
+
};
|
|
738
|
+
const column = columnByCounter[counter];
|
|
739
|
+
const numericBatchId = Number.parseInt(batchId, 10);
|
|
740
|
+
if (!Number.isFinite(numericBatchId)) {
|
|
741
|
+
log.event('warn', 'bulk.counter-bump.invalid-batch-id', {
|
|
742
|
+
counter,
|
|
743
|
+
batchId
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const drizzle = payload.db.drizzle;
|
|
748
|
+
if (!drizzle) {
|
|
749
|
+
// Non-postgres adapter — fall back to the read-modify-write path.
|
|
750
|
+
// Concurrency-sensitive counters will drift but the system stays
|
|
751
|
+
// functional. The maybeTransitionBatch reconciler re-derives the
|
|
752
|
+
// terminal status from the source-of-truth units count, so a
|
|
753
|
+
// skewed counter doesn't permanently block batch completion.
|
|
754
|
+
try {
|
|
755
|
+
const row = await payload.findByID({
|
|
756
|
+
collection: batchesSlug,
|
|
757
|
+
id: batchId,
|
|
758
|
+
overrideAccess: true,
|
|
759
|
+
depth: 0
|
|
760
|
+
});
|
|
761
|
+
if (!row) return;
|
|
762
|
+
const current = Number(row[counter] ?? 0);
|
|
763
|
+
const data = {
|
|
764
|
+
[counter]: current + 1
|
|
765
|
+
};
|
|
766
|
+
if (costUsd > 0) {
|
|
767
|
+
data.actualCostUsd = Number(row.actualCostUsd ?? 0) + costUsd;
|
|
768
|
+
}
|
|
769
|
+
await payload.update({
|
|
770
|
+
collection: batchesSlug,
|
|
771
|
+
id: batchId,
|
|
772
|
+
data,
|
|
773
|
+
overrideAccess: true
|
|
774
|
+
});
|
|
775
|
+
} catch (err) {
|
|
776
|
+
log.event('warn', 'bulk.counter-bump.failed', {
|
|
777
|
+
err,
|
|
778
|
+
counter,
|
|
779
|
+
batchId,
|
|
780
|
+
path: 'fallback'
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
let sqlTag;
|
|
786
|
+
try {
|
|
787
|
+
const drizzleMod = await import('drizzle-orm');
|
|
788
|
+
sqlTag = drizzleMod.sql;
|
|
789
|
+
} catch {
|
|
790
|
+
sqlTag = undefined;
|
|
791
|
+
}
|
|
792
|
+
if (!sqlTag) {
|
|
793
|
+
log.event('warn', 'bulk.counter-bump.drizzle-unavailable', {
|
|
794
|
+
counter,
|
|
795
|
+
batchId
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const columnExpr = sqlTag.raw(`"${column}"`);
|
|
801
|
+
if (costUsd > 0) {
|
|
802
|
+
await drizzle.execute(sqlTag`UPDATE "bulk_translate_batches"
|
|
803
|
+
SET ${columnExpr} = COALESCE(${columnExpr}, 0) + 1,
|
|
804
|
+
"actual_cost_usd" = COALESCE("actual_cost_usd", 0) + ${costUsd},
|
|
805
|
+
"updated_at" = now()
|
|
806
|
+
WHERE "id" = ${numericBatchId}`);
|
|
807
|
+
} else {
|
|
808
|
+
await drizzle.execute(sqlTag`UPDATE "bulk_translate_batches"
|
|
809
|
+
SET ${columnExpr} = COALESCE(${columnExpr}, 0) + 1,
|
|
810
|
+
"updated_at" = now()
|
|
811
|
+
WHERE "id" = ${numericBatchId}`);
|
|
812
|
+
}
|
|
813
|
+
} catch (err) {
|
|
814
|
+
log.event('warn', 'bulk.counter-bump.failed', {
|
|
815
|
+
err,
|
|
816
|
+
counter,
|
|
817
|
+
batchId
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Check whether every unit in the batch is in a terminal state. If
|
|
823
|
+
* yes, transition the batch and fire the callback.
|
|
824
|
+
*/ export async function maybeTransitionBatch(payload, batchesSlug, unitsSlug, batchId, callbacks, parentLog) {
|
|
825
|
+
const log = parentLog ?? createScopedLogger(payload, {
|
|
826
|
+
component: 'bulk.worker',
|
|
827
|
+
batchId
|
|
828
|
+
});
|
|
829
|
+
const row = await payload.findByID({
|
|
830
|
+
collection: batchesSlug,
|
|
831
|
+
id: batchId,
|
|
832
|
+
overrideAccess: true,
|
|
833
|
+
depth: 0
|
|
834
|
+
});
|
|
835
|
+
if (!row) return;
|
|
836
|
+
// v1.2.5: also handle `cancelling`. Pre-1.2.5 the cancel endpoint
|
|
837
|
+
// flipped the batch to `cancelling` and relied on workers finishing
|
|
838
|
+
// in-flight units to call maybeTransitionBatch; this function then
|
|
839
|
+
// bailed because status was no longer `running` / `queued`. Result:
|
|
840
|
+
// the batch sat in `cancelling` forever and only `force-reset`
|
|
841
|
+
// could unstick it. Allowing `cancelling` here lets the last worker
|
|
842
|
+
// to finish flip the batch to `cancelled`.
|
|
843
|
+
if (row.status !== 'running' && row.status !== 'queued' && row.status !== 'cancelling') return;
|
|
844
|
+
// Re-derive counts from the source of truth (units collection) so
|
|
845
|
+
// we don't trust the eventually-consistent counters on the batch
|
|
846
|
+
// row.
|
|
847
|
+
const totalsByStatus = await aggregateUnitStatuses(payload, unitsSlug, batchId);
|
|
848
|
+
const total = row.totalUnits ?? totalsByStatus.total;
|
|
849
|
+
if (total === 0) return; // coordinator hasn't stamped totals yet
|
|
850
|
+
const terminal = totalsByStatus.success + totalsByStatus.failed + totalsByStatus.skipped + totalsByStatus.reverted;
|
|
851
|
+
if (terminal < total) return;
|
|
852
|
+
// Determine terminal status. Vocabulary: 'success' (not 'completed')
|
|
853
|
+
// — aligned across schema + types + readers in v1.2.4.
|
|
854
|
+
//
|
|
855
|
+
// v1.2.5: when transitioning out of `cancelling`, the destination is
|
|
856
|
+
// always `cancelled` regardless of per-unit outcomes. The admin
|
|
857
|
+
// explicitly asked to stop; reporting `success` / `partial` /
|
|
858
|
+
// `failed` would erase the intent.
|
|
859
|
+
let nextStatus;
|
|
860
|
+
if (row.status === 'cancelling') {
|
|
861
|
+
nextStatus = 'cancelled';
|
|
862
|
+
} else if (totalsByStatus.failed === 0 && totalsByStatus.success > 0) {
|
|
863
|
+
nextStatus = 'success';
|
|
864
|
+
} else if (totalsByStatus.success === 0 && totalsByStatus.failed > 0) {
|
|
865
|
+
nextStatus = 'failed';
|
|
866
|
+
} else {
|
|
867
|
+
nextStatus = 'partial';
|
|
868
|
+
}
|
|
869
|
+
await payload.update({
|
|
870
|
+
collection: batchesSlug,
|
|
871
|
+
id: batchId,
|
|
872
|
+
data: {
|
|
873
|
+
status: nextStatus,
|
|
874
|
+
completedAt: new Date().toISOString()
|
|
875
|
+
},
|
|
876
|
+
overrideAccess: true
|
|
877
|
+
});
|
|
878
|
+
log.event('info', 'bulk.batch.transition', {
|
|
879
|
+
batchId,
|
|
880
|
+
nextStatus,
|
|
881
|
+
totalsByStatus
|
|
882
|
+
});
|
|
883
|
+
await fireBatchCallback(payload, callbacks, row, nextStatus, totalsByStatus, log);
|
|
884
|
+
}
|
|
885
|
+
async function aggregateUnitStatuses(payload, unitsSlug, batchId) {
|
|
886
|
+
const statuses = [
|
|
887
|
+
'success',
|
|
888
|
+
'failed',
|
|
889
|
+
'skipped',
|
|
890
|
+
'reverted',
|
|
891
|
+
'pending',
|
|
892
|
+
'running'
|
|
893
|
+
];
|
|
894
|
+
const out = {
|
|
895
|
+
total: 0,
|
|
896
|
+
success: 0,
|
|
897
|
+
failed: 0,
|
|
898
|
+
skipped: 0,
|
|
899
|
+
reverted: 0,
|
|
900
|
+
pending: 0,
|
|
901
|
+
running: 0
|
|
902
|
+
};
|
|
903
|
+
for (const status of statuses){
|
|
904
|
+
const result = await payload.count({
|
|
905
|
+
collection: unitsSlug,
|
|
906
|
+
where: {
|
|
907
|
+
and: [
|
|
908
|
+
{
|
|
909
|
+
batchId: {
|
|
910
|
+
equals: batchId
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
status: {
|
|
915
|
+
equals: status
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
]
|
|
919
|
+
},
|
|
920
|
+
overrideAccess: true
|
|
921
|
+
});
|
|
922
|
+
out[status] = result.totalDocs;
|
|
923
|
+
out.total += result.totalDocs;
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
async function fireBatchCallback(payload, callbacks, batch, status, totals, log) {
|
|
928
|
+
if (!callbacks) return;
|
|
929
|
+
const event = {
|
|
930
|
+
batchId: String(batch.id),
|
|
931
|
+
status,
|
|
932
|
+
scope: {
|
|
933
|
+
collections: batch.scope?.collections,
|
|
934
|
+
globals: batch.scope?.globals,
|
|
935
|
+
locales: batch.scope?.locales,
|
|
936
|
+
mode: batch.scope?.mode ?? 'changed'
|
|
937
|
+
},
|
|
938
|
+
counts: {
|
|
939
|
+
total: totals.total,
|
|
940
|
+
completed: totals.success,
|
|
941
|
+
failed: totals.failed,
|
|
942
|
+
skipped: totals.skipped
|
|
943
|
+
},
|
|
944
|
+
costUsd: {
|
|
945
|
+
estimated: Number(batch.estimatedCostUsd ?? 0),
|
|
946
|
+
actual: Number(batch.actualCostUsd ?? 0)
|
|
947
|
+
},
|
|
948
|
+
triggeredByUserId: String(batch.triggeredByUserId ?? ''),
|
|
949
|
+
triggeredByEmail: batch.triggeredByEmail,
|
|
950
|
+
durationMs: batch.startedAt ? Date.now() - new Date(batch.startedAt).getTime() : 0
|
|
951
|
+
};
|
|
952
|
+
try {
|
|
953
|
+
await callbacks.onBatchComplete?.(event);
|
|
954
|
+
if (status === 'failed') {
|
|
955
|
+
await callbacks.onBatchFailed?.(event);
|
|
956
|
+
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
log.event('error', 'bulk.batch.callback.failed', {
|
|
959
|
+
err,
|
|
960
|
+
batchId: String(batch.id),
|
|
961
|
+
status
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function isGlobalSlug(payload, slug) {
|
|
966
|
+
const globals = payload.config?.globals ?? [];
|
|
967
|
+
return globals.some((g)=>g.slug === slug);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Decision #29 + round-5 pr-reviewer blocker #3 — `undefined`
|
|
971
|
+
* `estimatedCostUsd` is the canonical signal that cost instrumentation
|
|
972
|
+
* is broken upstream. Silently coercing it to `0` (the previous
|
|
973
|
+
* behavior) corrupted `actualCostUsd` aggregates on every batch using
|
|
974
|
+
* the OpenRouter provider, hiding real spend from the cap + reporting.
|
|
975
|
+
*
|
|
976
|
+
* Hard-throw with a classified error so `classifyError` routes it to
|
|
977
|
+
* `permanent.config`, surfacing the broken instrumentation in the
|
|
978
|
+
* admin drill-down rather than silently aggregating $0.
|
|
979
|
+
*/ function requireCostUsd(result) {
|
|
980
|
+
const raw = result?.usage?.estimatedCostUsd;
|
|
981
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
|
|
982
|
+
throw Object.assign(new Error('Provider returned undefined / non-finite estimatedCostUsd — cost instrumentation broken; refusing to record $0'), {
|
|
983
|
+
code: 'COST_UNDEFINED'
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
return raw;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Lazy-load the plugin's translate API for the default builder. Tests
|
|
990
|
+
* inject their own implementation via `options.translateApi`.
|
|
991
|
+
*/ async function loadDefaultTranslateApi() {
|
|
992
|
+
const mod = await import('../api.js');
|
|
993
|
+
// Single narrow-cast (NOT `as unknown as`) — the real api signatures
|
|
994
|
+
// are a strict subset of `TranslateApiFns` (they accept a stricter
|
|
995
|
+
// `req?: PayloadRequest`, we expose the looser `req?: unknown`).
|
|
996
|
+
return {
|
|
997
|
+
translateDocument: mod.translateDocument,
|
|
998
|
+
translateGlobal: mod.translateGlobal
|
|
999
|
+
};
|
|
1000
|
+
}
|