FN-8172: persist import translations across restarts

Persist GitHub and GitLab import translation caches across application restarts.

- Normalize cache ownership scope for reads, writes, pruning, and PostgreSQL RLS
- Add forward migration 0016 to repair existing cache table partitioning
- Cover durable cache reuse and migration behavior with PostgreSQL and service tests

Files changed:
 .changeset/github-translation-cache-persistence.md |   7 ++
 docs/settings-reference.md                         |   2 +-
 .../postgres/import-translation-cache.pg.test.ts   | 105 +++++++++++++++++++++
 .../src/__tests__/postgres/schema-applier.test.ts  |  84 +++++++++++++++--
 .../migrations/0010_import_translation_cache.sql   |   6 +-
 .../0016_import_translation_cache_scope_fix.sql    |  50 ++++++++++
 packages/core/src/postgres/schema-applier.ts       |  29 +++++-
 packages/core/src/postgres/schema/project.ts       |   8 +-
 packages/core/src/task-store/remaining-ops-8.ts    |  21 +++--
 .../src/__tests__/import-translate-service.test.ts |  32 ++++++-
 packages/dashboard/src/import-translate-service.ts | Bin 10392 -> 11109 bytes
 11 files changed, 323 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-8172

Fusion-Task-Lineage: b3d18f58-dc0b-47e4-a1bf-acb8b2360869

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 18:46:36 -07:00
parent 25df9bf4fd
commit c449379d00
11 changed files with 323 additions and 21 deletions

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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();
}
});
});

View File

@@ -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,
]);
});
});

View File

@@ -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.

View File

@@ -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
$$;

View File

@@ -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 };
});
}

View File

@@ -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). */

View File

@@ -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<void> {
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,

View File

@@ -27,8 +27,10 @@ const {
} = await import("../import-translate-service.js");
/** Minimal in-memory stand-in for the durable cache. */
function makeStore(settings: Record<string, unknown> = {}): any {
const rows = new Map<string, { sourceHash: string; translatedTitle: string; translatedBody: string; detectedLocale: string | null; recordedAt: string }>();
function makeStore(
settings: Record<string, unknown> = {},
rows = new Map<string, { sourceHash: string; translatedTitle: string; translatedBody: string; detectedLocale: string | null; recordedAt: string }>(),
): 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 };