Skip to content

feat: implement V1.1 metadata-only local handoff#156

Open
seonghobae wants to merge 1 commit into
developfrom
feature/issue-150-local-handoff
Open

feat: implement V1.1 metadata-only local handoff#156
seonghobae wants to merge 1 commit into
developfrom
feature/issue-150-local-handoff

Conversation

@seonghobae
Copy link
Copy Markdown
Owner

Summary

Resolves #150. This PR implements the V1.1 Metadata-only Local Handoff functionality using the Stepwise Approach and /autoplan.

Changes

  • Updated shared-types with BndscpMetadata and rigorous validation.
  • Implemented lib/export.ts with JSZip to generate .bndscp archives enclosing metadata and 64kbps audio mixdowns.
  • Implemented lib/import.ts to parse files safely (path traversal sanitization) and prompt via OS file picker when original audio is missing.
  • Updated Workspace.tsx with Share and Import flows and Missing Audio empty states.

Verification

  • npm run test --workspaces passes with 100% logic coverage for export/import scenarios.
  • ./scripts/harness/quickcheck.sh checks succeed.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

📝 Walkthrough

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 워크스페이스를 .bndscp 파일로 공유 및 저장할 수 있습니다
    • 저장된 워크스페이스를 임포트하여 복원할 수 있습니다
    • 누락된 오디오 파일을 감지하고 "오디오 찾기" 기능으로 위치를 지정할 수 있습니다
  • 테스트

    • 워크스페이스 공유 및 임포트 기능에 대한 테스트 추가

Walkthrough

이 변경사항은 .bndscp ZIP 아카이브 형식의 워크스페이스 메타데이터 기반 공유/임포트 기능을 구현합니다. 내보내기 및 파싱 유틸리티, 타입 정의, UI 통합, 누락된 오디오 처리 로직을 추가합니다.

Changes

Cohort / File(s) Summary
Dependencies & Configuration
apps/desktop/package.json
jszip@types/jszip 의존성을 추가하고 기존 항목들을 재정렬합니다.
Export Functionality
apps/desktop/src/lib/export.ts, apps/desktop/src/lib/export.test.ts
generateBndscpArchive 함수를 추가하여 워크스페이스를 ZIP 아카이브로 변환하고, metadata.json 및 선택적 오디오 파일을 포함하며, 포괄적인 단위 테스트를 구현합니다.
Import Functionality
apps/desktop/src/lib/import.ts, apps/desktop/src/lib/import.test.ts
parseBndscpArchive, sanitizeImportPath, mockResolveMissingAudio 함수를 추가하여 ZIP 아카이브를 파싱하고, 누락된 오디오를 감지하며, 경로 안전성을 보장하고, 광범위한 테스트를 구현합니다.
Type Definitions & Validation
packages/shared-types/src/index.ts, packages/shared-types/test/index.test.ts
BndscpMetadata 타입, 검증 함수 validateBndscpMetadata, 타입 가드 isBndscpMetadata, 파서 parseBndscpMetadata를 추가하고 검증 테스트를 작성합니다.
UI Integration
apps/desktop/src/App.tsx, apps/desktop/src/App.test.tsx
"Share Workspace" 및 "Import Workspace" 버튼을 추가하고, 내보내기/임포트 핸들러를 구현하며, 누락된 오디오 상태 처리 및 "Locate Audio" 버튼 UI를 추가하고, E2E 테스트를 확장합니다.
Documentation
docs/plans/2026-04-25-v1.1-local-handoff.md
메타데이터 기반 로컬 핸드오프 워크플로우, 에러 케이스, 충돌 처리 규칙, 보안 요구사항, 테스트 커버리지 계획을 상세히 문서화합니다.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant App
    participant ExportLib
    participant FileSystem

    User->>App: "Share Workspace" 클릭
    App->>ExportLib: generateBndscpArchive(workspace, includeAudio)
    ExportLib->>ExportLib: metadata.json 생성
    ExportLib->>ExportLib: 오디오 파일 추가 (선택적)
    ExportLib->>ExportLib: ZIP 아카이브 생성
    ExportLib-->>App: Blob 반환
    App->>FileSystem: .bndscp 파일 다운로드
    FileSystem-->>User: 파일 저장 완료
