diff --git a/.changeset/github-translation-cache-persistence.md b/.changeset/github-translation-cache-persistence.md new file mode 100644 index 0000000000..7c8687a23d --- /dev/null +++ b/.changeset/github-translation-cache-persistence.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: GitHub/GitLab import translations now persist across app restarts. +category: fix +dev: Aligns import_translation_cache write/read partitioning and adds migration 0016 for existing PostgreSQL databases. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index cf4776163e..e0cf5e9aee 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -650,7 +650,7 @@ Default notes: | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | | `sessionAdvisorEnabledByDefault` | `boolean` | `false` | Project-level default for the session advisor (LLM overseer agent that reviews live executor transcripts). Off by default (opt-in). Quick Add exposes an eye toggle next to GitHub that inherits this default; each task can override via `sessionAdvisorEnabled`. Provider and model ids still come from workflow settings (`plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId`). Dashboard location: **Settings → Project → General → Session advisor (overseer agent)**. | | `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | -| `githubImportAutoTranslate` | `boolean` | `false` | Project-scoped, import-only option. When enabled, the Import Tasks panel automatically translates foreign-language GitHub/GitLab issue titles and bodies into `importTranslateTargetLocale` and shows the translation by default (the original text stays one toggle away). Off by default so all-English projects never pay for a per-issue AI call. Dashboard location: **Settings → Project → General → GitHub Tracking**. | +| `githubImportAutoTranslate` | `boolean` | `false` | Project-scoped, import-only option. When enabled, the Import Tasks panel automatically translates foreign-language GitHub/GitLab issue titles and bodies into `importTranslateTargetLocale` and shows the translation by default (the original text stays one toggle away). Translations persist across app restarts until the upstream issue changes or closes, avoiding repeat model calls. Off by default so all-English projects never pay for a per-issue AI call. Dashboard location: **Settings → Project → General → GitHub Tracking**. | | `importTranslateTargetLocale` | `Locale` | `undefined` | Target language for `githubImportAutoTranslate`. One of `SUPPORTED_LOCALES`. When unset, import translation follows the dashboard's own `language` setting. Dashboard location: **Settings → Project → General → GitHub Tracking**. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). | | `gitlabEnabled` | `boolean` | `undefined` (effective global fallback, then `true`) | Project GitLab integration enable switch. Explicit `false` disables outbound GitLab API imports, completion comments, close/reopen, source closed-at backfill, and tracking refresh side effects for this project without deleting saved URL/token fields. Dashboard location: **Settings → Project → General → GitLab Configuration** and **Settings → Project → Merge → GitLab Authentication** disclosure headers. | diff --git a/packages/core/src/__tests__/postgres/import-translation-cache.pg.test.ts b/packages/core/src/__tests__/postgres/import-translation-cache.pg.test.ts new file mode 100644 index 0000000000..124b13776e --- /dev/null +++ b/packages/core/src/__tests__/postgres/import-translation-cache.pg.test.ts @@ -0,0 +1,105 @@ +/* +FNXC:GitHubImportTranslate 2026-07-16-23:30: +Import translations are a durable project-owned cache, not a dashboard-session +optimization. These PostgreSQL regressions reopen a TaskStore against the same +database to prove cache rows survive the restart boundary and preserve the +project partition used by RLS. +*/ + +import { expect, it } from "vitest"; +import { createConnectionSetFromUrl } from "../../postgres/connection.js"; +import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js"; +import type { ResolvedBackend } from "../../postgres/backend-resolver.js"; +import { TaskStore } from "../../store.js"; +import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../__test-utils__/pg-test-harness.js"; + +const key = { + provider: "github", + repoKey: "owner/repo", + issueNumber: 42, + targetLocale: "en", + sourceHash: "original-source-hash", +}; + +const value = { + translatedTitle: "Translated title", + translatedBody: "Translated body", + detectedLocale: "es", +}; + +async function reopenStore(harness: PgTestHarness, projectId?: string): Promise<{ store: TaskStore; layer: AsyncDataLayer }> { + const backend: ResolvedBackend = { + mode: "external", + runtimeUrl: harness.testUrl, + migrationUrl: harness.testUrl, + migrationUrlOverridden: false, + }; + const connections = await createConnectionSetFromUrl(backend, { + poolMax: 1, + connectTimeoutSeconds: 5, + ...(projectId ? { projectId, bypassProjectIsolation: false } : {}), + }); + const layer = createAsyncDataLayer(connections, projectId ? { projectId } : undefined); + const store = new TaskStore(harness.rootDir, undefined, { asyncLayer: layer }); + await store.init(); + return { store, layer }; +} + +pgDescribe("import translation cache persistence (PostgreSQL)", () => { + it("records then reads a translation from a fresh store against the same database", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_translation_cache" }); + let reopened: { store: TaskStore; layer: AsyncDataLayer } | null = null; + try { + await harness.store.recordImportTranslation(key, value, "2026-07-16T00:00:00.000Z"); + reopened = await reopenStore(harness); + + await expect(reopened.store.getImportTranslation(key)).resolves.toEqual({ + ...value, + recordedAt: "2026-07-16T00:00:00.000Z", + }); + } finally { + await reopened?.store.close(); + await reopened?.layer.close(); + await harness.teardown(); + } + }); + + it("uses the same normalized legacy partition for blank compatibility stores", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_translation_legacy" }); + try { + const blankLayer: AsyncDataLayer = { ...harness.layer, projectId: "" }; + const blankStore = new TaskStore(harness.rootDir, undefined, { asyncLayer: blankLayer }); + await blankStore.recordImportTranslation({ ...key, provider: "gitlab" }, value, "2026-07-16T00:00:00.000Z"); + + await expect(blankStore.getImportTranslation({ ...key, provider: "gitlab" })).resolves.toMatchObject(value); + } finally { + await harness.teardown(); + } + }); + + it("prunes only closed rows in its own provider partition", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_translation_prune" }); + try { + await harness.store.recordImportTranslation(key, value, "2026-07-16T00:00:00.000Z"); + await harness.store.recordImportTranslation({ ...key, provider: "gitlab" }, value, "2026-07-16T00:00:00.000Z"); + await harness.store.pruneImportTranslations("github", key.repoKey, [key.issueNumber]); + + await expect(harness.store.getImportTranslation(key)).resolves.toBeNull(); + await expect(harness.store.getImportTranslation({ ...key, provider: "gitlab" })).resolves.toMatchObject(value); + } finally { + await harness.teardown(); + } + }); + + it("keeps source hashes stable when an absent body is normalized to an empty string", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_translation_hash" }); + try { + const emptyBodyKey = { ...key, issueNumber: 43, sourceHash: "title-and-empty-body" }; + await harness.store.recordImportTranslation(emptyBodyKey, value, "2026-07-16T00:00:00.000Z"); + + await expect(harness.store.getImportTranslation(emptyBodyKey)).resolves.toMatchObject(value); + } finally { + await harness.teardown(); + } + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 56535b6b47..7b0663478c 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -41,6 +41,7 @@ import { MULTI_PROJECT_CUTOVER_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, IMPORT_TRANSLATION_CACHE_VERSION, + IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, OWNER_PROJECT_ID_SPLIT_VERSION, /* FNXC:PostgresSchema 2026-07-16-08:00: @@ -49,6 +50,9 @@ import { skip the domain/partition split when the baseline marker advances. */ CHAT_SESSION_PINS_VERSION, + EXECUTOR_TOOL_FAILURE_RETRY_VERSION, + EXECUTOR_ESCALATION_ATTEMPT_VERSION, + GLOBAL_ROUTINES_SCHEMA_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, @@ -124,9 +128,14 @@ describe("schema-applier: immutable migration identities", () => { expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(OWNER_PROJECT_ID_SPLIT_VERSION)); }); - it("keeps chat session pins assigned to version 0012 (current baseline)", () => { + it("keeps chat session pins assigned to version 0012", () => { expect(CHAT_SESSION_PINS_VERSION).toBe("0012"); - expect(SCHEMA_BASELINE_VERSION).toBe(CHAT_SESSION_PINS_VERSION); + expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(CHAT_SESSION_PINS_VERSION)); + }); + + it("keeps the import translation scope fix assigned to version 0016", () => { + expect(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION).toBe("0016"); + expect(SCHEMA_BASELINE_VERSION).toBe(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); }); }); @@ -443,7 +452,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = null; }); - it("creates all 90 project tables, 17 central tables, 1 archive table", async () => { + it("creates all 90 project tables, 18 central tables, 1 archive table", async () => { ctx = await setupFreshDb(); // FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only. // applySchemaBaseline now runs the plugin schema-init hooks by default, @@ -462,7 +471,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", // + 1 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30). // Plugin tables are added separately by the hook. expect(bySchema.project).toBe(90); - expect(bySchema.central).toBe(17); + expect(bySchema.central).toBe(18); expect(bySchema.archive).toBe(1); }); @@ -964,6 +973,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { created_at text NOT NULL, updated_at text NOT NULL ); + /* FNXC:GitHubImportTranslate 2026-07-16-23:30: Later durable-task migrations run after this historical 0000 fixture, so retain their required task table surface. */ + CREATE TABLE project.tasks (id text PRIMARY KEY); CREATE TABLE project.automations ( id text PRIMARY KEY, name text NOT NULL, @@ -1035,7 +1046,11 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_FIX_IDEMPOTENCY_VERSION, IMPORT_TRANSLATION_CACHE_VERSION, OWNER_PROJECT_ID_SPLIT_VERSION, - SCHEMA_BASELINE_VERSION, + CHAT_SESSION_PINS_VERSION, + EXECUTOR_TOOL_FAILURE_RETRY_VERSION, + EXECUTOR_ESCALATION_ATTEMPT_VERSION, + GLOBAL_ROUTINES_SCHEMA_VERSION, + IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1073,10 +1088,55 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { MISSION_FIX_IDEMPOTENCY_VERSION, IMPORT_TRANSLATION_CACHE_VERSION, OWNER_PROJECT_ID_SPLIT_VERSION, - SCHEMA_BASELINE_VERSION, + CHAT_SESSION_PINS_VERSION, + EXECUTOR_TOOL_FAILURE_RETRY_VERSION, + EXECUTOR_ESCALATION_ATTEMPT_VERSION, + GLOBAL_ROUTINES_SCHEMA_VERSION, + IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, ]); }); + /* + FNXC:GitHubImportTranslate 2026-07-16-23:30: + An upgrade has already recorded 0010, so editing its SQL only fixes fresh + databases. Simulate that recorded pre-fix shape and prove 0016 converges the + existing default and RLS policy before a reopened store reads the cache. + */ + it("upgrades a 0010 import translation cache to the normalized scope contract", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db, { pluginHooks: [] }); + await ctx.db.execute(sql.raw(` + DELETE FROM public.fusion_schema_migrations WHERE version = '0016'; + ALTER TABLE project.import_translation_cache + ALTER COLUMN project_id SET DEFAULT current_setting('fusion.project_id', true); + DROP POLICY fusion_project_isolation ON project.import_translation_cache; + CREATE POLICY fusion_project_isolation ON project.import_translation_cache + USING (project_id = current_setting('fusion.project_id', true)) + WITH CHECK (project_id = current_setting('fusion.project_id', true)); + `)); + + expect(await getAppliedMigrations(ctx.db)).toContain(IMPORT_TRANSLATION_CACHE_VERSION); + expect(await getAppliedMigrations(ctx.db)).not.toContain(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + + const defaultRows = (await ctx.db.execute(sql` + SELECT pg_get_expr(ad.adbin, ad.adrelid) AS expression + FROM pg_attrdef ad + JOIN pg_class c ON c.oid = ad.adrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ad.adnum + WHERE n.nspname = 'project' AND c.relname = 'import_translation_cache' AND a.attname = 'project_id' + `)) as unknown as Array<{ expression: string }>; + expect(defaultRows[0]?.expression).toContain("__legacy_unscoped__"); + + const policies = (await ctx.db.execute(sql` + SELECT qual FROM pg_policies + WHERE schemaname = 'project' AND tablename = 'import_translation_cache' AND policyname = 'fusion_project_isolation' + `)) as unknown as Array<{ qual: string }>; + expect(policies[0]?.qual).toContain("__legacy_unscoped__"); + expect(await getAppliedMigrations(ctx.db)).toContain(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); + }); + it("upgrades a 0001 database by backfilling analytics ownership", async () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); @@ -1124,6 +1184,10 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "0010", "0011", "0012", + EXECUTOR_TOOL_FAILURE_RETRY_VERSION, + EXECUTOR_ESCALATION_ATTEMPT_VERSION, + GLOBAL_ROUTINES_SCHEMA_VERSION, + IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, ]); }); @@ -1176,6 +1240,10 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "0010", "0011", "0012", + EXECUTOR_TOOL_FAILURE_RETRY_VERSION, + EXECUTOR_ESCALATION_ATTEMPT_VERSION, + GLOBAL_ROUTINES_SCHEMA_VERSION, + IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, ]); }); @@ -1228,6 +1296,10 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "0010", "0011", "0012", + EXECUTOR_TOOL_FAILURE_RETRY_VERSION, + EXECUTOR_ESCALATION_ATTEMPT_VERSION, + GLOBAL_ROUTINES_SCHEMA_VERSION, + IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION, ]); }); }); diff --git a/packages/core/src/postgres/migrations/0010_import_translation_cache.sql b/packages/core/src/postgres/migrations/0010_import_translation_cache.sql index 089293b265..d2671acccb 100644 --- a/packages/core/src/postgres/migrations/0010_import_translation_cache.sql +++ b/packages/core/src/postgres/migrations/0010_import_translation_cache.sql @@ -8,7 +8,7 @@ Project isolation here is the SAME contract 0006 applies to every project-owned All projects share this one flat `project` schema, so a table that opts out of the contract would serve one project's translations to another. `schema-applier` verifies this invariant on boot and fails closed, so a new table MUST opt in here rather than rely on query-level predicates alone. */ CREATE TABLE IF NOT EXISTS project.import_translation_cache ( - project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), + project_id text NOT NULL DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), provider text NOT NULL, repo_key text NOT NULL, issue_number integer NOT NULL, @@ -38,11 +38,11 @@ BEGIN CREATE POLICY fusion_project_isolation ON project.import_translation_cache USING ( current_setting('fusion.project_bypass', true) = 'on' - OR project_id = current_setting('fusion.project_id', true) + OR project_id = COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__') ) WITH CHECK ( current_setting('fusion.project_bypass', true) = 'on' - OR project_id = current_setting('fusion.project_id', true) + OR project_id = COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__') ); -- Stamp project_id from the session setting, matching every other project table. diff --git a/packages/core/src/postgres/migrations/0016_import_translation_cache_scope_fix.sql b/packages/core/src/postgres/migrations/0016_import_translation_cache_scope_fix.sql new file mode 100644 index 0000000000..2c3c919ce6 --- /dev/null +++ b/packages/core/src/postgres/migrations/0016_import_translation_cache_scope_fix.sql @@ -0,0 +1,50 @@ +/* +FNXC:GitHubImportTranslate 2026-07-16-23:30: +0010 is already marked on deployed databases, so correcting its fresh-install +SQL cannot repair their import_translation_cache partition contract. This +forward, idempotent migration makes default, trigger, and RLS agree that an +unset/blank fusion.project_id is the explicit __legacy_unscoped__ partition. +*/ +ALTER TABLE project.import_translation_cache + ALTER COLUMN project_id SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'); + +CREATE OR REPLACE FUNCTION project.fusion_assign_project_id() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.project_id := COALESCE( + NULLIF(NEW.project_id, ''), + NULLIF(current_setting('fusion.project_id', true), ''), + '__legacy_unscoped__' + ); + RETURN NEW; +END; +$$; + +ALTER TABLE project.import_translation_cache ENABLE ROW LEVEL SECURITY; +ALTER TABLE project.import_translation_cache FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS fusion_project_isolation ON project.import_translation_cache; +CREATE POLICY fusion_project_isolation ON project.import_translation_cache + USING ( + current_setting('fusion.project_bypass', true) = 'on' + OR project_id = COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__') + ) + WITH CHECK ( + current_setting('fusion.project_bypass', true) = 'on' + OR project_id = COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__') + ); + +DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.import_translation_cache; +CREATE TRIGGER fusion_assign_project_id + BEFORE INSERT OR UPDATE OF project_id ON project.import_translation_cache + FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON project.import_translation_cache TO fusion_runtime; + END IF; +END +$$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index d705b49566..fe1c067bba 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -31,7 +31,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin FNXC:MultiProjectIsolation 2026-07-15-23:40: Advances to 0012 after the owner_project_id domain/partition split and chat pin timestamp. Per-migration identities above stay fixed; only this latest-version marker moves. */ -export const SCHEMA_BASELINE_VERSION = "0015"; +export const SCHEMA_BASELINE_VERSION = "0016"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -56,6 +56,12 @@ FNXC:GitHubImportTranslate 2026-07-15-09:30: Import-translation cache advances to 0010. Migrations are registered here explicitly (not auto-discovered from the migrations dir), so a new .sql file that is not wired through a version constant + bookkeeping check silently never runs. */ export const IMPORT_TRANSLATION_CACHE_VERSION = "0010"; +/** + * FNXC:GitHubImportTranslate 2026-07-16-23:30: + * Existing databases already recorded 0010, so the cache scope correction is + * deliberately a new forward migration rather than a retroactive SQL edit. + */ +export const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION = "0016"; /* FNXC:MultiProjectIsolation 2026-07-15-23:40: Version 0011 splits the domain "project" field from the RLS partition on the tables @@ -134,6 +140,11 @@ const IMPORT_TRANSLATION_CACHE_MIGRATION_PATH = join( "migrations", "0010_import_translation_cache.sql", ); +const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH = join( + __dirname, + "migrations", + "0016_import_translation_cache_scope_fix.sql", +); const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join( __dirname, "migrations", @@ -238,6 +249,7 @@ export async function applySchemaBaseline( const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION); const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION); + const importTranslationCacheScopeFixAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION); const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION); const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION); @@ -536,6 +548,21 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:GitHubImportTranslate 2026-07-16-23:30: + 0010's marker prevents its corrected fresh-install definition from running + on upgrades. Apply 0016 separately before runtime cache reads so existing + rows, RLS, and unbound compatibility stores share one partition contract. + */ + if (!importTranslationCacheScopeFixAlreadyApplied) { + const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index d7efdd41a9..16cb2b4c1a 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1917,7 +1917,13 @@ Import auto-translation must survive modal close and page reload — the operato `projectId` is part of the PK because all projects share one flat `project` schema — omitting it (as the older `verification_cache` PK does) would leak one project's translations into another. */ export const importTranslationCache = projectSchema.table("import_translation_cache", { - projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + /* + FNXC:GitHubImportTranslate 2026-07-16-23:30: + An unbound compatibility store owns cache rows in the explicit legacy + partition. Match fusion_assign_project_id so a defaulted insert and the + application scope predicate cannot disagree after a process restart. + */ + projectId: text("project_id").notNull().default(sql`COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__')`), /** Import source: "github" | "gitlab". */ provider: text("provider").notNull(), /** Canonical repo identity, e.g. "owner/repo" (GitLab: project path). */ diff --git a/packages/core/src/task-store/remaining-ops-8.ts b/packages/core/src/task-store/remaining-ops-8.ts index 53dfeb026b..585ecb11d8 100644 --- a/packages/core/src/task-store/remaining-ops-8.ts +++ b/packages/core/src/task-store/remaining-ops-8.ts @@ -24,7 +24,7 @@ import { PluginStore } from "../plugin-store.js"; import { SecretsStore } from "../secrets-store.js"; import { createAsyncDistributedTaskIdAllocator } from "./async-allocator.js"; import { getWorkflowRow, listWorkflowRows } from "../async-workflow-store.js"; -import { taskProjectScope } from "../postgres/data-layer.js"; +import { projectOwnershipPartition, projectScopeFor, taskProjectScope } from "../postgres/data-layer.js"; import { getInReviewDurationEvents as getInReviewDurationEventsAsync, getTaskMergedTaskIds as getTaskMergedTaskIdsAsync } from "./async-audit.js"; import { readProjectConfig, writeProjectConfig } from "./async-settings.js"; import { compactTaskActivityLog } from "./comments.js"; @@ -1041,11 +1041,18 @@ export interface ImportTranslationCacheKey { sourceHash: string; } +/* +FNXC:GitHubImportTranslate 2026-07-16-23:30: +The cache write, read, and prune paths must resolve the identical ownership +partition. In particular, compatibility layers without a project binding write +to `__legacy_unscoped__`; querying with an omitted/blank predicate afterwards +made those durable rows look like cache misses after a restart. +*/ function importTranslationScope(store: TaskStore) { - const projectId = store.asyncLayer?.projectId; - return projectId - ? eq(schema.project.importTranslationCache.projectId, projectId) - : undefined; + return projectScopeFor( + schema.project.importTranslationCache.projectId, + projectOwnershipPartition(store.asyncLayer?.projectId), + ); } /** @@ -1102,11 +1109,11 @@ export async function recordImportTranslationImpl( ): Promise { if (!store.asyncLayer) return; const table = schema.project.importTranslationCache; - const projectId = store.asyncLayer.projectId; + const projectId = projectOwnershipPartition(store.asyncLayer.projectId); await store.asyncLayer.db .insert(table) .values({ - ...(projectId ? { projectId } : {}), + projectId, provider: key.provider, repoKey: key.repoKey, issueNumber: key.issueNumber, diff --git a/packages/dashboard/src/__tests__/import-translate-service.test.ts b/packages/dashboard/src/__tests__/import-translate-service.test.ts index 2229fcb575..b9bebd5a01 100644 --- a/packages/dashboard/src/__tests__/import-translate-service.test.ts +++ b/packages/dashboard/src/__tests__/import-translate-service.test.ts @@ -27,8 +27,10 @@ const { } = await import("../import-translate-service.js"); /** Minimal in-memory stand-in for the durable cache. */ -function makeStore(settings: Record = {}): any { - const rows = new Map(); +function makeStore( + settings: Record = {}, + rows = new Map(), +): any { const key = (k: { provider: string; repoKey: string; issueNumber: number; targetLocale: string }) => `${k.provider}|${k.repoKey}|${k.issueNumber}|${k.targetLocale}`; return { @@ -129,6 +131,10 @@ describe("hashSourceContent", () => { it("is stable for identical content", () => { expect(hashSourceContent("t", "a")).toBe(hashSourceContent("t", "a")); }); + + it("uses the same hash when an upstream absent body is normalized to empty", () => { + expect(hashSourceContent("title", "")).toBe(hashSourceContent("title", "")); + }); }); describe("translateImportItems", () => { @@ -167,6 +173,28 @@ describe("translateImportItems", () => { expect(out.get(7)?.title).toBe("TRANSLATED"); }); + /* + FNXC:GitHubImportTranslate 2026-07-16-23:30: + Reopen the service's store boundary rather than reuse its object: a durable + cache must make the next GitHub and GitLab page fully free of model calls. + */ + it.each(["github", "gitlab"] as const)("serves a reopened %s load entirely from the durable cache", async (provider) => { + const firstStore = makeStore(); + const item = { number: 7, title: "Error del servidor", body: SPANISH_BODY, state: "open" as const }; + const firstContext = { ...ctx(firstStore), provider, targetLocale: "en" as const }; + await translateImportItems(firstContext, [item]); + translateTextMock.mockClear(); + + const reopenedStore = makeStore({}, firstStore.rows); + const reopenedContext = { ...ctx(reopenedStore), provider, targetLocale: "en" as const }; + const partition = await partitionImportItemsByCache(reopenedContext, [item]); + const out = await translateImportItems(reopenedContext, [item], partition); + + expect(partition.uncached).toHaveLength(0); + expect(translateTextMock).not.toHaveBeenCalled(); + expect(out.get(item.number)?.cached).toBe(true); + }); + it("re-translates when the issue body was edited (cache miss on new hash)", async () => { const store = makeStore(); const c = { ...ctx(store), targetLocale: "en" as const }; diff --git a/packages/dashboard/src/import-translate-service.ts b/packages/dashboard/src/import-translate-service.ts index f8c01b7e36..3f1318cc66 100644 Binary files a/packages/dashboard/src/import-translate-service.ts and b/packages/dashboard/src/import-translate-service.ts differ