add tag-album and clean-playlist-files tasks, script step support, stop fix, rename fix
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,14 @@ inputs:
|
|||||||
description: Artist / band name
|
description: Artist / band name
|
||||||
autofill: entry_name
|
autofill: entry_name
|
||||||
steps:
|
steps:
|
||||||
|
- task: convert-audio
|
||||||
|
inputs:
|
||||||
|
file_path: .
|
||||||
|
target_format: mp3
|
||||||
|
delete_source: "true"
|
||||||
|
- task: clean-playlist-files
|
||||||
|
inputs:
|
||||||
|
file_path: .
|
||||||
- task: prepare-discography
|
- task: prepare-discography
|
||||||
inputs:
|
inputs:
|
||||||
artist_name: ${artist_name}
|
artist_name: ${artist_name}
|
||||||
@@ -21,4 +29,10 @@ steps:
|
|||||||
inputs:
|
inputs:
|
||||||
artist_name: ${artist_name}
|
artist_name: ${artist_name}
|
||||||
album_name: ${folder_name}
|
album_name: ${folder_name}
|
||||||
|
- task: tag-album
|
||||||
|
foreach: subdirectory
|
||||||
|
concurrency: 5
|
||||||
|
inputs:
|
||||||
|
artist_name: ${artist_name}
|
||||||
|
album_name: ${folder_name}
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { randomUUID } from 'crypto';
|
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 { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import { getTaskByDirName, getUserSettings } from 'officerdb';
|
import { getTaskByDirName, getUserSettings } from 'officerdb';
|
||||||
import { getHomeDirForRole, getHomeDir } from '../../data-path';
|
import { getHomeDirForRole, getHomeDir } from '../../data-path';
|
||||||
import { resolveBaseCwd } from '../pi/websocket';
|
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 });
|
emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
|
||||||
break;
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
|
clearInterval(abortPoll);
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
resolve(event.cost);
|
resolve(event.cost);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
|
clearInterval(abortPoll);
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
reject(new Error(event.message));
|
reject(new Error(event.message));
|
||||||
break;
|
break;
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
|
clearInterval(abortPoll);
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
reject(new Error('Step was stopped'));
|
reject(new Error('Step was stopped'));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Poll for abort signal to kill the running agent
|
||||||
|
const abortPoll = setInterval(() => {
|
||||||
|
if (abortSignal.aborted && cleanup) {
|
||||||
|
clearInterval(abortPoll);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isClaudeCode) {
|
if (isClaudeCode) {
|
||||||
const handle = await sendClaudeCodeStreaming({
|
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 });
|
await sidecar.spawnPi({ sessionId, email, userId, username, role, cwd, model });
|
||||||
sidecar.sendPiPrompt(sessionId, prompt, randomUUID());
|
sidecar.sendPiPrompt(sessionId, prompt, randomUUID());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If already aborted while setting up, kill immediately
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
clearInterval(abortPoll);
|
||||||
|
cleanup?.();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearInterval(abortPoll);
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
@@ -180,6 +199,121 @@ function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targe
|
|||||||
return `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
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<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, string>, ...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<Uint8Array>) => {
|
||||||
|
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 ──
|
// ── Parallel foreach ──
|
||||||
|
|
||||||
type ParallelForeachParams = {
|
type ParallelForeachParams = {
|
||||||
@@ -251,7 +385,7 @@ async function runParallelForeach({
|
|||||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||||
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
||||||
const targetDir = toAgentPath(resolvedCwd, email, role);
|
const targetDir = toAgentPath(resolvedCwd, email, role);
|
||||||
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cost = await runAgenticStep({
|
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}` });
|
emit({ type: 'error', message: `Step task not found: ${step.task}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!stepTask.body) {
|
|
||||||
|
const isScript = stepTask.mode === 'script';
|
||||||
|
if (!isScript && !stepTask.body) {
|
||||||
emit({ type: 'error', message: `Step task "${step.task}" has no body` });
|
emit({ type: 'error', message: `Step task "${step.task}" has no body` });
|
||||||
return;
|
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') {
|
if (step.foreach === 'subdirectory') {
|
||||||
let subdirs: string[];
|
let subdirs: string[];
|
||||||
try {
|
try {
|
||||||
@@ -371,7 +536,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
|||||||
if (concurrency > 1) {
|
if (concurrency > 1) {
|
||||||
await runParallelForeach({
|
await runParallelForeach({
|
||||||
userId, email, username, role,
|
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,
|
subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -405,7 +570,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
|||||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||||
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
||||||
const targetDir = toAgentPath(resolvedCwd, email, role);
|
const targetDir = toAgentPath(resolvedCwd, email, role);
|
||||||
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir);
|
||||||
|
|
||||||
const cost = await runAgenticStep({
|
const cost = await runAgenticStep({
|
||||||
userId, email, username, role,
|
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 });
|
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
|
||||||
|
|
||||||
const targetDir = toAgentPath(baseCwd, email, role);
|
const targetDir = toAgentPath(baseCwd, email, role);
|
||||||
const prompt = buildStepPrompt(stepTask.body, resolvedInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir);
|
||||||
|
|
||||||
const cost = await runAgenticStep({
|
const cost = await runAgenticStep({
|
||||||
userId, email, username, role,
|
userId, email, username, role,
|
||||||
@@ -450,9 +615,11 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
emit({ type: 'pipeline:complete', totalCost });
|
throw new Error('Pipeline was stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit({ type: 'pipeline:complete', totalCost });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket handler (thin layer) ──
|
// ── WebSocket handler (thin layer) ──
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export async function startJob(params: StartJobParams): Promise<string> {
|
|||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const isStopped = job.abortSignal.aborted;
|
const isStopped = job.abortSignal.aborted;
|
||||||
|
broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message });
|
||||||
await updatePipelineJob(jobId, {
|
await updatePipelineJob(jobId, {
|
||||||
status: isStopped ? 'stopped' : 'failed',
|
status: isStopped ? 'stopped' : 'failed',
|
||||||
progress: job.lastProgress as Record<string, unknown>,
|
progress: job.lastProgress as Record<string, unknown>,
|
||||||
|
|||||||
+13
-7
@@ -368,13 +368,14 @@ const InlineRenameInput = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState(initialName);
|
const [value, setValue] = useState(initialName);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const focused = useRef(false);
|
const ready = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = inputRef.current;
|
const node = inputRef.current;
|
||||||
if (!node || focused.current) return;
|
if (!node) return;
|
||||||
focused.current = true;
|
// Delay focus to let the context menu fully close and avoid focus races
|
||||||
requestAnimationFrame(() => {
|
const timer = setTimeout(() => {
|
||||||
|
ready.current = true;
|
||||||
node.focus();
|
node.focus();
|
||||||
const dotIndex = initialName.lastIndexOf('.');
|
const dotIndex = initialName.lastIndexOf('.');
|
||||||
if (dotIndex > 0) {
|
if (dotIndex > 0) {
|
||||||
@@ -382,7 +383,8 @@ const InlineRenameInput = ({
|
|||||||
} else {
|
} else {
|
||||||
node.select();
|
node.select();
|
||||||
}
|
}
|
||||||
});
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
@@ -399,13 +401,17 @@ const InlineRenameInput = ({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(ev) => setValue(ev.target.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) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter') commit();
|
if (ev.key === 'Enter') commit();
|
||||||
if (ev.key === 'Escape') onCancel();
|
if (ev.key === 'Escape') onCancel();
|
||||||
}}
|
}}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user