diff --git a/go.mod b/go.mod index 4f19825a812..e7f1de7d8a1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( connectrpc.com/connect v1.21.0 gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 - gitea.dev/actionslib v1.3.0 + gitea.dev/actionslib v1.4.0 gitea.dev/sdk v1.3.0 github.com/42wim/httpsig v1.3.0 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 diff --git a/go.sum b/go.sum index 33fde2b1806..138aad2e243 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHq gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= -gitea.dev/actionslib v1.3.0 h1:xZRoTL1+sK/PaglT9uXkZJ9g0nk1WLMEH4FUXdXpKKw= -gitea.dev/actionslib v1.3.0/go.mod h1:svCefEsavx4jmn8vJ4wTG7Q0KBcGfrFmGUjrhxuNUAQ= +gitea.dev/actionslib v1.4.0 h1:k31smj22tnUZrrM5kkntXN8lKAe7dkoTEoiP1Fk5KJc= +gitea.dev/actionslib v1.4.0/go.mod h1:svCefEsavx4jmn8vJ4wTG7Q0KBcGfrFmGUjrhxuNUAQ= gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I= gitea.dev/sdk v1.3.0/go.mod h1:85EBqSAqUn1KlFCk40y3sTjWjBsB/KouL3slGXMA/2o= github.com/42wim/httpsig v1.3.0 h1:HK2bieeZ99X9IvOoc1ST6yrhLek0f4MqlEGES2JCP3g= diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 98321e2de16..9701fb6326f 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -435,6 +435,7 @@ func prepareMigrationTasks() []*migration { newMigration(356, "Add index on action_run commit_sha", v29.AddActionRunCommitSHAIndex), newMigration(357, "Normalize legacy team authorize values", v29.NormalizeLegacyTeamAuthorize), newMigration(358, "Track previous remember-me token hashes", v29.AddPreviousAuthTokenHash), + newMigration(359, "Add stage and number columns to action_task_step", v29.AddStageAndNumberToActionTaskStep), } return preparedMigrations } diff --git a/modelmigration/v29/v359.go b/modelmigration/v29/v359.go new file mode 100644 index 00000000000..12ccaa844d2 --- /dev/null +++ b/modelmigration/v29/v359.go @@ -0,0 +1,25 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v29 + +import ( + "context" + + "gitea.dev/modelmigration/base" + + "xorm.io/xorm" +) + +func AddStageAndNumberToActionTaskStep(_ context.Context, x base.EngineMigration) error { + type ActionTaskStep struct { + Stage int64 `xorm:"NOT NULL DEFAULT 0"` + Number int64 `xorm:"NOT NULL DEFAULT 0"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreConstrains: true, + IgnoreDropIndices: true, + }, new(ActionTaskStep)) + return err +} diff --git a/models/actions/task.go b/models/actions/task.go index 838db470f8a..cf13a81391d 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "time" @@ -482,6 +483,11 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task for _, v := range state.Steps { stepStates[v.Id] = v } + reportedSteps := state.ReportedSteps + if err := validateReportedSteps(reportedSteps); err != nil { + log.Warn("Ignore the steps reported for task %d: %v", state.Id, err) + reportedSteps = nil + } // Only one request can update the task because the final state needs to be calculated with all job states. // Otherwise, concurrent requests with transaction will make the SQL read stale job state and result in wrong final state. @@ -538,22 +544,14 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task return err } + if len(reportedSteps) > 0 { + return updateReportedSteps(ctx, task, reportedSteps, now) + } + if slices.ContainsFunc(task.Steps, (*ActionTaskStep).IsReported) { + return nil // the indexes of the steps of the workflow file no longer match the stored steps + } for _, step := range task.Steps { - var result runnerv1.Result - if v, ok := stepStates[step.Index]; ok { - result = v.Result - step.LogIndex = v.LogIndex - step.LogLength = v.LogLength - if step.Started == 0 && v.StartedAt != nil { - step.Started = now - } - } - if result != runnerv1.Result_RESULT_UNSPECIFIED { - step.Status = StatusFromResult(result) - step.Stopped = util.IfZero(step.Stopped, now) - } else if step.Started != 0 { - step.Status = StatusRunning - } + step.applyState(stepStates[step.Index], now) if _, err := db.GetEngine(ctx).ID(step.ID).Update(step); err != nil { return err } @@ -637,13 +635,7 @@ func StopTask(ctx context.Context, taskID int64, status Status) error { } for _, step := range task.Steps { - if !step.Status.IsDone() { - step.Status = status - if step.Started == 0 { - step.Started = now - } - step.Stopped = now - } + step.stop(status, now) if _, err := e.ID(step.ID).Update(step); err != nil { return err } diff --git a/models/actions/task_step.go b/models/actions/task_step.go index 598b9adb1ae..aefa32e8ac9 100644 --- a/models/actions/task_step.go +++ b/models/actions/task_step.go @@ -5,10 +5,14 @@ package actions import ( "context" + "fmt" "time" + runnerv1 "gitea.dev/actionslib/runner/v1" "gitea.dev/models/db" + "gitea.dev/modules/container" "gitea.dev/modules/timeutil" + "gitea.dev/modules/util" ) // ActionTaskStep represents a step of ActionTask @@ -21,6 +25,8 @@ type ActionTaskStep struct { Status Status `xorm:"index"` LogIndex int64 LogLength int64 + Stage int64 `xorm:"NOT NULL DEFAULT 0"` // a runnerv1.StepStage, unspecified for the steps parsed from the workflow file instead of reported by the runner + Number int64 `xorm:"NOT NULL DEFAULT 0"` // the index in the workflow file of the step a PRE, MAIN or POST stage belongs to Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` @@ -31,6 +37,27 @@ func (step *ActionTaskStep) Duration() time.Duration { return calculateDuration(step.Started, step.Stopped, step.Status, step.Updated) } +// StepStage returns the stored stage, which can be one added to the enum after this Gitea version +func (step *ActionTaskStep) StepStage() runnerv1.StepStage { + return runnerv1.StepStage(step.Stage) +} + +// IsReported reports whether the runner reported the step instead of Gitea parsing it from the workflow file. +func (step *ActionTaskStep) IsReported() bool { + return step.StepStage() != runnerv1.StepStage_STEP_STAGE_UNSPECIFIED +} + +// WorkflowStepIndex returns the index of the step in the workflow file, or -1 for a step that is not written there. +func (step *ActionTaskStep) WorkflowStepIndex() int64 { + switch step.StepStage() { + case runnerv1.StepStage_STEP_STAGE_UNSPECIFIED: + return step.Index + case runnerv1.StepStage_STEP_STAGE_MAIN: + return step.Number + } + return -1 +} + func init() { db.RegisterModel(new(ActionTaskStep)) } @@ -39,3 +66,120 @@ func GetTaskStepsByTaskID(ctx context.Context, taskID int64) ([]*ActionTaskStep, var steps []*ActionTaskStep return steps, db.GetEngine(ctx).Where("task_id=?", taskID).OrderBy("`index` ASC").Find(&steps) } + +// applyState applies the state the runner reported for the step, nil meaning it reported none. +func (step *ActionTaskStep) applyState(state *runnerv1.StepState, now timeutil.TimeStamp) { + var result runnerv1.Result + if state != nil { + result = state.Result + step.LogIndex = state.LogIndex + step.LogLength = state.LogLength + if step.Started == 0 && state.StartedAt != nil { + step.Started = now + } + } + if result != runnerv1.Result_RESULT_UNSPECIFIED { + step.Status = StatusFromResult(result) + step.Stopped = util.IfZero(step.Stopped, now) + } else if step.Started != 0 { + step.Status = StatusRunning + } +} + +// stop finishes a step that had not finished when its task stopped +func (step *ActionTaskStep) stop(status Status, now timeutil.TimeStamp) { + if step.Status.IsDone() { + return + } + step.Status = status + step.Started = util.IfZero(step.Started, now) + step.Stopped = now +} + +// maxReportedSteps bounds the steps a runner can make Gitea store for one task. +const maxReportedSteps = 1000 + +type reportedStepKey struct { + stage runnerv1.StepStage + number int64 +} + +func validateReportedSteps(steps []*runnerv1.StepState) error { + if len(steps) > maxReportedSteps { + return fmt.Errorf("%d steps exceed the limit of %d", len(steps), maxReportedSteps) + } + seen := make(container.Set[reportedStepKey], len(steps)) + for i, v := range steps { + if v.Stage <= runnerv1.StepStage_STEP_STAGE_UNSPECIFIED { // a stage added later is shown by its name + return fmt.Errorf("step %d has invalid stage %d", i, v.Stage) + } + if v.Id != int64(i) || v.Number < 0 || v.LogIndex < 0 || v.LogLength < 0 { + return fmt.Errorf("step %d has invalid id %d, number %d or log range %d+%d", i, v.Id, v.Number, v.LogIndex, v.LogLength) + } + if !seen.Add(reportedStepKey{v.Stage, v.Number}) { + return fmt.Errorf("step %d duplicates stage %s of number %d", i, v.Stage, v.Number) + } + } + return nil +} + +// updateReportedSteps replaces the steps of the task with the ones the runner reported. +// A step keeps its times and status when the runner reports it again at another position. +func updateReportedSteps(ctx context.Context, task *ActionTask, reported []*runnerv1.StepState, now timeutil.TimeStamp) error { + e := db.GetEngine(ctx) + sameLayout := len(task.Steps) == len(reported) + for i := 0; sameLayout && i < len(reported); i++ { + step := task.Steps[i] + sameLayout = step.IsReported() && reportedStepKey{step.StepStage(), step.Number} == reportedStepKey{reported[i].Stage, reported[i].Number} + } + + if sameLayout { + for i, step := range task.Steps { + step.Name = util.EllipsisDisplayString(reported[i].Name, 255) + step.applyState(reported[i], now) + if task.Status.IsDone() { + step.stop(task.Status, now) + } + if _, err := e.ID(step.ID).Update(step); err != nil { + return err + } + } + return nil + } + + known := make(map[reportedStepKey]*ActionTaskStep, len(task.Steps)) + for _, step := range task.Steps { + if step.IsReported() { + known[reportedStepKey{step.StepStage(), step.Number}] = step + } + } + steps := make([]*ActionTaskStep, len(reported)) + for i, v := range reported { + key := reportedStepKey{v.Stage, v.Number} + step := &ActionTaskStep{ + Name: util.EllipsisDisplayString(v.Name, 255), + TaskID: task.ID, + Index: int64(i), + RepoID: task.RepoID, + Stage: int64(key.stage), + Number: key.number, + Status: StatusWaiting, + } + if old, ok := known[key]; ok { + step.Status, step.Started, step.Stopped = old.Status, old.Started, old.Stopped + } + step.applyState(v, now) + if task.Status.IsDone() { + step.stop(task.Status, now) + } + steps[i] = step + } + if _, err := e.Delete(&ActionTaskStep{TaskID: task.ID}); err != nil { + return err + } + if _, err := e.Insert(steps); err != nil { + return err + } + task.Steps = steps + return nil +} diff --git a/models/actions/task_test.go b/models/actions/task_test.go index ca872089c49..5974d02fe9a 100644 --- a/models/actions/task_test.go +++ b/models/actions/task_test.go @@ -407,6 +407,114 @@ func TestUpdateTaskByStateIsAtomic(t *testing.T) { assert.GreaterOrEqual(t, unittest.AssertExistsAndLoadBean(t, &ActionTaskStep{TaskID: task.ID}).Started, before) } +func TestUpdateTaskByStateReportedSteps(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + task, _ := newRunningTaskForCancelling(t, "reported-steps-job", true) + require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{Name: "Run echo", TaskID: task.ID, RepoID: task.RepoID, Status: StatusWaiting})) + + report := func(t *testing.T, steps []*runnerv1.StepState, reported ...*runnerv1.StepState) []*ActionTaskStep { + t.Helper() + _, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{Id: task.ID, Steps: steps, ReportedSteps: reported}) + require.NoError(t, err) + got, err := GetTaskStepsByTaskID(t.Context(), task.ID) + require.NoError(t, err) + return got + } + started := timestamppb.Now() + setup := &runnerv1.StepState{Id: 0, Name: "Set up job", Stage: runnerv1.StepStage_STEP_STAGE_SETUP, StartedAt: started} + main := &runnerv1.StepState{Id: 1, Name: "Run echo", Stage: runnerv1.StepStage_STEP_STAGE_MAIN} + legacy := []*runnerv1.StepState{{Id: 0, StartedAt: started, LogLength: 3}} + + t.Run("invalid reported steps fall back to the workflow steps", func(t *testing.T) { + got := report(t, legacy, setup, &runnerv1.StepState{Id: 1, Stage: runnerv1.StepStage_STEP_STAGE_SETUP}) + require.Len(t, got, 1) + assert.False(t, got[0].IsReported()) + assert.Equal(t, StatusRunning, got[0].Status) + assert.EqualValues(t, 3, got[0].LogLength) + }) + + t.Run("reported steps replace the workflow steps", func(t *testing.T) { + got := report(t, legacy, setup, main) + require.Len(t, got, 2) + assert.Equal(t, runnerv1.StepStage_STEP_STAGE_SETUP, got[0].StepStage()) + assert.EqualValues(t, -1, got[0].WorkflowStepIndex()) + assert.Equal(t, StatusRunning, got[0].Status) + assert.NotZero(t, got[0].Started) + assert.Equal(t, "Run echo", got[1].Name) + assert.Equal(t, StatusWaiting, got[1].Status) + assert.EqualValues(t, 0, got[1].WorkflowStepIndex()) + }) + + t.Run("an inserted step keeps the state of the others", func(t *testing.T) { + before, err := GetTaskStepsByTaskID(t.Context(), task.ID) + require.NoError(t, err) + doneSetup := &runnerv1.StepState{Id: 0, Name: "Set up job", Stage: runnerv1.StepStage_STEP_STAGE_SETUP, StartedAt: started, Result: runnerv1.Result_RESULT_SUCCESS, LogLength: 4} + pre := &runnerv1.StepState{Id: 1, Name: "Pre Run checkout", Stage: runnerv1.StepStage_STEP_STAGE_PRE, StartedAt: started, LogIndex: 4, LogLength: 2} + movedMain := &runnerv1.StepState{Id: 2, Name: "Run echo", Stage: runnerv1.StepStage_STEP_STAGE_MAIN} + got := report(t, legacy, doneSetup, pre, movedMain) + require.Len(t, got, 3) + assert.Equal(t, StatusSuccess, got[0].Status) + assert.Equal(t, before[0].Started, got[0].Started) + assert.Equal(t, runnerv1.StepStage_STEP_STAGE_PRE, got[1].StepStage()) + assert.Equal(t, StatusRunning, got[1].Status) + assert.EqualValues(t, 4, got[1].LogIndex) + assert.EqualValues(t, 2, got[2].Index) + }) + + t.Run("workflow steps no longer apply once reported", func(t *testing.T) { + before, err := GetTaskStepsByTaskID(t.Context(), task.ID) + require.NoError(t, err) + got := report(t, []*runnerv1.StepState{{Id: 0, Result: runnerv1.Result_RESULT_FAILURE}}) + assert.Equal(t, before, got) + }) + + t.Run("unfinished steps stop with the task", func(t *testing.T) { + doneSetup := &runnerv1.StepState{Id: 0, Name: "Set up job", Stage: runnerv1.StepStage_STEP_STAGE_SETUP, StartedAt: started, Result: runnerv1.Result_RESULT_SUCCESS} + runningMain := &runnerv1.StepState{Id: 1, Name: "Run echo", Stage: runnerv1.StepStage_STEP_STAGE_MAIN, StartedAt: started} + cleanup := &runnerv1.StepState{Id: 2, Name: "Complete job", Stage: runnerv1.StepStage_STEP_STAGE_CLEANUP} + _, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{ + Id: task.ID, Result: runnerv1.Result_RESULT_FAILURE, + ReportedSteps: []*runnerv1.StepState{doneSetup, runningMain, cleanup}, + }) + require.NoError(t, err) + got, err := GetTaskStepsByTaskID(t.Context(), task.ID) + require.NoError(t, err) + require.Len(t, got, 3) + assert.Equal(t, StatusSuccess, got[0].Status) + for _, step := range got[1:] { + assert.Equal(t, StatusFailure, step.Status, step.Name) + assert.NotZero(t, step.Started, step.Name) + assert.NotZero(t, step.Stopped, step.Name) + } + }) +} + +func TestValidateReportedSteps(t *testing.T) { + step := func(id int64, stage runnerv1.StepStage, number int64) *runnerv1.StepState { + return &runnerv1.StepState{Id: id, Stage: stage, Number: number} + } + assert.NoError(t, validateReportedSteps([]*runnerv1.StepState{ + step(0, runnerv1.StepStage_STEP_STAGE_SETUP, 0), + step(1, runnerv1.StepStage_STEP_STAGE_PRE, 0), + step(2, runnerv1.StepStage_STEP_STAGE_MAIN, 0), + step(3, runnerv1.StepStage_STEP_STAGE_MAIN, 1), + step(4, runnerv1.StepStage_STEP_STAGE_CLEANUP, 0), + step(5, runnerv1.StepStage(100), 0), // a stage added later, with several steps told apart by number + step(6, runnerv1.StepStage(100), 1), + })) + for name, steps := range map[string][]*runnerv1.StepState{ + "unspecified stage": {step(0, runnerv1.StepStage_STEP_STAGE_UNSPECIFIED, 0)}, + "negative stage": {step(0, runnerv1.StepStage(-1), 0)}, + "id not position": {step(1, runnerv1.StepStage_STEP_STAGE_SETUP, 0)}, + "negative number": {step(0, runnerv1.StepStage_STEP_STAGE_MAIN, -1)}, + "duplicate main": {step(0, runnerv1.StepStage_STEP_STAGE_MAIN, 0), step(1, runnerv1.StepStage_STEP_STAGE_MAIN, 0)}, + "duplicate setup": {step(0, runnerv1.StepStage_STEP_STAGE_SETUP, 0), step(1, runnerv1.StepStage_STEP_STAGE_SETUP, 0)}, + "too many": make([]*runnerv1.StepState, maxReportedSteps+1), + } { + assert.Error(t, validateReportedSteps(steps), name) + } +} + // newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner, // which is the state every cancellation test starts from. func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) { diff --git a/modules/actions/task_state.go b/modules/actions/task_state.go index 457f949bf31..f5d1968b881 100644 --- a/modules/actions/task_state.go +++ b/modules/actions/task_state.go @@ -17,6 +17,9 @@ func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep { if len(task.Steps) == 0 { return fullStepsOfEmptySteps(task) } + if task.Steps[0].IsReported() { + return task.Steps // the runner reports its own setup and cleanup steps + } // firstStep is the first step that has run or running, not include preStep. // For example, diff --git a/modules/actions/task_state_test.go b/modules/actions/task_state_test.go index 93a0d598cdc..299ff5e40fc 100644 --- a/modules/actions/task_state_test.go +++ b/modules/actions/task_state_test.go @@ -6,6 +6,7 @@ package actions import ( "testing" + runnerv1 "gitea.dev/actionslib/runner/v1" actions_model "gitea.dev/models/actions" "github.com/stretchr/testify/assert" @@ -55,6 +56,21 @@ func TestFullSteps(t *testing.T) { {Name: postStepName, Status: actions_model.StatusFailure, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100}, }, }, + { + name: "reported by the runner", + task: &actions_model.ActionTask{ + Steps: []*actions_model.ActionTaskStep{ + {Name: "Set up job", Stage: int64(runnerv1.StepStage_STEP_STAGE_SETUP), Status: actions_model.StatusSuccess, LogLength: 10}, + {Name: "Run echo", Stage: int64(runnerv1.StepStage_STEP_STAGE_MAIN), Status: actions_model.StatusRunning, LogIndex: 10, LogLength: 5}, + }, + Status: actions_model.StatusRunning, + LogLength: 15, + }, + want: []*actions_model.ActionTaskStep{ + {Name: "Set up job", Stage: int64(runnerv1.StepStage_STEP_STAGE_SETUP), Status: actions_model.StatusSuccess, LogLength: 10}, + {Name: "Run echo", Stage: int64(runnerv1.StepStage_STEP_STAGE_MAIN), Status: actions_model.StatusRunning, LogIndex: 10, LogLength: 5}, + }, + }, { name: "first step is running", task: &actions_model.ActionTask{ diff --git a/routers/api/actions/job_summary.go b/routers/api/actions/job_summary.go index fa21cd6d11c..a75362422c9 100644 --- a/routers/api/actions/job_summary.go +++ b/routers/api/actions/job_summary.go @@ -41,7 +41,7 @@ func uploadJobSummary(ctx *ArtifactContext) { ctx.HTTPError(http.StatusInternalServerError, "Error getting task steps") return } - if !slices.ContainsFunc(steps, func(s *actions_model.ActionTaskStep) bool { return s.Index == stepIndex }) { + if !slices.ContainsFunc(steps, func(s *actions_model.ActionTaskStep) bool { return s.WorkflowStepIndex() == stepIndex }) { ctx.HTTPError(http.StatusBadRequest, "step_index mismatch") return } diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index 26ce4b53ec9..6c524c2f5ab 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -334,8 +334,12 @@ async function loadJob() { store.viewData.runArtifacts = runJobResp.artifacts || []; store.viewData.currentRun = runJobResp.state.run; + const previousSteps = currentJob.value.steps; currentJob.value = runJobResp.state.currentJob; - const jobLogs = runJobResp.logs.stepsLog ?? []; + // a runner reporting its own steps can change them while the job runs, which moves the logs of each step position + const stepsChanged = previousSteps.length > 0 && !isSameSteps(previousSteps, currentJob.value.steps); + if (stepsChanged) resetStepsLogs(previousSteps); + const jobLogs = stepsChanged ? [] : runJobResp.logs.stepsLog ?? []; // sync the currentJobStepsStates to store the job step states for (let i = 0; i < currentJob.value.steps.length; i++) { @@ -381,8 +385,8 @@ async function loadJob() { lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'}); } - // clear the interval timer if the job is done - if (run.value.done && intervalID) { + // clear the interval timer if the job is done, the logs of changed steps still need to be loaded + if (run.value.done && intervalID && !stepsChanged) { clearInterval(intervalID); intervalID = null; } @@ -395,6 +399,21 @@ async function loadJob() { } } +function isSameSteps(a: Array, b: Array) { + return a.length === b.length && a.every((step, idx) => step.summary === b[idx].summary); +} + +function resetStepsLogs(previousSteps: Array) { + const expandedSummaries = new Set(previousSteps.filter((_, idx) => currentJobStepsStates.value[idx].expanded).map((step) => step.summary)); + currentJobStepsStates.value = currentJob.value.steps.map((step) => ({cursor: null, expanded: expandedSummaries.has(step.summary), manuallyCollapsed: false})); + stepAnsiRenderers.length = 0; + for (const el of jobStepLogs.value) { + if (!el) continue; + el.replaceChildren(); + el._stepLogsActiveContainer = undefined; + } +} + function isDone(status: ActionsStatus) { return ['success', 'skipped', 'failure', 'cancelled'].includes(status); }