Loading
sequenceDiagram
    participant User
    participant App
    participant ImportLib
    participant AudioResolver

    User->>App: "Import Workspace" 클릭
    User->>App: .bndscp 파일 선택
    App->>ImportLib: parseBndscpArchive(file)
    ImportLib->>ImportLib: metadata.json 파싱 및 검증
    ImportLib->>ImportLib: 오디오 파일 매핑
    ImportLib->>ImportLib: 누락된 오디오 감지
    ImportLib-->>App: 메타데이터, 오디오 파일, 누락 목록 반환
    
    alt 누락된 오디오 존재
        App->>User: "Locate Audio" 버튼 표시
        User->>App: "Locate Audio" 클릭
        App->>AudioResolver: mockResolveMissingAudio(songId)
        AudioResolver-->>App: File 반환
        App->>App: 워크스페이스 상태 업데이트
    else 모든 오디오 존재
        App->>App: 워크스페이스 상태 업데이트
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 지퍼로 담아낸 음악의 꿈,
메타데이터 속에 워크스페이스를 담고,
손실 없이 건네는 핸드오프,
누락된 음향은 찾아내어,
협주의 여정이 계속되네! 🎼

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: implementing V1.1 metadata-only local handoff functionality, which is the primary objective of this PR.
Description check ✅ Passed The description is related to the changeset, providing a clear summary of changes including shared-types updates, export/import implementation, and workspace UI modifications.
Linked Issues check ✅ Passed All requirements from issue #150 are met: local sharing capabilities added via export/import functions, handoff artifact (.bndscp) includes workspace and song metadata with optional audio, and recipient-side missing audio handling is implemented.
Out of Scope Changes check ✅ Passed All changes are within scope of implementing V1.1 metadata-only local handoff: dependency additions (jszip), type definitions, export/import utilities, UI integration, and comprehensive test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/issue-150-local-handoff

Comment @coderabbitai help to get the list of available commands and usage tips.

@seonghobae seonghobae enabled auto-merge April 25, 2026 13:09
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/package.json`:
- Line 23: Remove the deprecated duplicate type package by deleting the
"@types/jszip" entry from devDependencies in package.json; ensure only the
built-in JSZip types (from JSZip v3.10.1's index.d.ts) remain, run npm/yarn
install to update the lockfile, and verify no TypeScript errors referencing
JSZip types occur afterward (look for the "@types/jszip" key in package.json to
locate the removal spot).

In `@apps/desktop/src/App.tsx`:
- Around line 61-62: The missingAudio state is only set once and never
synchronized with workspace updates; update the code so that the
subscribeToWorkspaceUpdates handler (or a useEffect that depends on workspace)
recalculates and sets missingAudio via setMissingAudio whenever packs change
(e.g., when enqueueSong in handleResolveMissingAudio triggers a pack state
change from "analyzing"→"ready" or when new packs are added). Specifically,
inside the subscription callback for subscribeToWorkspaceUpdates (or in the
effect that registers it), compute the current list of pack IDs that truly lack
audio from the latest workspace/packs and call setMissingAudio(...) to remove
IDs of packs that became ready and to add new missing IDs for newly added packs;
also ensure handleResolveMissingAudio updates missingAudio only if needed (or
rely on the workspace-driven update) to avoid stale entries.
- Line 195: The regex in the filename sanitization is double-escaped and won't
match whitespace; update the call that sets a.download (the
workspace.title.replace(...) expression) to use a proper regex with a single
backslash (i.e., /\s+/g) so whitespace is replaced with underscores when
building the download filename; ensure the rest of the expression (fallback to
"workspace" and the ".bndscp" suffix) remains unchanged.

In `@apps/desktop/src/lib/export.ts`:
- Around line 81-85: The metadata object currently hardcodes
analysis_engine_version as "1.1.0"; change it to read the version from a single
source of truth (e.g., an ENV var like ANALYSIS_ENGINE_VERSION, a central config
module, or package.json's version) and assign that value to
metadata.analysis_engine_version instead of the literal. Locate the
BndscpMetadata creation (the metadata constant) and replace the hardcoded string
with a call/lookup to your chosen source (falling back to a sensible default if
unset) so updates are managed centrally.

In `@apps/desktop/src/lib/import.ts`:
- Around line 75-80: Replace the fake File creation in mockResolveMissingAudio
with a call to the allowlisted native file-picker IPC (or a sanctioned 127.0.0.1
endpoint) and return null if the user cancels; specifically, in
mockResolveMissingAudio invoke the IPC channel that opens the OS file dialog,
pass sanitizeImportPath(expectedFileName) as a suggested filename, validate the
IPC response against a strict schema (e.g., { canceled: boolean, filePath?:
string }) before using it, and only construct and return a File-like object when
canceled is false and the filePath is present; do not create a synthetic File
without user interaction or call non-allowlisted local backend APIs.
- Around line 27-29: The code directly calls zip.loadAsync(fileBlob) (JSZip,
variables zip and loadedZip) without validating the incoming fileBlob; add
pre-checks to enforce a maximum file size on fileBlob before calling loadAsync
(reject/throw if fileBlob.size exceeds a safe threshold), then after loadAsync
validate loadedZip.file and loadedZip.folder entry counts and a cumulative
uncompressed size limit (reject/throw if entry count or total size exceed
limits) before any extraction or iteration; ensure these checks live in the same
function that handles the import so callers see a clear error path and upstream
code can abort safely.
- Around line 52-53: The code currently hardcodes expectedFileName to
audio/${sanitizeFilename(pack.song.title)}.txt which causes mismatches and
collisions; change the lookup to use the stable export/import key (pack.id) and
the audio handoff's agreed extension instead of the sanitized title — i.e.
replace sanitizeFilename(pack.song.title) with pack.id and use the contract's
extension (not ".txt") when building expectedFileName so
loadedZip.file(expectedFileName) reliably finds the audio file; ensure any
export code writes the audio file using the same pack.id + extension convention.

In `@docs/plans/2026-04-25-v1.1-local-handoff.md`:
- Line 1: Remove the local absolute path restore comment that exposes
developer-specific info by deleting the HTML comment containing
"/Users/seonghobae/.gstack/projects//feature-issue-150-local-handoff-autoplan-restore-20260425-212336.md"
from the file (the <!-- /autoplan restore point: ... --> line) so the commit no
longer contains any user-local absolute paths; if you need a placeholder,
replace it with a generic token like "<autoplan-restore-point>" or omit it
entirely.
- Around line 6-124: The document triggers multiple markdownlint warnings
(MD022/MD058/MD031) due to missing blank lines around headings, tables, and
fenced code blocks; fix by ensuring a single blank line before and after each
ATX header (e.g., "## Problem Statement", "## Design UI/UX Specifications", "##
Architecture & Data Format"), a blank line before and after the "Error & Rescue
Registry" table, and blank lines around the fenced block under "Test Plan
Diagram & Gaps" (also remove any trailing spaces and empty HTML-style lines),
then re-run markdownlint/CI to verify no remaining MD022/MD058/MD031 violations.
- Around line 99-108: The fenced code block that begins with "CODE PATHS        
USER FLOWS" is missing a language identifier which breaks MD040; update the
opening triple-backtick to include a language (e.g., use ```text) so the block
becomes ```text and keep the closing ``` unchanged; this fixes the lint error in
the docs/plans file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e90b216e-0202-4827-ac9c-984806ceee49

