Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
991 changes: 991 additions & 0 deletions docs/superpowers/plans/2026-07-19-editable-job-names.md

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions docs/superpowers/specs/2026-07-18-editable-job-names-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Editable Job Names — Design

## Goal

Give every job a human-editable name. At launch the name is prefilled with `app name - entry point`; the user can change it before launching and any time afterward. The name becomes the job's identity in the UI (jobs table link, job detail title), replacing the redundant App and Entry Point columns.

## Background

Today a job has no name/title. It is identified everywhere by `app_name` + `entry_point_name` (both snapshotted at submit time). There is no update endpoint for jobs (only POST submit, GET, cancel, DELETE).

## Data model

Backend:
- Add nullable `name` column to `JobDB` (`fileglancer/database.py`).
- Alembic migration: add the column, then backfill existing rows with `app_name || ' - ' || entry_point_name`.
- Add `name` to the `Job` and `JobSubmitRequest` Pydantic models (`fileglancer/model.py`).
- `submit_job` (`fileglancer/apps/jobs.py`) persists the supplied `name`.
- New `db.update_job(job_id, name)` in `database.py`.

Frontend:
- Add `name` to the TS `Job` and `JobSubmitRequest` types (`frontend/src/shared.types.ts`).

## API

New `PATCH /api/jobs/{job_id}` accepting `{ "name": string }`, mirroring the existing catalog `PATCH` endpoint (`server.py`).
- Trims the name; rejects empty/whitespace with 400.
- Returns the updated job.

Frontend: `useUpdateJobMutation` in `frontend/src/queries/jobsQueries.ts`, invalidating `jobsQueryKeys.all` on success (same shape as the other job mutations).

## UI

### Launch page (`AppLaunch` / `AppLaunchForm`)
- New "Job name" text input placed under the URL/breadcrumbs area.
- Prefilled with `${displayName} - ${entryPointName}`. It follows the computed default until the user edits it (a dirty flag); after the first edit it keeps the typed value even if the entry point changes.
- Threaded through the existing `onSubmit` prop into the `JobSubmitRequest`.

### Jobs table (`appsJobsColumns.tsx` / `AppJobs.tsx`)
- Drop the `app_name` ("App") and `entry_point_name` ("Entry Point") columns.
- New "Name" column, placed first; its cell links to `/apps/jobs/${jobId}` with link text = `job.name` (fallback `app_name - entry_point_name` for any legacy row still missing a name).
- Update the grid template accordingly (net one fewer column).
- Add a "Rename" `MenuItem` to the actions menu that opens a rename dialog.

### Rename dialog
- Cloned from `EditListingDialog.tsx` (name field, non-empty validation, Cancel/Save), wired to `useUpdateJobMutation`.

### Job detail page (`JobDetail.tsx`)
- Title (currently `` `${job.app_name} - ${job.entry_point_name}` ``) becomes `job.name` (same fallback).
- Pencil icon next to the title, hover text "Edit job name". Clicking swaps the title for an inline text field with Cancel/Save buttons (GitHub-issue-title style), saving via `useUpdateJobMutation`. Non-empty required.

## Validation / errors

- Empty/whitespace names are rejected both client-side (disable Save) and server-side (400). Previous name is kept on rejection.

## Non-goals / skipped

- No shared "editable text" component: the inline editor (job page) and the rename dialog (actions menu) both just call `useUpdateJobMutation`; two callers with different shells do not justify an abstraction. Add one if a third caller appears.
- No autosave/debounce — explicit Save only.
- Column header naming stays plain ("Name").

## Testing

- Backend: a test for `PATCH /api/jobs/{job_id}` (happy path + empty-name 400) and that `submit_job` persists a name.
- Migration: verify existing rows get backfilled names.
- Frontend: the inline editor / rename dialog non-empty behavior via existing unit-test patterns if present; otherwise a manual check.
30 changes: 30 additions & 0 deletions fileglancer/alembic/versions/e7b2a9c4f130_add_name_to_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add name column to jobs

