Repository navigation
馃Ь feat: Add Skill Upload Reservation Helpers #17041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
dustinhealy
merged 7 commits into
feat/skill-lifecycle-persistence
from
feat/skill-upload-intents
Oct 16, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
09e9b9e
feat: add Skill upload reservation helpers
dustinhealy 34652b1
fix: preserve reserved Skill blob keys and validate cleanup strategy
dustinhealy 69075cf
fix: preserve Skill upload compensation on journal failures
dustinhealy 85fb491
fix: reconcile ambiguous Skill upload intent states
dustinhealy ee7a7c9
fix: align Skill uploads and public helper exports
dustinhealy f6e2dac
fix: plan skill blob locations before writes
dustinhealy 1601c88
test: verify Skill exports without prior package build
dustinhealy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,307 @@ | ||
| import { FileSources } from 'librechat-data-provider'; | ||
| import type { SkillUploadIntentDeps, SkillUploadOperation } from './intent'; | ||
| import { withSkillUploadIntent } from './intent'; | ||
| import { reserveSkillBlob } from './reservation'; | ||
|
|
||
| function makeDeps(): jest.Mocked<SkillUploadIntentDeps> { | ||
| return { | ||
| reserveSkillUploadIntent: jest.fn(async (input) => ({ fileId: input.fileId })), | ||
| transitionSkillUploadIntent: jest.fn(async (_fileId, _writerId, _from, _to) => true), | ||
| }; | ||
| } | ||
|
|
||
| function makeOperation(): SkillUploadOperation<string> { | ||
| return { | ||
| skillId: '507f1f77bcf86cd799439011', | ||
| author: '507f1f77bcf86cd799439012', | ||
| fileId: 'file-1', | ||
| source: 'local', | ||
| reservation: { | ||
| source: 'local', | ||
| filepath: '/uploads/owner/file-1', | ||
| writeLocation: { userId: 'owner', fileName: 'file-1', basePath: 'uploads' }, | ||
| }, | ||
| save: jest.fn(async () => '/uploads/owner/file-1'), | ||
| persist: jest.fn(async () => 'saved'), | ||
| readPersisted: jest.fn(async () => null), | ||
| deleteReserved: jest.fn(async () => undefined), | ||
| }; | ||
| } | ||
|
|
||
| it('persists intent before writing bytes and acknowledges only after row commit', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| await expect(withSkillUploadIntent(deps, operation)).resolves.toBe('saved'); | ||
| expect(deps.reserveSkillUploadIntent.mock.invocationCallOrder[0]).toBeLessThan( | ||
| (operation.save as jest.Mock).mock.invocationCallOrder[0], | ||
| ); | ||
| expect(deps.transitionSkillUploadIntent.mock.invocationCallOrder[1]).toBeGreaterThan( | ||
| (operation.persist as jest.Mock).mock.invocationCallOrder[0], | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects a provider mismatch before journaling or writing bytes', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.source = FileSources.s3; | ||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow( | ||
| 'Skill upload source differs from its reserved provider', | ||
| ); | ||
| expect(deps.reserveSkillUploadIntent).not.toHaveBeenCalled(); | ||
| expect(operation.save).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('persists the canonical provider key instead of a public URL', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.reservation = { | ||
| source: 's3', | ||
| filepath: 'https://cdn.example.test/uploads/owner/file-1?signature=old', | ||
| storageKey: 'uploads/owner/file-1', | ||
| writeLocation: { userId: 'owner', fileName: 'file-1', basePath: 'uploads' }, | ||
| }; | ||
| operation.source = FileSources.s3; | ||
| operation.save = jest.fn( | ||
| async () => 'https://cdn.example.test/uploads/owner/file-1?signature=new', | ||
| ); | ||
| await withSkillUploadIntent(deps, operation); | ||
| expect(deps.reserveSkillUploadIntent).toHaveBeenCalledWith( | ||
| expect.objectContaining({ storageKey: 'uploads/owner/file-1' }), | ||
| ); | ||
| }); | ||
|
|
||
| it('journals the regional object key and compensates a mismatched provider result before persistence', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.reservation = await reserveSkillBlob({ | ||
| source: FileSources.s3, | ||
| userId: 'owner', | ||
| fileName: 'file-1', | ||
| tenantId: 'tenant-a', | ||
| storageRegion: 'us-east-1', | ||
| includeRegionInPath: true, | ||
| getFileURL: async () => | ||
| 'https://cdn.example.test/r/us-east-1/t/tenant-a/uploads/owner/file-1?old=signature', | ||
| }); | ||
| operation.source = FileSources.s3; | ||
| operation.save = jest.fn( | ||
| async () => | ||
| 'https://cdn.example.test/r/eu-west-1/t/tenant-a/uploads/owner/file-1?new=signature', | ||
| ); | ||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow( | ||
| 'Skill storage wrote outside its reserved location', | ||
| ); | ||
| expect(deps.reserveSkillUploadIntent).toHaveBeenCalledWith( | ||
| expect.objectContaining({ storageKey: operation.reservation.storageKey }), | ||
| ); | ||
| expect(operation.persist).not.toHaveBeenCalled(); | ||
| expect(operation.deleteReserved).toHaveBeenCalledWith( | ||
| operation.reservation, | ||
| 'https://cdn.example.test/r/eu-west-1/t/tenant-a/uploads/owner/file-1?new=signature', | ||
| ); | ||
| }); | ||
|
|
||
| it('retains the actual regional key for stopped-writer cleanup after bytes were written', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.reservation = await reserveSkillBlob({ | ||
| source: FileSources.cloudfront, | ||
| userId: 'owner', | ||
| fileName: 'file-1', | ||
| tenantId: 'tenant-a', | ||
| storageRegion: 'us-east-1', | ||
| includeRegionInPath: true, | ||
| getFileURL: async () => 'https://cdn.example.test/r/us-east-1/t/tenant-a/uploads/owner/file-1', | ||
| }); | ||
| operation.source = FileSources.cloudfront; | ||
| operation.save = jest.fn( | ||
| async () => | ||
| 'https://cdn.example.test/r/us-east-1/t/tenant-a/uploads/owner/file-1?signature=refreshed', | ||
| ); | ||
| operation.persist = jest.fn(async () => { | ||
| throw new Error('database unavailable'); | ||
| }); | ||
| operation.deleteReserved = jest.fn(async () => { | ||
| throw new Error('storage unavailable'); | ||
| }); | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow('database unavailable'); | ||
| expect(deps.reserveSkillUploadIntent).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| storageKey: 'r/us-east-1/t/tenant-a/uploads/owner/file-1', | ||
| }), | ||
| ); | ||
| expect(operation.deleteReserved).toHaveBeenCalledWith( | ||
| operation.reservation, | ||
| expect.stringContaining('/r/us-east-1/t/tenant-a/uploads/owner/file-1'), | ||
| ); | ||
| // A stopped writer leaves the pre-written canonical key in the durable intent. | ||
| expect(deps.reserveSkillUploadIntent).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('keeps an abandoned intent even when immediate compensation succeeds', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.save = jest.fn(async () => { | ||
| throw new Error('provider timeout'); | ||
| }); | ||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow('provider timeout'); | ||
| expect(deps.transitionSkillUploadIntent).toHaveBeenCalledWith( | ||
| 'file-1', | ||
| expect.any(String), | ||
| 'uploading', | ||
| 'abandoned', | ||
| ); | ||
| expect(operation.deleteReserved).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it.each([null, '', ' '])('compensates a storage write with no usable path (%p)', async (path) => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.save = jest.fn(async () => path as string); | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow( | ||
| 'Skill storage returned no saved object path', | ||
| ); | ||
| expect(operation.persist).not.toHaveBeenCalled(); | ||
| expect(operation.deleteReserved).toHaveBeenCalledWith(operation.reservation, undefined); | ||
| }); | ||
|
|
||
| it.each(['uploading', 'committing'] as const)( | ||
| 'compensates the reserved blob even when the %s abandonment update throws', | ||
| async (failedState) => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.save = jest.fn(async () => { | ||
| throw new Error('provider timeout'); | ||
| }); | ||
| deps.transitionSkillUploadIntent.mockImplementation(async (_fileId, _writerId, from, to) => { | ||
| if (from === failedState && to === 'abandoned') throw new Error('database unavailable'); | ||
| return true; | ||
| }); | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow('provider timeout'); | ||
| expect(deps.transitionSkillUploadIntent).toHaveBeenCalledWith( | ||
| 'file-1', | ||
| expect.any(String), | ||
| 'uploading', | ||
| 'abandoned', | ||
| ); | ||
| expect(deps.transitionSkillUploadIntent).toHaveBeenCalledWith( | ||
| 'file-1', | ||
| expect.any(String), | ||
| 'committing', | ||
| 'abandoned', | ||
| ); | ||
| expect(operation.deleteReserved).toHaveBeenCalledWith(operation.reservation, undefined); | ||
| }, | ||
| ); | ||
|
|
||
| it('preserves the original failure for stopped-writer recovery if journal and cleanup both fail', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.save = jest.fn(async () => { | ||
| throw new Error('provider timeout'); | ||
| }); | ||
| deps.transitionSkillUploadIntent.mockRejectedValue(new Error('database unavailable')); | ||
| operation.deleteReserved = jest.fn(async () => { | ||
| throw new Error('storage unavailable'); | ||
| }); | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow('provider timeout'); | ||
| expect(deps.reserveSkillUploadIntent).toHaveBeenCalledTimes(1); | ||
| expect(deps.transitionSkillUploadIntent).toHaveBeenCalledTimes(3); | ||
| expect(operation.deleteReserved).toHaveBeenCalledTimes(1); | ||
| // The intent is intentionally retained. If both stores stay unavailable, | ||
| // an operator must recover the stopped writer before background cleanup. | ||
| }); | ||
|
|
||
| it.each(['save', 'first transition'] as const)( | ||
| 'compensates a %s failure without reading persistence that was never attempted', | ||
| async (failurePoint) => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.readPersisted = jest.fn(async () => { | ||
| throw new Error('database unavailable'); | ||
| }); | ||
| if (failurePoint === 'save') { | ||
| operation.save = jest.fn(async () => { | ||
| throw new Error('provider timeout'); | ||
| }); | ||
| } else { | ||
| deps.transitionSkillUploadIntent.mockRejectedValueOnce(new Error('database unavailable')); | ||
| } | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow( | ||
| failurePoint === 'save' ? 'provider timeout' : 'database unavailable', | ||
| ); | ||
| expect(operation.persist).not.toHaveBeenCalled(); | ||
| expect(operation.readPersisted).not.toHaveBeenCalled(); | ||
| expect(operation.deleteReserved).toHaveBeenCalledTimes(1); | ||
| }, | ||
| ); | ||
|
|
||
| it.each(['missing', 'replaced'] as const)( | ||
| 'abandons an acknowledged commit after the original row is %s and cleanup fails', | ||
| async (rowState) => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| let intentState = 'uploading'; | ||
| deps.transitionSkillUploadIntent.mockImplementation(async (_fileId, _writerId, from, to) => { | ||
| if (intentState !== from) return intentState === to; | ||
| intentState = to; | ||
| if (from === 'committing' && to === 'committed') { | ||
| throw new Error('commit reply lost'); | ||
| } | ||
| return true; | ||
| }); | ||
| operation.readPersisted = jest.fn(async () => { | ||
| const row = rowState === 'replaced' ? { fileId: 'replacement' } : null; | ||
| return row?.fileId === operation.fileId ? 'saved' : null; | ||
| }); | ||
| operation.deleteReserved = jest.fn(async () => { | ||
| throw new Error('storage unavailable'); | ||
| }); | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).rejects.toThrow('commit reply lost'); | ||
| expect(intentState).toBe('abandoned'); | ||
| expect(deps.transitionSkillUploadIntent).toHaveBeenCalledWith( | ||
| operation.fileId, | ||
| expect.any(String), | ||
| 'committed', | ||
| 'abandoned', | ||
| ); | ||
| expect(operation.deleteReserved).toHaveBeenCalledTimes(1); | ||
| }, | ||
| ); | ||
|
|
||
| it('returns the persisted row after an ambiguous database commit', async () => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.persist = jest.fn(async () => { | ||
| throw new Error('lost database reply'); | ||
| }); | ||
| operation.readPersisted = jest.fn(async () => 'recovered'); | ||
| await expect(withSkillUploadIntent(deps, operation)).resolves.toBe('recovered'); | ||
| expect(operation.deleteReserved).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it.each(['acknowledgement rejected', 'reply lost'] as const)( | ||
| 'returns a persisted upload after recovering %s', | ||
| async (scenario) => { | ||
| const deps = makeDeps(); | ||
| const operation = makeOperation(); | ||
| operation.readPersisted = jest.fn(async () => 'saved'); | ||
| deps.transitionSkillUploadIntent.mockResolvedValueOnce(true); | ||
| if (scenario === 'reply lost') { | ||
| deps.transitionSkillUploadIntent.mockRejectedValueOnce(new Error('lost acknowledgement')); | ||
| } else { | ||
| deps.transitionSkillUploadIntent.mockResolvedValueOnce(false); | ||
| } | ||
| deps.transitionSkillUploadIntent.mockResolvedValueOnce(true); | ||
|
|
||
| await expect(withSkillUploadIntent(deps, operation)).resolves.toBe('saved'); | ||
| expect(deps.transitionSkillUploadIntent).toHaveBeenCalledTimes(3); | ||
| expect(operation.deleteReserved).not.toHaveBeenCalled(); | ||
| }, | ||
| ); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A repo-wide search finds these helpers only in their definitions and tests; the production paths still call
saveBufferdirectly beforeupsertSkillFileinpackages/api/src/skills/save.ts:85-114andpackages/api/src/skills/upload.ts:105-131, whileapi/server/services/Endpoints/agents/skillDeps.js:71-87continues wiring the old resolver. Consequently, a process exit between the blob write and metadata upsert still creates an unjournaled orphan, so exporting the helpers does not deliver the reservation behavior. Wire the TS helpers and lifecycle dependencies into both upload paths through the CJS adapter.AGENTS.md reference: AGENTS.md:L78-L82
Useful? React with 馃憤聽/ 馃憥.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a real integration requirement. This PR introduces the reservation/intent helpers, but does not claim to wire the production upload paths by itself. The dependent stack PR that converts direct upload/edit, import, and sync to pass one frozen reservation through the writer is #17043. The six PRs are being landed as one stack with no deployment between merges, so the orphan window is not considered closed until that integration PR lands. Leaving this thread open as an explicit stack release gate.