📥 Commits

Reviewing files that changed from the base of the PR and between 425ad71 and 8acc846.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (10)
  • apps/desktop/package.json
  • apps/desktop/src/App.test.tsx
  • apps/desktop/src/App.tsx
  • apps/desktop/src/lib/export.test.ts
  • apps/desktop/src/lib/export.ts
  • apps/desktop/src/lib/import.test.ts
  • apps/desktop/src/lib/import.ts
  • docs/plans/2026-04-25-v1.1-local-handoff.md
  • packages/shared-types/src/index.ts
  • packages/shared-types/test/index.test.ts

Comment thread apps/desktop/package.json
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jszip": "^3.4.0",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# jszip 패키지 내 타입 정의 확인
fd -t f 'd.ts' node_modules/jszip 2>/dev/null || echo "Run npm install first to check"

# package.json에서 jszip의 types 필드 확인
cat node_modules/jszip/package.json 2>/dev/null | jq '.types // .typings' || echo "Check after npm install"

Repository: seonghobae/bandscope

Length of output: 95


🌐 Web query:

jszip v3.10.1 TypeScript types built-in native

💡 Result:

Yes, JSZip v3.10.1 includes built-in native TypeScript types. The official package.json specifies "types": "./index.d.ts", and index.d.ts is present in the repository and npm package files, providing full TypeScript definitions for the library's API including JSZip interfaces, options, and methods. No separate @types/jszip package is required.

Citations:


🌐 Web query:

jszip npm package.json types field definition

💡 Result:

In the JSZip npm package's package.json file, the "types" field is defined as "./index.d.ts". This points to the main TypeScript declaration file, which provides full type definitions for the library, including interfaces like JSZip, JSZipObjectOptions, JSZipGeneratorOptions, and more. JSZip includes its own built-in types, so no separate @types/jszip package is needed.

