fix(scripts): AuthContext binding + load() cache invalidation (Codex review)

Codex (deepseek-v4-flash) review of feat(scripts) parent_path filter
surfaced four problems:

1. Critical — AuthContext listScripts binding silently dropped parentPath
   - frontend/app/context/AuthContext.tsx:224 had:
       listScripts: () => rawApi.listScripts(workspaceId)
     so loadScripts("foo/bar") hit GET /api/v1/scripts with no query and
     always received root-level scripts. Typecheck passed because
     `() => ...` is assignable to `(parentPath?: string) => ...`.
   - Fix: forward the parentPath argument.

2. High — load() cache invalidation gap on toolbar refresh / create / delete
   - load() replaced `scripts` with root-only items but never invalidated
     `loadedScriptPaths` for subfolders, so previously-expanded folders
     rendered empty (cached no-op on re-expand) and open tabs pointing
     into subfolders were dropped by the validIds filter.
   - Fix: load() now re-fetches every path currently in
     loadedScriptPaths (and loadedChildPaths for directories), then
     dedups by id/path. On initial mount the cache is empty so this
     degrades to a single root fetch.

3. Low — test docstring overclaimed coverage
   - The "actual ORM roundtrip is covered by the existing integration
     tests" line is false (no other list_scripts test exists).
   - Fix: honest docstring noting the repo has no endpoint integration
     test layer; SQL assertions are brittle to SQLAlchemy/dialect
     formatting.

4. Low — backend docstring overclaimed index role
   - Claimed `idx_storage_workspace_relative_path` "avoided全表扫", but
     the index isn't declared in the ORM model, only exists via the
     baseline migration's upgrade path, and EXPLAIN doesn't drive
     through it (scripts-first plan via idx_scripts_workspace).
   - Fix: accurate description — MySQL drives via idx_scripts_workspace,
     storage_objects PK lookup applies LIKE per row. Notes where to
     optimize if 10万-scale perf becomes a real problem.

Skipped findings (out of scope):
- Medium LIKE escape for `_` / `%` (pre-existing in
  list_workspace_directories; not introduced here).
- Low dashboard `scripts.length` count underreport (separate UI bug,
  pre-existing assumption that broke under the new semantics).

Verified: pytest 50 passed, pnpm typecheck clean.
This commit is contained in:
tao.chen
2026-08-21 10:42:38 +08:00
parent 02c25fcc8e
commit cee080b7df
4 changed files with 54 additions and 18 deletions
@@ -263,25 +263,55 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
// Fetch root-level scripts (parent_path="") and root directories in
// parallel. Deeper folders are lazy-fetched on expand via loadScripts
// / loadChildren.
const [items, folderItems] = await Promise.all([
api.listScripts(""),
// Re-fetch every path currently in the cache. On initial mount the
// cache is empty so this degrades to a single root fetch; on
// toolbar refresh / after createFolder / after deleteScript the
// previously-expanded folders are also re-fetched so the UI stays
// consistent (otherwise the cache would keep stale "loaded"
// markers while the corresponding scripts had been replaced by
// the root-only payload, leaving subfolders empty on re-expand).
const cachedScriptPaths = Array.from(get().loadedScriptPaths);
const cachedChildPaths = Array.from(get().loadedChildPaths).filter(
(p) => p !== "",
);
const scriptFetches =
cachedScriptPaths.length > 0
? cachedScriptPaths.map((p) =>
api.listScripts(p).catch(() => [] as ScriptItem[]),
)
: [api.listScripts("")];
const dirFetches = [
api.listWorkspaceDirectories(""),
...cachedChildPaths.map((p) =>
api.listWorkspaceDirectories(p).catch(() => [] as WorkspaceDirectory[]),
),
];
const [scriptLists, dirLists] = await Promise.all([
Promise.all(scriptFetches),
Promise.all(dirFetches),
]);
const freshScripts = scriptLists.flat();
const freshDirs = dirLists.flat();
// Dedup: later occurrences win so fresh per-path payloads override
// any duplicates coming through different fetch slots.
const dedupedScripts = Array.from(
new Map(freshScripts.map((s) => [s.script_id, s])).values(),
);
const dedupedDirs = Array.from(
new Map(freshDirs.map((d) => [d.path, d])).values(),
);
const nextLoadedScripts = new Set(cachedScriptPaths);
nextLoadedScripts.add("");
const nextLoadedChildren = new Set(get().loadedChildPaths);
nextLoadedChildren.add("");
const nextLoadedScripts = new Set(get().loadedScriptPaths);
nextLoadedScripts.add("");
set({
scripts: items,
directories: folderItems,
scripts: dedupedScripts,
directories: dedupedDirs,
apiOnline: true,
loadedChildPaths: nextLoadedChildren,
loadedScriptPaths: nextLoadedScripts,
loadedChildPaths: nextLoadedChildren,
});
const validIds = new Set(items.map((item) => item.script_id));
const validIds = new Set(dedupedScripts.map((item) => item.script_id));
const currentSelected = get().selectedId;
if (!currentSelected || !validIds.has(currentSelected)) {
_selectedId = null;