After #34 the workspace store only holds the root-level scripts plus
whatever subfolders the user has expanded. DashboardRoute's
"全部脚本"/"工作副本" counts derived from scripts.length therefore
underreport the workspace total until the user navigates to /scripts
and expands every folder.
Fix: separate count endpoint + dedicated store field, mounted
independently.
Backend — backend/src/backend/scripts.py
- New endpoint GET /api/v1/scripts/count.
- Route declared BEFORE /api/v1/scripts/{script_id}/... so FastAPI's
declaration-order matching does not interpret "count" as a script_id.
- Returns { data: { total: number }, meta: {} }; SQL is a single
COUNT(*) on scripts filtered by workspace_id + status='active'.
Frontend — services/api.ts + context/AuthContext.tsx
- countScripts(workspaceId) client; WorkspaceBoundApi gains the field;
AuthContext binding forwards workspaceId.
Frontend — state/scriptWorkspaceStore.ts
- scriptCount: number | null, scriptCountLoading: boolean.
- loadScriptCount() action: idempotent (no-op while in-flight), silent
on failure (dashboard tolerates a stale count).
- Initial state and reset() clear both fields.
Frontend — features/platform/DashboardRoute.tsx
- Subscribes to scriptCount; calls loadScriptCount() on mount.
- Falls back to scripts.length until the count resolves so the
dashboard never blanks.
Tests — backend/tests/test_count_scripts.py (new)
- 3 unit tests: scalar result handling, NULL coercion, route callable.
Verified: pytest 55 passed (52 + 3 new); pnpm typecheck clean.
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.
Backend — backend/src/backend/scripts.py
- list_scripts accepts optional parent_path Query (default "").
- Extracts _build_list_scripts_descendant_prefix helper for the
"direct children of parent_path" prefix.
- WHERE clause now adds:
StorageObjects.relative_path LIKE '<prefix>/%'
AND NOT LIKE '<prefix>/%/%'
so deeper descendants and prefix-siblings (foo/bar vs foo/bar2)
are excluded. Empty parent_path filters to root-level only —
this is the symmetric, intent-aligned behavior the lazy-load
frontend relies on.
- EXPLAIN confirms idx_scripts_workspace drives the scripts table;
storage_objects PK lookup applies the LIKE filter per row.
Frontend — frontend/app/services/api.ts + scriptWorkspaceStore.ts
- listScripts accepts optional parentPath; built URL preserves
the new filter param.
- Store gains loadedScriptPaths / loadingScriptPaths Sets and a
loadScripts(parentPath) action: idempotent, in-flight dedupe,
dedup-by-id when merging into the flat scripts array so existing
find() callers keep working.
- load() now uses listScripts("") instead of bulk fetch — root
only on first paint.
- toggleExpanded() triggers loadScripts(parentPath) in parallel
with loadChildren(parentPath) so folder expansion loads both
sub-directories and direct-child scripts.
Tests — backend/tests/test_list_scripts_parent_path.py
- 7 unit tests: prefix construction, normalization, traversal
rejection, and SQL compilation contract (LIKE prefix + NOT LIKE
prefix + scripts.status filter).
Verified:
- pytest backend/tests: 50 passed (43 existing + 7 new)
- pnpm typecheck: clean
- alembic: no schema changes (migration-less feature)
- EXPLAIN with real workspace: idx_scripts_workspace → PK lookup
B1: `_mark_upload_failed_and_raise` now commits on a separate session
- Helper takes `request + upload_id`, opens a fresh session from
`request.app.state.session_factory` and commits there before raising.
- Closes the named-lock connection-pool leak Codex flagged: the old
"commit-on-the-same-session" implementation could return the
GET_LOCK connection to the pool before the enclosing
`finally: release_named_lock` ran, leaking `mp:<hash>` for up to
`pool_recycle` and re-opening the same-key upload race.
- Same helper now used by `create_server_object_payload`'s put-failure
branch — two failure paths have identical semantics.
B2: streaming copy for soft-delete + restore (`get_stream() + put()`)
- LOCAL backend: zero-copy (aiofiles stream write). OOM fixed.
- S3 backend: still OOMs on multi-GB objects — `put()` materializes
the async iter via `b"".join(chunks)`. Multipart `put` is a
follow-up; do NOT claim "OOM fixed on production" since production
defaults to S3.
C1: worker re-verifies `Users.status='active' AND is_deleted=0`
- `_assert_user_active` called from `_execution_context` after
resolving `triggered_by`; skips `SYSTEM_CRON_USER_ID`.
- `USER_DISABLED` error_code goes into the `NODE_FINISHED_EVENT`
outbox payload — `schedule_node_runs` has no `error_code` column,
the row only carries the `message` text. Docstrings corrected to
say so explicitly (previous docstring falsely promised row-level
observability).
F1: honest browser-local file lock
- `api.ts` `acquireFileLock/heartbeatFileLock/releaseFileLock/
releaseFileLockOnUnload` are now no-ops with comments stating they
never call the network.
- `scriptWorkspaceStore` dropped `tickHeartbeats`; `tickCleanup`
simplified to just clear cache.
- `useEditSessionLifecycle` dropped its 15s heartbeat `setInterval`.
- `ScriptWorkspace.tsx` renders `.local-lock-banner` info bar when
`isEditing`. Two tabs may still silently last-write — banner is the
only guard (acceptable disclosure-only tradeoff).
Dead code: deleted the duplicate `upload_bytes_to_session` in
`backend/src/backend/storage_api.py`. The `services.storage` import
is now the only source of the function; `create_upload_record`'s
docstring updated to point at `backend.resources`.