From 53c78c76cb8dfbbca5b66443e9196e5a005b2bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 10 Mar 2026 04:10:14 +0000 Subject: [PATCH] add tag-album and clean-playlist-files tasks, script step support, stop fix, rename fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tag-album task: renames tracks to NNN format, sets ID3 tags via mutagen - New clean-playlist-files task: deletes .cue, .m3u, .nfo and similar junk files - Pipeline executor now supports script-mode steps (runs directly, no agent) - Build discography pipeline: convert-audio → clean-playlist-files → prepare → fetch → tag - Fix stop button: abort signal now kills running agent processes - Fix job manager: broadcast stopped/error events to WebSocket viewers - Fix file browser rename: delay focus to avoid context menu close race, left-align input Co-Authored-By: Claude Opus 4.6 --- seed/tasks/build-discography/TASK.md | 14 ++ seed/tasks/clean-playlist-files/TASK.md | 18 ++ seed/tasks/clean-playlist-files/run.sh | 31 +++ seed/tasks/tag-album/TASK.md | 115 +++++++++++ src/servers/api/tasks/pipeline-executor.ts | 183 +++++++++++++++++- src/servers/api/tasks/pipeline-job-manager.ts | 1 + .../FileBrowserApp/components/FileItem.tsx | 20 +- 7 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 seed/tasks/clean-playlist-files/TASK.md create mode 100644 seed/tasks/clean-playlist-files/run.sh create mode 100644 seed/tasks/tag-album/TASK.md diff --git a/seed/tasks/build-discography/TASK.md b/seed/tasks/build-discography/TASK.md index dd48e03e..54f6d1b2 100644 --- a/seed/tasks/build-discography/TASK.md +++ b/seed/tasks/build-discography/TASK.md @@ -11,6 +11,14 @@ inputs: description: Artist / band name autofill: entry_name steps: + - task: convert-audio + inputs: + file_path: . + target_format: mp3 + delete_source: "true" + - task: clean-playlist-files + inputs: + file_path: . - task: prepare-discography inputs: artist_name: ${artist_name} @@ -21,4 +29,10 @@ steps: inputs: artist_name: ${artist_name} album_name: ${folder_name} + - task: tag-album + foreach: subdirectory + concurrency: 5 + inputs: + artist_name: ${artist_name} + album_name: ${folder_name} --- diff --git a/seed/tasks/clean-playlist-files/TASK.md b/seed/tasks/clean-playlist-files/TASK.md new file mode 100644 index 00000000..d888d0bc --- /dev/null +++ b/seed/tasks/clean-playlist-files/TASK.md @@ -0,0 +1,18 @@ +--- +name: Clean Playlist Files +description: Recursively delete .cue, .m3u, .m3u8, .pls, .wpl, .xspf and other playlist files. +version: 1 +mode: script +language: bash +triggers: + - type: directory +inputs: + file_path: + type: string + description: Path to a directory to clean. +args: [file_path] +--- + +# Clean Playlist Files + +Remove playlist and cue sheet files from a directory tree. diff --git a/seed/tasks/clean-playlist-files/run.sh b/seed/tasks/clean-playlist-files/run.sh new file mode 100644 index 00000000..b17aad5d --- /dev/null +++ b/seed/tasks/clean-playlist-files/run.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXTENSIONS="cue|m3u|m3u8|pls|wpl|xspf|nfo|txt|log|accurip|sfv|md5" + +TARGET="${1:-${INPUT_FILE_PATH:-}}" + +if [[ -z "$TARGET" ]]; then + echo "Error: No directory path provided" >&2 + exit 1 +fi + +if [[ ! -d "$TARGET" ]]; then + echo "Error: Not a directory: $TARGET" >&2 + exit 1 +fi + +deleted=0 + +while IFS= read -r -d '' file; do + echo "Deleting: $file" + rm "$file" + deleted=$((deleted + 1)) +done < <(find "$TARGET" -type f -regextype posix-extended -iregex ".*\.($EXTENSIONS)" -print0 | sort -z) + +if [[ $deleted -eq 0 ]]; then + echo "No playlist/cue files found in: $TARGET" +else + echo "" + echo "Summary: $deleted files deleted" +fi diff --git a/seed/tasks/tag-album/TASK.md b/seed/tasks/tag-album/TASK.md new file mode 100644 index 00000000..44310aa6 --- /dev/null +++ b/seed/tasks/tag-album/TASK.md @@ -0,0 +1,115 @@ +--- +name: Tag Album +description: Rename track files and set ID3 tags based on album-info.md metadata. +version: 1 +mode: agentic +triggers: + - type: directory +inputs: + artist_name: + type: string + description: Artist name + autofill: entry_name + album_name: + type: string + description: Album name + autofill: entry_name +--- + +# Tag Album + +You are given an artist name and album name. Your job is to rename audio files and set proper ID3 tags using the metadata from `album-info.md` in the target directory. + +## Prerequisites + +The target directory (from Context) must contain: +- Audio files (`.mp3`) +- An `album-info.md` file (produced by the fetch-album-info task) +- Optionally a cover image (`cover.jpg`, `cover.png`, `front.jpg`, or similar) + +If `album-info.md` does not exist, stop and report that the album info must be fetched first. + +## Process + +### 1. Read album-info.md + +Parse the `album-info.md` file to extract: +- **Artist** (canonical name from the metadata table) +- **Album** title (canonical name from the metadata table) +- **Year** (from the metadata table) +- **Genre** (from the metadata table) +- **Tracklist** — track numbers, titles, and disc numbers (if multi-disc) + +### 2. Inventory existing files + +List all `.mp3` files in the target directory (and `Disc N/` subdirectories for multi-disc albums). + +Match each existing file to a track in the tracklist. Use track number, partial title match, or positional order to establish the mapping. If the album has multiple discs, match within each `Disc N/` subdirectory. + +### 3. Rename files + +Rename each audio file to the standardized format: + +``` +NNN - Track Title.mp3 +``` + +Where: +- `NNN` is the track number zero-padded to 3 digits (e.g. `001`, `012`) +- `Track Title` is the canonical title from `album-info.md` + +**Filename safety rules** — these characters are illegal on Windows and must be replaced: +- `:` → ` -` (space dash) +- `?` → removed +- `"` → removed +- `*`, `<`, `>`, `|`, `\`, `/` → removed + +Use `mv` to rename. Work within each disc subdirectory if the album is multi-disc. + +### 4. Set ID3 tags + +Use the `mutagen` tool to write tags on each track. Required tags: + +| Tag | Value | +|-----|-------| +| TIT2 | Track title (from tracklist, NO track number prefix) | +| TPE1 | Artist name (use the `artist_name` input exactly as provided) | +| TPE2 | Same as TPE1 | +| TALB | Album title | +| TRCK | `track/total` (e.g. `3/12`) | +| TDRC | Release year (4-digit) | +| TCON | Genre(s) from album-info | + +For multi-disc albums, also set: +| TPOS | `disc/total` (e.g. `1/2`) | + +Use the `mutagen` tool's `write` action. Example: + +``` +mutagen write --path "001 - Track Name.mp3" --tags '{"TIT2": "Track Name", "TPE1": "Artist", "TPE2": "Artist", "TALB": "Album", "TRCK": "1/12", "TDRC": "1967", "TCON": "Rock"}' +``` + +### 5. Embed cover art + +If a cover image exists in the target directory (check for `cover.jpg`, `cover.png`, `front.jpg`, `Front.jpg`, `Cover/Cover.jpg`, or any image file that looks like album art), embed it into every track using the `mutagen` tool's `embed_cover` action: + +``` +mutagen embed_cover --path "001 - Track Name.mp3" --image "cover.jpg" +``` + +### 6. Verify + +After all files are renamed and tagged, read back the tags of the first and last track using `mutagen read` to confirm the tags were written correctly. Report a summary of what was done: +- Number of tracks processed +- Any files that could not be matched or tagged +- Whether cover art was embedded + +## Important + +- Always read `album-info.md` as the source of truth for metadata — do NOT use web search. +- Do NOT modify the audio content, only metadata and filenames. +- Preserve disc subdirectory structure for multi-disc albums. +- If a track file cannot be matched to a tracklist entry, skip it and report it. +- Track titles in TIT2 must NOT include track numbers (e.g. "Song Name", not "01 - Song Name"). +- Use the `artist_name` input parameter exactly as provided for TPE1 and TPE2 — do NOT reformat it or use the artist name from `album-info.md`. The user organizes their library alphabetically by this value. +- Use the canonical album title from `album-info.md` for TALB. diff --git a/src/servers/api/tasks/pipeline-executor.ts b/src/servers/api/tasks/pipeline-executor.ts index afbd12c2..6e2541cd 100644 --- a/src/servers/api/tasks/pipeline-executor.ts +++ b/src/servers/api/tasks/pipeline-executor.ts @@ -1,7 +1,8 @@ import type { ServerWebSocket } from 'bun'; import { randomUUID } from 'crypto'; -import { readdirSync, existsSync } from 'node:fs'; +import { readdirSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { getTaskByDirName, getUserSettings } from 'officerdb'; import { getHomeDirForRole, getHomeDir } from '../../data-path'; import { resolveBaseCwd } from '../pi/websocket'; @@ -104,20 +105,31 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel }); break; case 'result': + clearInterval(abortPoll); cleanup?.(); resolve(event.cost); break; case 'error': + clearInterval(abortPoll); cleanup?.(); reject(new Error(event.message)); break; case 'stopped': + clearInterval(abortPoll); cleanup?.(); reject(new Error('Step was stopped')); break; } }; + // Poll for abort signal to kill the running agent + const abortPoll = setInterval(() => { + if (abortSignal.aborted && cleanup) { + clearInterval(abortPoll); + cleanup(); + } + }, 500); + try { if (isClaudeCode) { const handle = await sendClaudeCodeStreaming({ @@ -144,7 +156,14 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom await sidecar.spawnPi({ sessionId, email, userId, username, role, cwd, model }); sidecar.sendPiPrompt(sessionId, prompt, randomUUID()); } + + // If already aborted while setting up, kill immediately + if (abortSignal.aborted) { + clearInterval(abortPoll); + cleanup?.(); + } } catch (err) { + clearInterval(abortPoll); cleanup?.(); reject(err); } @@ -180,6 +199,121 @@ function buildStepPrompt(taskBody: string, inputs: Record, targe return `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`; } +// ── Script step execution ── + +type RunScriptStepParams = { + email: string; + role: string; + task: { name: string; implementation: string; language: string; args?: string[] | null }; + inputs: Record; + cwd: string; + abortSignal: AbortSignal; + emit: EmitEvent; + stepIndex: number; +}; + +function getRunner(language: string): string[] { + switch (language) { + case 'bash': return ['bash']; + case 'python': return ['python3']; + case 'typescript': return ['bun', 'run']; + case 'javascript': return ['node']; + default: return ['bash']; + } +} + +function getFileName(language: string): string { + switch (language) { + case 'bash': return 'run.sh'; + case 'python': return 'run.py'; + case 'typescript': return 'index.ts'; + case 'javascript': return 'index.js'; + default: return 'run.sh'; + } +} + +async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit, stepIndex }: RunScriptStepParams): Promise { + const language = task.language ?? 'bash'; + + // Write script to temp file + const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const fileName = getFileName(language); + const scriptPath = join(dir, fileName); + writeFileSync(scriptPath, task.implementation); + chmodSync(scriptPath, 0o755); + + const cleanup = () => { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + }; + + // Build env vars from inputs + const inputEnv: Record = {}; + for (const [key, value] of Object.entries(inputs)) { + inputEnv[`INPUT_${key.toUpperCase()}`] = value; + } + + // Build positional args + const positionalArgs = task.args?.map((name) => inputs[name] ?? '') ?? []; + + const runner = getRunner(language); + const cmd = [...runner, scriptPath, ...positionalArgs]; + + const spawnEnv = { ...process.env as Record, ...inputEnv }; + + console.log(`[pipeline] running script step ${stepIndex}: ${task.name} (cwd=${cwd})`); + + try { + const proc = Bun.spawn(cmd, { + cwd, + env: spawnEnv, + stdout: 'pipe', + stderr: 'pipe', + }); + + const decoder = new TextDecoder(); + + const readStream = async (reader: ReadableStreamDefaultReader) => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + emit({ type: 'assistant:delta', text, stepIndex }); + } + } catch { + // stream closed + } + }; + + // Check abort periodically + const abortCheck = setInterval(() => { + if (abortSignal.aborted) { + try { proc.kill(); } catch { /* already dead */ } + } + }, 500); + + const [, , exitCode] = await Promise.all([ + readStream(proc.stdout.getReader()), + readStream(proc.stderr.getReader()), + proc.exited, + ]); + + clearInterval(abortCheck); + cleanup(); + + // Flush the streamed text as a complete message + emit({ type: 'assistant:text', text: '', stepIndex }); + + if (exitCode !== 0 && !abortSignal.aborted) { + throw new Error(`Script exited with code ${exitCode}`); + } + } catch (err) { + cleanup(); + throw err; + } +} + // ── Parallel foreach ── type ParallelForeachParams = { @@ -251,7 +385,7 @@ async function runParallelForeach({ const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir; const resolvedCwd = resolveBaseCwd(email, role, cwdRelative); const targetDir = toAgentPath(resolvedCwd, email, role); - const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir); + const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir); try { const cost = await runAgenticStep({ @@ -341,7 +475,9 @@ export async function executePipeline({ userId, email, username, role, taskDirNa emit({ type: 'error', message: `Step task not found: ${step.task}` }); return; } - if (!stepTask.body) { + + const isScript = stepTask.mode === 'script'; + if (!isScript && !stepTask.body) { emit({ type: 'error', message: `Step task "${step.task}" has no body` }); return; } @@ -353,6 +489,35 @@ export async function executePipeline({ userId, email, username, role, taskDirNa } } + // Script-mode steps run directly (no agent), only single execution supported + if (isScript) { + if (!stepTask.implementation) { + emit({ type: 'error', message: `Script task "${step.task}" has no implementation` }); + return; + } + + emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name }); + + try { + await runScriptStep({ + email, role, + task: { name: stepTask.name, implementation: stepTask.implementation, language: stepTask.language ?? 'bash', args: stepTask.args as string[] | null }, + inputs: resolvedInputs, + cwd: baseCwd, + abortSignal, + emit, + stepIndex: stepIdx, + }); + emit({ type: 'step:complete', stepIndex: stepIdx }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emit({ type: 'error', message: `Script step "${step.task}" failed: ${message}` }); + return; + } + + continue; + } + if (step.foreach === 'subdirectory') { let subdirs: string[]; try { @@ -371,7 +536,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa if (concurrency > 1) { await runParallelForeach({ userId, email, username, role, - stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body }, + stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body! }, subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit, }); } else { @@ -405,7 +570,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir; const resolvedCwd = resolveBaseCwd(email, role, cwdRelative); const targetDir = toAgentPath(resolvedCwd, email, role); - const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir); + const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir); const cost = await runAgenticStep({ userId, email, username, role, @@ -430,7 +595,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name }); const targetDir = toAgentPath(baseCwd, email, role); - const prompt = buildStepPrompt(stepTask.body, resolvedInputs, targetDir); + const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir); const cost = await runAgenticStep({ userId, email, username, role, @@ -450,9 +615,11 @@ export async function executePipeline({ userId, email, username, role, taskDirNa } } - if (!abortSignal.aborted) { - emit({ type: 'pipeline:complete', totalCost }); + if (abortSignal.aborted) { + throw new Error('Pipeline was stopped'); } + + emit({ type: 'pipeline:complete', totalCost }); } // ── WebSocket handler (thin layer) ── diff --git a/src/servers/api/tasks/pipeline-job-manager.ts b/src/servers/api/tasks/pipeline-job-manager.ts index f89498ca..696adf0f 100644 --- a/src/servers/api/tasks/pipeline-job-manager.ts +++ b/src/servers/api/tasks/pipeline-job-manager.ts @@ -161,6 +161,7 @@ export async function startJob(params: StartJobParams): Promise { clearInterval(flushInterval); const message = err instanceof Error ? err.message : String(err); const isStopped = job.abortSignal.aborted; + broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message }); await updatePipelineJob(jobId, { status: isStopped ? 'stopped' : 'failed', progress: job.lastProgress as Record, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 4a7230d8..56919a72 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -368,13 +368,14 @@ const InlineRenameInput = ({ }) => { const [value, setValue] = useState(initialName); const inputRef = useRef(null); - const focused = useRef(false); + const ready = useRef(false); useEffect(() => { const node = inputRef.current; - if (!node || focused.current) return; - focused.current = true; - requestAnimationFrame(() => { + if (!node) return; + // Delay focus to let the context menu fully close and avoid focus races + const timer = setTimeout(() => { + ready.current = true; node.focus(); const dotIndex = initialName.lastIndexOf('.'); if (dotIndex > 0) { @@ -382,7 +383,8 @@ const InlineRenameInput = ({ } else { node.select(); } - }); + }, 50); + return () => clearTimeout(timer); }, []); const commit = () => { @@ -399,13 +401,17 @@ const InlineRenameInput = ({ ref={inputRef} value={value} onChange={(ev) => setValue(ev.target.value)} - onBlur={commit} + onBlur={() => { + // Ignore blur before the input is ready (context menu closing steals focus) + if (!ready.current) return; + commit(); + }} onKeyDown={(ev) => { if (ev.key === 'Enter') commit(); if (ev.key === 'Escape') onCancel(); }} onClick={(ev) => ev.stopPropagation()} - className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center" + className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none max-w-full text-left" /> ); };