Citations:


🌐 Web query:

@types/jszip necessity jszip 3.x TypeScript

💡 Result:

No, @types/jszip is not necessary for JSZip 3.x in TypeScript projects. JSZip provides its own built-in TypeScript definitions via index.d.ts, so installing the separate @types/jszip package is redundant and even deprecated. The @types/jszip package explicitly states it is a stub and that JSZip provides its own types, recommending against installation.

Citations:


@types/jszip는 필요하지 않습니다 - 제거해주세요.

JSZip v3.10.1은 index.d.ts를 통해 기본 내장 TypeScript 타입 정의를 제공합니다. @types/jszip 패키지는 이미 deprecated된 스텁 패키지이며, 별도 설치 시 중복된 타입 정의로 인한 혼동을 야기할 수 있습니다. devDependencies에서 @types/jszip을 제거하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/package.json` at line 23, Remove the deprecated duplicate type
package by deleting the "@types/jszip" entry from devDependencies in
package.json; ensure only the built-in JSZip types (from JSZip v3.10.1's
index.d.ts) remain, run npm/yarn install to update the lockfile, and verify no
TypeScript errors referencing JSZip types occur afterward (look for the
"@types/jszip" key in package.json to locate the removal spot).

Comment thread apps/desktop/src/App.tsx
Comment on lines +61 to +62
const [missingAudio, setMissingAudio] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(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.

⚠️ Potential issue | 🟠 Major

missingAudio 상태가 workspace 업데이트와 동기화되지 않습니다.

missingAudio는 import 시 한 번만 설정되고, subscribeToWorkspaceUpdates를 통한 workspace 업데이트와 동기화되지 않습니다. 이로 인해:

  1. pack이 "analyzing"에서 "ready"로 전환되어도 missingAudio에 여전히 해당 ID가 남아있음
  2. 새로운 pack이 workspace에 추가되어도 missing audio 상태가 반영되지 않음

handleResolveMissingAudio에서 enqueueSong 호출 후 pack의 상태가 변경되면, workspace 업데이트가 발생하지만 missingAudio 배열은 수동으로 업데이트해야 합니다.

💡 해결 방안 제안
 useEffect(() => {
   let unmounted = false;
   let unlistenFn: (() => void) | undefined;
   const unlistenPromise = subscribeToWorkspaceUpdates((ws) => {
-    if (!unmounted) setWorkspace(ws);
+    if (!unmounted) {
+      setWorkspace(ws);
+      // 완료된 pack은 missingAudio에서 제거
+      setMissingAudio(prev => 
+        prev.filter(id => {
+          const pack = ws.songs.find(s => s.id === id);
+          return pack && pack.packState !== "ready";
+        })
+      );
+    }
   });

Also applies to: 219-220, 246-246

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/App.tsx` around lines 61 - 62, The missingAudio state is
only set once and never synchronized with workspace updates; update the code so
that the subscribeToWorkspaceUpdates handler (or a useEffect that depends on
workspace) recalculates and sets missingAudio via setMissingAudio whenever packs
change (e.g., when enqueueSong in handleResolveMissingAudio triggers a pack
state change from "analyzing"→"ready" or when new packs are added).
Specifically, inside the subscription callback for subscribeToWorkspaceUpdates
(or in the effect that registers it), compute the current list of pack IDs that
truly lack audio from the latest workspace/packs and call setMissingAudio(...)
to remove IDs of packs that became ready and to add new missing IDs for newly
added packs; also ensure handleResolveMissingAudio updates missingAudio only if
needed (or rely on the workspace-driven update) to avoid stale entries.

Comment thread apps/desktop/src/App.tsx
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${workspace.title.replace(/\\s+/g, "_") || "workspace"}.bndscp`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

정규식 이스케이프 오류: \\s+가 의도한 대로 동작하지 않습니다.

문자열 리터럴 내에서 \\s+는 리터럴 \s+ 문자열을 매칭합니다. 공백을 매칭하려면 \s+를 사용해야 합니다.

🐛 수정 제안
-      a.download = `${workspace.title.replace(/\\s+/g, "_") || "workspace"}.bndscp`;
+      a.download = `${workspace.title.replace(/\s+/g, "_") || "workspace"}.bndscp`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
a.download = `${workspace.title.replace(/\\s+/g, "_") || "workspace"}.bndscp`;
a.download = `${workspace.title.replace(/\s+/g, "_") || "workspace"}.bndscp`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/App.tsx` at line 195, The regex in the filename sanitization
is double-escaped and won't match whitespace; update the call that sets
a.download (the workspace.title.replace(...) expression) to use a proper regex
with a single backslash (i.e., /\s+/g) so whitespace is replaced with
underscores when building the download filename; ensure the rest of the
expression (fallback to "workspace" and the ".bndscp" suffix) remains unchanged.

