Make a project a container of flows, with no run of its own
Running every flow in a project put two different things in one list: the sort control decided what you saw, while the up/down arrows set the order the run would actually use. Pressing an arrow therefore rewrote the run order from whatever the list happened to be sorted by - usually the alphabetical one - and the row often did not move, so the arrows read as broken. Rather than reconcile the two, a project is now just a grouping: the play button, the "Run project" menu entry and the reorder arrows are gone, with the client calls behind them. The server keeps its endpoints, so bringing the feature back is a frontend change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
82396e8b8f
commit
b1497ab3c4
|
|
@ -67,29 +67,3 @@ export function normalizeSharedContext(raw: unknown): ProjectContext {
|
|||
export function projectTemplateReference(name: string): string {
|
||||
return `\${{project.${name}}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived from the run's executions; the run keeps no state of its own.
|
||||
* BLOCKED = the next step still needs inputs or credentials. STOPPED = a step failed.
|
||||
*/
|
||||
export type ProjectRunStatus = 'PENDING' | 'RUNNING' | 'BLOCKED' | 'STOPPED' | 'COMPLETED';
|
||||
|
||||
/** One run of a project: the executions started together, tied by `projectRunId`. */
|
||||
export type ProjectRun = {
|
||||
projectRunId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
executionCount: number;
|
||||
status: ProjectRunStatus;
|
||||
currentExecutionId: string | null;
|
||||
completedCount: number;
|
||||
blockedReason: string | null;
|
||||
executionIds: string[];
|
||||
};
|
||||
|
||||
/** What a project run created, and the flows it deliberately left out. */
|
||||
export type ProjectExecutionPlan = {
|
||||
run: ProjectRun;
|
||||
skipped: { flowId: string; flowName: string; reason: string }[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ export abstract class ProjectsCallServiceBase {
|
|||
|
||||
abstract updateSharedContext(projectId: string, context: ProjectContext): Observable<Project>;
|
||||
|
||||
/** Sets the order a project run executes the flows in. */
|
||||
abstract updateFlowOrder(projectId: string, flowIds: string[]): Observable<Project>;
|
||||
|
||||
/**
|
||||
* Deletes the project and every flow in it, finalized flows included. Without `confirm` the
|
||||
* backend refuses a non-empty project with 409 rather than destroying anything.
|
||||
|
|
|
|||
|
|
@ -105,10 +105,6 @@ export class ProjectsCallServiceFake extends ProjectsCallServiceBase {
|
|||
});
|
||||
}
|
||||
|
||||
override updateFlowOrder(projectId: string, _flowIds: string[]): Observable<Project> {
|
||||
return defer(() => of(this.requireProject(projectId)));
|
||||
}
|
||||
|
||||
override deleteProject(projectId: string, confirm: boolean): Observable<void> {
|
||||
return defer(() => {
|
||||
this.requireProject(projectId);
|
||||
|
|
|
|||
|
|
@ -46,12 +46,6 @@ export class ProjectsCallService extends ProjectsCallServiceBase {
|
|||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override updateFlowOrder(projectId: string, flowIds: string[]): Observable<Project> {
|
||||
return this.http
|
||||
.put<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/flow-order`, { flowIds })
|
||||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override deleteProject(projectId: string, confirm: boolean): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}?confirm=${confirm}`
|
||||
|
|
|
|||
|
|
@ -115,16 +115,6 @@ export class ProjectsService {
|
|||
);
|
||||
}
|
||||
|
||||
updateFlowOrder(projectId: string, flowIds: string[]) {
|
||||
return this.projectsCallService.updateFlowOrder(projectId, flowIds).pipe(
|
||||
tap((updated) => this.patch(updated)),
|
||||
catchError((err) => {
|
||||
console.error('Update project flow order failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the project and every flow in it. The caller must also refresh the flow cache, which
|
||||
* this cascade leaves stale.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
BiasRerunRequest
|
||||
} from '@models/bias-impact';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class TaskExecutionsCallServiceBase {
|
||||
|
|
@ -20,16 +19,6 @@ export abstract class TaskExecutionsCallServiceBase {
|
|||
abstract retrieveStepIterations(executionId: string, stepId: string): Observable<TaskExecution[]>;
|
||||
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
|
||||
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
|
||||
/**
|
||||
* Runs a whole project: creates one execution per flow, all sharing a projectRunId. It does not
|
||||
* start them - the client still supplies inputs and credentials, exactly as for a single flow.
|
||||
*/
|
||||
abstract createProjectExecutions(projectId: string, skipNonExecutable: boolean): Observable<ProjectExecutionPlan>;
|
||||
/**
|
||||
* Starts, or resumes, a project run. The flows run one at a time in the project's order; each
|
||||
* step starts only when the previous one succeeded.
|
||||
*/
|
||||
abstract startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun>;
|
||||
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
abstract runBiasImpactExperiment(
|
||||
executionId: string,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
BiasRerunRequest
|
||||
} from '@models/bias-impact';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
|
||||
import { map, Observable, of, throwError } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -526,75 +525,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(this.withSimulationAvailability(execution));
|
||||
}
|
||||
|
||||
override createProjectExecutions(projectId: string, _skipNonExecutable: boolean): Observable<ProjectExecutionPlan> {
|
||||
// The dev fake seeds flows '1' and '2' into project 'p1'; one execution per flow, sharing a run id.
|
||||
const projectRunId = crypto.randomUUID();
|
||||
const flowIds = projectId === 'p1' ? ['1'] : projectId === 'p2' ? ['2'] : [];
|
||||
const now = Date.now();
|
||||
|
||||
const executionIds = flowIds.map((flowId) => {
|
||||
const execution: TaskExecution = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${projectId} - ${flowId}`,
|
||||
creationTime: now,
|
||||
flowId,
|
||||
sourceFlowId: flowId,
|
||||
projectId,
|
||||
projectRunId,
|
||||
runNumber: this.nextRunNumber(flowId),
|
||||
simulationAvailable: false,
|
||||
context: {
|
||||
inputs: {},
|
||||
result: {},
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
errors: {},
|
||||
warnings: {},
|
||||
steps: {},
|
||||
status: 'CREATED',
|
||||
waitingSteps: []
|
||||
}
|
||||
};
|
||||
this.data.unshift(execution);
|
||||
return execution.id;
|
||||
});
|
||||
|
||||
return of({
|
||||
run: {
|
||||
projectRunId,
|
||||
projectId,
|
||||
name: projectId,
|
||||
createdAt: now,
|
||||
executionCount: executionIds.length,
|
||||
status: 'PENDING' as const,
|
||||
currentExecutionId: executionIds[0] ?? null,
|
||||
completedCount: 0,
|
||||
blockedReason: null,
|
||||
executionIds
|
||||
},
|
||||
skipped: []
|
||||
});
|
||||
}
|
||||
|
||||
override startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun> {
|
||||
const executionIds = this.data
|
||||
.filter((execution) => execution.projectRunId === projectRunId)
|
||||
.map((execution) => execution.id);
|
||||
|
||||
return of({
|
||||
projectRunId,
|
||||
projectId,
|
||||
name: projectId,
|
||||
createdAt: Date.now(),
|
||||
executionCount: executionIds.length,
|
||||
status: 'RUNNING' as const,
|
||||
currentExecutionId: executionIds[0] ?? null,
|
||||
completedCount: 0,
|
||||
blockedReason: null,
|
||||
executionIds
|
||||
});
|
||||
}
|
||||
|
||||
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
const source = this.findExecution(executionId);
|
||||
const sourceFlowId = this.executionSourceFlowId(source);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
BiasValueImpact
|
||||
} from '@models/bias-impact';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup, normalizeExecutionOutcomes } from '@models/task-execution';
|
||||
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -70,58 +69,6 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override createProjectExecutions(projectId: string, skipNonExecutable: boolean): Observable<ProjectExecutionPlan> {
|
||||
return this.http
|
||||
.post<unknown>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/execute?skipNonExecutable=${skipNonExecutable}`,
|
||||
{}
|
||||
)
|
||||
.pipe(map((raw) => this.mapProjectExecutionPlan(raw)));
|
||||
}
|
||||
|
||||
override startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun> {
|
||||
return this.http
|
||||
.post<unknown>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`
|
||||
+ `/runs/${encodeURIComponent(projectRunId)}/start`,
|
||||
{}
|
||||
)
|
||||
.pipe(map((raw) => this.mapProjectRun(raw)));
|
||||
}
|
||||
|
||||
private mapProjectRun(raw: unknown): ProjectRun {
|
||||
const run = (raw ?? {}) as Record<string, any>;
|
||||
const executions = Array.isArray(run['executions']) ? run['executions'] : [];
|
||||
|
||||
return {
|
||||
projectRunId: String(run['projectRunId'] ?? ''),
|
||||
projectId: String(run['projectId'] ?? ''),
|
||||
name: String(run['name'] ?? ''),
|
||||
createdAt: typeof run['createdAt'] === 'number' ? run['createdAt'] : Date.now(),
|
||||
executionCount: typeof run['executionCount'] === 'number' ? run['executionCount'] : executions.length,
|
||||
status: (run['status'] ?? 'PENDING') as ProjectRun['status'],
|
||||
currentExecutionId: typeof run['currentExecutionId'] === 'string' ? run['currentExecutionId'] : null,
|
||||
completedCount: typeof run['completedCount'] === 'number' ? run['completedCount'] : 0,
|
||||
blockedReason: typeof run['blockedReason'] === 'string' ? run['blockedReason'] : null,
|
||||
executionIds: executions
|
||||
.map((execution: Record<string, unknown>) => String(execution?.['id'] ?? ''))
|
||||
.filter((id: string) => id.length > 0)
|
||||
};
|
||||
}
|
||||
|
||||
private mapProjectExecutionPlan(raw: unknown): ProjectExecutionPlan {
|
||||
const value = (raw ?? {}) as Record<string, any>;
|
||||
|
||||
return {
|
||||
run: this.mapProjectRun(value['run']),
|
||||
skipped: (Array.isArray(value['skipped']) ? value['skipped'] : []).map((entry: Record<string, unknown>) => ({
|
||||
flowId: String(entry?.['flowId'] ?? ''),
|
||||
flowName: String(entry?.['flowName'] ?? ''),
|
||||
reason: String(entry?.['reason'] ?? 'Flow is not executable')
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.post<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/rerun`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
|
|
|
|||
|
|
@ -130,33 +130,6 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a whole project. Mirrors createExecution so the pending flag and the /tasks refresh come
|
||||
* for free.
|
||||
*/
|
||||
createProjectExecutions(projectId: string, skipNonExecutable = false) {
|
||||
this._pendingExecutionCreation.set(true);
|
||||
return this.taskExecutionsCallService.createProjectExecutions(projectId, skipNonExecutable).pipe(
|
||||
finalize(() => this._pendingExecutionCreation.set(false)),
|
||||
tap(() => this.refresh()),
|
||||
catchError((err) => {
|
||||
console.error('Create project executions failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts, or resumes, a project run. */
|
||||
startProjectRun(projectId: string, projectRunId: string) {
|
||||
return this.taskExecutionsCallService.startProjectRun(projectId, projectRunId).pipe(
|
||||
tap(() => this.refresh()),
|
||||
catchError((err) => {
|
||||
console.error('Start project run failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
rerunExecution(executionId: string) {
|
||||
this._pendingExecutionCreation.set(true);
|
||||
return this.taskExecutionsCallService.rerunTaskExecution(executionId).pipe(
|
||||
|
|
|
|||
|
|
@ -88,27 +88,6 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Compact arrows: the sidebar is 320px, so they must not crowd out the flow card. */
|
||||
.flows-list-group-move {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flows-list-group-move button {
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.flows-list-group-move mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.flows-list-group-empty {
|
||||
padding: 0.25rem;
|
||||
color: #94a3b8;
|
||||
|
|
|
|||
|
|
@ -24,15 +24,6 @@
|
|||
</button>
|
||||
|
||||
@if (!isUngrouped()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="flows-list-group-run"
|
||||
[matTooltip]="runTooltip()"
|
||||
[disabled]="!canRun()"
|
||||
[attr.aria-label]="'Run project ' + title()"
|
||||
(click)="$event.stopPropagation(); runRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="play_arrow"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="flows-list-group-actions"
|
||||
|
|
@ -48,10 +39,6 @@
|
|||
<mat-icon fontIcon="note_add"></mat-icon>
|
||||
<span>New flow in this project</span>
|
||||
</button>
|
||||
<button mat-menu-item type="button" [disabled]="!canRun()" (click)="runRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="play_arrow"></mat-icon>
|
||||
<span>Run project</span>
|
||||
</button>
|
||||
<button mat-menu-item type="button" (click)="editRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="edit"></mat-icon>
|
||||
<span>Edit project</span>
|
||||
|
|
@ -77,26 +64,6 @@
|
|||
}
|
||||
@for (flow of flows(); track flow.id) {
|
||||
<div class="flows-list-group-flow">
|
||||
@if (canReorder()) {
|
||||
<div class="flows-list-group-move">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Run earlier in this project"
|
||||
[disabled]="$first"
|
||||
(click)="move(flow.id, -1, $event)">
|
||||
<mat-icon fontIcon="keyboard_arrow_up"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Run later in this project"
|
||||
[disabled]="$last"
|
||||
(click)="move(flow.id, 1, $event)">
|
||||
<mat-icon fontIcon="keyboard_arrow_down"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<app-flow-item class="flows-list-group-flow-item" [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,35 +37,16 @@ export class FlowsGroup {
|
|||
readonly contextRequested = output<Project>();
|
||||
readonly deleteRequested = output<Project>();
|
||||
readonly newFlowRequested = output<Project>();
|
||||
readonly runRequested = output<Project>();
|
||||
readonly moveRequested = output<{ project: Project; flowId: string; direction: -1 | 1 }>();
|
||||
|
||||
/** The ungrouped bucket is a pseudo-project: it has no actions of its own. */
|
||||
readonly title = computed(() => this.project()?.name ?? 'No project');
|
||||
readonly isUngrouped = computed(() => this.project() === null);
|
||||
/** A project run needs at least one executable flow, and the backend refuses the rest. */
|
||||
readonly canRun = computed(() => this.flows().some((flow) => flow.status === 'EXECUTABLE'));
|
||||
|
||||
readonly runTooltip = computed(() =>
|
||||
this.canRun() ? 'Run every executable flow in this project' : 'No executable flow in this project'
|
||||
);
|
||||
|
||||
readonly countLabel = computed(() => {
|
||||
const count = this.flows().length;
|
||||
return `${count} ${count === 1 ? 'flow' : 'flows'}`;
|
||||
});
|
||||
|
||||
/** Reordering is per-project, so it is offered only inside a real project group. */
|
||||
readonly canReorder = computed(() => !this.isUngrouped() && this.flows().length > 1);
|
||||
|
||||
move(flowId: string, direction: -1 | 1, event: Event) {
|
||||
event.stopPropagation();
|
||||
const project = this.project();
|
||||
if (project) {
|
||||
this.moveRequested.emit({ project, flowId, direction });
|
||||
}
|
||||
}
|
||||
|
||||
onHeaderClick(event: Event) {
|
||||
event.stopPropagation();
|
||||
this.toggled.emit();
|
||||
|
|
|
|||
|
|
@ -75,9 +75,7 @@
|
|||
(editRequested)="editProject($event)"
|
||||
(contextRequested)="editProjectContext($event)"
|
||||
(deleteRequested)="deleteProject($event)"
|
||||
(newFlowRequested)="createFlowInProject($event)"
|
||||
(runRequested)="runProject($event)"
|
||||
(moveRequested)="moveFlowInProject($event)">
|
||||
(newFlowRequested)="createFlowInProject($event)">
|
||||
</app-flows-group>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,11 @@
|
|||
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Flow } from '@models/flow';
|
||||
import { Project } from '@models/project';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ListState } from '@stores/list-state';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
|
|
@ -43,20 +40,7 @@ function makeProject(id: string, name: string): Project {
|
|||
};
|
||||
}
|
||||
|
||||
let taskExecutionsStub: {
|
||||
createProjectExecutions: ReturnType<typeof vi.fn>;
|
||||
startProjectRun: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
async function build(flows: Flow[], projects: Project[]) {
|
||||
taskExecutionsStub = {
|
||||
createProjectExecutions: vi.fn(),
|
||||
startProjectRun: vi.fn().mockReturnValue(of({
|
||||
projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1,
|
||||
executionCount: 1, status: 'RUNNING', currentExecutionId: 'e1',
|
||||
completedCount: 0, blockedReason: null, executionIds: ['e1']
|
||||
}))
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FlowsList],
|
||||
providers: [
|
||||
|
|
@ -78,8 +62,7 @@ async function build(flows: Flow[], projects: Project[]) {
|
|||
hasLoadedProjects: vi.fn().mockReturnValue(true),
|
||||
getAllProjects: vi.fn().mockResolvedValue(signal(projects))
|
||||
}
|
||||
},
|
||||
{ provide: TaskExecutionsService, useValue: taskExecutionsStub }
|
||||
}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
|
|
@ -177,67 +160,6 @@ describe('FlowsList', () => {
|
|||
expect(fixture.componentInstance.view.expandedGroupIds?.has('p1')).toBe(true);
|
||||
});
|
||||
|
||||
it('runs a project and navigates to the created executions', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
|
||||
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1, executionIds: ['e1'] },
|
||||
skipped: []
|
||||
}));
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
// Non-executable flows are skipped rather than failing the whole run from the UI.
|
||||
expect(taskExecutionsStub.createProjectExecutions).toHaveBeenCalledWith('p1', true);
|
||||
// Creating is not enough: the run must also be started, or nothing would happen.
|
||||
expect(taskExecutionsStub.startProjectRun).toHaveBeenCalledWith('p1', 'r1');
|
||||
expect(navigate).toHaveBeenCalledWith(['/tasks'], { queryParams: { executionId: 'e1' } });
|
||||
});
|
||||
|
||||
it('still navigates when a project run produced no executions', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
|
||||
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 0, executionIds: [] },
|
||||
skipped: [{ flowId: '1', flowName: 'Alpha', reason: 'Flow is not executable' }]
|
||||
}));
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(['/tasks'], { queryParams: {} });
|
||||
});
|
||||
|
||||
it('does not navigate when the project run fails', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(throwError(() => new Error('boom')));
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a blocked run instead of claiming it is running', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
|
||||
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1,
|
||||
status: 'PENDING', currentExecutionId: 'e1', completedCount: 0, blockedReason: null, executionIds: ['e1'] },
|
||||
skipped: []
|
||||
}));
|
||||
taskExecutionsStub.startProjectRun.mockReturnValue(of({
|
||||
projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1,
|
||||
status: 'BLOCKED', currentExecutionId: 'e1', completedCount: 0,
|
||||
blockedReason: 'missing global inputs: requirements', executionIds: ['e1']
|
||||
}));
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
// Still navigates, so the user can supply what the run is waiting for.
|
||||
expect(navigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the filters collapsed by default, so the flows are what you see first', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha')], []);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Flow, FlowVisibility } from '@models/flow';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
|
|
@ -21,7 +20,6 @@ import { ProjectContextDialogService } from '@services/dialogs/project-context-d
|
|||
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
|
||||
import { ProjectDialogService } from '@services/dialogs/project-dialog';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { FLOW_FINALIZATION_ENABLED, PROJECTS_ENABLED } from '@shared/feature-flags';
|
||||
|
|
@ -62,8 +60,6 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
private projectDeleteDialog = inject(ProjectDeleteDialogService);
|
||||
private projectContextDialog = inject(ProjectContextDialogService);
|
||||
private notifications = inject(NotificationService);
|
||||
private taskExecutions = inject(TaskExecutionsService);
|
||||
private router = inject(Router);
|
||||
private editorState = inject(EditorStateHolder);
|
||||
|
||||
readonly projectsEnabled = PROJECTS_ENABLED;
|
||||
|
|
@ -299,55 +295,6 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one execution per executable flow in the project and shows them in /tasks. They are
|
||||
* created, not started: each still needs its inputs and credentials, exactly as a single run does.
|
||||
*/
|
||||
async runProject(project: Project) {
|
||||
try {
|
||||
const plan = await firstValueFrom(
|
||||
this.taskExecutions.createProjectExecutions(project.id, true));
|
||||
|
||||
// The flows run one at a time in the project's order; each step starts only when the
|
||||
// previous one succeeded, so this kicks off the first and the backend advances the rest.
|
||||
const run = await firstValueFrom(
|
||||
this.taskExecutions.startProjectRun(project.id, plan.run.projectRunId));
|
||||
|
||||
const skipped = plan.skipped.length;
|
||||
const skippedNote = skipped > 0 ? `; ${skipped} flow(s) skipped as not executable` : '';
|
||||
this.notifications.show(
|
||||
run.status === 'BLOCKED'
|
||||
? `“${project.name}” is waiting: ${run.blockedReason ?? 'the first flow needs inputs'}`
|
||||
: `Running “${project.name}” — ${plan.run.executionCount} flow(s), one at a time${skippedNote}`,
|
||||
run.status === 'BLOCKED' ? 'info' : 'success');
|
||||
|
||||
await this.router.navigate(['/tasks'], {
|
||||
queryParams: plan.run.executionIds.length ? { executionId: plan.run.executionIds[0] } : {}
|
||||
});
|
||||
} catch {
|
||||
this.notifications.show('Could not run the project.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves a flow one position within its project, persisting the run order. */
|
||||
async moveFlowInProject(event: { project: Project; flowId: string; direction: -1 | 1 }) {
|
||||
const current = this.groupedFlows().find((group) => group.key === event.project.id)?.flows ?? [];
|
||||
const index = current.findIndex((flow) => flow.id === event.flowId);
|
||||
const target = index + event.direction;
|
||||
if (index < 0 || target < 0 || target >= current.length) return;
|
||||
|
||||
const reordered = [...current];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.projectsService.updateFlowOrder(
|
||||
event.project.id, reordered.map((flow) => flow.id)));
|
||||
await this.flowsService.refresh(true);
|
||||
} catch {
|
||||
this.notifications.show('Could not reorder the flows.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async createFlowInProject(project: Project) {
|
||||
try {
|
||||
const created = await firstValueFrom(this.flowsService.createNewFlow());
|
||||
|
|
|
|||
Loading…
Reference in New Issue