From 0cbd8b8e1a845451f2479ae9691110b535e93716 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Wed, 9 Sep 2026 11:17:48 -0400 Subject: [PATCH] fix(knowledge): admit immutable committed overlays for publication retry --- .../control-plane/knowledge/staging-admission.ts | 6 +++++- .../knowledge/staging-admission.test.ts | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/api/control-plane/knowledge/staging-admission.ts b/src/api/control-plane/knowledge/staging-admission.ts index c6dc4863..6d55e41f 100644 --- a/src/api/control-plane/knowledge/staging-admission.ts +++ b/src/api/control-plane/knowledge/staging-admission.ts @@ -13,8 +13,12 @@ export async function admitStagingPublication(store: any, connection: any, revie connection.client.status({ workspaceId: workspace.treeDxWorkspaceId }), ]); const paths = (value: unknown) => Array.isArray(value) ? [...value].map(String).sort().join('\n') : ''; + // TreeDX retains committed overlays for diff/read-back. They are not a Git + // dirty worktree: committed workspaces reject writes and release the lease. + const retained = status.changes; if (!paths(review.changedPaths) || paths(review.changedPaths) !== paths(diff.changedPaths) - || status.commitSha !== review.commitSha || (status.changes ?? []).length > 0) { + || status.status !== 'committed' || status.commitSha !== review.commitSha + || !Array.isArray(retained) || retained.length > 0 && paths(retained.map((item: any) => item?.path)) !== paths(review.changedPaths)) { throw new KnowledgeOperationError(409, 'knowledge_review_diff_changed', 'The workspace no longer matches its submitted commit.'); } if (review.status === 'approved') return; diff --git a/tests/unit/control-plane/knowledge/staging-admission.test.ts b/tests/unit/control-plane/knowledge/staging-admission.test.ts index 3684a318..e18e73da 100644 --- a/tests/unit/control-plane/knowledge/staging-admission.test.ts +++ b/tests/unit/control-plane/knowledge/staging-admission.test.ts @@ -12,7 +12,7 @@ function fixture() { principalCanAccessTeam: vi.fn(async () => true), getTeamAccessSummary: vi.fn(async () => ({ permissions: ['knowledge:publish', 'knowledge:review'] })), listKnowledgeReviews: vi.fn(async () => [review]), listKnowledgeReviewComments: vi.fn(async () => []), listKnowledgeWorkspacePresence: vi.fn(async () => []), }; - const connection = { client: { diff: vi.fn(async () => ({ changedPaths: review.changedPaths })), status: vi.fn(async () => ({ commitSha: review.commitSha, changes: [] as unknown[] })) } }; + const connection = { client: { diff: vi.fn(async () => ({ changedPaths: review.changedPaths })), status: vi.fn(async () => ({ status: 'committed', commitSha: review.commitSha, changes: [] as unknown[] })) } }; return { review, workspace, store, connection }; } @@ -27,15 +27,22 @@ describe('staging library admission', () => { await admitStagingPublication(f.store, f.connection, f.review, f.workspace, 'author', 11); expect(f.store.decideKnowledgeReview).toHaveBeenCalledWith('review', expect.objectContaining({ decidedByUserId: 'author', workspaceVersion: 11 })); }); - it.each(['version', 'commit', 'paths', 'dirty', 'rejected'])('rejects changed or unavailable revision: %s', async (kind) => { + it.each(['version', 'commit', 'paths', 'dirty', 'ready', 'expired', 'rejected'])('rejects changed or unavailable revision: %s', async (kind) => { const f = fixture(); - if (kind === 'commit') f.connection.client.status.mockResolvedValue({ commitSha: 'moved', changes: [] }); + if (kind === 'commit') f.connection.client.status.mockResolvedValue({ status: 'committed', commitSha: 'moved', changes: [] }); if (kind === 'paths') f.connection.client.diff.mockResolvedValue({ changedPaths: ['books/other.md'] }); - if (kind === 'dirty') f.connection.client.status.mockResolvedValue({ commitSha: 'exact-commit', changes: [{}] }); + if (kind === 'dirty') f.connection.client.status.mockResolvedValue({ status: 'committed', commitSha: 'exact-commit', changes: [{ path: 'books/unreviewed.md' }] }); + if (kind === 'ready' || kind === 'expired') f.connection.client.status.mockResolvedValue({ status: kind, commitSha: 'exact-commit', changes: [] }); if (kind === 'rejected') f.review.status = 'changes-requested'; await expect(admitStagingPublication(f.store, f.connection, f.review, f.workspace, 'author', kind === 'version' ? 10 : 11)).rejects.toBeDefined(); expect(f.store.decideKnowledgeReview).not.toHaveBeenCalled(); }); + it('admits exact retained overlays from an immutable committed TreeDX workspace', async () => { + const f = fixture(); f.review.status = f.workspace.status = 'approved'; + f.connection.client.status.mockResolvedValue({ status: 'committed', commitSha: 'exact-commit', changes: [{ path: 'books/guide.md', status: 'added' }] }); + await admitStagingPublication(f.store, f.connection, f.review, f.workspace, 'author', 11); + expect(f.store.decideKnowledgeReview).not.toHaveBeenCalled(); + }); it('does not decide an already admitted revision twice', async () => { const f = fixture(); f.review.status = f.workspace.status = 'approved'; await admitStagingPublication(f.store, f.connection, f.review, f.workspace, 'author', 11);