Comment on lines +81 to +85
const metadata: BndscpMetadata = {
workspace,
analysis_engine_version: "1.1.0", // mock version
includes_audio: includeAudio
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

하드코딩된 analysis_engine_version 값을 설정 또는 환경 변수에서 가져오는 것을 고려해주세요.

현재 "1.1.0"이 하드코딩되어 있습니다. 버전 관리 일관성을 위해 이 값을 중앙 설정, 환경 변수, 또는 package.json에서 가져오는 것이 좋습니다. 이렇게 하면 버전 업데이트 시 수동 변경을 방지할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/export.ts` around lines 81 - 85, The metadata object
currently hardcodes analysis_engine_version as "1.1.0"; change it to read the
version from a single source of truth (e.g., an ENV var like
ANALYSIS_ENGINE_VERSION, a central config module, or package.json's version) and
assign that value to metadata.analysis_engine_version instead of the literal.
Locate the BndscpMetadata creation (the metadata constant) and replace the
hardcoded string with a call/lookup to your chosen source (falling back to a
sensible default if unset) so updates are managed centrally.

Comment on lines +27 to +29
const zip = new JSZip();
const loadedZip = await zip.loadAsync(fileBlob);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

비신뢰 ZIP 입력에 대한 크기/엔트리 상한 검증이 없습니다

Line 27-29에서 아카이브를 바로 로드하고 있어, 과도한 크기/엔트리 수의 .bndscp로 메모리 고갈(DoS)을 유발할 수 있습니다. 파싱 전에 파일 크기와 로드 후 엔트리 수 상한을 강제하세요.

🔧 제안 수정안
+const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
+const MAX_ARCHIVE_ENTRIES = 5000;
+
 export async function parseBndscpArchive(fileBlob: Blob | File): Promise<{
   metadata: BndscpMetadata;
   audioFiles: Map<string, Blob>;
   requiresMissingAudio: string[];
 }> {
+  if (fileBlob.size > MAX_ARCHIVE_BYTES) {
+    throw new Error("Invalid .bndscp archive: file is too large");
+  }
+
   const zip = new JSZip();
   const loadedZip = await zip.loadAsync(fileBlob);
+  if (Object.keys(loadedZip.files).length > MAX_ARCHIVE_ENTRIES) {
+    throw new Error("Invalid .bndscp archive: too many entries");
+  }

As per coding guidelines, "Treat files, URLs, metadata, model artifacts, and project files as untrusted input".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const zip = new JSZip();
const loadedZip = await zip.loadAsync(fileBlob);
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 5000;
export async function parseBndscpArchive(fileBlob: Blob | File): Promise<{
metadata: BndscpMetadata;
audioFiles: Map<string, Blob>;
requiresMissingAudio: string[];
}> {
if (fileBlob.size > MAX_ARCHIVE_BYTES) {
throw new Error("Invalid .bndscp archive: file is too large");
}
const zip = new JSZip();
const loadedZip = await zip.loadAsync(fileBlob);
if (Object.keys(loadedZip.files).length > MAX_ARCHIVE_ENTRIES) {
throw new Error("Invalid .bndscp archive: too many entries");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/import.ts` around lines 27 - 29, The code directly calls
zip.loadAsync(fileBlob) (JSZip, variables zip and loadedZip) without validating
the incoming fileBlob; add pre-checks to enforce a maximum file size on fileBlob
before calling loadAsync (reject/throw if fileBlob.size exceeds a safe
threshold), then after loadAsync validate loadedZip.file and loadedZip.folder
entry counts and a cumulative uncompressed size limit (reject/throw if entry
count or total size exceed limits) before any extraction or iteration; ensure
these checks live in the same function that handles the import so callers see a
clear error path and upstream code can abort safely.

Comment on lines +52 to +53
const expectedFileName = `audio/${sanitizeFilename(pack.song.title)}.txt`;
const audioFile = loadedZip.file(expectedFileName);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

오디오 파일 경로를 제목+.txt로 고정하면 매칭 실패와 충돌이 발생합니다

Line 52의 audio/${sanitizeFilename(pack.song.title)}.txt는 오디오 핸드오프 계약과 어긋날 가능성이 높고, 동일 제목 곡이 있으면 파일명이 충돌합니다. export/import가 공유하는 안정 키(예: pack.id)와 동일 확장자 규약으로 맞추세요.

🔧 제안 수정안 (계약 예시)
-        const expectedFileName = `audio/${sanitizeFilename(pack.song.title)}.txt`;
+        const expectedFileName = `audio/${pack.id}.mp3`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/import.ts` around lines 52 - 53, The code currently
hardcodes expectedFileName to audio/${sanitizeFilename(pack.song.title)}.txt
which causes mismatches and collisions; change the lookup to use the stable
export/import key (pack.id) and the audio handoff's agreed extension instead of
the sanitized title — i.e. replace sanitizeFilename(pack.song.title) with
pack.id and use the contract's extension (not ".txt") when building
expectedFileName so loadedZip.file(expectedFileName) reliably finds the audio
file; ensure any export code writes the audio file using the same pack.id +
extension convention.

Comment on lines +75 to +80
export async function mockResolveMissingAudio(songId: string, expectedFileName: string): Promise<File | null> {
// This simulates an OS-level file picker establishing a user-consented trust boundary
// Note: UI mockup resolution of missing audio
const mockContent = `mock_raw_audio_data_for_${songId}`;
return new File([mockContent], sanitizeImportPath(expectedFileName), { type: "audio/wav" });
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

mockResolveMissingAudio는 실제 누락 오디오 복구 흐름을 구현하지 않습니다

Line 75-80은 사용자 선택 없이 가짜 File을 생성합니다. 이 상태로는 “원본 오디오 선택 후 로컬 재분석” 요구사항을 충족하지 못합니다. 네이티브 파일 피커를 allowlisted IPC로 호출하고, 취소 시 null을 반환하도록 바꾸세요.

As per coding guidelines, "Keep local backend access on allowlisted IPC or 127.0.0.1 only, with strict schema validation".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/import.ts` around lines 75 - 80, Replace the fake File
creation in mockResolveMissingAudio with a call to the allowlisted native
file-picker IPC (or a sanctioned 127.0.0.1 endpoint) and return null if the user
cancels; specifically, in mockResolveMissingAudio invoke the IPC channel that
opens the OS file dialog, pass sanitizeImportPath(expectedFileName) as a
suggested filename, validate the IPC response against a strict schema (e.g., {
canceled: boolean, filePath?: string }) before using it, and only construct and
return a File-like object when canceled is false and the filePath is present; do
not create a synthetic File without user interaction or call non-allowlisted
local backend APIs.

@@ -0,0 +1,125 @@
<!-- /autoplan restore point: /Users/seonghobae/.gstack/projects//feature-issue-150-local-handoff-autoplan-restore-20260425-212336.md -->
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

로컬 절대 경로 복원 주석은 커밋에서 제거해야 합니다

Line 1의 /Users/... 경로는 개발자 로컬 환경 정보(사용자명 포함)를 노출하고 문서 이식성을 떨어뜨립니다. 릴리스 브랜치에서는 제거하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-25-v1.1-local-handoff.md` at line 1, Remove the local
absolute path restore comment that exposes developer-specific info by deleting
the HTML comment containing
"/Users/seonghobae/.gstack/projects//feature-issue-150-local-handoff-autoplan-restore-20260425-212336.md"
from the file (the <!-- /autoplan restore point: ... --> line) so the commit no
longer contains any user-local absolute paths; if you need a placeholder,
replace it with a generic token like "<autoplan-restore-point>" or omit it
entirely.

Comment on lines +6 to +124
## Problem Statement
BandScope V1.0 shipped a multi-song rehearsal workspace, but it remains locked to a single machine. The next priority is allowing band members to hand off a prepared workspace to other members, without requiring a full cloud synchronization infrastructure. This allows the band leader or designated prep member to structure the song, adjust the sections, and configure the rehearsal pack, and then distribute this configuration.

## Scope
- Implement a lightweight "Export/Share Workspace" functionality.
- Generate a handoff artifact (`.bndscp` or JSON format) containing:
- Workspace metadata (title, version)
- Song metadata and IDs
- Section map labels
- Role bucket selections and visible confidence constraints
- Do NOT bundle the actual stems or original audio files.
- Implement an "Import Workspace" flow on the recipient side.
- Add local re-analysis logic: if the recipient machine lacks the stems, prompt the user to link the original audio file so the Python analysis engine can regenerate the stems locally based on the shared metadata constraints.

## Out of Scope
- Full cloud sync.
- Real-time collaborative editing.
- Bundling large audio/STEM assets inside the project file.


## What already exists
- `RehearsalWorkspace` and `SongRehearsalPack` objects
- Local persistence model for workspaces
- Analysis pipeline to regenerate stems

## NOT in scope
- Full cloud synchronization infrastructure
- Real-time collaborative editing
- Bundling raw uncompressed WAV stems (too large, hurts portability)

## Error & Rescue Registry
| Error | Impact | Rescue |
|-------|--------|--------|
| Target audio file missing | Analysis cannot run | Prompt user to locate file or download compressed bundle |
| Timecode drift | Section map misaligned | Verify acoustic fingerprint; offer manual offset shift |
| Malformed .bndscp file | Crash | Strict JSON schema validation before IPC load |

## Dream State Delta
**Current:** Single-machine isolated prep.
**This Plan:** Handoff capability via file sharing with compressed audio fallback and alignment safety.
**12-Month Ideal:** Seamless cloud-synced band workspaces.

## CEO Review Completion Summary
- Mode: SELECTIVE EXPANSION
- Scope Decisions:
- Approved: Add acoustic fingerprinting / audio hash to prevent timecode drift (P1 Completeness).
- Approved: Add option to bundle 64kbps compressed rehearsal mixdown to prevent heavy recipient compute burden (P2 Boil Lakes).
- Approved: Add strict JSON schema validation for imported files (Security).
- Dual Voices: `[single-model]` (Codex unavailable, Claude subagent provided 4 critical/high findings).


## Design UI/UX Specifications

### Information Architecture & Entry Points
- **Export/Share:** Prominent "Share Workspace" button in the Workspace Header.
- **Export Modal:** 1. Workspace Name/Summary. 2. Toggle for 64kbps mixdown (Checked by Default, labelled "Include lightweight rehearsal audio (~3MB/song)"). 3. "Export .bndscp" CTA.
- **Import:** "Open/Import Workspace" from the Home screen.
- **Import Modal:** 1. "You are importing [Workspace Name] by [Creator]". 2. List of included songs and status (Ready vs. Missing Audio). 3. Import CTA.

### Interaction States
- **Loading State:** Non-blocking progress UI during local re-analysis (e.g., "Regenerating stems for 'Song 1'... 45%").
- **Conflict State:** If importing an existing workspace ID: "You already have a workspace named 'Summer Setlist'. [Overwrite] [Keep Both]".
- **Success State:** "Import complete. 3 songs ready, 1 song requires original audio."
- **Missing Audio Empty State:** On a song view, if audio is missing: "Missing audio for 'Song Name'. Please locate the file originally named `Rough_Demo_v3.mp3` (03:45) to unlock stems and analysis." with a drag-and-drop zone.

### Specific UI Mechanisms
- **Manual Offset Shift:** A numerical input (`+/- ms`) near the section map for timecode alignment, avoiding complex waveform UI for V1.1.

## Design Review Completion Summary
- Initial Score: 3/10
- Final Score: 10/10
- Decisions Made: 5 structural issues fixed via Claude Subagent.
- Dual Voices: `[single-model]` (Codex unavailable).


## Engineering Review Completion Summary
- Initial Assessment: Structural gaps in security, data format, and complexity.
- Final State: Issues mitigated and explicitly scoped.
- Dual Voices: `[single-model]` (Codex unavailable, Claude subagent provided 6 critical/high findings).

### Architecture & Data Format
- The `.bndscp` format will NOT be a raw JSON file if it includes audio. It will be a standard ZIP archive containing a `metadata.json` and an `/audio` directory for the 64kbps mixdowns, avoiding Base64 parsing crashes on large setlists.
- The artifact must embed `analysis_engine_version` to warn users if local stem regeneration might produce slightly different deterministic output than the creator's machine.

### Security & Path Traversal Prevention
- The app must **never** trust absolute file paths provided in the `.bndscp` file. The schema validation will strip all path information and retain only the filename.
- Missing original audio MUST be resolved by a native OS file picker dialog, establishing a secure, user-consented trust boundary.

### Complexity Reduction (Pragmatic over Clever)
- **Timecode Drift:** Full acoustic fingerprinting (e.g., Chromaprint) is too heavy for V1.1. Fallback to `exact file duration (ms) + basic file hash`. If duration matches within 50ms but hash differs, accept with a visible warning and rely on the Manual Offset Shift UI.
- **Conflict Resolution:** Global band metadata (form, sections) will be merged while preserving the recipient's local user preferences (mix levels, UI state). "Overwrite" will not nuke personal mix settings.

### Test Plan Diagram & Gaps
```
CODE PATHS USER FLOWS
[+] apps/desktop/src/lib/export.ts [+] Handoff Export
├── generateBndscpArchive() ├── [GAP] [→E2E] Export with 64kbps audio mixdown
│ ├── [GAP] Base64 vs ZIP memory handling └── [GAP] Export metadata only
[+] apps/desktop/src/lib/import.ts [+] Handoff Import
├── parseBndscpArchive() ├── [GAP] [→E2E] Import missing audio -> Open file picker
│ ├── [GAP] Path traversal sanitization ├── [GAP] Import with matching duration but different hash -> Show warning
│ └── [GAP] Schema validation └── [GAP] Import conflict -> Preserve local mix settings
```
- **Action:** Unit tests must aggressively target JSON schema validation with intentionally malformed/path-traversal payloads. E2E must verify the Missing Audio and Conflict States.

## Security Notes
### Attack Surface
The imported `.bndscp` archive is completely untrusted and parsed in the desktop environment.

### Mitigations
Strict JSON schema parsing removes any extra fields. Path sanitization applies to files inside the zip to prevent directory traversal. Missing audio resolution requires a native OS file picker dialog, establishing a secure user-consented trust boundary.

### Test Points
Aggressive unit tests target JSON schema validation with intentionally malformed payloads.

### Realistic Threats
Path traversal via maliciously constructed zip archives to overwrite system files.

### Remaining Risk
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

마크다운 린트 경고(MD022/MD058/MD031)가 다수 남아 있습니다

헤더/테이블/펜스 블록 전후 공백 규칙 위반이 반복되어 문서 CI 신뢰도를 떨어뜨립니다. 경고 구간(예: Line 6, 9, 20, 37, 99, 108 등)을 일괄 정리해 주세요.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 6-6: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 9-9: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 20-20: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 26-26: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 31-31: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 36-36: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 37-37: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 43-43: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 48-48: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 59-59: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 65-65: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 71-71: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 74-74: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 81-81: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 86-86: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 90-90: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 94-94: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 98-98: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 99-99: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 99-99: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 108-108: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 111-111: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 112-112: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Above

(MD022, blanks-around-headings)


[warning] 112-112: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 115-115: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 118-118: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 121-121: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 124-124: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-25-v1.1-local-handoff.md` around lines 6 - 124, The
document triggers multiple markdownlint warnings (MD022/MD058/MD031) due to
missing blank lines around headings, tables, and fenced code blocks; fix by
ensuring a single blank line before and after each ATX header (e.g., "## Problem
Statement", "## Design UI/UX Specifications", "## Architecture & Data Format"),
a blank line before and after the "Error & Rescue Registry" table, and blank
lines around the fenced block under "Test Plan Diagram & Gaps" (also remove any
trailing spaces and empty HTML-style lines), then re-run markdownlint/CI to
verify no remaining MD022/MD058/MD031 violations.

Comment on lines +99 to +108
```
CODE PATHS USER FLOWS
[+] apps/desktop/src/lib/export.ts [+] Handoff Export
├── generateBndscpArchive() ├── [GAP] [→E2E] Export with 64kbps audio mixdown
│ ├── [GAP] Base64 vs ZIP memory handling └── [GAP] Export metadata only
[+] apps/desktop/src/lib/import.ts [+] Handoff Import
├── parseBndscpArchive() ├── [GAP] [→E2E] Import missing audio -> Open file picker
│ ├── [GAP] Path traversal sanitization ├── [GAP] Import with matching duration but different hash -> Show warning
│ └── [GAP] Schema validation └── [GAP] Import conflict -> Preserve local mix settings
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

코드 펜스 언어를 명시하세요 (MD040)

Line 99-108 코드 블록에 언어 식별자가 없어 린트가 실패합니다. 최소 text라도 지정해 주세요.

🔧 제안 수정안
-```
+```text
 CODE PATHS                                            USER FLOWS
 ...
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 99-99: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 99-99: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 108-108: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-25-v1.1-local-handoff.md` around lines 99 - 108, The
fenced code block that begins with "CODE PATHS                                  
USER FLOWS" is missing a language identifier which breaks MD040; update the
opening triple-backtick to include a language (e.g., use ```text) so the block
becomes ```text and keep the closing ``` unchanged; this fixes the lint error in
the docs/plans file.

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.

V1.1: Metadata-only local handoff

1 participant