tasks: a direct run runs, it does not queue
"direct" meant a job row and a link to the jobs page. that is not direct — it is the same work with a screen nobody opened between you and the result. inline decides which kind of direct run it is, which is what that field has always meant. true: run it here and wait, a toast while it works and the listing refreshed when it lands — the file viewer's old transcribe button, reached by the menu instead of a header icon. false: a background job, because a task saying inline false is saying it expects to outlive the click. runTaskNow is the promise-shaped sibling of useTaskRunner: same socket, same protocol, opened for the run and closed after it. the hook could not be reused — it holds a connection for as long as it is mounted, and a hook in the file browser would open one per panel, permanently, for a path that fires on a right-click. a socket that closes before exit rejects. it did not finish, and a success toast would be a lie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
+69
@@ -0,0 +1,69 @@
|
||||
type RunResult = { code: number; output: string };
|
||||
|
||||
type RunTaskNowParams = {
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a script task and wait for it, with no modal and no job row.
|
||||
*
|
||||
* ── Why this exists next to `useTaskRunner` ──
|
||||
*
|
||||
* Same socket, same protocol, different shape. `useTaskRunner` is a hook: it holds a connection for as
|
||||
* long as the modal is mounted and streams output into it, which is what a visible run needs. This is a
|
||||
* promise: it opens the socket, runs, resolves on exit and closes. Nothing is watching, so nothing is
|
||||
* kept open.
|
||||
*
|
||||
* The hook could not be reused for it. A hook in the file browser would open a websocket for every panel
|
||||
* that mounts one, permanently, for a path that fires on a right-click — and the file browser is the
|
||||
* panel people leave open.
|
||||
*
|
||||
* ── Why not a job ──
|
||||
*
|
||||
* A job row exists to be watched: it survives navigating away, it appears in Jobs, it can be queued. A
|
||||
* run with nothing to ask has none of that to offer — it is the file viewer's old transcribe button, a
|
||||
* spinner and then the file is there. Creating a job for it means the answer arrives on a screen nobody
|
||||
* opened.
|
||||
*/
|
||||
export function runTaskNow({ taskDirName, inputs, cwd }: RunTaskNowParams): Promise<RunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
if (!token) {
|
||||
reject(new Error('not signed in'));
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/tasks/run/ws?token=${token}`);
|
||||
|
||||
let output = '';
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fn();
|
||||
ws.close();
|
||||
};
|
||||
|
||||
ws.addEventListener('open', () => ws.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd })));
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg: { type?: string; data?: string; code?: number; message?: string };
|
||||
try {
|
||||
msg = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'stdout' || msg.type === 'stderr') output += msg.data ?? '';
|
||||
if (msg.type === 'exit') finish(() => resolve({ code: msg.code ?? 0, output }));
|
||||
if (msg.type === 'error') finish(() => reject(new Error(msg.message || 'Task failed')));
|
||||
});
|
||||
|
||||
// A socket that closes before `exit` took the run with it — the server went away mid-run, or the
|
||||
// token was refused. Either way it did not finish, and reporting success would be a lie.
|
||||
ws.addEventListener('close', () => finish(() => reject(new Error(output.trim() || 'Connection lost'))));
|
||||
ws.addEventListener('error', () => finish(() => reject(new Error('Connection failed'))));
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAgents, type AgentSummary } from '../useAgents';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { runTaskNow } from './components/runTaskNow';
|
||||
import { useFilesRefresh } from '../../../channels';
|
||||
import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft';
|
||||
import { copyToClipboard, canReadClipboard } from 'helpers/clipboard';
|
||||
@@ -202,7 +203,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
}
|
||||
};
|
||||
|
||||
const [refreshSignal] = useFilesRefresh();
|
||||
const [refreshSignal, bumpFilesRefresh] = useFilesRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
|
||||
@@ -585,20 +586,45 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
* inputs and cwd are computed exactly as `TaskRunnerModal` computes them, because this is the same run
|
||||
* by a shorter route: `file_path` is the clicked entry (the parent folder for a multi-selection, scoped
|
||||
* by `include`), and a directory trigger descends into the entry.
|
||||
*
|
||||
* `inline` still decides what KIND of run it is, which is what that field has always meant:
|
||||
*
|
||||
* - `true` — run it here and wait. No job row, no navigation: a toast while it works, the listing
|
||||
* refreshed when it lands. This is the file viewer's old transcribe button, reached by the menu.
|
||||
* - `false` — a background job, because that is a task saying it expects to outlive the click. The
|
||||
* toast carries a link to it rather than taking you there.
|
||||
*/
|
||||
const runTaskDirectly = async (task: TaskSummary, entry: DirEntry, names: string[]) => {
|
||||
const multi = names.length > 1;
|
||||
const entryAbs = absPath(entryPath(entry.name));
|
||||
const base = currentPath.replace(/^\//, '');
|
||||
const cwd = entry.type === 'directory' ? (base ? `${base}/${entry.name}` : entry.name) : base || undefined;
|
||||
|
||||
const inputs: Record<string, string> = { file_path: multi ? entryAbs.replace(/\/[^/]+$/, '') : entryAbs };
|
||||
if (multi) inputs.include = names.join('\n');
|
||||
|
||||
if (task.inline === true) {
|
||||
const toastId = toast.loading(t('taskRunner.running', { name: task.name }));
|
||||
try {
|
||||
const { code, output } = await runTaskNow({ taskDirName: task.dirName, inputs, cwd });
|
||||
if (code !== 0) throw new Error(output.trim().split('\n').pop() || '');
|
||||
toast.success(t('taskRunner.taskComplete'), { id: toastId });
|
||||
// Bumping the channel refreshes this panel too — the effect below listens to the same signal —
|
||||
// so a second `refresh()` here would only be the same listing fetched twice.
|
||||
bumpFilesRefresh();
|
||||
} catch (err) {
|
||||
// The script's own last line, when it left one — it knows why it failed and this does not.
|
||||
const detail = err instanceof Error ? err.message : '';
|
||||
toast.error(detail || t('taskRunner.jobStartFailed', { name: task.name }), { id: toastId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { jobId } = await client.post<{ jobId: string }>('/jobs', {
|
||||
taskDirName: task.dirName,
|
||||
inputs,
|
||||
cwd: entry.type === 'directory' ? (base ? `${base}/${entry.name}` : entry.name) : base || undefined,
|
||||
cwd,
|
||||
action: 'start',
|
||||
});
|
||||
toast.success(t('taskRunner.jobStarted', { name: task.name }), {
|
||||
|
||||
@@ -94,9 +94,10 @@ test("'ask' takes the modal, because the choice is itself the question", () => {
|
||||
expect(runsWithoutPrompting(scriptTask({ inline: 'ask' }))).toBe(false);
|
||||
});
|
||||
|
||||
// The modal is where an ephemeral run's output goes. There is nowhere else to put it.
|
||||
test('an inline task takes the modal', () => {
|
||||
expect(runsWithoutPrompting(scriptTask({ inline: true }))).toBe(false);
|
||||
// `inline` decides what KIND of direct run it is — here and now, versus a background job — not whether
|
||||
// there is one.
|
||||
test('an inline task with nothing to ask still skips the modal', () => {
|
||||
expect(runsWithoutPrompting(scriptTask({ inline: true }))).toBe(true);
|
||||
});
|
||||
|
||||
test('agentic and pipeline always take the modal', () => {
|
||||
|
||||
@@ -47,8 +47,10 @@ export type TaskSummary = {
|
||||
Three things always take the modal, whatever their inputs:
|
||||
- `agentic`, which takes a prompt and a model;
|
||||
- `pipeline`, which takes a model and a starting step;
|
||||
- `inline: 'ask'` or `true` — 'ask' because the choice IS a question, `true` because the modal is where
|
||||
an ephemeral run's output goes and there is nowhere else to put it.
|
||||
- `inline: 'ask'`, because the choice IS a question.
|
||||
|
||||
`inline` otherwise decides what kind of direct run it is, not whether there is one — `true` runs here
|
||||
and waits, `false` creates a background job. See `runTaskDirectly` in `useFileBrowserApp`.
|
||||
*/
|
||||
const BROWSER_AUTOFILL = new Set(['entry_name', 'entry_path']);
|
||||
|
||||
@@ -57,7 +59,7 @@ const isFilledByBrowser = (key: string, spec: TaskInputSpec): boolean =>
|
||||
|
||||
export const runsWithoutPrompting = (task: TaskSummary): boolean => {
|
||||
if (task.mode !== 'script') return false;
|
||||
if (task.inline === true || task.inline === 'ask') return false;
|
||||
if (task.inline === 'ask') return false;
|
||||
return Object.entries(task.inputs ?? {}).every(([key, spec]) => isFilledByBrowser(key, spec ?? {}));
|
||||
};
|
||||
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"viewInJobs": "In Jobs anzeigen",
|
||||
"jobStarted": "{{name}} gestartet",
|
||||
"jobStartFailed": "{{name}} konnte nicht gestartet werden",
|
||||
"running": "{{name}} läuft…",
|
||||
"copyOutput": "Ausgabe kopieren",
|
||||
"track": "Spur {{n}}",
|
||||
"runFromStep": "Ab Schritt {{n}} ausführen"
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"viewInJobs": "View in Jobs",
|
||||
"jobStarted": "{{name}} started",
|
||||
"jobStartFailed": "Could not start {{name}}",
|
||||
"running": "Running {{name}}…",
|
||||
"copyOutput": "Copy output",
|
||||
"track": "Track {{n}}",
|
||||
"runFromStep": "Run from step {{n}}"
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"viewInJobs": "Ver en Trabajos",
|
||||
"jobStarted": "{{name}} iniciada",
|
||||
"jobStartFailed": "No se ha podido iniciar {{name}}",
|
||||
"running": "Ejecutando {{name}}…",
|
||||
"copyOutput": "Copiar salida",
|
||||
"track": "Pista {{n}}",
|
||||
"runFromStep": "Ejecutar desde el paso {{n}}"
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"viewInJobs": "Voir dans les Jobs",
|
||||
"jobStarted": "{{name}} lancée",
|
||||
"jobStartFailed": "Impossible de lancer {{name}}",
|
||||
"running": "Exécution de {{name}}…",
|
||||
"copyOutput": "Copier la sortie",
|
||||
"track": "Piste {{n}}",
|
||||
"runFromStep": "Exécuter à partir de l'étape {{n}}"
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"viewInJobs": "Ver nos trabalhos",
|
||||
"jobStarted": "{{name}} iniciada",
|
||||
"jobStartFailed": "Não foi possível iniciar {{name}}",
|
||||
"running": "A executar {{name}}…",
|
||||
"copyOutput": "Copiar a saída",
|
||||
"track": "Faixa {{n}}",
|
||||
"runFromStep": "Executar a partir do passo {{n}}"
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"viewInJobs": "Visa i Jobb",
|
||||
"jobStarted": "{{name}} har startat",
|
||||
"jobStartFailed": "Det gick inte att starta {{name}}",
|
||||
"running": "Kör {{name}}…",
|
||||
"copyOutput": "Kopiera utdata",
|
||||
"track": "Spår {{n}}",
|
||||
"runFromStep": "Kör från steg {{n}}"
|
||||
|
||||
Reference in New Issue
Block a user