Revision ID: e7b2a9c4f130
Revises: b6c4e8a25f19
Create Date: 2026-07-19 00:00:00.000000

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'e7b2a9c4f130'
down_revision = 'b6c4e8a25f19'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('jobs', sa.Column('name', sa.String(), nullable=True))
# Backfill existing rows so every job has a name. '||' is string
# concatenation on both SQLite and PostgreSQL.
op.execute(
"UPDATE jobs SET name = app_name || ' - ' || entry_point_name "
"WHERE name IS NULL"
)


def downgrade() -> None:
op.drop_column('jobs', 'name')
2 changes: 2 additions & 0 deletions fileglancer/apps/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ async def submit_job(
resources: Optional[dict] = None,
extra_args: Optional[str] = None,
manifest_path: str = "",
name: Optional[str] = None,
env: Optional[dict] = None,
clean_env: bool = False,
pre_run: Optional[str] = None,
Expand Down Expand Up @@ -833,6 +834,7 @@ async def submit_job(
username=username,
app_url=app_url,
app_name=app_name,
name=name,
entry_point_id=entry_point.id,
entry_point_name=entry_point.name,
entry_point_type=entry_point.type,
Expand Down
20 changes: 20 additions & 0 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ class JobDB(Base):
manifest_path = Column(String, nullable=False, server_default="")
entry_point_id = Column(String, nullable=False)
entry_point_name = Column(String, nullable=False)
# Human-editable label for the job. Defaults to "app_name - entry_point_name"
# at create time; NULL only for rows created before this column (backfilled
# by migration).
name = Column(String, nullable=True)
entry_point_type = Column(String, nullable=False, server_default="job")
parameters = Column(JSON, nullable=False)
# Environment-tab parameter values. A separate namespace from `parameters`
Expand Down Expand Up @@ -983,6 +987,7 @@ def create_job(session: Session, username: str, app_url: str, app_name: str,
env_parameters: Optional[Dict] = None,
resources: Optional[Dict] = None, manifest_path: str = "",
entry_point_type: str = "job",
name: Optional[str] = None,
env: Optional[Dict] = None, pre_run: Optional[str] = None,
post_run: Optional[str] = None,
container: Optional[str] = None,
Expand All @@ -995,10 +1000,13 @@ def create_job(session: Session, username: str, app_url: str, app_name: str,
clean_env: bool = False) -> JobDB:
"""Create a new job record"""
now = datetime.now(UTC)
if not (name and name.strip()):
name = f"{app_name} - {entry_point_name}"
job = JobDB(
username=username,
app_url=canonical_github_url(app_url),
app_name=app_name,
name=name,
manifest_path=manifest_path,
entry_point_id=entry_point_id,
entry_point_name=entry_point_name,
Expand Down Expand Up @@ -1039,6 +1047,18 @@ def get_job(session: Session, job_id: int, username: str) -> Optional[JobDB]:
return session.query(JobDB).filter_by(id=job_id, username=username).first()


def update_job(session: Session, job_id: int, username: str,
name: str) -> Optional[JobDB]:
"""Rename a job. Returns the job, or None if it doesn't exist or isn't
owned by username."""
job = session.query(JobDB).filter_by(id=job_id, username=username).first()
if job is None:
return None
job.name = name
session.commit()
return job


def count_active_jobs_by_username(session: Session, username: str) -> int:
"""Count a user's jobs that are not known-terminal (see get_active_jobs)."""
return session.query(JobDB).filter_by(username=username).filter(
Expand Down
10 changes: 10 additions & 0 deletions fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,7 @@ class Job(BaseModel):
manifest_path: str = Field(description="Relative manifest path within the app repo", default="")
entry_point_id: str = Field(description="Entry point that was executed")
entry_point_name: str = Field(description="Display name of the entry point")
name: str = Field(description="Human-editable job name")
entry_point_type: str = Field(description="Whether this is a batch job or long-running service", default="job")
parameters: Dict = Field(description="Parameters used for the job")
env_parameters: Optional[Dict] = Field(
Expand Down Expand Up @@ -949,6 +950,10 @@ class JobSubmitRequest(BaseModel):
"""Request to submit a new job"""
app_url: str = Field(description="URL of the app manifest")
manifest_path: str = Field(description="Relative manifest path within the app repo", default="")
name: Optional[str] = Field(
description="Job name; defaults to 'app name - entry point' when omitted",
default=None,
)
entry_point_id: str = Field(description="Entry point to execute")
parameters: Dict = Field(description="Parameter values keyed by parameter key")
env_parameters: Dict = Field(
Expand Down Expand Up @@ -1007,6 +1012,11 @@ def validate_container_args(cls, v):
return v


class UpdateJobRequest(BaseModel):
"""Request to rename a job"""
name: str = Field(description="New job name")


class PathValidationRequest(BaseModel):
"""Request to validate file/directory paths"""
paths: Dict[str, str] = Field(description="Map of parameter key to path value")
Expand Down
16 changes: 16 additions & 0 deletions fileglancer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2315,6 +2315,7 @@ async def submit_job(body: JobSubmitRequest,
resources=resources_dict,
extra_args=body.extra_args,
manifest_path=body.manifest_path,
name=body.name,
env=body.env,
clean_env=body.clean_env,
pre_run=body.pre_run,
Expand Down Expand Up @@ -2378,6 +2379,20 @@ async def get_job(job_id: int,
pass
return _convert_job(db_job, service_url=service_url, files=files, phase=phase)

@app.patch("/api/jobs/{job_id}", response_model=Job,
description="Rename a job")
async def rename_job(job_id: int,
body: UpdateJobRequest,
username: str = Depends(get_current_user)):
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Job name must not be empty")
with db.get_db_session(settings.db_url) as session:
db_job = db.update_job(session, job_id, username, name)
if db_job is None:
raise HTTPException(status_code=404, detail="Job not found")
return _convert_job(db_job)

@app.post("/api/jobs/{job_id}/cancel",
description="Cancel a running job")
async def cancel_job(job_id: int,
Expand Down Expand Up @@ -2454,6 +2469,7 @@ def _convert_job(db_job: db.JobDB, service_url: str = None, files: dict = None,
id=db_job.id,
app_url=db_job.app_url,
app_name=db_job.app_name,
name=db_job.name or f"{db_job.app_name} - {db_job.entry_point_name}",
manifest_path=db_job.manifest_path,
entry_point_id=db_job.entry_point_id,
entry_point_name=db_job.entry_point_name,
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/components/AppJobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
import FgLink from '@/components/designSystem/atoms/FgLink';
import CancelJobDialog from '@/components/ui/Dialogs/CancelJob';
import DeleteJobDialog from '@/components/ui/Dialogs/DeleteJob';
import RenameJobDialog from '@/components/ui/Dialogs/RenameJob';
import { TableCard } from '@/components/ui/Table/TableCard';
import { createAppsJobsColumns } from '@/components/ui/Table/appsJobsColumns';
import { buildRelaunchPath, parseGithubUrl } from '@/utils';
Expand All @@ -15,16 +16,19 @@ import type { Job } from '@/shared.types';
import {
useJobsQuery,
useCancelJobMutation,
useDeleteJobMutation
useDeleteJobMutation,
useUpdateJobMutation
} from '@/queries/jobsQueries';

export default function AppJobs() {
const navigate = useNavigate();
const jobsQuery = useJobsQuery();
const cancelJobMutation = useCancelJobMutation();
const deleteJobMutation = useDeleteJobMutation();
const updateJobMutation = useUpdateJobMutation();
const [jobToCancel, setJobToCancel] = useState<Job | null>(null);
const [jobToDelete, setJobToDelete] = useState<number | null>(null);
const [jobToRename, setJobToRename] = useState<Job | null>(null);

const handleViewJobDetail = (jobId: number) => {
navigate(`/apps/jobs/${jobId}`);
Expand Down Expand Up @@ -83,11 +87,20 @@ export default function AppJobs() {
}
};

const handleConfirmRename = async (name: string) => {
if (!jobToRename) {
return;
}
await updateJobMutation.mutateAsync({ jobId: jobToRename.id, name });
toast.success('Job renamed');
};

const jobsColumns = useMemo(
() =>
createAppsJobsColumns({
onViewDetail: handleViewJobDetail,
onRelaunch: handleRelaunch,
onRename: setJobToRename,
onCancel: setJobToCancel,
onDelete: setJobToDelete
}),
Expand All @@ -107,7 +120,7 @@ export default function AppJobs() {
data={jobsQuery.data || []}
dataType="jobs"
errorState={jobsQuery.error}
gridColsClass="grid-cols-[2fr_2fr_1fr_2fr_1fr_1fr]"
gridColsClass="grid-cols-[3fr_1fr_2fr_1fr_1fr]"
initialPageSize={50}
loadingState={jobsQuery.isPending}
/>
Expand All @@ -126,6 +139,19 @@ export default function AppJobs() {
onConfirm={handleConfirmDeleteJob}
open={jobToDelete !== null}
/>

<RenameJobDialog
initialName={
jobToRename
? jobToRename.name ||
`${jobToRename.app_name} - ${jobToRename.entry_point_name}`
: ''
}
onClose={() => setJobToRename(null)}
onSave={handleConfirmRename}
open={jobToRename !== null}
saving={updateJobMutation.isPending}
/>
</div>
);
}
5 changes: 4 additions & 1 deletion frontend/src/components/AppLaunch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ export default function AppLaunch() {
preRun?: string,
postRun?: string,
container?: string,
containerArgs?: string
containerArgs?: string,
jobName?: string
) => {
if (!selectedEntryPoint) {
return;
Expand All @@ -186,6 +187,7 @@ export default function AppLaunch() {
{
app_url: appUrl,
manifest_path: manifestPath,
name: jobName,
entry_point_id: selectedEntryPoint.id,
parameters,
env_parameters: envParameters,
Expand Down Expand Up @@ -300,6 +302,7 @@ export default function AppLaunch() {
</div>
) : manifest && selectedEntryPoint ? (
<AppLaunchForm
defaultJobName={`${displayName ?? ''} - ${selectedEntryPoint.name}`}
entryPoint={selectedEntryPoint}
headerActionsTarget={launchActionsTarget}
initialCleanEnv={relaunchCleanEnv}
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/components/JobDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import toast from 'react-hot-toast';

import AnsiText from '@/components/ui/AppsPage/AnsiText';
import AppPageHeader from '@/components/ui/AppsPage/AppPageHeader';
import JobTitleEditor from '@/components/ui/AppsPage/JobTitleEditor';
import FgButton from '@/components/designSystem/atoms/FgButton';
import FgIcon from '@/components/designSystem/atoms/FgIcon';
import FgLink from '@/components/designSystem/atoms/FgLink';
Expand Down Expand Up @@ -368,9 +369,7 @@ function JobOverview({
label="App"
value={
detailPath ? (
<FgLink to={detailPath}>
{`${job.app_name}`}
</FgLink>
<FgLink to={detailPath}>{`${job.app_name}`}</FgLink>
) : (
`${job.app_name}`
)
Expand Down Expand Up @@ -715,7 +714,7 @@ export default function JobDetail() {
description={jobEntryPoint?.description ?? null}
githubUrl={job.app_url}
icon={getEntryPointTypeIconType(job.entry_point_type)}
title={`${job.app_name} - ${job.entry_point_name}`}
title={<JobTitleEditor job={job} />}
>
<JobStatusBadge status={job.status} />
</AppPageHeader>
Expand Down
Loading
Loading