From 7931dab0b7359108e2ddfbe49d839d0d11c590c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pacanovsk=C3=BD?= Date: Sat, 14 Mar 2026 18:10:38 +0100 Subject: [PATCH 01/30] Added a resolver test for merging Original file hashes Refactor mergeOriginalFilesHashes function and add comprehensive unit tests - Changed the mergeOriginalFilesHashes function to be exported for external use. - Introduced a new test suite for mergeOriginalFilesHashes, covering various scenarios including handling undefined inputs, merging entries with different hashes, and deduplicating referencedBy and originalNames. - Ensured that version numbers are correctly managed and that fileNameToHash is built accurately from merged entries. --- src/projectManager/utils/merge/resolvers.ts | 2 +- .../mergeOriginalFilesHashes.test.ts | 234 ++++++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/test/suite/resolvers/mergeOriginalFilesHashes.test.ts diff --git a/src/projectManager/utils/merge/resolvers.ts b/src/projectManager/utils/merge/resolvers.ts index d366bb2d2..2d99d432b 100644 --- a/src/projectManager/utils/merge/resolvers.ts +++ b/src/projectManager/utils/merge/resolvers.ts @@ -236,7 +236,7 @@ function mergeProjectSwap( * Merges originalFilesHashes registries from base, ours, and theirs. * Union by hash: combine all entries. For same hash, merge referencedBy and originalNames. */ -function mergeOriginalFilesHashes( +export function mergeOriginalFilesHashes( base: { version?: number; files?: Record; fileNameToHash?: Record } | undefined, ours: { version?: number; files?: Record; fileNameToHash?: Record } | undefined, theirs: { version?: number; files?: Record; fileNameToHash?: Record } | undefined diff --git a/src/test/suite/resolvers/mergeOriginalFilesHashes.test.ts b/src/test/suite/resolvers/mergeOriginalFilesHashes.test.ts new file mode 100644 index 000000000..0a9acb436 --- /dev/null +++ b/src/test/suite/resolvers/mergeOriginalFilesHashes.test.ts @@ -0,0 +1,234 @@ +import * as assert from "assert"; +import { mergeOriginalFilesHashes } from "../../../projectManager/utils/merge/resolvers"; + +type HashRegistryInput = Parameters[0]; + +const makeEntry = (hash: string, fileName: string, referencedBy: string[], originalNames: string[] = [fileName], addedAt = new Date().toISOString()) => ({ + hash, + fileName, + originalNames, + referencedBy, + addedAt, +}); + +const makeRegistry = ( + files: Record>, + version = 1 +): HashRegistryInput => ({ + version, + files, + fileNameToHash: Object.fromEntries( + Object.entries(files).map(([hash, entry]) => [entry.fileName, hash]) + ), +}); + +suite("Resolver unit: mergeOriginalFilesHashes", () => { + test("returns undefined when all three inputs are undefined", () => { + const result = mergeOriginalFilesHashes(undefined, undefined, undefined); + assert.strictEqual(result, undefined); + }); + + test("returns undefined when ours and theirs have no files and base is also undefined", () => { + const result = mergeOriginalFilesHashes(undefined, { version: 1, files: {}, fileNameToHash: {} }, { version: 1, files: {}, fileNameToHash: {} }); + assert.strictEqual(result, undefined); + }); + + test("falls back to base when ours and theirs have no files but base does", () => { + const base = makeRegistry({ abc: makeEntry("abc", "doc.docx", ["MAT"]) }); + const result = mergeOriginalFilesHashes(base, { version: 1, files: {}, fileNameToHash: {} }, { version: 1, files: {}, fileNameToHash: {} }); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 1); + assert.ok(result.files["abc"]); + assert.strictEqual(result.fileNameToHash["doc.docx"], "abc"); + }); + + test("returns ours entry when theirs is undefined", () => { + const ours = makeRegistry({ h1: makeEntry("h1", "file1.docx", ["MAT"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, undefined); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 1); + assert.deepStrictEqual(result.files["h1"].referencedBy, ["MAT"]); + }); + + test("returns theirs entry when ours is undefined", () => { + const theirs = makeRegistry({ h2: makeEntry("h2", "file2.docx", ["GEN"]) }); + const result = mergeOriginalFilesHashes(undefined, undefined, theirs); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 1); + assert.deepStrictEqual(result.files["h2"].referencedBy, ["GEN"]); + }); + + test("unions entries with different hashes from ours and theirs", () => { + const ours = makeRegistry({ h1: makeEntry("h1", "user1-doc.docx", ["MAT"]) }); + const theirs = makeRegistry({ h2: makeEntry("h2", "user2-doc.docx", ["GEN"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 2); + assert.ok(result.files["h1"]); + assert.ok(result.files["h2"]); + assert.strictEqual(result.fileNameToHash["user1-doc.docx"], "h1"); + assert.strictEqual(result.fileNameToHash["user2-doc.docx"], "h2"); + }); + + test("merges referencedBy when both sides have the same hash", () => { + const ours = makeRegistry({ shared: makeEntry("shared", "document.docx", ["MAT-user1"]) }); + const theirs = makeRegistry({ shared: makeEntry("shared", "document.docx", ["MAT-user2"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 1); + const entry = result.files["shared"]; + assert.deepStrictEqual(entry.referencedBy.sort(), ["MAT-user1", "MAT-user2"]); + }); + + test("deduplicates referencedBy when both sides have overlapping entries", () => { + const ours = makeRegistry({ h: makeEntry("h", "doc.docx", ["MAT", "GEN"]) }); + const theirs = makeRegistry({ h: makeEntry("h", "doc.docx", ["MAT", "REV"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + const entry = result.files["h"]; + assert.deepStrictEqual(entry.referencedBy.sort(), ["GEN", "MAT", "REV"]); + }); + + test("merges originalNames when both sides have the same hash", () => { + const ours = makeRegistry({ h: makeEntry("h", "stored.docx", ["MAT"], ["original-a.docx"]) }); + const theirs = makeRegistry({ h: makeEntry("h", "stored.docx", ["GEN"], ["original-b.docx"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + const entry = result.files["h"]; + assert.deepStrictEqual(entry.originalNames.sort(), ["original-a.docx", "original-b.docx"]); + }); + + test("deduplicates originalNames when both sides share entries", () => { + const ours = makeRegistry({ h: makeEntry("h", "stored.docx", ["MAT"], ["same.docx", "unique-a.docx"]) }); + const theirs = makeRegistry({ h: makeEntry("h", "stored.docx", ["GEN"], ["same.docx", "unique-b.docx"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + const entry = result.files["h"]; + assert.deepStrictEqual(entry.originalNames.sort(), ["same.docx", "unique-a.docx", "unique-b.docx"]); + }); + + test("uses Math.max for version number", () => { + const ours = makeRegistry({ h1: makeEntry("h1", "a.docx", ["MAT"]) }, 2); + const theirs = makeRegistry({ h2: makeEntry("h2", "b.docx", ["GEN"]) }, 5); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(result.version, 5); + }); + + test("defaults version to 1 when both sides omit it", () => { + const ours: HashRegistryInput = { files: { h: makeEntry("h", "a.docx", ["MAT"]) }, fileNameToHash: { "a.docx": "h" } }; + const theirs: HashRegistryInput = { files: {}, fileNameToHash: {} }; + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(result.version, 1); + }); + + test("builds fileNameToHash from merged entries correctly", () => { + const ours = makeRegistry({ + h1: makeEntry("h1", "alpha.docx", ["MAT"]), + h2: makeEntry("h2", "beta.docx", ["GEN"]), + }); + const theirs = makeRegistry({ + h2: makeEntry("h2", "beta.docx", ["REV"]), + h3: makeEntry("h3", "gamma.docx", ["EXO"]), + }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(result.fileNameToHash["alpha.docx"], "h1"); + assert.strictEqual(result.fileNameToHash["beta.docx"], "h2"); + assert.strictEqual(result.fileNameToHash["gamma.docx"], "h3"); + }); + + test("handles ours-only entry correctly (theirs missing that hash)", () => { + const ours = makeRegistry({ + exclusive: makeEntry("exclusive", "only-ours.docx", ["MAT"]), + shared: makeEntry("shared", "common.docx", ["GEN"]), + }); + const theirs = makeRegistry({ + shared: makeEntry("shared", "common.docx", ["REV"]), + }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 2); + assert.deepStrictEqual(result.files["exclusive"].referencedBy, ["MAT"]); + assert.deepStrictEqual(result.files["shared"].referencedBy.sort(), ["GEN", "REV"]); + }); + + test("handles theirs-only entry correctly (ours missing that hash)", () => { + const ours = makeRegistry({ + shared: makeEntry("shared", "common.docx", ["MAT"]), + }); + const theirs = makeRegistry({ + shared: makeEntry("shared", "common.docx", ["GEN"]), + exclusive: makeEntry("exclusive", "only-theirs.docx", ["REV"]), + }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 2); + assert.deepStrictEqual(result.files["exclusive"].referencedBy, ["REV"]); + assert.deepStrictEqual(result.files["shared"].referencedBy.sort(), ["GEN", "MAT"]); + }); + + test("preserves extra properties on entries during merge", () => { + const addedAt = "2025-01-15T10:00:00.000Z"; + const ours = makeRegistry({ h: makeEntry("h", "doc.docx", ["MAT"], ["doc.docx"], addedAt) }); + const theirs = makeRegistry({ h: makeEntry("h", "doc.docx", ["GEN"], ["doc.docx"], addedAt) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(result.files["h"].addedAt, addedAt); + assert.strictEqual(result.files["h"].hash, "h"); + assert.strictEqual(result.files["h"].fileName, "doc.docx"); + }); + + test("handles empty referencedBy and originalNames arrays gracefully", () => { + const ours = makeRegistry({ h: { hash: "h", fileName: "doc.docx", originalNames: [], referencedBy: [], addedAt: "" } as any }); + const theirs = makeRegistry({ h: makeEntry("h", "doc.docx", ["MAT"], ["original.docx"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.deepStrictEqual(result.files["h"].referencedBy, ["MAT"]); + assert.deepStrictEqual(result.files["h"].originalNames, ["original.docx"]); + }); + + test("handles missing referencedBy and originalNames properties via fallback to empty array", () => { + const ours: HashRegistryInput = { + version: 1, + files: { h: { hash: "h", fileName: "doc.docx" } as any }, + fileNameToHash: { "doc.docx": "h" }, + }; + const theirs = makeRegistry({ h: makeEntry("h", "doc.docx", ["GEN"], ["name.docx"]) }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.deepStrictEqual(result.files["h"].referencedBy, ["GEN"]); + assert.deepStrictEqual(result.files["h"].originalNames, ["name.docx"]); + }); + + test("complex scenario: multiple hashes, some shared, some exclusive per side", () => { + const ours = makeRegistry({ + shared1: makeEntry("shared1", "common1.docx", ["MAT-u1"], ["common1.docx"]), + shared2: makeEntry("shared2", "common2.idml", ["GEN-u1", "EXO-u1"], ["common2.idml"]), + oursOnly: makeEntry("oursOnly", "local.pdf", ["REV-u1"], ["local.pdf"]), + }); + const theirs = makeRegistry({ + shared1: makeEntry("shared1", "common1.docx", ["MAT-u2"], ["common1-renamed.docx"]), + shared2: makeEntry("shared2", "common2.idml", ["GEN-u2"], ["common2.idml"]), + theirsOnly: makeEntry("theirsOnly", "remote.pdf", ["LEV-u2"], ["remote.pdf"]), + }); + const result = mergeOriginalFilesHashes(undefined, ours, theirs); + assert.ok(result); + assert.strictEqual(Object.keys(result.files).length, 4); + + assert.deepStrictEqual(result.files["shared1"].referencedBy.sort(), ["MAT-u1", "MAT-u2"]); + assert.deepStrictEqual(result.files["shared1"].originalNames.sort(), ["common1-renamed.docx", "common1.docx"]); + + assert.deepStrictEqual(result.files["shared2"].referencedBy.sort(), ["EXO-u1", "GEN-u1", "GEN-u2"]); + assert.deepStrictEqual(result.files["shared2"].originalNames, ["common2.idml"]); + + assert.deepStrictEqual(result.files["oursOnly"].referencedBy, ["REV-u1"]); + assert.deepStrictEqual(result.files["theirsOnly"].referencedBy, ["LEV-u2"]); + + assert.strictEqual(result.fileNameToHash["common1.docx"], "shared1"); + assert.strictEqual(result.fileNameToHash["common2.idml"], "shared2"); + assert.strictEqual(result.fileNameToHash["local.pdf"], "oursOnly"); + assert.strictEqual(result.fileNameToHash["remote.pdf"], "theirsOnly"); + }); +}); From a5ac8cdef282a5eca935df35d383788a7e8b7846 Mon Sep 17 00:00:00 2001 From: jmavescodex-arch Date: Tue, 17 Mar 2026 11:24:36 -0400 Subject: [PATCH 02/30] Create partner-issue.md --- .github/ISSUE_TEMPLATE/partner-issue.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/partner-issue.md diff --git a/.github/ISSUE_TEMPLATE/partner-issue.md b/.github/ISSUE_TEMPLATE/partner-issue.md new file mode 100644 index 000000000..89a72f6e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/partner-issue.md @@ -0,0 +1,23 @@ +--- +name: Partner issue +about: Issues reported by partners via support +title: "[Partner] " +labels: partner-issue +assignees: '' +--- + +**Partner:** + +**Help Scout conversation URL:** + +**Priority:** P1 / P2 / P3 + +**Category:** Bug / Performance / Onboarding / Other + +**What they reported:** + +**Steps to reproduce:** + +**Expected vs. actual behavior:** + +**Partner impact (how many affected, what it cost them):** From 76a0f9ee6fdbe1eb5ff51af997329594f83e23b9 Mon Sep 17 00:00:00 2001 From: BenjaminScholtens Date: Wed, 18 Mar 2026 13:45:10 -0400 Subject: [PATCH 03/30] add safeExecuteSmartEditCommand (#768) --- .../codexCellEditorMessagehandling.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index b0e8cf6da..c6c9875de 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -98,6 +98,14 @@ async function withErrorHandling( } } +async function safeExecuteSmartEditCommand(commandId: string, ...args: unknown[]): Promise { + const allCommands = await vscode.commands.getCommands(true); + if (!allCommands.includes(commandId)) { + return null; + } + return vscode.commands.executeCommand(commandId, ...args); +} + // Message handler context type interface MessageHandlerContext { event: EditorPostMessages; @@ -1348,7 +1356,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.generateBacktranslation", typedEvent.content.text, typedEvent.content.cellId, @@ -1362,7 +1370,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const updatedBacktranslation = await vscode.commands.executeCommand( + const updatedBacktranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.editBacktranslation", typedEvent.content.cellId, typedEvent.content.newText, @@ -1377,7 +1385,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.getBacktranslation", typedEvent.content.cellId ); @@ -1391,10 +1399,9 @@ const messageHandlers: Record Promise; const cellIds = typedEvent.content.cellIds; - // Fetch backtranslations for all cell IDs const backtranslations: { [cellId: string]: SavedBacktranslation | null; } = {}; for (const cellId of cellIds) { - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.getBacktranslation", cellId ); @@ -1409,7 +1416,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.setBacktranslation", typedEvent.content.cellId, typedEvent.content.originalText, From cad833d572633616fa1287399c8e30ec8b337344 Mon Sep 17 00:00:00 2001 From: Ben Scholtens Date: Wed, 18 Mar 2026 17:28:12 -0400 Subject: [PATCH 04/30] Update version to 0.23.0 and increment schema version to 16 in SQLite index --- package.json | 2 +- .../contextAware/contentIndexes/indexes/sqliteIndex.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ecb7b9627..cc22dc669 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "url": "https://github.com/genesis-ai-dev/codex-editor" }, "license": "MIT", - "version": "0.22.0", + "version": "0.23.0", "engines": { "node": ">=18.0.0", "vscode": "^1.78.0" diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts index aed97c8f2..a69138846 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts @@ -15,7 +15,7 @@ const debug = (message: string, ...args: any[]) => { }; // Schema version for migrations -export const CURRENT_SCHEMA_VERSION = 15; // cell_label format: "BOOK CHAPTER:POSITION" (e.g., "GEN 5:12") +export const CURRENT_SCHEMA_VERSION = 16; // cell_label format: "BOOK CHAPTER:POSITION" (e.g., "GEN 5:12") export class SQLiteIndexManager { private sql: SqlJsStatic | null = null; From e73f4a7b07cca495a4ed4f7cd0f135127546b8dd Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Wed, 11 Mar 2026 12:23:49 -0600 Subject: [PATCH 05/30] fix: suppress requiredExtensions ratchet when codex-editor pin is active When a project has a pinnedExtension for codex-editor, updateExtensionVersions now skips the codexEditor floor bump. This prevents the version gate from blocking collaborators who are intentionally running a pinned (older) version after the project was last opened on a newer build. frontierAuthentication ratcheting is unaffected. --- src/utils/metadataManager.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/utils/metadataManager.ts b/src/utils/metadataManager.ts index 7dade1be6..a09d51d8b 100644 --- a/src/utils/metadataManager.ts +++ b/src/utils/metadataManager.ts @@ -326,11 +326,16 @@ export class MetadataManager { metadata.meta.requiredExtensions = {}; } - // Only update codexEditor if new version is greater or missing + // Only update codexEditor if new version is greater or missing, + // AND no codex-editor pin is active (pinned version owns the floor while active). if (versions.codexEditor !== undefined) { - const existingVersion = metadata.meta.requiredExtensions.codexEditor; - if (!existingVersion || compareVersions(versions.codexEditor, existingVersion) >= 0) { - metadata.meta.requiredExtensions.codexEditor = versions.codexEditor; + const pin = (metadata.meta as any).pinnedExtension; + const codexPinActive = pin?.id === "codex-editor" && pin?.version; + if (!codexPinActive) { + const existingVersion = metadata.meta.requiredExtensions.codexEditor; + if (!existingVersion || compareVersions(versions.codexEditor, existingVersion) >= 0) { + metadata.meta.requiredExtensions.codexEditor = versions.codexEditor; + } } } From 911f8fbe5902d8d274b9f188436afb8cea323e8d Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Wed, 11 Mar 2026 12:23:58 -0600 Subject: [PATCH 06/30] test: re-enable MetadataManager suite and add pin-aware ratchet tests Tests were disabled in aabd9428 during a MetadataManager refactor with no specific failure documented. All existing tests pass cleanly in the current VS Code test environment. Adds three new cases to cover the pin-aware ratchet behaviour introduced in the previous commit: no-pin (ratchet works normally), codex-editor pin active (codexEditor bump suppressed), and frontier pin irrelevance (frontierAuthentication still ratchets when codex-editor is pinned). --- src/test/suite/metadataManager.test.ts | 67 +++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/src/test/suite/metadataManager.test.ts b/src/test/suite/metadataManager.test.ts index 8c55857df..0de9f0c04 100644 --- a/src/test/suite/metadataManager.test.ts +++ b/src/test/suite/metadataManager.test.ts @@ -5,14 +5,6 @@ import * as fs from 'fs'; import { MetadataManager } from '../../utils/metadataManager'; suite('MetadataManager Tests', () => { - // Temporarily disable these tests until VS Code test environment issues are resolved - test('MetadataManager integration tests temporarily disabled', () => { - console.log('MetadataManager tests are temporarily disabled due to VS Code test environment compatibility issues.'); - console.log('The MetadataManager system is fully integrated and functional in production.'); - console.log('These tests can be run in a standalone Node.js environment for validation.'); - }); - return; // Exit early to skip all tests in this suite - let testWorkspaceUri: vscode.Uri; let metadataPath: vscode.Uri; @@ -223,6 +215,65 @@ suite('MetadataManager Tests', () => { }); }); + suite('Pin-aware ratchet', () => { + test('ratchet works normally when no pin is active', async () => { + const initial = { + meta: { requiredExtensions: { codexEditor: '0.22.0' } } + }; + await vscode.workspace.fs.writeFile(metadataPath, + new TextEncoder().encode(JSON.stringify(initial, null, 4))); + + const result = await MetadataManager.updateExtensionVersions(testWorkspaceUri, { + codexEditor: '0.22.91' + }); + + assert.strictEqual(result.success, true); + const versions = await MetadataManager.getExtensionVersions(testWorkspaceUri); + assert.strictEqual(versions.versions?.codexEditor, '0.22.91'); + }); + + test('ratchet is suppressed for codexEditor when pin is active', async () => { + const initial = { + meta: { + requiredExtensions: { codexEditor: '0.22.90' }, + pinnedExtension: { id: 'codex-editor', version: '0.22.90' } + } + }; + await vscode.workspace.fs.writeFile(metadataPath, + new TextEncoder().encode(JSON.stringify(initial, null, 4))); + + const result = await MetadataManager.updateExtensionVersions(testWorkspaceUri, { + codexEditor: '0.22.91' + }); + + assert.strictEqual(result.success, true); + const versions = await MetadataManager.getExtensionVersions(testWorkspaceUri); + // Should NOT have been bumped to .91 + assert.strictEqual(versions.versions?.codexEditor, '0.22.90'); + }); + + test('frontierAuthentication ratchet is unaffected by a codex-editor pin', async () => { + const initial = { + meta: { + requiredExtensions: { codexEditor: '0.22.90', frontierAuthentication: '0.4.0' }, + pinnedExtension: { id: 'codex-editor', version: '0.22.90' } + } + }; + await vscode.workspace.fs.writeFile(metadataPath, + new TextEncoder().encode(JSON.stringify(initial, null, 4))); + + const result = await MetadataManager.updateExtensionVersions(testWorkspaceUri, { + codexEditor: '0.22.91', + frontierAuthentication: '0.4.25' + }); + + assert.strictEqual(result.success, true); + const versions = await MetadataManager.getExtensionVersions(testWorkspaceUri); + assert.strictEqual(versions.versions?.codexEditor, '0.22.90'); // suppressed + assert.strictEqual(versions.versions?.frontierAuthentication, '0.4.25'); // ratcheted + }); + }); + suite('Performance', () => { test('should complete updates within reasonable time', async () => { const startTime = Date.now(); From 103e05d6ad4be1f51148a56a6160972ea0533ccb Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Wed, 11 Mar 2026 16:46:06 -0600 Subject: [PATCH 07/30] fix: update ratchet suppression to use pinnedExtensions map schema Both codexEditor and frontierAuthentication ratchet checks now read from meta.pinnedExtensions (keyed by extension id) instead of the old singular meta.pinnedExtension object. Updates test fixtures to match. --- src/test/suite/metadataManager.test.ts | 23 +++++++++++++++++++++-- src/utils/metadataManager.ts | 20 ++++++++++++-------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/test/suite/metadataManager.test.ts b/src/test/suite/metadataManager.test.ts index 0de9f0c04..bab62cbda 100644 --- a/src/test/suite/metadataManager.test.ts +++ b/src/test/suite/metadataManager.test.ts @@ -236,7 +236,7 @@ suite('MetadataManager Tests', () => { const initial = { meta: { requiredExtensions: { codexEditor: '0.22.90' }, - pinnedExtension: { id: 'codex-editor', version: '0.22.90' } + pinnedExtensions: { 'codex-editor': { version: '0.22.90' } } } }; await vscode.workspace.fs.writeFile(metadataPath, @@ -252,11 +252,30 @@ suite('MetadataManager Tests', () => { assert.strictEqual(versions.versions?.codexEditor, '0.22.90'); }); + test('ratchet is suppressed for frontierAuthentication when frontier pin is active', async () => { + const initial = { + meta: { + requiredExtensions: { frontierAuthentication: '0.4.0' }, + pinnedExtensions: { 'frontier-authentication': { version: '0.4.0' } } + } + }; + await vscode.workspace.fs.writeFile(metadataPath, + new TextEncoder().encode(JSON.stringify(initial, null, 4))); + + const result = await MetadataManager.updateExtensionVersions(testWorkspaceUri, { + frontierAuthentication: '0.4.25' + }); + + assert.strictEqual(result.success, true); + const versions = await MetadataManager.getExtensionVersions(testWorkspaceUri); + assert.strictEqual(versions.versions?.frontierAuthentication, '0.4.0'); + }); + test('frontierAuthentication ratchet is unaffected by a codex-editor pin', async () => { const initial = { meta: { requiredExtensions: { codexEditor: '0.22.90', frontierAuthentication: '0.4.0' }, - pinnedExtension: { id: 'codex-editor', version: '0.22.90' } + pinnedExtensions: { 'codex-editor': { version: '0.22.90' } } } }; await vscode.workspace.fs.writeFile(metadataPath, diff --git a/src/utils/metadataManager.ts b/src/utils/metadataManager.ts index a09d51d8b..5946f7d6d 100644 --- a/src/utils/metadataManager.ts +++ b/src/utils/metadataManager.ts @@ -326,24 +326,28 @@ export class MetadataManager { metadata.meta.requiredExtensions = {}; } + const pinnedExtensions: Record = + (metadata.meta as any).pinnedExtensions ?? {}; + // Only update codexEditor if new version is greater or missing, // AND no codex-editor pin is active (pinned version owns the floor while active). if (versions.codexEditor !== undefined) { - const pin = (metadata.meta as any).pinnedExtension; - const codexPinActive = pin?.id === "codex-editor" && pin?.version; - if (!codexPinActive) { + if (!pinnedExtensions["codex-editor"]?.version) { const existingVersion = metadata.meta.requiredExtensions.codexEditor; if (!existingVersion || compareVersions(versions.codexEditor, existingVersion) >= 0) { metadata.meta.requiredExtensions.codexEditor = versions.codexEditor; } } } - - // Only update frontierAuthentication if new version is greater or missing + + // Only update frontierAuthentication if new version is greater or missing, + // AND no frontier-authentication pin is active. if (versions.frontierAuthentication !== undefined) { - const existingVersion = metadata.meta.requiredExtensions.frontierAuthentication; - if (!existingVersion || compareVersions(versions.frontierAuthentication, existingVersion) >= 0) { - metadata.meta.requiredExtensions.frontierAuthentication = versions.frontierAuthentication; + if (!pinnedExtensions["frontier-authentication"]?.version) { + const existingVersion = metadata.meta.requiredExtensions.frontierAuthentication; + if (!existingVersion || compareVersions(versions.frontierAuthentication, existingVersion) >= 0) { + metadata.meta.requiredExtensions.frontierAuthentication = versions.frontierAuthentication; + } } } From 6a647a5a97a05c3250f30b411122c724faa689e2 Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Thu, 12 Mar 2026 12:15:09 -0600 Subject: [PATCH 08/30] Retry on empty-content read in readExistingFileOrThrowWithFs On Windows, fs.writeFile can resolve before the written bytes are visible to a subsequent read, causing readFile to return empty content from a file that stat reports as non-empty. The function already retried on transient EntryNotFound errors; extend the same 25 ms retry loop to cover this empty-content case so callers are not spuriously blocked from saving. --- src/utils/notebookSafeSaveUtils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils/notebookSafeSaveUtils.ts b/src/utils/notebookSafeSaveUtils.ts index a7d7031be..6cf73e71a 100644 --- a/src/utils/notebookSafeSaveUtils.ts +++ b/src/utils/notebookSafeSaveUtils.ts @@ -165,6 +165,12 @@ export async function readExistingFileOrThrowWithFs( // Empty file on disk: allow caller to treat this as "missing" and do an initial write. return { kind: "missing" }; } + // Non-empty file but empty read — Windows filesystem flush timing issue. + // Retry with a short delay before giving up. + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 25)); + continue; + } throw new Error(`Read empty content from non-empty file: ${uri.fsPath}`); } return { kind: "readable", content }; From bd2e93f4fd1ff8a11c9f5ba0b6bffd81089244f3 Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Thu, 12 Mar 2026 12:15:15 -0600 Subject: [PATCH 09/30] Use readJsonFromDiskWithRetry in retainValidations=true test One test in the search/replace suite was using a bare JSON.parse on a direct readFile call instead of the existing readJsonFromDiskWithRetry helper, making it susceptible to the same Windows filesystem flush timing flakiness that the helper was introduced to guard against. --- src/test/suite/codexCellEditorProvider.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/suite/codexCellEditorProvider.test.ts b/src/test/suite/codexCellEditorProvider.test.ts index 61a7d4d83..8c3d184bb 100644 --- a/src/test/suite/codexCellEditorProvider.test.ts +++ b/src/test/suite/codexCellEditorProvider.test.ts @@ -3075,9 +3075,9 @@ suite("CodexCellEditorProvider Test Suite", () => { // Then, perform search/replace with retainValidations=true (simulating updateCellContentDirect with retainValidations=true) await (document as any).updateCellContent(cellId, "Replaced value", EditType.USER_EDIT, true, true, true); - // Persist to disk to assert the stored structure + // Persist to disk to assert the stored structure (retry to handle Windows filesystem flush timing) await provider.saveCustomDocument(document, new vscode.CancellationTokenSource().token); - const diskData = JSON.parse(new TextDecoder().decode(await vscode.workspace.fs.readFile(document.uri))); + const diskData = await readJsonFromDiskWithRetry(document.uri); const diskCell = diskData.cells.find((c: any) => c.metadata.id === cellId); // Latest value edit should have a NEW validation entry (not copied from old) From de2aae12559c20ea3e5c1ec8e29b2acbb3b4e9a4 Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Tue, 17 Mar 2026 11:34:45 -0600 Subject: [PATCH 10/30] feat: implement ratchet suppression for version pins in MetadataManager --- src/utils/metadataManager.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/metadataManager.ts b/src/utils/metadataManager.ts index 5946f7d6d..6e4c71ab8 100644 --- a/src/utils/metadataManager.ts +++ b/src/utils/metadataManager.ts @@ -17,6 +17,7 @@ interface ProjectMetadata { codexEditor?: string; frontierAuthentication?: string; }; + pinnedExtensions?: Record; [key: string]: unknown; }; edits?: any[]; @@ -414,8 +415,12 @@ export class MetadataManager { const existingVersions = currentVersions.versions || {}; const versionsToUpdate: { codexEditor?: string; frontierAuthentication?: string } = {}; + // Suppress ratchet if a pin exists (The Conductor is in charge) + const fullMetadata = await this.safeReadMetadata(workspaceUri); + const pinnedExtensions = fullMetadata.metadata?.meta?.pinnedExtensions || {}; + // Check codexEditor - update if missing or if installed version is newer - if (codexEditorVersion) { + if (codexEditorVersion && !pinnedExtensions['codex-editor']) { if (!existingVersions.codexEditor) { versionsToUpdate.codexEditor = codexEditorVersion; } else if (compareVersions(codexEditorVersion, existingVersions.codexEditor) > 0) { @@ -424,7 +429,7 @@ export class MetadataManager { } // Check frontierAuthentication - update if missing or if installed version is newer - if (frontierAuthVersion) { + if (frontierAuthVersion && !pinnedExtensions['frontier-authentication']) { if (!existingVersions.frontierAuthentication) { versionsToUpdate.frontierAuthentication = frontierAuthVersion; } else if (compareVersions(frontierAuthVersion, existingVersions.frontierAuthentication) > 0) { From e202cab728acebf793212087dbb212fa094832af Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Wed, 18 Mar 2026 16:08:48 -0600 Subject: [PATCH 11/30] Use full VS Code extension IDs in pinnedExtensions schema Update ratchet suppression to use publisher.name format (e.g. 'project-accelerate.codex-editor-extension') instead of short names. Aligns with conductor's metadata.json key format. --- src/test/suite/metadataManager.test.ts | 6 +++--- src/utils/metadataManager.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/test/suite/metadataManager.test.ts b/src/test/suite/metadataManager.test.ts index bab62cbda..5293b6029 100644 --- a/src/test/suite/metadataManager.test.ts +++ b/src/test/suite/metadataManager.test.ts @@ -236,7 +236,7 @@ suite('MetadataManager Tests', () => { const initial = { meta: { requiredExtensions: { codexEditor: '0.22.90' }, - pinnedExtensions: { 'codex-editor': { version: '0.22.90' } } + pinnedExtensions: { 'project-accelerate.codex-editor-extension': { version: '0.22.90' } } } }; await vscode.workspace.fs.writeFile(metadataPath, @@ -256,7 +256,7 @@ suite('MetadataManager Tests', () => { const initial = { meta: { requiredExtensions: { frontierAuthentication: '0.4.0' }, - pinnedExtensions: { 'frontier-authentication': { version: '0.4.0' } } + pinnedExtensions: { 'frontier-rnd.frontier-authentication': { version: '0.4.0' } } } }; await vscode.workspace.fs.writeFile(metadataPath, @@ -275,7 +275,7 @@ suite('MetadataManager Tests', () => { const initial = { meta: { requiredExtensions: { codexEditor: '0.22.90', frontierAuthentication: '0.4.0' }, - pinnedExtensions: { 'codex-editor': { version: '0.22.90' } } + pinnedExtensions: { 'project-accelerate.codex-editor-extension': { version: '0.22.90' } } } }; await vscode.workspace.fs.writeFile(metadataPath, diff --git a/src/utils/metadataManager.ts b/src/utils/metadataManager.ts index 6e4c71ab8..74926d45c 100644 --- a/src/utils/metadataManager.ts +++ b/src/utils/metadataManager.ts @@ -331,9 +331,9 @@ export class MetadataManager { (metadata.meta as any).pinnedExtensions ?? {}; // Only update codexEditor if new version is greater or missing, - // AND no codex-editor pin is active (pinned version owns the floor while active). + // AND no codex-editor pin is active (the Conductor owns the floor while active). if (versions.codexEditor !== undefined) { - if (!pinnedExtensions["codex-editor"]?.version) { + if (!pinnedExtensions["project-accelerate.codex-editor-extension"]?.version) { const existingVersion = metadata.meta.requiredExtensions.codexEditor; if (!existingVersion || compareVersions(versions.codexEditor, existingVersion) >= 0) { metadata.meta.requiredExtensions.codexEditor = versions.codexEditor; @@ -344,7 +344,7 @@ export class MetadataManager { // Only update frontierAuthentication if new version is greater or missing, // AND no frontier-authentication pin is active. if (versions.frontierAuthentication !== undefined) { - if (!pinnedExtensions["frontier-authentication"]?.version) { + if (!pinnedExtensions["frontier-rnd.frontier-authentication"]?.version) { const existingVersion = metadata.meta.requiredExtensions.frontierAuthentication; if (!existingVersion || compareVersions(versions.frontierAuthentication, existingVersion) >= 0) { metadata.meta.requiredExtensions.frontierAuthentication = versions.frontierAuthentication; @@ -420,7 +420,7 @@ export class MetadataManager { const pinnedExtensions = fullMetadata.metadata?.meta?.pinnedExtensions || {}; // Check codexEditor - update if missing or if installed version is newer - if (codexEditorVersion && !pinnedExtensions['codex-editor']) { + if (codexEditorVersion && !pinnedExtensions['project-accelerate.codex-editor-extension']) { if (!existingVersions.codexEditor) { versionsToUpdate.codexEditor = codexEditorVersion; } else if (compareVersions(codexEditorVersion, existingVersions.codexEditor) > 0) { @@ -429,7 +429,7 @@ export class MetadataManager { } // Check frontierAuthentication - update if missing or if installed version is newer - if (frontierAuthVersion && !pinnedExtensions['frontier-authentication']) { + if (frontierAuthVersion && !pinnedExtensions['frontier-rnd.frontier-authentication']) { if (!existingVersions.frontierAuthentication) { versionsToUpdate.frontierAuthentication = frontierAuthVersion; } else if (compareVersions(frontierAuthVersion, existingVersions.frontierAuthentication) > 0) { From 0452c7946aa8b1c7a4e4fa4b775f0c8eb7e28508 Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Wed, 18 Mar 2026 16:08:52 -0600 Subject: [PATCH 12/30] Bump version to 0.24.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cc22dc669..92f751873 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "url": "https://github.com/genesis-ai-dev/codex-editor" }, "license": "MIT", - "version": "0.23.0", + "version": "0.24.0", "engines": { "node": ">=18.0.0", "vscode": "^1.78.0" From 0abe26fcc61000ec54bbf6526194e6cd47c66b49 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 19 Mar 2026 08:56:45 -0400 Subject: [PATCH 13/30] - Update syncManager test. --- src/test/suite/syncManager.test.ts | 44 ++++++++++++------------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/src/test/suite/syncManager.test.ts b/src/test/suite/syncManager.test.ts index 44ea4a8aa..2dac81513 100644 --- a/src/test/suite/syncManager.test.ts +++ b/src/test/suite/syncManager.test.ts @@ -102,6 +102,11 @@ suite('SyncManager VS Code Version Warning Tests', () => { syncManager = SyncManager.getInstance(); }); + let connectivityModule: any; + let isOnlineStub: sinon.SinonStub; + let dugiteModule: any; + let listRemotesStub: sinon.SinonStub; + setup(async () => { // Restore any existing stubs first to avoid double-wrapping errors sinon.restore(); @@ -128,41 +133,26 @@ suite('SyncManager VS Code Version Warning Tests', () => { versionChecksModule = await import('../../projectManager/utils/versionChecks'); getFrontierVersionStatusStub = sinon.stub(versionChecksModule, 'getFrontierVersionStatus').resolves({ ok: true, - installedVersion: '0.4.18', - requiredVersion: '0.4.18' + installedVersion: '0.4.24', + requiredVersion: '0.4.24' }); + // Stub isOnline to return true so executeSync doesn't bail out early + connectivityModule = await import('../../utils/connectivityChecker'); + isOnlineStub = sinon.stub(connectivityModule, 'isOnline').resolves(true); + + // Stub dugiteGit.listRemotes so the "no git remote" check doesn't exit early + dugiteModule = await import('../../utils/dugiteGit'); + listRemotesStub = sinon.stub(dugiteModule, 'listRemotes').resolves([ + { remote: 'origin', url: 'https://example.com/repo.git' } + ]); + // Stub VS Code APIs showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage'); openExternalStub = sinon.stub(vscode.env, 'openExternal'); }); teardown(() => { - // Restore all stubs - if (checkVSCodeVersionStub) { - checkVSCodeVersionStub.restore(); - } - if (getFrontierVersionStatusStub) { - getFrontierVersionStatusStub.restore(); - } - if (getAuthApiStub) { - getAuthApiStub.restore(); - } - // Restore VS Code API stubs if they exist - try { - if (showInformationMessageStub && typeof showInformationMessageStub.restore === 'function') { - showInformationMessageStub.restore(); - } - } catch { - // Already restored or not a stub - } - try { - if (openExternalStub && typeof openExternalStub.restore === 'function') { - openExternalStub.restore(); - } - } catch { - // Already restored or not a stub - } sinon.restore(); }); From 358700e731b9cf723d0137f69ebc14d5671855cc Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 19 Mar 2026 14:43:28 -0400 Subject: [PATCH 14/30] - Add posthog information --- package.json | 1 + src/extension.ts | 5 +++ src/utils/telemetry.ts | 74 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/utils/telemetry.ts diff --git a/package.json b/package.json index 60aca0721..1f11ebd84 100644 --- a/package.json +++ b/package.json @@ -1107,6 +1107,7 @@ "pdf-parse": "^1.1.1", "pdfjs-dist": "4.0.379", "pnpm": "^8.15.5", + "posthog-node": "^5.21.2", "proc-log": "^5.0.0", "react-wordcloud": "^1.2.7", "sax": "^1.4.1", diff --git a/src/extension.ts b/src/extension.ts index 6523ae8bb..b05f21139 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -59,6 +59,7 @@ import { import { initializeAudioProcessor } from "./utils/audioProcessor"; import { initializeAudioMerger } from "./utils/audioMerger"; import { cleanupOrphanedProjectFiles } from "./utils/fileUtils"; +import { initTelemetry, shutdownTelemetry } from "./utils/telemetry"; // markUserAsUpdatedInRemoteList is now called in performProjectUpdate before window reload import * as fs from "fs"; import * as os from "os"; @@ -324,6 +325,8 @@ export async function activate(context: vscode.ExtensionContext) { // Continue with activation even if splash screen fails } + initTelemetry(context); + let stepStart = activationStart; try { @@ -1303,6 +1306,8 @@ export async function deactivate() { currentStepTimer = null; } + await shutdownTelemetry(); + // Close the index manager's database connection and clear the global reference try { const { clearSQLiteIndexManager } = await import( diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts new file mode 100644 index 000000000..3db45c677 --- /dev/null +++ b/src/utils/telemetry.ts @@ -0,0 +1,74 @@ +import * as vscode from "vscode"; +import * as os from "os"; +import { PostHog } from "posthog-node"; + +const EXTENSION_ID = "project-accelerate.codex-editor-extension"; +const POSTHOG_PROJECT_TOKEN = "phc_RI95xdYMQyCjOFSfPmsWrj9zviS4ywf56XwEX9cZ6Mf"; + +let client: PostHog | undefined; +let distinctId: string | undefined; + +const getExtensionVersion = (): string => { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + return (ext?.packageJSON?.version as string) ?? "unknown"; +}; + +const getSystemProperties = () => ({ + osPlatform: os.platform(), + osRelease: os.release(), + osArch: os.arch(), + totalMemoryGB: Math.round(os.totalmem() / 1024 / 1024 / 1024), + freeMemoryGB: Math.round(os.freemem() / 1024 / 1024 / 1024), + cpuCores: os.cpus().length, + cpuModel: os.cpus()[0]?.model, + vscodeVersion: vscode.version, + extensionVersion: getExtensionVersion(), + nodeVersion: process.version, + locale: vscode.env.language, +}); + +export const initTelemetry = (context: vscode.ExtensionContext): void => { + distinctId = vscode.env.machineId; + + client = new PostHog(POSTHOG_PROJECT_TOKEN, { + host: "https://us.i.posthog.com", + flushAt: 20, + flushInterval: 30_000, + }); + + client.identify({ + distinctId, + properties: getSystemProperties(), + }); + + captureTelemetryEvent("extension_activated", getSystemProperties()); +}; + +export const captureTelemetryEvent = ( + event: string, + properties?: Record, +): void => { + if (!client || !distinctId) { + return; + } + + client.capture({ + distinctId, + event, + properties, + }); +}; + +export const shutdownTelemetry = async (): Promise => { + if (!client) { + return; + } + + try { + await client.shutdown(); + } catch (error) { + console.warn("[Telemetry] Error during PostHog shutdown:", error); + } finally { + client = undefined; + } +}; From c808e4007c92f8c53d8c20512da8ba95213c53c7 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 19 Mar 2026 17:30:21 -0400 Subject: [PATCH 15/30] - Add posthog js for screen recording. --- package.json | 6 ++++ src/cellLabelImporter/cellLabelImporter.ts | 5 ++- src/codexMigrationTool/codexMigrationTool.ts | 5 ++- src/copilotSettings/copilotSettings.ts | 2 ++ src/globalProvider.ts | 2 ++ .../SplashScreen/SplashScreenProvider.ts | 2 +- .../VideoPlayer/VideoPlayerProvider.ts | 2 +- .../codexCellEditorProvider.ts | 4 ++- .../navigationWebviewProvider.ts | 2 +- src/utils/telemetry.ts | 18 ++++++++++- src/utils/webviewTemplate.ts | 4 ++- webviews/codex-webviews/package.json | 1 + .../src/CellLabelImporterView/index.tsx | 1 + .../src/CodexCellEditor/index.tsx | 1 + .../src/CodexMigrationToolView/index.tsx | 1 + .../codex-webviews/src/CommentsView/index.tsx | 1 + .../src/CopilotSettings/index.tsx | 1 + .../codex-webviews/src/MainMenu/index.tsx | 1 + .../src/NavigationView/index.tsx | 1 + .../src/NewSourceUploader/index.tsx | 1 + .../codex-webviews/src/ParallelView/index.tsx | 1 + .../src/PublishProject/index.tsx | 1 + .../codex-webviews/src/SplashScreen/index.tsx | 1 + .../codex-webviews/src/StartupFlow/index.tsx | 1 + webviews/codex-webviews/src/shared/posthog.ts | 31 +++++++++++++++++++ 25 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 webviews/codex-webviews/src/shared/posthog.ts diff --git a/package.json b/package.json index 1f11ebd84..64bb3140b 100644 --- a/package.json +++ b/package.json @@ -868,6 +868,12 @@ "default": "", "description": "Limits context building to specified books. Leave empty to include all. We recommend to leave empty for best results." }, + "codex-editor-extension.sessionRecordingEnabled": { + "title": "Session Recording", + "type": "boolean", + "default": true, + "description": "Enable PostHog session recording in webviews for product analytics." + }, "codex-editor-extension.debugMode": { "title": "Debugging Mode", "type": "boolean", diff --git a/src/cellLabelImporter/cellLabelImporter.ts b/src/cellLabelImporter/cellLabelImporter.ts index 0b8df32d8..5c570db42 100644 --- a/src/cellLabelImporter/cellLabelImporter.ts +++ b/src/cellLabelImporter/cellLabelImporter.ts @@ -12,6 +12,7 @@ import { matchCellLabels } from "./matcher"; import { copyToTempStorage, getColumnHeaders } from "./utils"; import { updateCellLabels } from "./updater"; import { getNonce } from "../utils/getNonce"; +import { getPostHogWebviewScript } from "../utils/telemetry"; import { safePostMessageToPanel } from "../utils/webviewUtils"; const DEBUG_CELL_LABEL_IMPORTER = false; @@ -122,7 +123,8 @@ async function getHtmlForCellLabelImporterView( img-src ${webview.cspSource} https: data:; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; - font-src ${webview.cspSource};"> + font-src ${webview.cspSource}; + connect-src https://*.posthog.com https://*.i.posthog.com;"> @@ -136,6 +138,7 @@ async function getHtmlForCellLabelImporterView(
+ ${getPostHogWebviewScript(nonce)} `; diff --git a/src/codexMigrationTool/codexMigrationTool.ts b/src/codexMigrationTool/codexMigrationTool.ts index c1866dad9..07541c5c1 100644 --- a/src/codexMigrationTool/codexMigrationTool.ts +++ b/src/codexMigrationTool/codexMigrationTool.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import * as path from "path"; import { getNonce } from "../utils/getNonce"; +import { getPostHogWebviewScript } from "../utils/telemetry"; import { safePostMessageToPanel } from "../utils/webviewUtils"; import { matchMigrationCells } from "./matcher"; import { applyMigrationToTargetFile } from "./updater"; @@ -50,7 +51,8 @@ async function getHtmlForCodexMigrationToolView( img-src ${webview.cspSource} https: data:; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; - font-src ${webview.cspSource};"> + font-src ${webview.cspSource}; + connect-src https://*.posthog.com https://*.i.posthog.com;"> `; diff --git a/src/copilotSettings/copilotSettings.ts b/src/copilotSettings/copilotSettings.ts index e1e012a3b..a981f2ff4 100644 --- a/src/copilotSettings/copilotSettings.ts +++ b/src/copilotSettings/copilotSettings.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { callLLM } from "../utils/llmUtils"; import { CompletionConfig } from "@/utils/llmUtils"; import { MetadataManager } from "../utils/metadataManager"; +import { getPostHogWebviewScript } from "../utils/telemetry"; interface ProjectLanguage { tag: string; @@ -68,6 +69,7 @@ export async function openSystemMessageEditor() {
+ ${getPostHogWebviewScript(nonce)} `; diff --git a/src/globalProvider.ts b/src/globalProvider.ts index e213a3359..ee6b65b56 100644 --- a/src/globalProvider.ts +++ b/src/globalProvider.ts @@ -3,6 +3,7 @@ import { CodexCellEditorProvider } from "./providers/codexCellEditorProvider/cod import { CustomWebviewProvider } from "./providers/parallelPassagesWebview/customParallelPassagesWebviewProvider"; import { GlobalContentType, GlobalMessage } from "../types"; import { getNonce } from "./utils/getNonce"; +import { getPostHogWebviewScript } from "./utils/telemetry"; import { safePostMessageToView } from "./utils/webviewUtils"; @@ -137,6 +138,7 @@ export abstract class BaseWebviewProvider implements vscode.WebviewViewProvider
+ ${getPostHogWebviewScript(nonce)} `; diff --git a/src/providers/SplashScreen/SplashScreenProvider.ts b/src/providers/SplashScreen/SplashScreenProvider.ts index 9395fe298..76e537916 100644 --- a/src/providers/SplashScreen/SplashScreenProvider.ts +++ b/src/providers/SplashScreen/SplashScreenProvider.ts @@ -166,7 +166,7 @@ export class SplashScreenProvider { return getWebviewHtml(webview, { extensionUri: this._extensionUri } as vscode.ExtensionContext, { title: "Codex Editor Loading", scriptPath: ["SplashScreen", "index.js"], - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' 'strict-dynamic' https://static.cloudflareinsights.com; worker-src ${webview.cspSource} blob:; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com; img-src ${webview.cspSource} https: data:; font-src ${webview.cspSource}; media-src ${webview.cspSource} https: blob:;`, + csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' 'strict-dynamic' https://static.cloudflareinsights.com; worker-src ${webview.cspSource} blob:; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com https://*.posthog.com https://*.i.posthog.com; img-src ${webview.cspSource} https: data:; font-src ${webview.cspSource}; media-src ${webview.cspSource} https: blob:;`, initialData: { timings: this._timings, syncDetails: this._syncDetails }, inlineStyles: ` body { margin: 0; padding: 0; height: 100vh; width: 100vw; overflow: hidden; background-color: var(--vscode-editor-background); color: var(--vscode-foreground); font-family: var(--vscode-font-family); } diff --git a/src/providers/VideoPlayer/VideoPlayerProvider.ts b/src/providers/VideoPlayer/VideoPlayerProvider.ts index 31513e93b..afa5a8a20 100644 --- a/src/providers/VideoPlayer/VideoPlayerProvider.ts +++ b/src/providers/VideoPlayer/VideoPlayerProvider.ts @@ -50,7 +50,7 @@ export class VideoPlayerProvider return getWebviewHtml(webview, this.context, { title: "Codex Video Player", scriptPath: ["VideoPlayer", "index.js"], - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' https://static.cloudflareinsights.com; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com; worker-src ${webview.cspSource}; img-src ${webview.cspSource} https:; font-src ${webview.cspSource};` + csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' https://static.cloudflareinsights.com; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com https://*.posthog.com https://*.i.posthog.com; worker-src ${webview.cspSource}; img-src ${webview.cspSource} https:; font-src ${webview.cspSource};` }); } } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 3580cd06a..b5f5a5431 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -28,6 +28,7 @@ import { SyncManager } from "../../projectManager/syncManager"; import bibleData from "../../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; import { getNonce } from "../../utils/getNonce"; +import { getPostHogWebviewScript } from "../../utils/telemetry"; import { safePostMessageToPanel } from "../../utils/webviewUtils"; import path from "path"; import * as fs from "fs"; @@ -1541,7 +1542,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider - + Codex Cell Editor @@ -1561,6 +1562,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider
+ ${getPostHogWebviewScript(nonce)}