Skip to content

🖊️ fix: Persist Skill Subfile Edits - #16362

Open
lia-by-librechat[bot] wants to merge 4 commits into
devfrom
lia/skill-subfile-edit
Open

lia-by-librechat[bot] wants to merge 4 commits into
devfrom
lia/skill-subfile-edit

Conversation

@lia-by-librechat

@lia-by-librechat lia-by-librechat Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Skill sub-files appear in the sidebar but open in a read-only viewer. The unused tree editor does not reach that UI. This PR lets authorized users edit inline text sub-files in the live viewer and persist changes through the existing storage pipeline. Saves match the file revision atomically; conflicts preserve drafts instead of overwriting newer content. SKILL.md continues to use the skill-body form. Binary, oversized, and externally managed files remain read-only.

Confirmed 404/410/403 reads clear cached file bytes without discarding an open draft. Unavailable files retry when revisited and have a Retry action while open. Transient network/5xx failures retain the last confirmed content. Skills stored before the source field existed are treated as inline by both the API and upload handler, so their files remain editable.

Fixes #16316

How it works

GET /api/skills/:id/files/*relativePath -> content + fileId revision
  -> SkillTextEditor captures the content and revision
  -> POST /api/skills/:id/files/*relativePath (multipart + expectedFileId)
  -> typed upload handler -> atomic metadata replacement matching file_id
  -> refresh file list and content cache

The new conditional POST route requires a matching path and revision. The legacy collection upload route retains its unconditional contract for existing clients. In a mixed deployment, an older server rejects the conditional route instead of silently ignoring the revision. No /tree route is introduced.

Upload behavior lives in packages/api with injected storage and database methods; the legacy API route only wires it. Privileged skill and file reads start together, while validation gates storage writes. A 409 cannot recreate deleted files. Losing uploads clean up only their own blob. When a file replacement commits but the separate parent version update fails, the new live blob is retained, best-effort cleanup targets the superseded blob, and the request still reports the error. GitHub and Notion files are rejected before storage writes. Existing executable-file metadata is preserved.

The editor keeps its draft through refreshes, save errors, and permission changes. Conflicts require the user to copy the draft and reopen the current file rather than blindly retry. Successful saves cancel older content reads and install their confirmed revision. List-cache updates avoid partial singleton responses and refresh the complete list. Delayed HTTP and agent content-cache writes match the file revision they read, including through the deployment adapter.

Type of change

  • Bug fix
  • Tests / tooling / CI

Testing

Check Result
Client: viewer, SkillsView, skill mutations, markdown 46 passed
Real MongoDB: skill routes and tenant routes 61 passed
API package: upload, skill and agent handlers, deployment, GitHub sync 340 passed
npx tsc --noEmit: client and packages/api Passed
npm run static-checks: staged change and PR diff Passed
npm run lighthouse Production package/frontend builds passed; local browser audit blocked by missing Chrome

Real MongoDB tests used a temporary TCP-only --nounixsocket preload and the cached mongod binary. No harness files are committed. The full test suites, schema-only SkillFile methods, config migration, unused-i18n-key, and unused-dependency checks were not run locally for this patch. CI and review status should be evaluated at the exact PR head.

@lia-by-librechat

lia-by-librechat Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Ready for review at head 668c582. This commit makes nested skill text files editable in the live, permission-aware file viewer through the existing multipart replacement route. It includes Mongo-backed read/replace/read coverage, UI save/retry/permission tests, a passing frontend typecheck and staged static checks. Lighthouse's production build passed, but the local Playwright server could not start MongoDB in this sandbox; the browser audit did not run.

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-25T18:22:01.327633Z fe145ce Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 668c58268c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +58 to +61
const formData = new FormData();
formData.append('relativePath', relativePath);
formData.append('file', new File([content], file.filename, { type: file.mimeType }));
upload.mutate({ skillId, formData });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject stale subfile saves before overwriting newer content

When two editors—or an editor and an agent—modify the same subfile, this mutation sends only the path and file bytes, so the upload endpoint unconditionally upserts the row even if the skill version changed after the editor captured file.content. Saving an older draft therefore silently destroys the newer revision; include a version/file revision precondition and surface a conflict instead of overwriting stored data.

AGENTS.md reference: AGENTS.md:L42-L45

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in d7f7120. The file read now returns its storage revision; the editor submits that captured revision, and MongoDB matches it in the atomic replacement query with upsert disabled. A competing editor or agent replacement invalidates the draft; a deleted file cannot be recreated by a stale save. HTTP 409 keeps the draft and prevents blind retries. Conditional saves use a new POST file-path route so an old server rejects the request rather than ignoring the precondition.

Coverage: real MongoDB tests for two concurrent writers, an intervening unconditional authoring write, and deletion; HTTP tests for a race after blob storage and loser-only cleanup; UI conflict/draft preservation. The invariant review also scoped delayed API and agent read-cache writes to their captured file revision and covered forwarding through the deployment adapter. Legacy uploads without a precondition retain their existing unconditional contract.

Comment on lines +282 to +286
queryClient.invalidateQueries([
QueryKeys.skillFileContent,
variables.skillId,
skillFile.relativePath,
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve saved content when the verification refetch fails

After a successful upload, the editor first installs the confirmed content in the cache, but this unconditional invalidation immediately refetches it. In React Query v4, a transient failure of that background GET sets isError even while retaining cached data, and SkillFileViewer then hides that successfully saved content behind the load-error state; keep cached data visible for refetch errors or avoid forcing this reread.

AGENTS.md reference: AGENTS.md:L42-L44

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in d7f7120. The viewer renders retained file data even if a background verification GET fails; the load-error-only view is now restricted to a query with no data. Confirmed save content and its new revision remain visible.

Coverage: SkillFileViewer regression exercises the real query/mutation hooks with a successful upload followed by a failed verification GET, waits for React Query's error state, and verifies the saved text remains visible.

const isSkillMd = relativePath === 'SKILL.md';
const isText = data != null && !data.isBinary && data.content != null;
const isEditing = editingFile != null;
const canEdit = skill != null && !permissions.isLoading && permissions.canEdit;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable local edits for GitHub-managed subfiles

For a skill whose canonical source is github, an admin or ACL editor still satisfies this canEdit check and is offered the local file editor. The multipart replacement writes only LibreChat storage and cannot update the upstream repository, while GitHub synchronization compares the file's upstream blob metadata and will replace this local revision on a later upstream change, so a successful-looking edit is eventually lost; exclude externally managed skills from this action or provide an upstream-aware edit flow.

AGENTS.md reference: AGENTS.md:L42-L45

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in d7f7120. The edit affordance now requires an inline skill in addition to edit permission. The typed upload handler also rejects GitHub- and Notion-managed files before storage, so the protection is not just a UI check. Deployment files retain their existing read-only ACL gate.

Coverage: parameterized UI and real-route tests for github/notion sources with an authorized user; route tests assert no blob write or file row is created.

const queryClient = useQueryClient();
const [content, setContent] = useState(file.content);

const upload = useUploadSkillFileMutation({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refetch the complete file list when its cache is absent

When this editor is reached from a direct file URL, or the initial file-list request failed, [skillFiles, skillId] can be absent when the upload succeeds. useUploadSkillFileMutation then seeds that cache with only the edited file and never invalidates it; because useListSkillFilesQuery disables mount, focus, and reconnect refetches, the sidebar can subsequently treat that singleton as the complete list and hide every other subfile until the cache is cleared. Invalidate/fetch the file list when no prior list exists rather than manufacturing a partial response. CLAUDE.mdL277-L281

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in d7f7120. Successful uploads no longer manufacture a singleton list when no file-list cache exists. They cancel stale list reads, update an existing complete cache when available, and invalidate the list for a fresh complete response.

Coverage: mutation-hook regressions for a direct-file save followed by the first sidebar mount, and an in-flight stale list response settling after replacement. Both retain the edited file and its sibling.

@lia-by-librechat

lia-by-librechat Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Ready for another review at exact head d7f712087ba4c4526547ac4da2c310b32a834aba.

All four findings from 668c58268c9f were valid and are addressed: atomic sub-file revision checks with conflict-preserved drafts, retained saved content on verification-read failure, read-only externally managed files in UI and API, and complete sidebar file-cache refreshes. The invariant sweep also covered delayed cache writes, loser-blob cleanup, committed-write failures, authorization, and mixed-version rollout. Each inline thread has its rationale and regression coverage.

Local verification: 34 client tests, 59 real MongoDB route/tenant tests, 14 focused SkillFile database tests, and 333 related agent/deployment/sync tests passed. tsc --noEmit passed in client, packages/api, packages/data-schemas, and packages/data-provider. Staged static checks and the production build passed. Lighthouse was attempted but its local web server failed starting MongoDB before the browser audit.

A maintainer needs to trigger the next Codex review; this handoff does not trigger it. GitHub has not registered CI checks or workflow runs for this SHA yet; checks on the previous head do not verify this commit.

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review the latest head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7f712087b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)}

{data.isBinary && !isImage && (
{isError && !data && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict deleted content after a conflict refetch

When a save conflicts because another actor deleted the file, the conflict handler invalidates this query and the resulting GET returns 404, but React Query retains the old data. This !data guard then suppresses the error state after the user cancels, leaving the deleted content and Edit action visible; reopening only produces another conflict. Distinguish a not-found refetch by evicting the cached file or navigating away, while continuing to retain data for transient verification failures.

AGENTS.md reference: AGENTS.md:L42-L45

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid and reproduced against d7f7120 before the fix. Fixed in fe145ce.

The content query replaces cached bytes with a null unavailable state for HTTP 404/410/403; transient network/5xx failures still retain confirmed content. The editor keeps its local draft until cancellation, then the unavailable state has neither stale text nor an Edit action, including when reopening with the same QueryClient. A later successful invalidation restores the file normally.

The final cache-writer sweep also cancels older in-flight content reads before publishing a successful save and publishes the captured content/revision even if a previous reread had cleared the cache. Regression coverage exercises deletion/conflict/cancel/reopen, access denial, restored files, transient verification errors, and old terminal reads settling after a save.

Comment thread packages/api/src/skills/upload.ts Outdated
) {
return res;
}
const existingFile = await deps.getSkillFileByPath(skillId, relativePath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parallelize privileged upload lookups

For a user with the resource-wide skill capability, canAccessResource bypasses its ID resolver and leaves resourceAccess.resourceInfo unset, so this handler first awaits getSkillById and only afterward starts this independent getSkillFileByPath Mongo read. That adds a full database round trip to every privileged file save; start both scoped reads together and still gate storage writes on the skill/source result.

AGENTS.md reference: AGENTS.md:L51-L53

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid and reproduced against d7f7120 before the fix. Fixed in fe145ce.

The handler now starts getSkillById and getSkillFileByPath in one Promise.all, reusing resourceAccess.resourceInfo when ACL middleware already resolved the skill. The lookups keep their existing skill/path/tenant scope. Storage resolution and writes remain after the source, content-policy, and revision checks.

New upload.spec.ts regressions assert that both scoped reads are called before either deferred promise settles, cover either completion order, prove ACL-loaded skill data is reused, and assert zero storage writes for missing skills, managed sources, stale revisions, and lookup failure. The real MongoDB route/tenant suites also pass. This proves removal of the serialized lookup dependency, not a measured Lighthouse latency improvement.

@lia-by-librechat

Copy link
Copy Markdown
Contributor Author

Final-pass handoff for exact head fe145cee8dca283155e95028f5917f43229bfcc7.

Both new P2 findings were valid and reproduced before the fix. The terminal-response cache fix covers deleted or unavailable files, draft preservation, cancel/reopen, and later recovery while retaining confirmed content for transient failures. The upload fix overlaps the independent privileged lookups and reuses ACL-loaded skill data without moving writes ahead of validation. Each inline finding has the fix commit and regression coverage.

The subsystem pass also checked query/cache writers, delayed read completion, revision checks, storage cleanup, authorization/tenant scope, externally managed files, and mixed-version behavior. Older in-flight reads cannot clobber a confirmed save, and confirmed save content can repopulate a cache cleared during the request. The existing unconditional legacy/agent write contract is unchanged.

Local results: 41 client tests, 340 related API tests (including seven upload-ordering cases), 59 real MongoDB route/tenant tests, and 14 focused SkillFile database tests passed. npx tsc --noEmit passed in client, packages/api, packages/data-schemas, and packages/data-provider. Scoped static checks and the production build passed. Lighthouse was attempted but its MongoDB test server failed before the browser audit. Full suites, config migration, unused-i18n-key, and unused-dependency gates were not run locally.

CI checks are registered for this exact head; results are still being collected. A maintainer must trigger Codex for this new head; this comment is a handoff, not a trigger.

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review the latest head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe145cee8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +148 to +149
if (status === 404 || status === 410 || status === 403) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow unavailable file queries to recover

When a file read temporarily returns 403, or a deleted path is later recreated, returning null records a successful query result; with staleTime: Infinity and mount/focus/reconnect refetching disabled below, revisiting the file never issues another GET, so the error view persists until a full reload or an unrelated explicit invalidation. The fresh issue introduced by the prior cache-eviction fix is this permanent success state; keep terminal responses retryable or explicitly invalidate them when access/files can be restored.

AGENTS.md reference: AGENTS.md:L42-L44

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in c21600e. Returning null still clears stale bytes on 403/404/410; a null cache entry now forces a GET on the next mount despite infinite staleTime. A Retry button also recovers without leaving the file, and confirmed content remains cached. Coverage: real query/viewer regressions for unavailable revisits, repeated 403 retries, conflict/deletion/cancel/reopen/recreation, and transient failures. All 46 related client tests passed.

Comment thread packages/api/src/skills/upload.ts Outdated
Comment on lines +132 to +135
// A parent-version update can fail after the file row committed. Never
// delete its live blob on that ambiguous failure; retain it for reread.
const persisted = await deps.getSkillFileByPath(skillId, relativePath);
if (persisted?.file_id !== fileId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean up the superseded blob after a committed replacement

When replacing an existing file, if the SkillFile update commits but the subsequent parent-version update fails, this reread confirms that the new fileId is live and deliberately retains its blob, but rethrowing then skips the normal old-blob cleanup at lines 148–158. The previous storage object is no longer referenced and remains permanently orphaned; after confirming that the replacement committed, also delete existingFile while retaining the new blob.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in c21600e. When a reread confirms the new file revision committed despite a parent-version update failure, the handler retains its live blob and invokes the same best-effort cleanup for the superseded blob as on successful replacements. Losing uploads still delete only their own new blob. Coverage: real MongoDB route failure injection after metadata replacement verifies old blob and original owner are targeted, the committed blob remains, and the response stays 500. All 61 route/tenant tests passed.

Comment thread packages/api/src/skills/upload.ts Outdated
Comment on lines +88 to +89
if (skill.source !== 'inline') {
return res.status(403).json({ error: 'Externally managed skill files are read-only' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat legacy skills without a source as inline

For a stored skill created before the source field was populated, getSkillById returns a lean Mongo row with source absent—the schema default does not backfill existing documents—and this strict comparison classifies it as externally managed and returns 403. The previous upload route accepted these skills, so existing inline skills can no longer add or replace files; treat an absent source as inline or backfill it before enforcing the managed-source restriction.

AGENTS.md reference: AGENTS.md:L42-L44

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in c21600e. A nullish stored source is now interpreted as inline in the upload check and normalized to inline in both full and list API responses, so existing inline skills remain editable. Explicit GitHub/Notion/deployment sources remain blocked. Coverage: real MongoDB route test unsets the source on the raw stored document, verifies GET detail/list, legacy upload and revision-checked replacement; existing managed-source tests assert rejection before storage. All 61 route/tenant tests passed.

@lia-by-librechat

Copy link
Copy Markdown
Contributor Author

The three new findings have been assessed and fixed at c21600e. Recovery on revisit and Retry, old-blob cleanup after a committed replacement, and legacy inline-source compatibility now have regression coverage. Focused client, API and real-MongoDB tests, affected TypeScript typechecks, scoped static checks and production builds pass. Local Lighthouse browser audit was blocked by missing Chrome; CI runs independently. Please review this exact PR head when a maintainer can trigger review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants